mirror of
https://github.com/luckyyzh/pi-agent-integrated.git
synced 2026-10-04 11:39:36 +00:00
feat: integrate Pi backend and Pi Web
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
import { Agent } from "@earendil-works/pi-agent-core";
|
||||
import { createModels } from "@earendil-works/pi-ai";
|
||||
import { anthropicProvider } from "@earendil-works/pi-ai/providers/anthropic";
|
||||
|
||||
const models = createModels();
|
||||
models.setProvider(anthropicProvider());
|
||||
const model = models.getModel("anthropic", "claude-sonnet-4-5");
|
||||
if (!model) throw new Error("Anthropic smoke-test model not found");
|
||||
|
||||
export const agent = new Agent({
|
||||
initialState: { model },
|
||||
streamFn: models.streamSimple.bind(models),
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { createAssistantMessageEventStream, Type } from "@earendil-works/pi-ai";
|
||||
import { complete, getModel, getProviders, streamSimple } from "@earendil-works/pi-ai/compat";
|
||||
import {
|
||||
Agent,
|
||||
bashExecutionToText,
|
||||
convertToLlm,
|
||||
createCustomMessage,
|
||||
FileError,
|
||||
formatPromptTemplateInvocation,
|
||||
formatSkillInvocation,
|
||||
formatSkillsForSystemPrompt,
|
||||
getOrThrow,
|
||||
InMemorySessionRepo,
|
||||
ok,
|
||||
parseCommandArgs,
|
||||
streamProxy,
|
||||
toError,
|
||||
truncateHead,
|
||||
} from "@earendil-works/pi-agent-core";
|
||||
|
||||
// Keep this entry browser-safe. It is bundled by scripts/check-browser-smoke.mjs
|
||||
// to catch accidental Node-only runtime imports in browser-facing package exports.
|
||||
const model = getModel("google", "gemini-2.5-flash");
|
||||
const schema = Type.Object({ prompt: Type.String() });
|
||||
const stream = createAssistantMessageEventStream();
|
||||
|
||||
const agent = new Agent({ initialState: { model }, streamFn: streamSimple });
|
||||
agent.steer({ role: "user", content: [{ type: "text", text: "queued" }], timestamp: 0 });
|
||||
const repo = new InMemorySessionRepo();
|
||||
const result = getOrThrow(ok({ value: 1 }));
|
||||
const customMessage = createCustomMessage("note", "hello", true, undefined, "2026-01-01T00:00:00.000Z");
|
||||
const llmMessages = convertToLlm([customMessage]);
|
||||
const skill = { name: "browser-safe", description: "Smoke test", content: "Use browser APIs.", filePath: "/skills/browser-safe/SKILL.md" };
|
||||
|
||||
console.log(
|
||||
model.id,
|
||||
getProviders().length,
|
||||
typeof complete,
|
||||
schema.type,
|
||||
typeof stream.push,
|
||||
agent.hasQueuedMessages(),
|
||||
typeof repo.create,
|
||||
result.value,
|
||||
llmMessages.length,
|
||||
bashExecutionToText({
|
||||
role: "bashExecution",
|
||||
command: "echo ok",
|
||||
output: "ok",
|
||||
exitCode: 0,
|
||||
cancelled: false,
|
||||
truncated: false,
|
||||
timestamp: 0,
|
||||
}),
|
||||
formatSkillsForSystemPrompt([skill]).length,
|
||||
formatSkillInvocation(skill).length,
|
||||
formatPromptTemplateInvocation({ name: "example", content: "$1 $@" }, parseCommandArgs('one "two three"')),
|
||||
truncateHead("a\nb", { maxLines: 1 }).content,
|
||||
new FileError("not_found", "missing").code,
|
||||
toError("boom").message,
|
||||
typeof streamProxy,
|
||||
);
|
||||
@@ -0,0 +1,256 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Build pi binaries for all platforms locally.
|
||||
# Mirrors .github/workflows/build-binaries.yml
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/build-binaries.sh [--skip-install] [--skip-deps] [--skip-build] [--offline-model-data] [--platform <platform>] [--out <dir>]
|
||||
#
|
||||
# Options:
|
||||
# --skip-install Skip npm ci
|
||||
# --skip-deps Skip installing cross-platform dependencies
|
||||
# --skip-build Skip the package build
|
||||
# --offline-model-data Build with bundled model data instead of refreshing it
|
||||
# --platform <name> Build only for specified platform (darwin-arm64, darwin-x64, linux-x64, linux-arm64, windows-x64, windows-arm64)
|
||||
# --out <dir> Output directory (default: packages/coding-agent/binaries)
|
||||
#
|
||||
# Output:
|
||||
# packages/coding-agent/binaries/
|
||||
# pi-darwin-arm64.tar.gz
|
||||
# pi-darwin-x64.tar.gz
|
||||
# pi-linux-x64.tar.gz
|
||||
# pi-linux-arm64.tar.gz
|
||||
# pi-windows-x64.zip
|
||||
# pi-windows-arm64.zip
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
SKIP_INSTALL=false
|
||||
SKIP_DEPS=false
|
||||
SKIP_BUILD=false
|
||||
OFFLINE_MODEL_DATA=false
|
||||
PLATFORM=""
|
||||
OUTPUT_DIR=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--skip-install)
|
||||
SKIP_INSTALL=true
|
||||
shift
|
||||
;;
|
||||
--skip-deps)
|
||||
SKIP_DEPS=true
|
||||
shift
|
||||
;;
|
||||
--skip-build)
|
||||
SKIP_BUILD=true
|
||||
shift
|
||||
;;
|
||||
--offline-model-data)
|
||||
OFFLINE_MODEL_DATA=true
|
||||
shift
|
||||
;;
|
||||
--platform)
|
||||
PLATFORM="$2"
|
||||
shift 2
|
||||
;;
|
||||
--out)
|
||||
OUTPUT_DIR="$2"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Validate platform if specified
|
||||
if [[ -n "$PLATFORM" ]]; then
|
||||
case "$PLATFORM" in
|
||||
darwin-arm64|darwin-x64|linux-x64|linux-arm64|windows-x64|windows-arm64)
|
||||
;;
|
||||
*)
|
||||
echo "Invalid platform: $PLATFORM"
|
||||
echo "Valid platforms: darwin-arm64, darwin-x64, linux-x64, linux-arm64, windows-x64, windows-arm64"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
if [[ -z "$OUTPUT_DIR" ]]; then
|
||||
OUTPUT_DIR="packages/coding-agent/binaries"
|
||||
fi
|
||||
if [[ "$OUTPUT_DIR" != /* ]]; then
|
||||
OUTPUT_DIR="$(pwd)/$OUTPUT_DIR"
|
||||
fi
|
||||
|
||||
if [[ "$SKIP_INSTALL" == "false" ]]; then
|
||||
echo "==> Installing dependencies..."
|
||||
npm ci --ignore-scripts
|
||||
else
|
||||
echo "==> Skipping npm ci (--skip-install)"
|
||||
fi
|
||||
|
||||
if [[ "$SKIP_DEPS" == "false" ]]; then
|
||||
echo "==> Installing cross-platform native bindings..."
|
||||
CLIPBOARD_VERSION=$(node -p "require('./packages/coding-agent/package.json').optionalDependencies['@mariozechner/clipboard']")
|
||||
# npm ci only installs optional deps for the current platform
|
||||
# We need the base clipboard package and all platform bindings for bun cross-compilation
|
||||
# Use --force to bypass platform checks (os/cpu restrictions in package.json)
|
||||
# Install all in one command to avoid npm removing packages from previous installs
|
||||
npm install --include=optional --no-save --package-lock=false --force --ignore-scripts \
|
||||
@mariozechner/clipboard@"$CLIPBOARD_VERSION" \
|
||||
@mariozechner/clipboard-darwin-arm64@"$CLIPBOARD_VERSION" \
|
||||
@mariozechner/clipboard-darwin-x64@"$CLIPBOARD_VERSION" \
|
||||
@mariozechner/clipboard-linux-x64-gnu@"$CLIPBOARD_VERSION" \
|
||||
@mariozechner/clipboard-linux-arm64-gnu@"$CLIPBOARD_VERSION" \
|
||||
@mariozechner/clipboard-win32-x64-msvc@"$CLIPBOARD_VERSION" \
|
||||
@mariozechner/clipboard-win32-arm64-msvc@"$CLIPBOARD_VERSION"
|
||||
else
|
||||
echo "==> Skipping cross-platform native bindings (--skip-deps)"
|
||||
fi
|
||||
|
||||
if [[ "$SKIP_BUILD" == "false" ]]; then
|
||||
if [[ "$OFFLINE_MODEL_DATA" == "true" ]]; then
|
||||
echo "==> Building all packages with bundled model data..."
|
||||
npm run build:offline
|
||||
else
|
||||
echo "==> Building all packages..."
|
||||
npm run build
|
||||
fi
|
||||
else
|
||||
echo "==> Skipping package build (--skip-build)"
|
||||
fi
|
||||
|
||||
echo "==> Building binaries..."
|
||||
cd packages/coding-agent
|
||||
|
||||
# Clean previous builds
|
||||
rm -rf "$OUTPUT_DIR"
|
||||
mkdir -p "$OUTPUT_DIR"/{darwin-arm64,darwin-x64,linux-x64,linux-arm64,windows-x64,windows-arm64}
|
||||
|
||||
# Determine which platforms to build
|
||||
if [[ -n "$PLATFORM" ]]; then
|
||||
PLATFORMS=("$PLATFORM")
|
||||
else
|
||||
PLATFORMS=(darwin-arm64 darwin-x64 linux-x64 linux-arm64 windows-x64 windows-arm64)
|
||||
fi
|
||||
|
||||
for platform in "${PLATFORMS[@]}"; do
|
||||
echo "Building for $platform..."
|
||||
# Bun compiled executables only embed worker scripts when they are passed as
|
||||
# explicit build entrypoints. The runtime can still use new URL(...), but the
|
||||
# worker must be present in the compiled executable.
|
||||
if [[ "$platform" == windows-* ]]; then
|
||||
bun build --compile --target=bun-$platform ./dist/bun/cli.js ./src/utils/image-resize-worker.ts --outfile "$OUTPUT_DIR/$platform/pi.exe"
|
||||
else
|
||||
bun build --compile --target=bun-$platform ./dist/bun/cli.js ./src/utils/image-resize-worker.ts --outfile "$OUTPUT_DIR/$platform/pi"
|
||||
fi
|
||||
done
|
||||
|
||||
echo "==> Creating release archives..."
|
||||
|
||||
# Copy shared files to each platform directory
|
||||
for platform in "${PLATFORMS[@]}"; do
|
||||
cp package.json "$OUTPUT_DIR/$platform/"
|
||||
cp README.md "$OUTPUT_DIR/$platform/"
|
||||
cp CHANGELOG.md "$OUTPUT_DIR/$platform/"
|
||||
cp ../../node_modules/@silvia-odwyer/photon-node/photon_rs_bg.wasm "$OUTPUT_DIR/$platform/"
|
||||
mkdir -p "$OUTPUT_DIR/$platform/theme"
|
||||
cp dist/modes/interactive/theme/*.json "$OUTPUT_DIR/$platform/theme/"
|
||||
mkdir -p "$OUTPUT_DIR/$platform/assets"
|
||||
cp dist/modes/interactive/assets/* "$OUTPUT_DIR/$platform/assets/"
|
||||
cp -r dist/core/export-html "$OUTPUT_DIR/$platform/"
|
||||
cp -r docs "$OUTPUT_DIR/$platform/"
|
||||
cp -r examples "$OUTPUT_DIR/$platform/"
|
||||
|
||||
case "$platform" in
|
||||
darwin-arm64)
|
||||
clipboard_native_package="clipboard-darwin-arm64"
|
||||
clipboard_native_file="clipboard.darwin-arm64.node"
|
||||
;;
|
||||
darwin-x64)
|
||||
clipboard_native_package="clipboard-darwin-x64"
|
||||
clipboard_native_file="clipboard.darwin-x64.node"
|
||||
;;
|
||||
linux-x64)
|
||||
clipboard_native_package="clipboard-linux-x64-gnu"
|
||||
clipboard_native_file="clipboard.linux-x64-gnu.node"
|
||||
;;
|
||||
linux-arm64)
|
||||
clipboard_native_package="clipboard-linux-arm64-gnu"
|
||||
clipboard_native_file="clipboard.linux-arm64-gnu.node"
|
||||
;;
|
||||
windows-x64)
|
||||
clipboard_native_package="clipboard-win32-x64-msvc"
|
||||
clipboard_native_file="clipboard.win32-x64-msvc.node"
|
||||
;;
|
||||
windows-arm64)
|
||||
clipboard_native_package="clipboard-win32-arm64-msvc"
|
||||
clipboard_native_file="clipboard.win32-arm64-msvc.node"
|
||||
;;
|
||||
esac
|
||||
mkdir -p "$OUTPUT_DIR/$platform/node_modules/@mariozechner"
|
||||
cp -r ../../node_modules/@mariozechner/clipboard "$OUTPUT_DIR/$platform/node_modules/@mariozechner/"
|
||||
cp -r ../../node_modules/@mariozechner/$clipboard_native_package "$OUTPUT_DIR/$platform/node_modules/@mariozechner/"
|
||||
cp "../../node_modules/@mariozechner/$clipboard_native_package/$clipboard_native_file" \
|
||||
"$OUTPUT_DIR/$platform/node_modules/@mariozechner/clipboard/"
|
||||
|
||||
# Copy terminal input native helpers next to compiled binaries.
|
||||
if [[ "$platform" == darwin-* ]]; then
|
||||
mkdir -p "$OUTPUT_DIR/$platform/native/darwin/prebuilds/$platform"
|
||||
cp ../tui/native/darwin/prebuilds/$platform/darwin-modifiers.node "$OUTPUT_DIR/$platform/native/darwin/prebuilds/$platform/"
|
||||
fi
|
||||
if [[ "$platform" == windows-* ]]; then
|
||||
if [[ "$platform" == "windows-arm64" ]]; then
|
||||
win32_arch_dir="win32-arm64"
|
||||
else
|
||||
win32_arch_dir="win32-x64"
|
||||
fi
|
||||
mkdir -p "$OUTPUT_DIR/$platform/native/win32/prebuilds/$win32_arch_dir"
|
||||
cp ../tui/native/win32/prebuilds/$win32_arch_dir/win32-console-mode.node "$OUTPUT_DIR/$platform/native/win32/prebuilds/$win32_arch_dir/"
|
||||
fi
|
||||
done
|
||||
|
||||
# Create archives
|
||||
cd "$OUTPUT_DIR"
|
||||
|
||||
for platform in "${PLATFORMS[@]}"; do
|
||||
if [[ "$platform" == windows-* ]]; then
|
||||
# Windows (zip)
|
||||
echo "Creating pi-$platform.zip..."
|
||||
(cd "$platform" && zip -r ../pi-$platform.zip .)
|
||||
else
|
||||
# Unix platforms (tar.gz) - use wrapper directory for mise compatibility
|
||||
echo "Creating pi-$platform.tar.gz..."
|
||||
mv "$platform" pi && tar -czf pi-$platform.tar.gz pi && mv pi "$platform"
|
||||
fi
|
||||
done
|
||||
|
||||
# Extract archives for easy local testing
|
||||
echo "==> Extracting archives for testing..."
|
||||
for platform in "${PLATFORMS[@]}"; do
|
||||
rm -rf "$platform"
|
||||
if [[ "$platform" == windows-* ]]; then
|
||||
mkdir -p "$platform" && (cd "$platform" && unzip -q ../pi-$platform.zip)
|
||||
else
|
||||
tar -xzf pi-$platform.tar.gz && mv pi "$platform"
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "==> Build complete!"
|
||||
echo "Archives available in $OUTPUT_DIR/"
|
||||
ls -lh *.tar.gz *.zip 2>/dev/null || true
|
||||
echo ""
|
||||
echo "Extracted directories for testing:"
|
||||
for platform in "${PLATFORMS[@]}"; do
|
||||
if [[ "$platform" == windows-* ]]; then
|
||||
echo " $OUTPUT_DIR/$platform/pi.exe"
|
||||
else
|
||||
echo " $OUTPUT_DIR/$platform/pi"
|
||||
fi
|
||||
done
|
||||
@@ -0,0 +1,128 @@
|
||||
import { existsSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { build } from "esbuild";
|
||||
|
||||
const outputPath = join(tmpdir(), "pi-browser-smoke.js");
|
||||
const agentTreeshakeOutputPath = join(tmpdir(), "pi-agent-treeshake-smoke.js");
|
||||
const errorLogPath = join(tmpdir(), "pi-browser-smoke-errors.log");
|
||||
const generatedCatalogDataDir = join(process.cwd(), "packages/ai/src/providers/data");
|
||||
|
||||
// Fresh checkouts do not materialize provider JSON until model data is hydrated.
|
||||
const generatedCatalogDataPlugin = {
|
||||
name: "generated-model-catalog",
|
||||
setup(build) {
|
||||
build.onResolve({ filter: /^\.\/data\/[^/]+\.json$/ }, (args) => {
|
||||
const path = resolve(dirname(args.importer), args.path);
|
||||
if (dirname(path) !== generatedCatalogDataDir || existsSync(path)) return;
|
||||
return { path, namespace: "empty-generated-model-catalog" };
|
||||
});
|
||||
build.onLoad({ filter: /.*/, namespace: "empty-generated-model-catalog" }, () => ({
|
||||
contents: "{}",
|
||||
loader: "json",
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
function normalizePath(path) {
|
||||
return path.replaceAll("\\", "/");
|
||||
}
|
||||
|
||||
function findInput(inputs, suffix) {
|
||||
return Object.keys(inputs).find((input) => {
|
||||
const normalized = normalizePath(input);
|
||||
return normalized === suffix || normalized.endsWith(`/${suffix}`);
|
||||
});
|
||||
}
|
||||
|
||||
function includesNodePackage(inputs, packageName) {
|
||||
const marker = `node_modules/${packageName}/`;
|
||||
return Object.keys(inputs).some((input) => normalizePath(input).includes(marker));
|
||||
}
|
||||
|
||||
try {
|
||||
await build({
|
||||
entryPoints: ["scripts/browser-smoke-entry.ts"],
|
||||
bundle: true,
|
||||
platform: "browser",
|
||||
format: "esm",
|
||||
logLevel: "silent",
|
||||
outfile: outputPath,
|
||||
plugins: [generatedCatalogDataPlugin],
|
||||
});
|
||||
|
||||
const agentTreeshakeBuild = await build({
|
||||
entryPoints: ["scripts/agent-treeshake-smoke-entry.ts"],
|
||||
bundle: true,
|
||||
platform: "browser",
|
||||
format: "esm",
|
||||
logLevel: "silent",
|
||||
metafile: true,
|
||||
outfile: agentTreeshakeOutputPath,
|
||||
plugins: [generatedCatalogDataPlugin],
|
||||
write: false,
|
||||
});
|
||||
const inputs = agentTreeshakeBuild.metafile.inputs;
|
||||
for (const forbiddenInput of [
|
||||
"packages/ai/src/compat.ts",
|
||||
"packages/ai/src/models.generated.ts",
|
||||
"packages/ai/src/providers/all.ts",
|
||||
]) {
|
||||
const includedInput = findInput(inputs, forbiddenInput);
|
||||
if (includedInput) {
|
||||
throw new Error(`Agent selective-provider bundle unexpectedly includes ${includedInput}`);
|
||||
}
|
||||
}
|
||||
|
||||
const contributingInputs = new Set(
|
||||
Object.values(agentTreeshakeBuild.metafile.outputs).flatMap((output) =>
|
||||
Object.entries(output.inputs)
|
||||
.filter(([, contribution]) => contribution.bytesInOutput > 0)
|
||||
.map(([input]) => input),
|
||||
),
|
||||
);
|
||||
const catalogInputs = Array.from(contributingInputs).filter((input) =>
|
||||
normalizePath(input).includes("packages/ai/src/providers/data/"),
|
||||
);
|
||||
if (catalogInputs.length !== 1 || !normalizePath(catalogInputs[0]).endsWith("/anthropic.json")) {
|
||||
throw new Error(
|
||||
`Agent selective-provider bundle catalogs: expected only anthropic.json, found ${catalogInputs.join(", ") || "none"}`,
|
||||
);
|
||||
}
|
||||
|
||||
const aiSdkPackages = [
|
||||
"@anthropic-ai/sdk",
|
||||
"@aws-sdk/client-bedrock-runtime",
|
||||
"@google/genai",
|
||||
"@mistralai/mistralai",
|
||||
"openai",
|
||||
];
|
||||
const includedAiSdkPackages = aiSdkPackages.filter((packageName) => includesNodePackage(inputs, packageName));
|
||||
if (
|
||||
includedAiSdkPackages.length !== 1 ||
|
||||
includedAiSdkPackages[0] !== "@anthropic-ai/sdk"
|
||||
) {
|
||||
throw new Error(
|
||||
`Agent selective-provider bundle SDKs: expected only @anthropic-ai/sdk, found ${includedAiSdkPackages.join(", ") || "none"}`,
|
||||
);
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
let detailedErrors = "";
|
||||
if (error && typeof error === "object" && "errors" in error && Array.isArray(error.errors)) {
|
||||
detailedErrors = error.errors
|
||||
.map((entry) => {
|
||||
const location = entry.location
|
||||
? `${entry.location.file}:${entry.location.line}:${entry.location.column}`
|
||||
: "";
|
||||
return [location, entry.text].filter(Boolean).join(" ");
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
const baseError = error instanceof Error ? (error.stack ?? error.message) : String(error);
|
||||
writeFileSync(errorLogPath, [detailedErrors, baseError].filter(Boolean).join("\n\n"), "utf-8");
|
||||
console.error(`Browser smoke check failed. See ${errorLogPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execFileSync } from "node:child_process";
|
||||
|
||||
const allowValue = process.env.PI_ALLOW_LOCKFILE_CHANGE;
|
||||
const allowed = allowValue === "1" || allowValue === "true" || allowValue === "yes";
|
||||
|
||||
function git(args) {
|
||||
return execFileSync("git", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
|
||||
}
|
||||
|
||||
function readJsonFromGit(ref) {
|
||||
try {
|
||||
return JSON.parse(git(["show", ref]));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function packageNameFromLockPath(lockPath) {
|
||||
const marker = "node_modules/";
|
||||
const index = lockPath.lastIndexOf(marker);
|
||||
if (index === -1) return lockPath || "<root>";
|
||||
const parts = lockPath.slice(index + marker.length).split("/");
|
||||
return parts[0]?.startsWith("@") ? `${parts[0]}/${parts[1]}` : parts[0];
|
||||
}
|
||||
|
||||
function packageLabel(lockPath, entry) {
|
||||
const name = entry?.name ?? packageNameFromLockPath(lockPath);
|
||||
return entry?.version ? `${name}@${entry.version}` : name;
|
||||
}
|
||||
|
||||
function getLockfilePackageChanges() {
|
||||
const before = readJsonFromGit("HEAD:package-lock.json");
|
||||
const after = readJsonFromGit(":package-lock.json");
|
||||
if (!before?.packages || !after?.packages) return undefined;
|
||||
|
||||
const changes = [];
|
||||
const paths = new Set([...Object.keys(before.packages), ...Object.keys(after.packages)]);
|
||||
for (const lockPath of [...paths].sort()) {
|
||||
const oldEntry = before.packages[lockPath];
|
||||
const newEntry = after.packages[lockPath];
|
||||
if (JSON.stringify(oldEntry) !== JSON.stringify(newEntry)) {
|
||||
changes.push({ lockPath, oldEntry, newEntry });
|
||||
}
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
function isWorkspacePackagePath(lockPath) {
|
||||
return lockPath.startsWith("packages/");
|
||||
}
|
||||
|
||||
function hasOnlyWorkspacePackageChanges(changes) {
|
||||
return changes.length > 0 && changes.every((change) => isWorkspacePackagePath(change.lockPath));
|
||||
}
|
||||
|
||||
function summarizeLockfileChange(changes) {
|
||||
const nodeModuleChanges = changes.filter((change) => change.lockPath.includes("node_modules/"));
|
||||
const summary = [];
|
||||
for (const { lockPath, oldEntry, newEntry } of nodeModuleChanges) {
|
||||
if (!oldEntry && newEntry) {
|
||||
summary.push(`added ${packageLabel(lockPath, newEntry)}`);
|
||||
} else if (oldEntry && !newEntry) {
|
||||
summary.push(`removed ${packageLabel(lockPath, oldEntry)}`);
|
||||
} else if (oldEntry?.version !== newEntry?.version) {
|
||||
summary.push(
|
||||
`changed ${packageNameFromLockPath(lockPath)} ${oldEntry?.version ?? "<none>"} -> ${newEntry?.version ?? "<none>"}`,
|
||||
);
|
||||
} else {
|
||||
summary.push(`changed ${packageLabel(lockPath, newEntry)}`);
|
||||
}
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
const stagedFiles = git(["diff", "--cached", "--name-only"])
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
if (!stagedFiles.includes("package-lock.json")) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (allowed) {
|
||||
console.error("package-lock.json is staged; PI_ALLOW_LOCKFILE_CHANGE is set, allowing commit.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const changes = getLockfilePackageChanges();
|
||||
if (changes && hasOnlyWorkspacePackageChanges(changes)) {
|
||||
console.error("package-lock.json only updates workspace package metadata; allowing commit.");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.error("package-lock.json is staged.");
|
||||
console.error("");
|
||||
console.error("Review lockfile changes before committing:");
|
||||
console.error(" - confirm every new/updated package is intentional");
|
||||
console.error(" - confirm npm age gates were active for resolution");
|
||||
console.error(" - review any new lifecycle scripts in the dependency tree");
|
||||
console.error(" - regenerate/check coding-agent shrinkwrap if release deps changed");
|
||||
|
||||
const summary = changes ? summarizeLockfileChange(changes) : [];
|
||||
if (summary.length > 0) {
|
||||
console.error("");
|
||||
console.error("Detected package version changes:");
|
||||
for (const change of summary.slice(0, 40)) {
|
||||
console.error(` - ${change}`);
|
||||
}
|
||||
if (summary.length > 40) {
|
||||
console.error(` ... ${summary.length - 40} more`);
|
||||
}
|
||||
}
|
||||
|
||||
console.error("");
|
||||
console.error("If this lockfile change is intentional, commit with:");
|
||||
console.error(" PI_ALLOW_LOCKFILE_CHANGE=1 git commit ...");
|
||||
process.exit(1);
|
||||
@@ -0,0 +1,63 @@
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const dependencySections = ["dependencies", "devDependencies", "optionalDependencies"];
|
||||
const exactVersionPattern = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
||||
const ignoredDirectories = new Set([".git", "dist", "node_modules"]);
|
||||
const packageJsonFiles = [];
|
||||
|
||||
function collectPackageJsonFiles(directory) {
|
||||
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||
if (entry.isDirectory()) {
|
||||
if (!ignoredDirectories.has(entry.name)) {
|
||||
collectPackageJsonFiles(join(directory, entry.name));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isFile() && entry.name === "package.json") {
|
||||
packageJsonFiles.push(join(directory, entry.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isInternalWorkspaceDependency(name) {
|
||||
return name.startsWith("@earendil-works/pi-");
|
||||
}
|
||||
|
||||
function isNonRegistrySpecifier(specifier) {
|
||||
return /^(?:workspace:|file:|link:|portal:|git\+|github:|git:|https?:|ssh:|git:\/\/)/.test(specifier);
|
||||
}
|
||||
|
||||
function getVersionSpecifier(specifier) {
|
||||
if (!specifier.startsWith("npm:")) return specifier;
|
||||
const aliasTarget = specifier.slice("npm:".length);
|
||||
const versionSeparator = aliasTarget.lastIndexOf("@");
|
||||
if (versionSeparator <= 0) return specifier;
|
||||
return aliasTarget.slice(versionSeparator + 1);
|
||||
}
|
||||
|
||||
const failures = [];
|
||||
|
||||
collectPackageJsonFiles(".");
|
||||
|
||||
for (const file of packageJsonFiles.sort()) {
|
||||
const packageJson = JSON.parse(readFileSync(file, "utf8"));
|
||||
|
||||
for (const section of dependencySections) {
|
||||
const dependencies = packageJson[section];
|
||||
if (!dependencies) continue;
|
||||
|
||||
for (const [name, specifier] of Object.entries(dependencies)) {
|
||||
if (isInternalWorkspaceDependency(name) || isNonRegistrySpecifier(specifier)) continue;
|
||||
if (exactVersionPattern.test(getVersionSpecifier(specifier))) continue;
|
||||
failures.push(`${file}: ${section}.${name} must be pinned, found ${specifier}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error("Direct external dependencies must use exact versions:");
|
||||
for (const failure of failures) console.error(` ${failure}`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import ts from "typescript";
|
||||
|
||||
const ignoredDirectories = new Set([".git", "coverage", "dist", "node_modules"]);
|
||||
const files = [];
|
||||
|
||||
function collectTypescriptFiles(directory) {
|
||||
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||
if (entry.isDirectory()) {
|
||||
if (!ignoredDirectories.has(entry.name)) {
|
||||
collectTypescriptFiles(join(directory, entry.name));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isFile() && entry.name.endsWith(".ts") && !entry.name.endsWith(".d.ts")) {
|
||||
files.push(join(directory, entry.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isRelativeJavaScriptSpecifier(specifier) {
|
||||
return /^\.\.?\//.test(specifier) && /\.js(?:[?#].*)?$/.test(specifier);
|
||||
}
|
||||
|
||||
function getImportTypeSpecifier(node) {
|
||||
if (!ts.isLiteralTypeNode(node.argument)) return undefined;
|
||||
if (!ts.isStringLiteralLike(node.argument.literal)) return undefined;
|
||||
return node.argument.literal;
|
||||
}
|
||||
|
||||
const failures = [];
|
||||
|
||||
collectTypescriptFiles(".");
|
||||
|
||||
for (const file of files.sort()) {
|
||||
const sourceText = readFileSync(file, "utf8");
|
||||
const sourceFile = ts.createSourceFile(file, sourceText, ts.ScriptTarget.Latest, true);
|
||||
|
||||
function checkSpecifier(node) {
|
||||
if (!isRelativeJavaScriptSpecifier(node.text)) return;
|
||||
const { line, character } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
|
||||
failures.push(`${file}:${line + 1}:${character + 1}: ${node.text}`);
|
||||
}
|
||||
|
||||
function visit(node) {
|
||||
if (ts.isImportDeclaration(node) && ts.isStringLiteralLike(node.moduleSpecifier)) {
|
||||
checkSpecifier(node.moduleSpecifier);
|
||||
} else if (ts.isExportDeclaration(node) && node.moduleSpecifier && ts.isStringLiteralLike(node.moduleSpecifier)) {
|
||||
checkSpecifier(node.moduleSpecifier);
|
||||
} else if (
|
||||
ts.isCallExpression(node) &&
|
||||
node.expression.kind === ts.SyntaxKind.ImportKeyword &&
|
||||
node.arguments[0] &&
|
||||
ts.isStringLiteralLike(node.arguments[0])
|
||||
) {
|
||||
checkSpecifier(node.arguments[0]);
|
||||
} else if (ts.isImportTypeNode(node)) {
|
||||
const specifier = getImportTypeSpecifier(node);
|
||||
if (specifier) checkSpecifier(specifier);
|
||||
}
|
||||
|
||||
ts.forEachChild(node, visit);
|
||||
}
|
||||
|
||||
visit(sourceFile);
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error("Relative .js imports are not allowed in non-declaration .ts files:");
|
||||
for (const failure of failures) console.error(` ${failure}`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
// Parse args
|
||||
const args = process.argv.slice(2);
|
||||
let directory: string | undefined;
|
||||
let days: number | undefined;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === "--dir" || args[i] === "-d") {
|
||||
directory = args[++i];
|
||||
} else if (args[i] === "--days" || args[i] === "-n") {
|
||||
days = parseInt(args[++i], 10);
|
||||
} else if (args[i] === "--help" || args[i] === "-h") {
|
||||
console.log(`Usage: cost.ts -d <path> -n <days>
|
||||
-d, --dir <path> Directory path (required)
|
||||
-n, --days <num> Number of days to track (required)
|
||||
-h, --help Show this help`);
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
if (!directory || !days) {
|
||||
console.error("Error: both --dir and --days are required");
|
||||
console.error("Run with --help for usage");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Encode directory path to session folder name
|
||||
function encodeSessionDir(dir: string): string {
|
||||
// Remove leading slash, replace remaining slashes with dashes
|
||||
const normalized = dir.startsWith("/") ? dir.slice(1) : dir;
|
||||
return "--" + normalized.replace(/\//g, "-") + "--";
|
||||
}
|
||||
|
||||
const sessionsBase = path.join(process.env.HOME!, ".pi/agent/sessions");
|
||||
const encodedDir = encodeSessionDir(directory);
|
||||
const sessionsDir = path.join(sessionsBase, encodedDir);
|
||||
|
||||
if (!fs.existsSync(sessionsDir)) {
|
||||
console.error(`Sessions directory not found: ${sessionsDir}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Get cutoff date
|
||||
const cutoff = new Date();
|
||||
cutoff.setDate(cutoff.getDate() - days);
|
||||
cutoff.setHours(0, 0, 0, 0);
|
||||
|
||||
interface DayCost {
|
||||
total: number;
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
requests: number;
|
||||
}
|
||||
|
||||
interface Stats {
|
||||
[day: string]: {
|
||||
[provider: string]: DayCost;
|
||||
};
|
||||
}
|
||||
|
||||
const stats: Stats = {};
|
||||
|
||||
// Process session files
|
||||
const files = fs.readdirSync(sessionsDir).filter((f) => f.endsWith(".jsonl"));
|
||||
|
||||
for (const file of files) {
|
||||
// Extract timestamp from filename: <timestamp>_<uuid>.jsonl
|
||||
// Format: 2025-12-17T08-25-07-381Z (dashes instead of colons)
|
||||
const timestamp = file.split("_")[0];
|
||||
// Convert back to valid ISO: replace T08-25-07-381Z with T08:25:07.381Z
|
||||
const isoTimestamp = timestamp.replace(/T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z/, "T$1:$2:$3.$4Z");
|
||||
const fileDate = new Date(isoTimestamp);
|
||||
|
||||
if (fileDate < cutoff) continue;
|
||||
|
||||
const filepath = path.join(sessionsDir, file);
|
||||
const content = fs.readFileSync(filepath, "utf8");
|
||||
const lines = content.trim().split("\n");
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line) continue;
|
||||
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
|
||||
if (entry.type !== "message") continue;
|
||||
if (entry.message?.role !== "assistant") continue;
|
||||
if (!entry.message?.usage?.cost) continue;
|
||||
|
||||
const { provider, usage } = entry.message;
|
||||
const { cost } = usage;
|
||||
const entryDate = new Date(entry.timestamp);
|
||||
const day = entryDate.toISOString().split("T")[0];
|
||||
|
||||
if (!stats[day]) stats[day] = {};
|
||||
if (!stats[day][provider]) {
|
||||
stats[day][provider] = {
|
||||
total: 0,
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
requests: 0,
|
||||
};
|
||||
}
|
||||
|
||||
stats[day][provider].total += cost.total || 0;
|
||||
stats[day][provider].input += cost.input || 0;
|
||||
stats[day][provider].output += cost.output || 0;
|
||||
stats[day][provider].cacheRead += cost.cacheRead || 0;
|
||||
stats[day][provider].cacheWrite += cost.cacheWrite || 0;
|
||||
stats[day][provider].requests += 1;
|
||||
} catch {
|
||||
// Skip malformed lines
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort days and output
|
||||
const sortedDays = Object.keys(stats).sort();
|
||||
|
||||
if (sortedDays.length === 0) {
|
||||
console.log(`No sessions found in the last ${days} days for: ${directory}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(`\nCost breakdown for: ${directory}`);
|
||||
console.log(`Period: last ${days} days (since ${cutoff.toISOString().split("T")[0]})`);
|
||||
console.log("=".repeat(80));
|
||||
|
||||
let grandTotal = 0;
|
||||
const providerTotals: { [p: string]: DayCost } = {};
|
||||
|
||||
for (const day of sortedDays) {
|
||||
console.log(`\n${day}`);
|
||||
console.log("-".repeat(40));
|
||||
|
||||
let dayTotal = 0;
|
||||
const providers = Object.keys(stats[day]).sort();
|
||||
|
||||
for (const provider of providers) {
|
||||
const s = stats[day][provider];
|
||||
dayTotal += s.total;
|
||||
|
||||
if (!providerTotals[provider]) {
|
||||
providerTotals[provider] = { total: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0, requests: 0 };
|
||||
}
|
||||
providerTotals[provider].total += s.total;
|
||||
providerTotals[provider].input += s.input;
|
||||
providerTotals[provider].output += s.output;
|
||||
providerTotals[provider].cacheRead += s.cacheRead;
|
||||
providerTotals[provider].cacheWrite += s.cacheWrite;
|
||||
providerTotals[provider].requests += s.requests;
|
||||
|
||||
console.log(
|
||||
` ${provider.padEnd(15)} $${s.total.toFixed(4).padStart(8)} (${s.requests} reqs, in: $${s.input.toFixed(4)}, out: $${s.output.toFixed(4)}, cache: $${(s.cacheRead + s.cacheWrite).toFixed(4)})`
|
||||
);
|
||||
}
|
||||
|
||||
console.log(` ${"Day total:".padEnd(15)} $${dayTotal.toFixed(4).padStart(8)}`);
|
||||
grandTotal += dayTotal;
|
||||
}
|
||||
|
||||
console.log("\n" + "=".repeat(80));
|
||||
console.log("TOTALS BY PROVIDER");
|
||||
console.log("-".repeat(40));
|
||||
|
||||
for (const provider of Object.keys(providerTotals).sort()) {
|
||||
const t = providerTotals[provider];
|
||||
console.log(
|
||||
` ${provider.padEnd(15)} $${t.total.toFixed(4).padStart(8)} (${t.requests} reqs, in: $${t.input.toFixed(4)}, out: $${t.output.toFixed(4)}, cache: $${(t.cacheRead + t.cacheWrite).toFixed(4)})`
|
||||
);
|
||||
}
|
||||
|
||||
console.log("-".repeat(40));
|
||||
console.log(` ${"GRAND TOTAL:".padEnd(15)} $${grandTotal.toFixed(4).padStart(8)}`);
|
||||
console.log();
|
||||
@@ -0,0 +1,150 @@
|
||||
#!/usr/bin/env bash
|
||||
# Create the deterministic source archive uploaded with GitHub releases.
|
||||
#
|
||||
# Usage:
|
||||
# npm run hydrate:model-data
|
||||
# ./scripts/create-source-archive.sh --version <version> --ref <git-ref> --out <archive.tar.gz>
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
version=""
|
||||
source_ref="HEAD"
|
||||
output=""
|
||||
invocation_dir="$PWD"
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 --version <version> [--ref <git-ref>] --out <archive.tar.gz>"
|
||||
}
|
||||
|
||||
require_value() {
|
||||
if [[ $# -lt 2 || -z "$2" ]]; then
|
||||
echo "$1 requires a value" >&2
|
||||
usage >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--version)
|
||||
require_value "$@"
|
||||
version="$2"
|
||||
shift 2
|
||||
;;
|
||||
--ref)
|
||||
require_value "$@"
|
||||
source_ref="$2"
|
||||
shift 2
|
||||
;;
|
||||
--out)
|
||||
require_value "$@"
|
||||
output="$2"
|
||||
shift 2
|
||||
;;
|
||||
--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown option: $1" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$version" || -z "$output" ]]; then
|
||||
usage >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! "$version" =~ ^[0-9A-Za-z][0-9A-Za-z._-]*$ ]]; then
|
||||
echo "Invalid version: $version" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
repo_root="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
cd "$repo_root"
|
||||
|
||||
commit="$(git rev-parse --verify --end-of-options "${source_ref}^{commit}")"
|
||||
|
||||
package_version="$(git show "${commit}:packages/coding-agent/package.json" | node -p 'JSON.parse(require("fs").readFileSync(0, "utf8")).version')"
|
||||
if [[ "$package_version" != "$version" ]]; then
|
||||
echo "Version ${version} does not match package version ${package_version} at ${source_ref}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$output" != /* ]]; then
|
||||
output="$invocation_dir/$output"
|
||||
fi
|
||||
mkdir -p "$(dirname "$output")"
|
||||
output="$(cd "$(dirname "$output")" && pwd)/$(basename "$output")"
|
||||
|
||||
model_data_dir="packages/ai/src/providers/data"
|
||||
if [[ ! -f "${model_data_dir}/.manifest.json" ]]; then
|
||||
echo "Generated model data is missing. Run npm run hydrate:model-data first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
shopt -s nullglob
|
||||
model_data_files=("${model_data_dir}/.manifest.json" "${model_data_dir}"/*.json)
|
||||
shopt -u nullglob
|
||||
if [[ ${#model_data_files[@]} -eq 1 ]]; then
|
||||
echo "Generated model data is missing from ${model_data_dir}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
temporary_archive="$(mktemp "${output}.tmp.XXXXXX")"
|
||||
temporary_index="$(mktemp "${output}.index.XXXXXX")"
|
||||
manifest="$(mktemp "${output}.manifest.XXXXXX")"
|
||||
validation_root="$(mktemp -d "${output}.validation.XXXXXX")"
|
||||
rm -f "$temporary_index"
|
||||
trap 'rm -f "$temporary_archive" "$temporary_index" "$manifest"; rm -rf "$validation_root"' EXIT
|
||||
|
||||
# Add the ignored release model-data snapshot to a temporary index based on the
|
||||
# release commit. Archiving the resulting tree keeps the source artifact
|
||||
# deterministic for the same commit and generated model data.
|
||||
GIT_INDEX_FILE="$temporary_index" git read-tree "$commit"
|
||||
GIT_INDEX_FILE="$temporary_index" git add -f -- "${model_data_files[@]}"
|
||||
archive_tree="$(GIT_INDEX_FILE="$temporary_index" git write-tree)"
|
||||
archive_mtime="$(git show -s --format=%ct "$commit")"
|
||||
|
||||
archive_root="pi-${version}"
|
||||
git archive --format=tar --prefix="${archive_root}/" --mtime="@${archive_mtime}" "$archive_tree" \
|
||||
| gzip -n -9 > "$temporary_archive"
|
||||
tar -tzf "$temporary_archive" > "$manifest"
|
||||
|
||||
required_paths=(
|
||||
"package.json"
|
||||
"package-lock.json"
|
||||
"scripts/build-binaries.sh"
|
||||
"packages/ai/src/models.generated.ts"
|
||||
"packages/ai/src/image-models.generated.ts"
|
||||
"packages/ai/src/providers/data/.manifest.json"
|
||||
"packages/coding-agent/package.json"
|
||||
"packages/coding-agent/src/utils/image-resize-worker.ts"
|
||||
"packages/coding-agent/src/core/export-html/template.css"
|
||||
)
|
||||
|
||||
for path in "${required_paths[@]}"; do
|
||||
if ! grep -Fxq "${archive_root}/${path}" "$manifest"; then
|
||||
echo "Source archive is missing required path: $path" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
if ! awk -v prefix="${archive_root}/" 'index($0, prefix) != 1 { exit 1 }' "$manifest"; then
|
||||
echo "Source archive contains a path outside ${archive_root}/" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if grep -Eq '(^|/)node_modules/|(^|/)packages/coding-agent/binaries/' "$manifest"; then
|
||||
echo "Source archive contains generated dependencies or binaries" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
tar -xzf "$temporary_archive" -C "$validation_root"
|
||||
node "${validation_root}/${archive_root}/packages/ai/scripts/check-model-data.ts"
|
||||
|
||||
mv "$temporary_archive" "$output"
|
||||
trap 'rm -f "$temporary_index" "$manifest"; rm -rf "$validation_root"' EXIT
|
||||
printf '%s\n' "$output"
|
||||
@@ -0,0 +1,241 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { copyFileSync, existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
function printUsage() {
|
||||
console.log(`Usage: node scripts/diff-model-catalog.mjs [--thinking] [provider ...]
|
||||
|
||||
Generates the model catalog at HEAD and in the current worktree, then shows
|
||||
JSON differences. If providers are omitted, all providers are compared.
|
||||
|
||||
--thinking compares each worktree's effective thinking levels using that
|
||||
worktree's getSupportedThinkingLevels() implementation.
|
||||
|
||||
Examples:
|
||||
node scripts/diff-model-catalog.mjs github-copilot
|
||||
npm run diff:model-catalog -- --thinking moonshotai kimi-coding
|
||||
`);
|
||||
}
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: options.cwd,
|
||||
encoding: "utf8",
|
||||
stdio: options.capture ? ["ignore", "pipe", "pipe"] : "inherit",
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
const details = [result.stdout, result.stderr].filter(Boolean).join("\n");
|
||||
throw new Error(`Command failed: ${[command, ...args].join(" ")}\n${details}`);
|
||||
}
|
||||
return result.stdout ?? "";
|
||||
}
|
||||
|
||||
function runDiff(args, cwd) {
|
||||
return spawnSync("git", args, {
|
||||
cwd,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
if (args.includes("--help")) {
|
||||
printUsage();
|
||||
process.exit(0);
|
||||
}
|
||||
const thinkingOnly = args.includes("--thinking");
|
||||
const requestedProviders = args.filter((arg) => arg !== "--thinking");
|
||||
if (requestedProviders.some((arg) => arg.startsWith("-"))) {
|
||||
printUsage();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const repoRoot = run("git", ["rev-parse", "--show-toplevel"], { capture: true }).trim();
|
||||
const temporaryRoot = mkdtempSync(join(tmpdir(), "pi-model-catalog-diff-"));
|
||||
const baselineWorktree = join(temporaryRoot, "baseline-worktree");
|
||||
const baselineOutput = join(temporaryRoot, "before");
|
||||
const currentOutput = join(temporaryRoot, "after");
|
||||
const baselineThinkingOutput = join(temporaryRoot, "before-thinking");
|
||||
const currentThinkingOutput = join(temporaryRoot, "after-thinking");
|
||||
let worktreeAdded = false;
|
||||
|
||||
function generateCatalog(cwd, outputDir, pretty = false) {
|
||||
const args = ["packages/ai/scripts/generate-models.ts", "--strict", "--json-only", "--json-output", outputDir];
|
||||
if (pretty) args.push("--pretty");
|
||||
run(process.execPath, args, { cwd, capture: true });
|
||||
}
|
||||
|
||||
function formatProviderCatalogs(outputDir) {
|
||||
const providersDir = join(outputDir, "providers");
|
||||
for (const entry of readdirSync(providersDir)) {
|
||||
if (!entry.endsWith(".json")) continue;
|
||||
const path = join(providersDir, entry);
|
||||
const value = JSON.parse(readFileSync(path, "utf8"));
|
||||
writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
function readProviderCatalog(outputDir, provider) {
|
||||
const path = join(outputDir, "providers", `${provider}.json`);
|
||||
return existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : undefined;
|
||||
}
|
||||
|
||||
function generateThinkingCatalog(cwd, catalogPath, outputDir) {
|
||||
run(process.execPath, ["scripts/generate-thinking-capabilities.mjs", catalogPath, outputDir], {
|
||||
cwd,
|
||||
capture: true,
|
||||
});
|
||||
}
|
||||
|
||||
const THINKING_LEVEL_ORDER = ["off", "minimal", "low", "medium", "high", "xhigh", "max"];
|
||||
const THINKING_LEVEL_RANKS = new Map(THINKING_LEVEL_ORDER.map((key, index) => [key, index]));
|
||||
|
||||
function sortJsonKeys(keys, parentKey) {
|
||||
if (parentKey !== "thinkingLevelMap" && parentKey !== "values") return keys.sort();
|
||||
return keys.sort((left, right) => {
|
||||
const leftRank = THINKING_LEVEL_RANKS.get(left) ?? Number.POSITIVE_INFINITY;
|
||||
const rightRank = THINKING_LEVEL_RANKS.get(right) ?? Number.POSITIVE_INFINITY;
|
||||
return leftRank - rightRank || left.localeCompare(right);
|
||||
});
|
||||
}
|
||||
|
||||
function canonicalizeJson(value, parentKey) {
|
||||
if (Array.isArray(value)) return value.map((entry) => canonicalizeJson(entry));
|
||||
if (value === null || typeof value !== "object") return value;
|
||||
|
||||
const result = {};
|
||||
for (const key of sortJsonKeys(Object.keys(value), parentKey)) {
|
||||
result[key] = canonicalizeJson(value[key], key);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function formatJsonForDiff(value, indent = "") {
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) return "[]";
|
||||
const childIndent = `${indent} `;
|
||||
return `[\n${value.map((entry) => `${childIndent}${formatJsonForDiff(entry, childIndent)},`).join("\n")}\n${indent}]`;
|
||||
}
|
||||
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
||||
|
||||
const entries = Object.entries(value);
|
||||
if (entries.length === 0) return "{}";
|
||||
const childIndent = `${indent} `;
|
||||
return `{\n${entries
|
||||
.map(([key, entry]) => `${childIndent}${JSON.stringify(key)}: ${formatJsonForDiff(entry, childIndent)},`)
|
||||
.join("\n")}\n${indent}}`;
|
||||
}
|
||||
|
||||
function writeModelSnapshot(path, model) {
|
||||
writeFileSync(path, model === undefined ? "" : `${formatJsonForDiff(canonicalizeJson(model))}\n`);
|
||||
}
|
||||
|
||||
function writeChangedLines(output) {
|
||||
const changedLines = output.split("\n").filter((line) => {
|
||||
const withoutColor = line.replace(/\u001b\[[0-9;]*m/g, "");
|
||||
return (
|
||||
(withoutColor.startsWith("+") && !withoutColor.startsWith("+++")) ||
|
||||
(withoutColor.startsWith("-") && !withoutColor.startsWith("---"))
|
||||
);
|
||||
});
|
||||
if (changedLines.length > 0) process.stdout.write(`${changedLines.join("\n")}\n`);
|
||||
}
|
||||
|
||||
try {
|
||||
run("git", ["worktree", "add", "--detach", baselineWorktree, "HEAD"], { cwd: repoRoot });
|
||||
worktreeAdded = true;
|
||||
copyFileSync(
|
||||
join(repoRoot, "scripts", "generate-thinking-capabilities.mjs"),
|
||||
join(baselineWorktree, "scripts", "generate-thinking-capabilities.mjs"),
|
||||
);
|
||||
|
||||
const nodeModules = join(repoRoot, "node_modules");
|
||||
if (existsSync(nodeModules)) {
|
||||
symlinkSync(nodeModules, join(baselineWorktree, "node_modules"), process.platform === "win32" ? "junction" : "dir");
|
||||
}
|
||||
|
||||
console.log("Generating catalog from HEAD...");
|
||||
generateCatalog(baselineWorktree, baselineOutput);
|
||||
formatProviderCatalogs(baselineOutput);
|
||||
console.log("Generating catalog from the current worktree...");
|
||||
generateCatalog(repoRoot, currentOutput, true);
|
||||
formatProviderCatalogs(currentOutput);
|
||||
|
||||
if (thinkingOnly) {
|
||||
console.log("Computing effective thinking capabilities...");
|
||||
generateThinkingCatalog(baselineWorktree, join(baselineOutput, "models.json"), baselineThinkingOutput);
|
||||
generateThinkingCatalog(repoRoot, join(currentOutput, "models.json"), currentThinkingOutput);
|
||||
}
|
||||
|
||||
const beforeProviders = JSON.parse(readFileSync(join(baselineOutput, "providers.json"), "utf8"));
|
||||
const afterProviders = JSON.parse(readFileSync(join(currentOutput, "providers.json"), "utf8"));
|
||||
const providers =
|
||||
requestedProviders.length > 0 ? requestedProviders : [...new Set([...beforeProviders, ...afterProviders])].sort();
|
||||
const beforeCatalogOutput = thinkingOnly ? baselineThinkingOutput : baselineOutput;
|
||||
const currentCatalogOutput = thinkingOnly ? currentThinkingOutput : currentOutput;
|
||||
const beforeModelPath = "before-model.json";
|
||||
const afterModelPath = "after-model.json";
|
||||
const changedModels = [];
|
||||
let differences = 0;
|
||||
|
||||
for (const provider of providers) {
|
||||
const beforeModels = readProviderCatalog(beforeCatalogOutput, provider);
|
||||
const afterModels = readProviderCatalog(currentCatalogOutput, provider);
|
||||
if (beforeModels === undefined && afterModels === undefined) {
|
||||
throw new Error(`Unknown provider: ${provider}`);
|
||||
}
|
||||
|
||||
const modelIds = [...new Set([...Object.keys(beforeModels ?? {}), ...Object.keys(afterModels ?? {})])].sort();
|
||||
for (const modelId of modelIds) {
|
||||
const beforeModel = beforeModels?.[modelId];
|
||||
const afterModel = afterModels?.[modelId];
|
||||
if (JSON.stringify(canonicalizeJson(beforeModel)) === JSON.stringify(canonicalizeJson(afterModel))) continue;
|
||||
|
||||
writeModelSnapshot(join(temporaryRoot, beforeModelPath), beforeModel);
|
||||
writeModelSnapshot(join(temporaryRoot, afterModelPath), afterModel);
|
||||
const result = runDiff(
|
||||
[
|
||||
"diff",
|
||||
"--no-index",
|
||||
"--no-ext-diff",
|
||||
"--color=always",
|
||||
"--unified=0",
|
||||
"--",
|
||||
beforeModelPath,
|
||||
afterModelPath,
|
||||
],
|
||||
temporaryRoot,
|
||||
);
|
||||
if (result.status === 1) {
|
||||
const changedModel = `${provider}/${modelId}`;
|
||||
console.log(`\n${changedModel}`);
|
||||
writeChangedLines(result.stdout);
|
||||
changedModels.push(changedModel);
|
||||
differences++;
|
||||
} else if (result.status !== 0) {
|
||||
throw new Error(`Could not compare ${provider}/${modelId}: ${result.stderr || result.stdout}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (differences === 0) {
|
||||
console.log(`No model catalog changes${requestedProviders.length === 1 ? ` for ${requestedProviders[0]}` : ""}.`);
|
||||
} else {
|
||||
console.log(`\n${differences} model catalog entr${differences === 1 ? "y" : "ies"} changed.`);
|
||||
for (const changedModel of changedModels) {
|
||||
console.log(`- ${changedModel}`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (worktreeAdded) {
|
||||
try {
|
||||
run("git", ["worktree", "remove", "--force", baselineWorktree], { cwd: repoRoot });
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
rmSync(temporaryRoot, { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1,834 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { createReadStream } from "node:fs";
|
||||
import { promises as fs } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { createInterface } from "node:readline";
|
||||
|
||||
const DEFAULT_SESSIONS_DIR = path.join(homedir(), ".pi/agent/sessions");
|
||||
const DEFAULT_ACTIVE_EDIT_EXTENSION_PATH = path.join(homedir(), ".pi/agent/extensions/edit.ts");
|
||||
const DEFAULT_TOP = 20;
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
sessionsDir: DEFAULT_SESSIONS_DIR,
|
||||
json: false,
|
||||
includeRecords: false,
|
||||
failedOnly: false,
|
||||
modelFilter: undefined,
|
||||
extFilter: undefined,
|
||||
top: DEFAULT_TOP,
|
||||
help: false,
|
||||
allSessions: false,
|
||||
since: undefined,
|
||||
autoSincePath: DEFAULT_ACTIVE_EDIT_EXTENSION_PATH,
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
options.help = true;
|
||||
} else if (arg === "--json") {
|
||||
options.json = true;
|
||||
} else if (arg === "--include-records") {
|
||||
options.includeRecords = true;
|
||||
} else if (arg === "--failed-only") {
|
||||
options.failedOnly = true;
|
||||
} else if (arg === "--model") {
|
||||
options.modelFilter = argv[++i];
|
||||
} else if (arg === "--ext") {
|
||||
options.extFilter = argv[++i]?.toLowerCase();
|
||||
} else if (arg === "--top") {
|
||||
const value = Number.parseInt(argv[++i] ?? "", 10);
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
throw new Error("--top must be a positive integer");
|
||||
}
|
||||
options.top = value;
|
||||
} else if (arg === "--sessions-dir") {
|
||||
options.sessionsDir = argv[++i];
|
||||
} else if (arg === "--all-sessions") {
|
||||
options.allSessions = true;
|
||||
} else if (arg === "--since") {
|
||||
options.since = argv[++i];
|
||||
} else if (arg === "--auto-since-path") {
|
||||
options.autoSincePath = argv[++i];
|
||||
} else {
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`Usage: node scripts/edit-tool-stats.mjs [options]
|
||||
|
||||
Options:
|
||||
--sessions-dir <path> Sessions directory (default: ~/.pi/agent/sessions)
|
||||
--model <substring> Filter provider/model by substring
|
||||
--ext <extension> Filter by file extension, e.g. .ts
|
||||
--failed-only Include only failed edit calls
|
||||
--top <n> Number of examples to show (default: ${DEFAULT_TOP})
|
||||
--since <iso> Only scan session files created at or after this ISO time
|
||||
--all-sessions Disable the automatic since filter
|
||||
--auto-since-path <p> Use birth time of this file for the automatic since filter
|
||||
--json Print JSON summary instead of human report
|
||||
--include-records Include raw records in JSON output
|
||||
-h, --help Show this help
|
||||
`);
|
||||
}
|
||||
|
||||
function parseSessionFileTimestamp(sessionFile) {
|
||||
const base = path.basename(sessionFile);
|
||||
const rawTimestamp = base.split("_")[0];
|
||||
if (!rawTimestamp) return null;
|
||||
const isoTimestamp = rawTimestamp.replace(/T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z$/, "T$1:$2:$3.$4Z");
|
||||
const ms = Date.parse(isoTimestamp);
|
||||
if (!Number.isFinite(ms)) return null;
|
||||
return ms;
|
||||
}
|
||||
|
||||
function formatIso(ms) {
|
||||
return new Date(ms).toISOString();
|
||||
}
|
||||
|
||||
async function resolveAutoSinceMs(options) {
|
||||
if (options.allSessions) return null;
|
||||
if (options.since) {
|
||||
const ms = Date.parse(options.since);
|
||||
if (!Number.isFinite(ms)) {
|
||||
throw new Error(`Invalid --since value: ${options.since}`);
|
||||
}
|
||||
return { ms, source: `--since ${options.since}` };
|
||||
}
|
||||
if (!options.autoSincePath) return null;
|
||||
try {
|
||||
const stats = await fs.stat(options.autoSincePath);
|
||||
const birthtimeMs = Number.isFinite(stats.birthtimeMs) && stats.birthtimeMs > 0 ? stats.birthtimeMs : stats.mtimeMs;
|
||||
if (!Number.isFinite(birthtimeMs) || birthtimeMs <= 0) return null;
|
||||
return { ms: birthtimeMs, source: `birth time of ${options.autoSincePath}` };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function* walkJsonlFiles(dir) {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
entries.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
yield* walkJsonlFiles(fullPath);
|
||||
} else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
|
||||
yield fullPath;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getPathExtension(filePath) {
|
||||
if (typeof filePath !== "string" || filePath.length === 0) return "[unknown]";
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
if (ext) return ext;
|
||||
const base = path.basename(filePath);
|
||||
if (base.startsWith(".") && !base.slice(1).includes(".")) return base.toLowerCase();
|
||||
return "[no_ext]";
|
||||
}
|
||||
|
||||
function utf8Bytes(value) {
|
||||
return Buffer.byteLength(value ?? "", "utf8");
|
||||
}
|
||||
|
||||
function longestCommonPrefixLength(a, b) {
|
||||
const max = Math.min(a.length, b.length);
|
||||
let index = 0;
|
||||
while (index < max && a[index] === b[index]) index++;
|
||||
return index;
|
||||
}
|
||||
|
||||
function longestCommonSuffixLength(a, b) {
|
||||
const max = Math.min(a.length, b.length);
|
||||
let index = 0;
|
||||
while (index < max && a[a.length - 1 - index] === b[b.length - 1 - index]) index++;
|
||||
return index;
|
||||
}
|
||||
|
||||
function analyzeReplacement(oldText, newText) {
|
||||
const prefixChars = longestCommonPrefixLength(oldText, newText);
|
||||
const oldRemainder = oldText.slice(prefixChars);
|
||||
const newRemainder = newText.slice(prefixChars);
|
||||
const suffixChars = longestCommonSuffixLength(oldRemainder, newRemainder);
|
||||
const oldCore = suffixChars > 0 ? oldRemainder.slice(0, -suffixChars) : oldRemainder;
|
||||
const newCore = suffixChars > 0 ? newRemainder.slice(0, -suffixChars) : newRemainder;
|
||||
const prefix = oldText.slice(0, prefixChars);
|
||||
const suffix = suffixChars > 0 ? oldRemainder.slice(-suffixChars) : "";
|
||||
|
||||
const oldBytes = utf8Bytes(oldText);
|
||||
const newBytes = utf8Bytes(newText);
|
||||
const sharedPrefixBytes = utf8Bytes(prefix);
|
||||
const sharedSuffixBytes = utf8Bytes(suffix);
|
||||
const sharedContextBytes = sharedPrefixBytes + sharedSuffixBytes;
|
||||
const coreOldBytes = utf8Bytes(oldCore);
|
||||
const coreNewBytes = utf8Bytes(newCore);
|
||||
const coreBytes = coreOldBytes + coreNewBytes;
|
||||
const totalEditBytes = oldBytes + newBytes;
|
||||
const wrapperPayloadBytes = totalEditBytes - coreBytes;
|
||||
const inflationRatio = coreBytes === 0 ? null : totalEditBytes / coreBytes;
|
||||
|
||||
return {
|
||||
oldBytes,
|
||||
newBytes,
|
||||
totalEditBytes,
|
||||
sharedPrefixBytes,
|
||||
sharedSuffixBytes,
|
||||
sharedContextBytes,
|
||||
coreOldBytes,
|
||||
coreNewBytes,
|
||||
coreBytes,
|
||||
wrapperPayloadBytes,
|
||||
inflationRatio,
|
||||
noCoreChange: coreBytes === 0,
|
||||
};
|
||||
}
|
||||
|
||||
function median(numbers) {
|
||||
return quantile(numbers, 0.5);
|
||||
}
|
||||
|
||||
function quantile(numbers, q) {
|
||||
const finite = numbers.filter((value) => Number.isFinite(value)).sort((a, b) => a - b);
|
||||
if (finite.length === 0) return null;
|
||||
if (finite.length === 1) return finite[0];
|
||||
const position = (finite.length - 1) * q;
|
||||
const lower = Math.floor(position);
|
||||
const upper = Math.ceil(position);
|
||||
if (lower === upper) return finite[lower];
|
||||
const weight = position - lower;
|
||||
return finite[lower] * (1 - weight) + finite[upper] * weight;
|
||||
}
|
||||
|
||||
function formatInt(value) {
|
||||
return new Intl.NumberFormat("en-US").format(value);
|
||||
}
|
||||
|
||||
function formatPercent(part, total) {
|
||||
if (total === 0) return "n/a";
|
||||
return `${((part / total) * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function formatRatio(value) {
|
||||
if (value === null) return "no-core-change";
|
||||
if (!Number.isFinite(value)) return "∞";
|
||||
if (value >= 100) return `${value.toFixed(0)}x`;
|
||||
if (value >= 10) return `${value.toFixed(1)}x`;
|
||||
return `${value.toFixed(2)}x`;
|
||||
}
|
||||
|
||||
function formatBytes(value) {
|
||||
if (value < 1024) return `${value}B`;
|
||||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(value >= 10 * 1024 ? 0 : 1)}KB`;
|
||||
return `${(value / (1024 * 1024)).toFixed(1)}MB`;
|
||||
}
|
||||
|
||||
function extractTextContent(content) {
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
return content
|
||||
.filter((block) => block?.type === "text" && typeof block.text === "string")
|
||||
.map((block) => block.text)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function classifyErrorKind(text, isError, matchedResult) {
|
||||
if (!matchedResult) return "missing_result";
|
||||
if (!isError) return null;
|
||||
const normalized = text.toLowerCase();
|
||||
if (normalized.includes("file not found")) return "file_not_found";
|
||||
if (normalized.includes("could not find the exact text")) return "not_found_exact_text";
|
||||
if (normalized.includes("found multiple occurrences") || /^found \d+ occurrences/m.test(normalized)) {
|
||||
return "multiple_occurrences";
|
||||
}
|
||||
if (normalized.includes("no changes made")) return "no_changes_made";
|
||||
if (normalized.includes("input is invalid")) return "invalid_input";
|
||||
if (normalized.includes("must not overlap")) return "overlapping_edits";
|
||||
if (normalized.includes("aborted")) return "aborted";
|
||||
return "other";
|
||||
}
|
||||
|
||||
function getArgStyle(args) {
|
||||
const hasEdits = Array.isArray(args?.edits);
|
||||
const hasOldText = typeof args?.oldText === "string" || typeof args?.newText === "string";
|
||||
const hasOldString = typeof args?.old_string === "string" || typeof args?.new_string === "string";
|
||||
if (hasEdits) return "edits";
|
||||
if (hasOldText && hasOldString) return "mixed";
|
||||
if (hasOldText) return "oldText/newText";
|
||||
if (hasOldString) return "old_string/new_string";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function analyzeToolArguments(args) {
|
||||
const normalizedArgs = args && typeof args === "object" ? args : {};
|
||||
const filePath = typeof normalizedArgs.path === "string" ? normalizedArgs.path : "";
|
||||
const extension = getPathExtension(filePath);
|
||||
const argStyle = getArgStyle(normalizedArgs);
|
||||
|
||||
if (Array.isArray(normalizedArgs.edits)) {
|
||||
const perEdit = normalizedArgs.edits.map((edit) =>
|
||||
analyzeReplacement(typeof edit?.oldText === "string" ? edit.oldText : "", typeof edit?.newText === "string" ? edit.newText : "")
|
||||
);
|
||||
const inflations = perEdit.map((edit) => edit.inflationRatio).filter((value) => value !== null);
|
||||
const totals = perEdit.reduce(
|
||||
(acc, edit) => ({
|
||||
oldBytes: acc.oldBytes + edit.oldBytes,
|
||||
newBytes: acc.newBytes + edit.newBytes,
|
||||
totalEditBytes: acc.totalEditBytes + edit.totalEditBytes,
|
||||
sharedPrefixBytes: acc.sharedPrefixBytes + edit.sharedPrefixBytes,
|
||||
sharedSuffixBytes: acc.sharedSuffixBytes + edit.sharedSuffixBytes,
|
||||
sharedContextBytes: acc.sharedContextBytes + edit.sharedContextBytes,
|
||||
coreOldBytes: acc.coreOldBytes + edit.coreOldBytes,
|
||||
coreNewBytes: acc.coreNewBytes + edit.coreNewBytes,
|
||||
coreBytes: acc.coreBytes + edit.coreBytes,
|
||||
wrapperPayloadBytes: acc.wrapperPayloadBytes + edit.wrapperPayloadBytes,
|
||||
noCoreChangeCount: acc.noCoreChangeCount + (edit.noCoreChange ? 1 : 0),
|
||||
}),
|
||||
{
|
||||
oldBytes: 0,
|
||||
newBytes: 0,
|
||||
totalEditBytes: 0,
|
||||
sharedPrefixBytes: 0,
|
||||
sharedSuffixBytes: 0,
|
||||
sharedContextBytes: 0,
|
||||
coreOldBytes: 0,
|
||||
coreNewBytes: 0,
|
||||
coreBytes: 0,
|
||||
wrapperPayloadBytes: 0,
|
||||
noCoreChangeCount: 0,
|
||||
}
|
||||
);
|
||||
return {
|
||||
path: filePath,
|
||||
extension,
|
||||
mode: "multi",
|
||||
argStyle,
|
||||
editsCount: normalizedArgs.edits.length,
|
||||
...totals,
|
||||
inflationRatio: totals.coreBytes === 0 ? null : totals.totalEditBytes / totals.coreBytes,
|
||||
medianEditInflationRatio: median(inflations),
|
||||
maxEditInflationRatio: inflations.length > 0 ? Math.max(...inflations) : null,
|
||||
perEdit,
|
||||
};
|
||||
}
|
||||
|
||||
const oldText = typeof normalizedArgs.oldText === "string" ? normalizedArgs.oldText : typeof normalizedArgs.old_string === "string" ? normalizedArgs.old_string : "";
|
||||
const newText = typeof normalizedArgs.newText === "string" ? normalizedArgs.newText : typeof normalizedArgs.new_string === "string" ? normalizedArgs.new_string : "";
|
||||
const replacement = analyzeReplacement(oldText, newText);
|
||||
return {
|
||||
path: filePath,
|
||||
extension,
|
||||
mode: "single",
|
||||
argStyle,
|
||||
editsCount: 1,
|
||||
...replacement,
|
||||
medianEditInflationRatio: replacement.inflationRatio,
|
||||
maxEditInflationRatio: replacement.inflationRatio,
|
||||
perEdit: [replacement],
|
||||
};
|
||||
}
|
||||
|
||||
function groupCounts(records, keyFn) {
|
||||
const counts = new Map();
|
||||
for (const record of records) {
|
||||
const key = keyFn(record);
|
||||
counts.set(key, (counts.get(key) ?? 0) + 1);
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
function collectInflations(records) {
|
||||
return records.map((record) => record.inflationRatio).filter((value) => value !== null);
|
||||
}
|
||||
|
||||
function summarizeGroups(records, keyFn) {
|
||||
const groups = new Map();
|
||||
for (const record of records) {
|
||||
const key = keyFn(record);
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key).push(record);
|
||||
}
|
||||
|
||||
return [...groups.entries()]
|
||||
.map(([key, group]) => {
|
||||
const resolved = group.filter((record) => record.success !== null);
|
||||
const success = resolved.filter((record) => record.success).length;
|
||||
const failed = resolved.filter((record) => record.success === false).length;
|
||||
const multi = group.filter((record) => record.mode === "multi").length;
|
||||
const inflations = collectInflations(group);
|
||||
return {
|
||||
key,
|
||||
calls: group.length,
|
||||
resolved: resolved.length,
|
||||
success,
|
||||
failed,
|
||||
unresolved: group.length - resolved.length,
|
||||
multi,
|
||||
multiRate: group.length === 0 ? null : multi / group.length,
|
||||
successRate: resolved.length === 0 ? null : success / resolved.length,
|
||||
medianInflation: quantile(inflations, 0.5),
|
||||
p95Inflation: quantile(inflations, 0.95),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.calls - a.calls || a.key.localeCompare(b.key));
|
||||
}
|
||||
|
||||
function buildSameFileClusterStats(records) {
|
||||
const groups = new Map();
|
||||
for (const record of records) {
|
||||
const key = `${record.sessionFile}::${record.assistantEntryId}::${record.path}`;
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key).push(record);
|
||||
}
|
||||
|
||||
const clusters = [...groups.values()].filter((group) => group.length >= 2);
|
||||
const assistantMessagesWithCluster = new Set(clusters.map((group) => `${group[0].sessionFile}::${group[0].assistantEntryId}`));
|
||||
const assistantMessagesWithMultiEdit = new Set(
|
||||
records
|
||||
.filter((record) => record.mode === "multi" && record.editsCount > 1)
|
||||
.map((record) => `${record.sessionFile}::${record.assistantEntryId}`)
|
||||
);
|
||||
const callsInsideClusters = clusters.reduce((sum, group) => sum + group.length, 0);
|
||||
|
||||
return {
|
||||
clustersCount: clusters.length,
|
||||
assistantMessagesWithCluster: assistantMessagesWithCluster.size,
|
||||
assistantMessagesWithMultiEdit: assistantMessagesWithMultiEdit.size,
|
||||
callsInsideClusters,
|
||||
averageCallsPerCluster: clusters.length === 0 ? null : callsInsideClusters / clusters.length,
|
||||
ratioClusterAssistantMessagesToMultiEditAssistantMessages:
|
||||
assistantMessagesWithMultiEdit.size === 0 ? null : assistantMessagesWithCluster.size / assistantMessagesWithMultiEdit.size,
|
||||
};
|
||||
}
|
||||
|
||||
function buildInflationBuckets(records) {
|
||||
const buckets = [
|
||||
{ key: "no_core_change", label: "no-core-change", test: (record) => record.inflationRatio === null },
|
||||
{ key: "lt4", label: "<4x", test: (record) => record.inflationRatio !== null && record.inflationRatio < 4 },
|
||||
{ key: "4to10", label: "4-10x", test: (record) => record.inflationRatio !== null && record.inflationRatio >= 4 && record.inflationRatio < 10 },
|
||||
{ key: "10to25", label: "10-25x", test: (record) => record.inflationRatio !== null && record.inflationRatio >= 10 && record.inflationRatio < 25 },
|
||||
{ key: "gte25", label: "25x+", test: (record) => record.inflationRatio !== null && record.inflationRatio >= 25 },
|
||||
];
|
||||
|
||||
return buckets.map((bucket) => {
|
||||
const bucketRecords = records.filter(bucket.test);
|
||||
const resolved = bucketRecords.filter((record) => record.success !== null);
|
||||
const failed = resolved.filter((record) => record.success === false).length;
|
||||
return {
|
||||
key: bucket.key,
|
||||
label: bucket.label,
|
||||
count: bucketRecords.length,
|
||||
resolved: resolved.length,
|
||||
failed,
|
||||
failureRate: resolved.length === 0 ? null : failed / resolved.length,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function buildHugeReplacementStats(records) {
|
||||
const thresholds = [1024, 4096, 16384, 65536];
|
||||
return thresholds.map((threshold) => ({
|
||||
threshold,
|
||||
count: records.filter((record) => record.totalEditBytes > threshold).length,
|
||||
}));
|
||||
}
|
||||
|
||||
function buildWorstExamples(records, top) {
|
||||
const scored = [...records].sort((a, b) => {
|
||||
const aScore = a.inflationRatio === null ? Number.POSITIVE_INFINITY : a.inflationRatio;
|
||||
const bScore = b.inflationRatio === null ? Number.POSITIVE_INFINITY : b.inflationRatio;
|
||||
if (aScore !== bScore) return bScore - aScore;
|
||||
if (a.totalEditBytes !== b.totalEditBytes) return b.totalEditBytes - a.totalEditBytes;
|
||||
return a.path.localeCompare(b.path);
|
||||
});
|
||||
|
||||
return scored.slice(0, top).map((record) => ({
|
||||
providerModel: record.providerModel,
|
||||
extension: record.extension,
|
||||
path: record.path,
|
||||
inflationRatio: record.inflationRatio,
|
||||
totalEditBytes: record.totalEditBytes,
|
||||
coreBytes: record.coreBytes,
|
||||
mode: record.mode,
|
||||
editsCount: record.editsCount,
|
||||
failed: record.success === false,
|
||||
errorKind: record.errorKind,
|
||||
sessionFile: record.sessionFile,
|
||||
}));
|
||||
}
|
||||
|
||||
function buildSummary(records, meta, options) {
|
||||
const uniqueAssistantMessages = new Set(records.map((record) => `${record.sessionFile}::${record.assistantEntryId}`)).size;
|
||||
const resolved = records.filter((record) => record.success !== null);
|
||||
const success = resolved.filter((record) => record.success).length;
|
||||
const failed = resolved.filter((record) => record.success === false).length;
|
||||
const unresolved = records.length - resolved.length;
|
||||
const single = records.filter((record) => record.mode === "single").length;
|
||||
const multi = records.filter((record) => record.mode === "multi").length;
|
||||
const modeStats = ["single", "multi"].map((mode) => {
|
||||
const modeRecords = records.filter((record) => record.mode === mode);
|
||||
const modeResolved = modeRecords.filter((record) => record.success !== null);
|
||||
const modeSuccess = modeResolved.filter((record) => record.success).length;
|
||||
const modeFailed = modeResolved.filter((record) => record.success === false).length;
|
||||
return {
|
||||
mode,
|
||||
calls: modeRecords.length,
|
||||
resolved: modeResolved.length,
|
||||
success: modeSuccess,
|
||||
failed: modeFailed,
|
||||
unresolved: modeRecords.length - modeResolved.length,
|
||||
successRate: modeResolved.length === 0 ? null : modeSuccess / modeResolved.length,
|
||||
failureRate: modeResolved.length === 0 ? null : modeFailed / modeResolved.length,
|
||||
};
|
||||
});
|
||||
const multiEditLengthBuckets = [
|
||||
{ key: "1", label: "edits.length === 1", test: (record) => record.mode === "multi" && record.editsCount === 1 },
|
||||
{ key: "2", label: "edits.length === 2", test: (record) => record.mode === "multi" && record.editsCount === 2 },
|
||||
{ key: "3plus", label: "edits.length >= 3", test: (record) => record.mode === "multi" && record.editsCount >= 3 },
|
||||
].map((bucket) => {
|
||||
const bucketRecords = records.filter(bucket.test);
|
||||
const bucketResolved = bucketRecords.filter((record) => record.success !== null);
|
||||
const bucketSuccess = bucketResolved.filter((record) => record.success).length;
|
||||
const bucketFailed = bucketResolved.filter((record) => record.success === false).length;
|
||||
return {
|
||||
key: bucket.key,
|
||||
label: bucket.label,
|
||||
calls: bucketRecords.length,
|
||||
resolved: bucketResolved.length,
|
||||
success: bucketSuccess,
|
||||
failed: bucketFailed,
|
||||
unresolved: bucketRecords.length - bucketResolved.length,
|
||||
successRate: bucketResolved.length === 0 ? null : bucketSuccess / bucketResolved.length,
|
||||
failureRate: bucketResolved.length === 0 ? null : bucketFailed / bucketResolved.length,
|
||||
};
|
||||
});
|
||||
const argStyles = [...groupCounts(records, (record) => record.argStyle).entries()]
|
||||
.map(([style, count]) => ({ style, count }))
|
||||
.sort((a, b) => b.count - a.count || a.style.localeCompare(b.style));
|
||||
const providerStats = summarizeGroups(records, (record) => record.providerModel);
|
||||
const extensionStats = summarizeGroups(records, (record) => record.extension);
|
||||
const inflations = collectInflations(records);
|
||||
const noCoreChange = records.filter((record) => record.inflationRatio === null).length;
|
||||
const pathologicalThresholds = [10, 25, 100].map((threshold) => ({
|
||||
threshold,
|
||||
count: records.filter((record) => record.inflationRatio !== null && record.inflationRatio >= threshold).length,
|
||||
}));
|
||||
const failureKinds = [...groupCounts(records.filter((record) => record.success === false), (record) => record.errorKind ?? "other").entries()]
|
||||
.map(([kind, count]) => ({ kind, count }))
|
||||
.sort((a, b) => b.count - a.count || a.kind.localeCompare(b.kind));
|
||||
|
||||
return {
|
||||
filters: {
|
||||
model: options.modelFilter ?? null,
|
||||
extension: options.extFilter ?? null,
|
||||
failedOnly: options.failedOnly,
|
||||
},
|
||||
scan: {
|
||||
sessionsDir: meta.sessionsDir,
|
||||
sessionFilesScanned: meta.sessionFilesScanned,
|
||||
sessionFilesIncluded: meta.sessionFilesIncluded,
|
||||
sessionFilesSkippedOlderThanSince: meta.sessionFilesSkippedOlderThanSince,
|
||||
sessionFilesWithEditCalls: meta.sessionFilesWithEditCalls,
|
||||
since: meta.since ? { ms: meta.since.ms, iso: formatIso(meta.since.ms), source: meta.since.source } : null,
|
||||
malformedLines: meta.malformedLines,
|
||||
unmatchedToolResults: meta.unmatchedToolResults,
|
||||
},
|
||||
counts: {
|
||||
assistantMessagesWithEditCalls: uniqueAssistantMessages,
|
||||
totalEditCalls: records.length,
|
||||
resolvedEditCalls: resolved.length,
|
||||
success,
|
||||
failed,
|
||||
unresolved,
|
||||
single,
|
||||
multi,
|
||||
noCoreChange,
|
||||
},
|
||||
modeStats,
|
||||
multiEditLengthBuckets,
|
||||
argStyles,
|
||||
providerStats,
|
||||
extensionStats,
|
||||
inflation: {
|
||||
median: quantile(inflations, 0.5),
|
||||
p90: quantile(inflations, 0.9),
|
||||
p95: quantile(inflations, 0.95),
|
||||
p99: quantile(inflations, 0.99),
|
||||
pathologicalThresholds,
|
||||
hugeReplacements: buildHugeReplacementStats(records),
|
||||
failureByBucket: buildInflationBuckets(records),
|
||||
},
|
||||
sameFileClusters: buildSameFileClusterStats(records),
|
||||
failureKinds,
|
||||
worstExamples: buildWorstExamples(records, options.top),
|
||||
};
|
||||
}
|
||||
|
||||
function printGroupTable(title, groups, formatter) {
|
||||
if (groups.length === 0) return;
|
||||
console.log(`\n${title}`);
|
||||
for (const group of groups) {
|
||||
console.log(formatter(group));
|
||||
}
|
||||
}
|
||||
|
||||
function printHumanReport(summary) {
|
||||
const { scan, counts, modeStats, multiEditLengthBuckets, argStyles, providerStats, extensionStats, inflation, sameFileClusters, failureKinds, worstExamples, filters } = summary;
|
||||
console.log(`Scanned ${formatInt(scan.sessionFilesIncluded)} session files in ${scan.sessionsDir}`);
|
||||
if (scan.since) {
|
||||
console.log(`Session filter: files created at or after ${scan.since.iso} (${scan.since.source})`);
|
||||
console.log(`Skipped older session files: ${formatInt(scan.sessionFilesSkippedOlderThanSince)} of ${formatInt(scan.sessionFilesScanned)}`);
|
||||
}
|
||||
console.log(`Found ${formatInt(counts.totalEditCalls)} edit tool calls in ${formatInt(counts.assistantMessagesWithEditCalls)} assistant messages`);
|
||||
if (filters.model || filters.extension || filters.failedOnly) {
|
||||
const filterParts = [];
|
||||
if (filters.model) filterParts.push(`model contains \"${filters.model}\"`);
|
||||
if (filters.extension) filterParts.push(`extension = ${filters.extension}`);
|
||||
if (filters.failedOnly) filterParts.push("failed only");
|
||||
console.log(`Filters: ${filterParts.join(", ")}`);
|
||||
}
|
||||
|
||||
console.log("\nSuccess rate");
|
||||
console.log(` success: ${formatInt(counts.success)} ${formatPercent(counts.success, counts.resolvedEditCalls)}`);
|
||||
console.log(` failed: ${formatInt(counts.failed)} ${formatPercent(counts.failed, counts.resolvedEditCalls)}`);
|
||||
console.log(` unresolved: ${formatInt(counts.unresolved)}`);
|
||||
|
||||
console.log("\nMode usage");
|
||||
console.log(` single replacement: ${formatInt(counts.single)} ${formatPercent(counts.single, counts.totalEditCalls)}`);
|
||||
console.log(` multi-edit (edits): ${formatInt(counts.multi)} ${formatPercent(counts.multi, counts.totalEditCalls)}`);
|
||||
|
||||
console.log("\nFailures by edit type");
|
||||
for (const mode of modeStats) {
|
||||
console.log(
|
||||
` ${mode.mode.padEnd(6)} calls=${formatInt(mode.calls).padStart(4)} success=${mode.successRate === null ? "n/a" : formatPercent(mode.success, mode.resolved).padStart(6)} failed=${mode.failureRate === null ? "n/a" : formatPercent(mode.failed, mode.resolved).padStart(6)} unresolved=${formatInt(mode.unresolved)}`
|
||||
);
|
||||
}
|
||||
|
||||
console.log("\nMulti-edit bucket split");
|
||||
for (const bucket of multiEditLengthBuckets) {
|
||||
console.log(
|
||||
` ${bucket.label.padEnd(20)} ${formatInt(bucket.calls).padStart(4)} calls success=${bucket.successRate === null ? "n/a" : formatPercent(bucket.success, bucket.resolved).padStart(6)} failed=${bucket.failureRate === null ? "n/a" : formatPercent(bucket.failed, bucket.resolved).padStart(6)}`
|
||||
);
|
||||
}
|
||||
|
||||
console.log("\nArgument style");
|
||||
for (const entry of argStyles) {
|
||||
console.log(` ${entry.style.padEnd(22)} ${formatInt(entry.count).padStart(8)} ${formatPercent(entry.count, counts.totalEditCalls)}`);
|
||||
}
|
||||
|
||||
printGroupTable("By provider/model", providerStats, (group) => {
|
||||
return [
|
||||
` ${group.key}`,
|
||||
` calls: ${formatInt(group.calls)}`,
|
||||
` success: ${group.successRate === null ? "n/a" : formatPercent(group.success, group.resolved)}`,
|
||||
` multi-edit: ${group.multiRate === null ? "n/a" : formatPercent(group.multi, group.calls)}`,
|
||||
` median inflation: ${formatRatio(group.medianInflation)}`,
|
||||
` p95 inflation: ${formatRatio(group.p95Inflation)}`,
|
||||
].join("\n");
|
||||
});
|
||||
|
||||
printGroupTable("By file extension", extensionStats, (group) => {
|
||||
return ` ${group.key.padEnd(10)} calls=${formatInt(group.calls).padStart(6)} success=${group.successRate === null ? "n/a" : formatPercent(group.success, group.resolved).padStart(6)} medianInflation=${formatRatio(group.medianInflation)}`;
|
||||
});
|
||||
|
||||
console.log("\nContext inflation");
|
||||
console.log(` median inflation: ${formatRatio(inflation.median)}`);
|
||||
console.log(` p90 inflation: ${formatRatio(inflation.p90)}`);
|
||||
console.log(` p95 inflation: ${formatRatio(inflation.p95)}`);
|
||||
console.log(` p99 inflation: ${formatRatio(inflation.p99)}`);
|
||||
console.log(` no-core-change: ${formatInt(counts.noCoreChange)}`);
|
||||
|
||||
console.log("\nHuge replacements");
|
||||
for (const entry of inflation.hugeReplacements) {
|
||||
console.log(` >${formatBytes(entry.threshold).padEnd(6)} ${formatInt(entry.count).padStart(8)}`);
|
||||
}
|
||||
|
||||
console.log("\nPathological wrappers");
|
||||
for (const entry of inflation.pathologicalThresholds) {
|
||||
console.log(` inflation >= ${String(entry.threshold).padEnd(3)}x ${formatInt(entry.count).padStart(8)}`);
|
||||
}
|
||||
|
||||
console.log("\nSame-file multi-call behavior");
|
||||
console.log(` assistant msgs with 2+ edit calls to same file: ${formatInt(sameFileClusters.assistantMessagesWithCluster)}`);
|
||||
console.log(` total same-file clusters: ${formatInt(sameFileClusters.clustersCount)}`);
|
||||
console.log(` calls inside those clusters: ${formatInt(sameFileClusters.callsInsideClusters)}`);
|
||||
console.log(` average calls per cluster: ${sameFileClusters.averageCallsPerCluster === null ? "n/a" : sameFileClusters.averageCallsPerCluster.toFixed(2)}`);
|
||||
console.log(` assistant msgs using one multi-edit call: ${formatInt(sameFileClusters.assistantMessagesWithMultiEdit)}`);
|
||||
console.log(` ratio multi-call / multi-edit assistant msgs: ${sameFileClusters.ratioClusterAssistantMessagesToMultiEditAssistantMessages === null ? "n/a" : sameFileClusters.ratioClusterAssistantMessagesToMultiEditAssistantMessages.toFixed(2)}`);
|
||||
|
||||
console.log("\nFailures by kind");
|
||||
if (failureKinds.length === 0) {
|
||||
console.log(" none");
|
||||
} else {
|
||||
for (const failure of failureKinds) {
|
||||
console.log(` ${failure.kind.padEnd(22)} ${formatInt(failure.count).padStart(8)}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\nFailure rate by inflation bucket");
|
||||
for (const bucket of inflation.failureByBucket) {
|
||||
console.log(` ${bucket.label.padEnd(14)} ${bucket.failureRate === null ? "n/a" : formatPercent(bucket.failed, bucket.resolved).padStart(6)} (${formatInt(bucket.count)} calls)`);
|
||||
}
|
||||
|
||||
console.log(`\nWorst ${formatInt(worstExamples.length)} examples`);
|
||||
for (let i = 0; i < worstExamples.length; i++) {
|
||||
const example = worstExamples[i];
|
||||
console.log(
|
||||
` ${i + 1}. ${example.providerModel} ${example.extension} inflation=${formatRatio(example.inflationRatio)} failed=${example.failed ? example.errorKind : "false"}`
|
||||
);
|
||||
console.log(` path: ${example.path}`);
|
||||
console.log(` totalEditBytes=${formatBytes(example.totalEditBytes)} coreBytes=${formatBytes(example.coreBytes)} mode=${example.mode} edits=${example.editsCount}`);
|
||||
}
|
||||
|
||||
if (scan.malformedLines > 0 || scan.unmatchedToolResults > 0) {
|
||||
console.log("\nParser notes");
|
||||
if (scan.malformedLines > 0) console.log(` malformed lines skipped: ${formatInt(scan.malformedLines)}`);
|
||||
if (scan.unmatchedToolResults > 0) console.log(` unmatched edit tool results: ${formatInt(scan.unmatchedToolResults)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function scanSessions(sessionsDir, since) {
|
||||
const records = [];
|
||||
const meta = {
|
||||
sessionsDir,
|
||||
sessionFilesScanned: 0,
|
||||
sessionFilesIncluded: 0,
|
||||
sessionFilesSkippedOlderThanSince: 0,
|
||||
sessionFilesWithEditCalls: 0,
|
||||
since,
|
||||
malformedLines: 0,
|
||||
unmatchedToolResults: 0,
|
||||
};
|
||||
|
||||
for await (const sessionFile of walkJsonlFiles(sessionsDir)) {
|
||||
meta.sessionFilesScanned++;
|
||||
const sessionTimestampMs = parseSessionFileTimestamp(sessionFile);
|
||||
if (since && sessionTimestampMs !== null && sessionTimestampMs < since.ms) {
|
||||
meta.sessionFilesSkippedOlderThanSince++;
|
||||
continue;
|
||||
}
|
||||
meta.sessionFilesIncluded++;
|
||||
const pending = new Map();
|
||||
let fileHadEditCall = false;
|
||||
const input = createReadStream(sessionFile, { encoding: "utf8" });
|
||||
const rl = createInterface({ input, crlfDelay: Infinity });
|
||||
|
||||
for await (const line of rl) {
|
||||
if (!line.trim()) continue;
|
||||
let entry;
|
||||
try {
|
||||
entry = JSON.parse(line);
|
||||
} catch {
|
||||
meta.malformedLines++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry?.type !== "message" || !entry.message) continue;
|
||||
const message = entry.message;
|
||||
|
||||
if (message.role === "assistant" && Array.isArray(message.content)) {
|
||||
for (const block of message.content) {
|
||||
if (block?.type !== "toolCall" || block.name !== "edit") continue;
|
||||
fileHadEditCall = true;
|
||||
const analysis = analyzeToolArguments(block.arguments);
|
||||
const record = {
|
||||
sessionFile,
|
||||
assistantEntryId: entry.id,
|
||||
toolCallId: typeof block.id === "string" ? block.id : "",
|
||||
timestamp: entry.timestamp,
|
||||
api: typeof message.api === "string" ? message.api : null,
|
||||
provider: typeof message.provider === "string" ? message.provider : "[unknown]",
|
||||
model: typeof message.model === "string" ? message.model : "[unknown]",
|
||||
providerModel: `${typeof message.provider === "string" ? message.provider : "[unknown]"}/${typeof message.model === "string" ? message.model : "[unknown]"}`,
|
||||
success: null,
|
||||
errorKind: null,
|
||||
errorText: "",
|
||||
resultSummary: "",
|
||||
matchedResult: false,
|
||||
...analysis,
|
||||
};
|
||||
records.push(record);
|
||||
if (record.toolCallId) pending.set(record.toolCallId, record);
|
||||
}
|
||||
}
|
||||
|
||||
if (message.role === "toolResult" && message.toolName === "edit") {
|
||||
const toolCallId = typeof message.toolCallId === "string" ? message.toolCallId : "";
|
||||
const record = pending.get(toolCallId);
|
||||
if (!record) {
|
||||
meta.unmatchedToolResults++;
|
||||
continue;
|
||||
}
|
||||
const text = extractTextContent(message.content);
|
||||
record.matchedResult = true;
|
||||
record.success = message.isError === true ? false : true;
|
||||
record.resultSummary = text;
|
||||
record.errorText = message.isError === true ? text : "";
|
||||
record.errorKind = classifyErrorKind(text, message.isError === true, true);
|
||||
pending.delete(toolCallId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const record of pending.values()) {
|
||||
record.matchedResult = false;
|
||||
record.success = null;
|
||||
record.errorKind = classifyErrorKind("", false, false);
|
||||
}
|
||||
|
||||
if (fileHadEditCall) meta.sessionFilesWithEditCalls++;
|
||||
}
|
||||
|
||||
return { records, meta };
|
||||
}
|
||||
|
||||
function applyFilters(records, options) {
|
||||
return records.filter((record) => {
|
||||
if (options.modelFilter && !record.providerModel.toLowerCase().includes(options.modelFilter.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
if (options.extFilter && record.extension !== options.extFilter) {
|
||||
return false;
|
||||
}
|
||||
if (options.failedOnly && record.success !== false) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (options.help) {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionsDir = path.resolve(options.sessionsDir);
|
||||
await fs.access(sessionsDir);
|
||||
const since = await resolveAutoSinceMs(options);
|
||||
|
||||
const { records, meta } = await scanSessions(sessionsDir, since);
|
||||
const filteredRecords = applyFilters(records, options);
|
||||
const summary = buildSummary(filteredRecords, meta, options);
|
||||
|
||||
if (options.json) {
|
||||
const output = options.includeRecords ? { summary, records: filteredRecords } : { summary };
|
||||
console.log(JSON.stringify(output, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
printHumanReport(summary);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,439 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join, posix, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = resolve(scriptDir, "..");
|
||||
const codingAgentDir = join(repoRoot, "packages/coding-agent");
|
||||
const outputDir = join(codingAgentDir, "install-lock");
|
||||
const rootLockfilePath = join(repoRoot, "package-lock.json");
|
||||
const outputPackageJsonPath = join(outputDir, "package.json");
|
||||
const outputLockfilePath = join(outputDir, "package-lock.json");
|
||||
const internalPackagePrefix = "@earendil-works/pi-";
|
||||
const installPackageName = "@earendil-works/pi-coding-agent-install";
|
||||
const allowedInstallScriptPackages = new Map([
|
||||
["@google/genai@1.52.0", "preinstall is a no-op in the published package"],
|
||||
["protobufjs@7.6.5", "postinstall only warns about protobufjs version scheme mismatches"],
|
||||
]);
|
||||
|
||||
const args = new Set(process.argv.slice(2));
|
||||
const checkOnly = args.has("--check");
|
||||
|
||||
for (const arg of args) {
|
||||
if (arg !== "--check") {
|
||||
console.error(`Unknown argument: ${arg}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function readJson(path) {
|
||||
return JSON.parse(readFileSync(path, "utf8"));
|
||||
}
|
||||
|
||||
function packageDependencies(entry) {
|
||||
return {
|
||||
...(entry.dependencies ?? {}),
|
||||
...(entry.optionalDependencies ?? {}),
|
||||
};
|
||||
}
|
||||
|
||||
function sortedObject(object) {
|
||||
return Object.fromEntries(Object.entries(object).sort(([a], [b]) => a.localeCompare(b)));
|
||||
}
|
||||
|
||||
function sortedPackageEntry(entry) {
|
||||
const fieldOrder = [
|
||||
"name",
|
||||
"version",
|
||||
"resolved",
|
||||
"integrity",
|
||||
"license",
|
||||
"dependencies",
|
||||
"optionalDependencies",
|
||||
"peerDependencies",
|
||||
"peerDependenciesMeta",
|
||||
"bin",
|
||||
"engines",
|
||||
"os",
|
||||
"cpu",
|
||||
"libc",
|
||||
"optional",
|
||||
"hasInstallScript",
|
||||
"deprecated",
|
||||
"funding",
|
||||
];
|
||||
const sorted = {};
|
||||
|
||||
for (const field of fieldOrder) {
|
||||
if (entry[field] !== undefined) {
|
||||
sorted[field] = entry[field];
|
||||
}
|
||||
}
|
||||
for (const [field, value] of Object.entries(entry).sort(([a], [b]) => a.localeCompare(b))) {
|
||||
if (sorted[field] === undefined) {
|
||||
sorted[field] = value;
|
||||
}
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
|
||||
function copyLockEntry(entry) {
|
||||
const copied = { ...entry };
|
||||
delete copied.dev;
|
||||
delete copied.devOptional;
|
||||
delete copied.extraneous;
|
||||
delete copied.link;
|
||||
return sortedPackageEntry(copied);
|
||||
}
|
||||
|
||||
function copyPackageJsonEntry(packageJson, options) {
|
||||
const entry = options.includeName
|
||||
? { name: packageJson.name, version: packageJson.version }
|
||||
: { version: packageJson.version };
|
||||
|
||||
for (const field of [
|
||||
"license",
|
||||
"dependencies",
|
||||
"optionalDependencies",
|
||||
"peerDependencies",
|
||||
"peerDependenciesMeta",
|
||||
"bin",
|
||||
"engines",
|
||||
"os",
|
||||
"cpu",
|
||||
"libc",
|
||||
]) {
|
||||
if (packageJson[field] !== undefined) {
|
||||
entry[field] = packageJson[field];
|
||||
}
|
||||
}
|
||||
|
||||
return sortedPackageEntry(entry);
|
||||
}
|
||||
|
||||
function packageNameFromLockPath(lockPath) {
|
||||
const marker = "node_modules/";
|
||||
const index = lockPath.lastIndexOf(marker);
|
||||
if (index === -1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parts = lockPath.slice(index + marker.length).split("/");
|
||||
if (parts[0]?.startsWith("@")) {
|
||||
return `${parts[0]}/${parts[1]}`;
|
||||
}
|
||||
return parts[0];
|
||||
}
|
||||
|
||||
function registryTarballUrl(packageName, version) {
|
||||
const tarballName = packageName.startsWith("@") ? packageName.split("/")[1] : packageName;
|
||||
return `https://registry.npmjs.org/${packageName}/-/${tarballName}-${version}.tgz`;
|
||||
}
|
||||
|
||||
function isExactVersionSpec(spec) {
|
||||
return /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(spec);
|
||||
}
|
||||
|
||||
function getInternalWorkspaces(lockPackages) {
|
||||
const workspaces = new Map();
|
||||
|
||||
for (const [lockPath, entry] of Object.entries(lockPackages)) {
|
||||
if (!lockPath.startsWith("packages/") || lockPath.includes("/node_modules/") || !entry.name || !entry.version) {
|
||||
continue;
|
||||
}
|
||||
if (!entry.name.startsWith(internalPackagePrefix)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
workspaces.set(entry.name, {
|
||||
lockPath,
|
||||
packageJson: readJson(join(repoRoot, lockPath, "package.json")),
|
||||
});
|
||||
}
|
||||
|
||||
return workspaces;
|
||||
}
|
||||
|
||||
function resolveExternalDependency(lockPackages, packageName, fromLockPath) {
|
||||
const candidateDirs = [];
|
||||
let current = fromLockPath;
|
||||
|
||||
while (current) {
|
||||
candidateDirs.push(current);
|
||||
const parent = posix.dirname(current);
|
||||
if (parent === "." || parent === current) {
|
||||
break;
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
candidateDirs.push("");
|
||||
|
||||
const tried = new Set();
|
||||
for (const directory of candidateDirs) {
|
||||
const candidate = directory ? `${directory}/node_modules/${packageName}` : `node_modules/${packageName}`;
|
||||
if (tried.has(candidate)) {
|
||||
continue;
|
||||
}
|
||||
tried.add(candidate);
|
||||
|
||||
const entry = lockPackages[candidate];
|
||||
if (entry && !entry.link) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
const suffix = `node_modules/${packageName}`;
|
||||
const matches = Object.entries(lockPackages)
|
||||
.filter(([lockPath, entry]) => !entry.link && (lockPath === suffix || lockPath.endsWith(`/${suffix}`)))
|
||||
.map(([lockPath]) => lockPath);
|
||||
|
||||
if (matches.length === 1) {
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Cannot resolve ${packageName} from ${fromLockPath || "root"}. ` +
|
||||
(matches.length > 1 ? `Matches: ${matches.join(", ")}` : "No matching lockfile entry found."),
|
||||
);
|
||||
}
|
||||
|
||||
function addInternalWorkspace(installLockPackages, addedPaths, queue, name, workspace) {
|
||||
const packageJson = workspace.packageJson;
|
||||
const outputPath = `node_modules/${name}`;
|
||||
const entry = copyPackageJsonEntry(packageJson, { includeName: false });
|
||||
entry.resolved = registryTarballUrl(name, packageJson.version);
|
||||
|
||||
installLockPackages[outputPath] = sortedPackageEntry(entry);
|
||||
addedPaths.add(outputPath);
|
||||
|
||||
for (const dependencyName of Object.keys(packageDependencies(packageJson))) {
|
||||
queue.push({ name: dependencyName, from: outputPath });
|
||||
}
|
||||
}
|
||||
|
||||
function addExternalPackage(lockPackages, installLockPackages, addedPaths, queue, name, from) {
|
||||
const lockPath = resolveExternalDependency(lockPackages, name, from);
|
||||
if (addedPaths.has(lockPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = lockPackages[lockPath];
|
||||
installLockPackages[lockPath] = copyLockEntry(entry);
|
||||
addedPaths.add(lockPath);
|
||||
|
||||
for (const dependencyName of Object.keys(packageDependencies(entry))) {
|
||||
queue.push({ name: dependencyName, from: lockPath });
|
||||
}
|
||||
}
|
||||
|
||||
function createInstallerPackageJson(codingAgentPackage) {
|
||||
const packageJson = {
|
||||
name: installPackageName,
|
||||
version: codingAgentPackage.version,
|
||||
private: true,
|
||||
description: "Lockfile root used by the Pi installer and updater.",
|
||||
dependencies: {
|
||||
[codingAgentPackage.name]: codingAgentPackage.version,
|
||||
},
|
||||
};
|
||||
if (codingAgentPackage.overrides) {
|
||||
packageJson.overrides = codingAgentPackage.overrides;
|
||||
}
|
||||
if (codingAgentPackage.engines) {
|
||||
packageJson.engines = codingAgentPackage.engines;
|
||||
}
|
||||
return packageJson;
|
||||
}
|
||||
|
||||
function createRootLockEntry(installerPackageJson) {
|
||||
const entry = {
|
||||
name: installerPackageJson.name,
|
||||
version: installerPackageJson.version,
|
||||
dependencies: installerPackageJson.dependencies,
|
||||
};
|
||||
if (installerPackageJson.engines) {
|
||||
entry.engines = installerPackageJson.engines;
|
||||
}
|
||||
return sortedPackageEntry(entry);
|
||||
}
|
||||
|
||||
function validateGeneratedFiles(installerPackageJson, installLock, internalNames) {
|
||||
const errors = [];
|
||||
const rootEntry = installLock.packages[""];
|
||||
const includedPackageNames = new Set();
|
||||
const seenAllowedInstallScriptPackages = new Set();
|
||||
|
||||
if (installLock.lockfileVersion !== 3) {
|
||||
errors.push("package-lock.json must use lockfileVersion 3");
|
||||
}
|
||||
if (installLock.name !== installerPackageJson.name) {
|
||||
errors.push(`lockfile name ${installLock.name} does not match package.json name ${installerPackageJson.name}`);
|
||||
}
|
||||
if (installLock.version !== installerPackageJson.version) {
|
||||
errors.push(
|
||||
`lockfile version ${installLock.version} does not match package.json version ${installerPackageJson.version}`,
|
||||
);
|
||||
}
|
||||
if (JSON.stringify(rootEntry?.dependencies ?? {}) !== JSON.stringify(installerPackageJson.dependencies)) {
|
||||
errors.push("lockfile root dependencies do not match package.json dependencies");
|
||||
}
|
||||
|
||||
for (const [lockPath, entry] of Object.entries(installLock.packages)) {
|
||||
const packageName = packageNameFromLockPath(lockPath);
|
||||
if (packageName) {
|
||||
includedPackageNames.add(packageName);
|
||||
}
|
||||
if (entry.link) {
|
||||
errors.push(`${lockPath} is a link entry`);
|
||||
}
|
||||
if (typeof entry.resolved === "string" && /^(file:|link:|workspace:|\.\.?\/|\/)/.test(entry.resolved)) {
|
||||
errors.push(`${lockPath} has a local resolved value: ${entry.resolved}`);
|
||||
}
|
||||
if (entry.dev || entry.devOptional || entry.extraneous) {
|
||||
errors.push(`${lockPath || "root"} contains dev/extraneous metadata`);
|
||||
}
|
||||
if (packageName?.startsWith(internalPackagePrefix) && entry.version !== installerPackageJson.version) {
|
||||
errors.push(`${lockPath} internal package version ${entry.version} does not match ${installerPackageJson.version}`);
|
||||
}
|
||||
if (entry.hasInstallScript) {
|
||||
if (!packageName || !entry.version) {
|
||||
errors.push(`${lockPath || "root"} has install scripts but no package name/version`);
|
||||
} else {
|
||||
const packageId = `${packageName}@${entry.version}`;
|
||||
if (allowedInstallScriptPackages.has(packageId)) {
|
||||
seenAllowedInstallScriptPackages.add(packageId);
|
||||
} else {
|
||||
errors.push(
|
||||
`${lockPath} has install scripts (${packageId}). Review it and add it to allowedInstallScriptPackages if intentional.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const packageId of allowedInstallScriptPackages.keys()) {
|
||||
if (!seenAllowedInstallScriptPackages.has(packageId)) {
|
||||
errors.push(`allowed install-script package ${packageId} is no longer present; remove it from the allowlist`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const name of internalNames) {
|
||||
if (!includedPackageNames.has(name)) {
|
||||
errors.push(`internal dependency ${name} is missing`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [lockPath, entry] of Object.entries(installLock.packages)) {
|
||||
for (const [dependencyName, dependencySpec] of Object.entries(packageDependencies(entry))) {
|
||||
let dependencyLockPath;
|
||||
try {
|
||||
dependencyLockPath = resolveExternalDependency(installLock.packages, dependencyName, lockPath);
|
||||
} catch {
|
||||
errors.push(`${lockPath || "root"} dependency ${dependencyName} is missing`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const dependencyEntry = installLock.packages[dependencyLockPath];
|
||||
if (isExactVersionSpec(dependencySpec) && dependencyEntry.version !== dependencySpec) {
|
||||
errors.push(
|
||||
`${lockPath || "root"} dependency ${dependencyName}@${dependencySpec} resolves to ${dependencyEntry.version}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const platformPackageCount = Object.values(installLock.packages).filter((entry) => entry.os || entry.cpu || entry.libc)
|
||||
.length;
|
||||
if (platformPackageCount === 0) {
|
||||
errors.push("no platform-specific optional dependency entries found");
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`Generated installer lock failed validation:\n${errors.map((error) => ` - ${error}`).join("\n")}`);
|
||||
}
|
||||
}
|
||||
|
||||
function generateInstallLock() {
|
||||
const rootLock = readJson(rootLockfilePath);
|
||||
if (rootLock.lockfileVersion !== 3 || !rootLock.packages) {
|
||||
throw new Error("package-lock.json must be lockfileVersion 3 and contain a packages map");
|
||||
}
|
||||
|
||||
const lockPackages = rootLock.packages;
|
||||
const codingAgentPackage = readJson(join(codingAgentDir, "package.json"));
|
||||
const installerPackageJson = createInstallerPackageJson(codingAgentPackage);
|
||||
const internalWorkspaces = getInternalWorkspaces(lockPackages);
|
||||
const installLockPackages = {
|
||||
"": createRootLockEntry(installerPackageJson),
|
||||
};
|
||||
const addedPaths = new Set([""]);
|
||||
const internalNames = new Set();
|
||||
const queue = Object.keys(packageDependencies(installerPackageJson)).map((name) => ({ name, from: "" }));
|
||||
|
||||
while (queue.length > 0) {
|
||||
const item = queue.shift();
|
||||
if (!item) {
|
||||
break;
|
||||
}
|
||||
|
||||
const workspace = internalWorkspaces.get(item.name);
|
||||
if (workspace) {
|
||||
const outputPath = `node_modules/${item.name}`;
|
||||
internalNames.add(item.name);
|
||||
if (!addedPaths.has(outputPath)) {
|
||||
addInternalWorkspace(installLockPackages, addedPaths, queue, item.name, workspace);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
addExternalPackage(lockPackages, installLockPackages, addedPaths, queue, item.name, item.from);
|
||||
}
|
||||
|
||||
const installLock = {
|
||||
name: installerPackageJson.name,
|
||||
version: installerPackageJson.version,
|
||||
lockfileVersion: 3,
|
||||
requires: true,
|
||||
packages: sortedObject(installLockPackages),
|
||||
};
|
||||
|
||||
validateGeneratedFiles(installerPackageJson, installLock, internalNames);
|
||||
return { installerPackageJson, installLock };
|
||||
}
|
||||
|
||||
try {
|
||||
const { installerPackageJson, installLock } = generateInstallLock();
|
||||
const packageJsonContent = `${JSON.stringify(installerPackageJson, null, "\t")}\n`;
|
||||
const lockfileContent = `${JSON.stringify(installLock, null, "\t")}\n`;
|
||||
|
||||
if (checkOnly) {
|
||||
if (!existsSync(outputPackageJsonPath) || !existsSync(outputLockfilePath)) {
|
||||
console.error("packages/coding-agent/install-lock is missing generated files.");
|
||||
console.error("Run: npm run install-lock:coding-agent");
|
||||
process.exit(1);
|
||||
}
|
||||
const currentPackageJson = readFileSync(outputPackageJsonPath, "utf8");
|
||||
const currentLockfile = readFileSync(outputLockfilePath, "utf8");
|
||||
if (currentPackageJson !== packageJsonContent || currentLockfile !== lockfileContent) {
|
||||
console.error("packages/coding-agent/install-lock is out of date.");
|
||||
console.error("Run: npm run install-lock:coding-agent");
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("packages/coding-agent/install-lock is up to date.");
|
||||
} else {
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
writeFileSync(outputPackageJsonPath, packageJsonContent);
|
||||
writeFileSync(outputLockfilePath, lockfileContent);
|
||||
const packageCount = Object.keys(installLock.packages).length - 1;
|
||||
const platformPackageCount = Object.values(installLock.packages).filter((entry) => entry.os || entry.cpu || entry.libc)
|
||||
.length;
|
||||
console.log(
|
||||
`Wrote packages/coding-agent/install-lock/package.json and package-lock.json (${packageCount} packages, ${platformPackageCount} platform-specific).`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join, posix, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = resolve(scriptDir, "..");
|
||||
const codingAgentDir = join(repoRoot, "packages/coding-agent");
|
||||
const rootLockfilePath = join(repoRoot, "package-lock.json");
|
||||
const shrinkwrapPath = join(codingAgentDir, "npm-shrinkwrap.json");
|
||||
const internalPackagePrefix = "@earendil-works/pi-";
|
||||
const allowedInstallScriptPackages = new Map([
|
||||
["@google/genai@1.52.0", "preinstall is a no-op in the published package"],
|
||||
["protobufjs@7.6.5", "postinstall only warns about protobufjs version scheme mismatches"],
|
||||
]);
|
||||
|
||||
const args = new Set(process.argv.slice(2));
|
||||
const checkOnly = args.has("--check");
|
||||
|
||||
for (const arg of args) {
|
||||
if (arg !== "--check") {
|
||||
console.error(`Unknown argument: ${arg}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function readJson(path) {
|
||||
return JSON.parse(readFileSync(path, "utf8"));
|
||||
}
|
||||
|
||||
function packageDependencies(entry) {
|
||||
return {
|
||||
...(entry.dependencies ?? {}),
|
||||
...(entry.optionalDependencies ?? {}),
|
||||
};
|
||||
}
|
||||
|
||||
function sortedObject(object) {
|
||||
return Object.fromEntries(Object.entries(object).sort(([a], [b]) => a.localeCompare(b)));
|
||||
}
|
||||
|
||||
function sortedPackageEntry(entry) {
|
||||
const fieldOrder = [
|
||||
"name",
|
||||
"version",
|
||||
"resolved",
|
||||
"integrity",
|
||||
"license",
|
||||
"dependencies",
|
||||
"optionalDependencies",
|
||||
"peerDependencies",
|
||||
"peerDependenciesMeta",
|
||||
"bin",
|
||||
"engines",
|
||||
"os",
|
||||
"cpu",
|
||||
"libc",
|
||||
"optional",
|
||||
"hasInstallScript",
|
||||
"deprecated",
|
||||
"funding",
|
||||
];
|
||||
const sorted = {};
|
||||
|
||||
for (const field of fieldOrder) {
|
||||
if (entry[field] !== undefined) {
|
||||
sorted[field] = entry[field];
|
||||
}
|
||||
}
|
||||
for (const [field, value] of Object.entries(entry).sort(([a], [b]) => a.localeCompare(b))) {
|
||||
if (sorted[field] === undefined) {
|
||||
sorted[field] = value;
|
||||
}
|
||||
}
|
||||
return sorted;
|
||||
}
|
||||
|
||||
function copyLockEntry(entry) {
|
||||
const copied = { ...entry };
|
||||
delete copied.dev;
|
||||
delete copied.devOptional;
|
||||
delete copied.extraneous;
|
||||
delete copied.link;
|
||||
return sortedPackageEntry(copied);
|
||||
}
|
||||
|
||||
function copyPackageJsonEntry(packageJson, options) {
|
||||
const entry = options.includeName
|
||||
? { name: packageJson.name, version: packageJson.version }
|
||||
: { version: packageJson.version };
|
||||
|
||||
for (const field of [
|
||||
"license",
|
||||
"dependencies",
|
||||
"optionalDependencies",
|
||||
"peerDependencies",
|
||||
"peerDependenciesMeta",
|
||||
"bin",
|
||||
"engines",
|
||||
"os",
|
||||
"cpu",
|
||||
"libc",
|
||||
]) {
|
||||
if (packageJson[field] !== undefined) {
|
||||
entry[field] = packageJson[field];
|
||||
}
|
||||
}
|
||||
|
||||
return sortedPackageEntry(entry);
|
||||
}
|
||||
|
||||
function packageNameFromLockPath(lockPath) {
|
||||
const marker = "node_modules/";
|
||||
const index = lockPath.lastIndexOf(marker);
|
||||
if (index === -1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parts = lockPath.slice(index + marker.length).split("/");
|
||||
if (parts[0]?.startsWith("@")) {
|
||||
return `${parts[0]}/${parts[1]}`;
|
||||
}
|
||||
return parts[0];
|
||||
}
|
||||
|
||||
function registryTarballUrl(packageName, version) {
|
||||
const tarballName = packageName.startsWith("@") ? packageName.split("/")[1] : packageName;
|
||||
return `https://registry.npmjs.org/${packageName}/-/${tarballName}-${version}.tgz`;
|
||||
}
|
||||
|
||||
function getInternalWorkspaces(lockPackages) {
|
||||
const workspaces = new Map();
|
||||
|
||||
for (const [lockPath, entry] of Object.entries(lockPackages)) {
|
||||
if (!lockPath.startsWith("packages/") || lockPath.includes("/node_modules/") || !entry.name || !entry.version) {
|
||||
continue;
|
||||
}
|
||||
if (!entry.name.startsWith(internalPackagePrefix)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
workspaces.set(entry.name, {
|
||||
lockPath,
|
||||
packageJson: readJson(join(repoRoot, lockPath, "package.json")),
|
||||
});
|
||||
}
|
||||
|
||||
return workspaces;
|
||||
}
|
||||
|
||||
function resolveExternalDependency(lockPackages, packageName, fromLockPath) {
|
||||
const candidateDirs = [];
|
||||
let current = fromLockPath;
|
||||
|
||||
while (current) {
|
||||
candidateDirs.push(current);
|
||||
const parent = posix.dirname(current);
|
||||
if (parent === "." || parent === current) {
|
||||
break;
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
candidateDirs.push("");
|
||||
|
||||
const tried = new Set();
|
||||
for (const directory of candidateDirs) {
|
||||
const candidate = directory ? `${directory}/node_modules/${packageName}` : `node_modules/${packageName}`;
|
||||
if (tried.has(candidate)) {
|
||||
continue;
|
||||
}
|
||||
tried.add(candidate);
|
||||
|
||||
const entry = lockPackages[candidate];
|
||||
if (entry && !entry.link) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
const suffix = `node_modules/${packageName}`;
|
||||
const matches = Object.entries(lockPackages)
|
||||
.filter(([lockPath, entry]) => !entry.link && (lockPath === suffix || lockPath.endsWith(`/${suffix}`)))
|
||||
.map(([lockPath]) => lockPath);
|
||||
|
||||
if (matches.length === 1) {
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Cannot resolve ${packageName} from ${fromLockPath || "root"}. ` +
|
||||
(matches.length > 1 ? `Matches: ${matches.join(", ")}` : "No matching lockfile entry found."),
|
||||
);
|
||||
}
|
||||
|
||||
function addInternalWorkspace(shrinkwrapPackages, addedPaths, queue, name, workspace) {
|
||||
const packageJson = workspace.packageJson;
|
||||
const outputPath = `node_modules/${name}`;
|
||||
const entry = copyPackageJsonEntry(packageJson, { includeName: false });
|
||||
entry.resolved = registryTarballUrl(name, packageJson.version);
|
||||
|
||||
shrinkwrapPackages[outputPath] = sortedPackageEntry(entry);
|
||||
addedPaths.add(outputPath);
|
||||
|
||||
for (const dependencyName of Object.keys(packageDependencies(packageJson))) {
|
||||
queue.push({ name: dependencyName, from: outputPath });
|
||||
}
|
||||
}
|
||||
|
||||
function addExternalPackage(lockPackages, shrinkwrapPackages, addedPaths, queue, name, from) {
|
||||
const lockPath = resolveExternalDependency(lockPackages, name, from);
|
||||
if (addedPaths.has(lockPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = lockPackages[lockPath];
|
||||
shrinkwrapPackages[lockPath] = copyLockEntry(entry);
|
||||
addedPaths.add(lockPath);
|
||||
|
||||
for (const dependencyName of Object.keys(packageDependencies(entry))) {
|
||||
queue.push({ name: dependencyName, from: lockPath });
|
||||
}
|
||||
}
|
||||
|
||||
function validateShrinkwrap(shrinkwrap, internalNames) {
|
||||
const errors = [];
|
||||
const includedPaths = new Set(Object.keys(shrinkwrap.packages));
|
||||
const includedPackageNames = new Set();
|
||||
const seenAllowedInstallScriptPackages = new Set();
|
||||
|
||||
for (const [lockPath, entry] of Object.entries(shrinkwrap.packages)) {
|
||||
const packageName = packageNameFromLockPath(lockPath);
|
||||
if (packageName) {
|
||||
includedPackageNames.add(packageName);
|
||||
}
|
||||
if (entry.link) {
|
||||
errors.push(`${lockPath} is a link entry`);
|
||||
}
|
||||
if (typeof entry.resolved === "string" && /^(file:|link:|workspace:|\.\.?\/|\/)/.test(entry.resolved)) {
|
||||
errors.push(`${lockPath} has a local resolved value: ${entry.resolved}`);
|
||||
}
|
||||
if (entry.hasInstallScript) {
|
||||
if (!packageName || !entry.version) {
|
||||
errors.push(`${lockPath || "root"} has install scripts but no package name/version`);
|
||||
} else {
|
||||
const packageId = `${packageName}@${entry.version}`;
|
||||
if (allowedInstallScriptPackages.has(packageId)) {
|
||||
seenAllowedInstallScriptPackages.add(packageId);
|
||||
} else {
|
||||
errors.push(
|
||||
`${lockPath} has install scripts (${packageId}). Review it and add it to allowedInstallScriptPackages if intentional.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const packageId of allowedInstallScriptPackages.keys()) {
|
||||
if (!seenAllowedInstallScriptPackages.has(packageId)) {
|
||||
errors.push(`allowed install-script package ${packageId} is no longer present; remove it from the allowlist`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const name of internalNames) {
|
||||
if (!includedPackageNames.has(name)) {
|
||||
errors.push(`internal dependency ${name} is missing`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [lockPath, entry] of Object.entries(shrinkwrap.packages)) {
|
||||
for (const dependencyName of Object.keys(packageDependencies(entry))) {
|
||||
const dependencyIncluded = [...includedPaths].some(
|
||||
(candidate) => candidate === `node_modules/${dependencyName}` || candidate.endsWith(`/node_modules/${dependencyName}`),
|
||||
);
|
||||
if (!dependencyIncluded) {
|
||||
errors.push(`${lockPath || "root"} dependency ${dependencyName} is missing`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const platformPackageCount = Object.values(shrinkwrap.packages).filter((entry) => entry.os || entry.cpu || entry.libc).length;
|
||||
if (platformPackageCount === 0) {
|
||||
errors.push("no platform-specific optional dependency entries found");
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`Generated shrinkwrap failed validation:\n${errors.map((error) => ` - ${error}`).join("\n")}`);
|
||||
}
|
||||
}
|
||||
|
||||
function generateShrinkwrap() {
|
||||
const rootLock = readJson(rootLockfilePath);
|
||||
if (rootLock.lockfileVersion !== 3 || !rootLock.packages) {
|
||||
throw new Error("package-lock.json must be lockfileVersion 3 and contain a packages map");
|
||||
}
|
||||
|
||||
const lockPackages = rootLock.packages;
|
||||
const codingAgentPackage = readJson(join(codingAgentDir, "package.json"));
|
||||
const internalWorkspaces = getInternalWorkspaces(lockPackages);
|
||||
const shrinkwrapPackages = {
|
||||
"": copyPackageJsonEntry(codingAgentPackage, { includeName: true }),
|
||||
};
|
||||
const addedPaths = new Set([""]);
|
||||
const internalNames = new Set();
|
||||
const queue = Object.keys(packageDependencies(codingAgentPackage)).map((name) => ({ name, from: "" }));
|
||||
|
||||
while (queue.length > 0) {
|
||||
const item = queue.shift();
|
||||
if (!item) {
|
||||
break;
|
||||
}
|
||||
|
||||
const workspace = internalWorkspaces.get(item.name);
|
||||
if (workspace) {
|
||||
const outputPath = `node_modules/${item.name}`;
|
||||
internalNames.add(item.name);
|
||||
if (!addedPaths.has(outputPath)) {
|
||||
addInternalWorkspace(shrinkwrapPackages, addedPaths, queue, item.name, workspace);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
addExternalPackage(lockPackages, shrinkwrapPackages, addedPaths, queue, item.name, item.from);
|
||||
}
|
||||
|
||||
const shrinkwrap = {
|
||||
name: codingAgentPackage.name,
|
||||
version: codingAgentPackage.version,
|
||||
lockfileVersion: 3,
|
||||
requires: true,
|
||||
packages: sortedObject(shrinkwrapPackages),
|
||||
};
|
||||
|
||||
validateShrinkwrap(shrinkwrap, internalNames);
|
||||
return shrinkwrap;
|
||||
}
|
||||
|
||||
try {
|
||||
const shrinkwrap = generateShrinkwrap();
|
||||
const content = `${JSON.stringify(shrinkwrap, null, "\t")}\n`;
|
||||
|
||||
if (checkOnly) {
|
||||
if (!existsSync(shrinkwrapPath)) {
|
||||
console.error("packages/coding-agent/npm-shrinkwrap.json is missing.");
|
||||
console.error("Run: npm run shrinkwrap:coding-agent");
|
||||
process.exit(1);
|
||||
}
|
||||
const current = readFileSync(shrinkwrapPath, "utf8");
|
||||
if (current !== content) {
|
||||
console.error("packages/coding-agent/npm-shrinkwrap.json is out of date.");
|
||||
console.error("Run: npm run shrinkwrap:coding-agent");
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("packages/coding-agent/npm-shrinkwrap.json is up to date.");
|
||||
} else {
|
||||
writeFileSync(shrinkwrapPath, content);
|
||||
const packageCount = Object.keys(shrinkwrap.packages).length - 1;
|
||||
const platformPackageCount = Object.values(shrinkwrap.packages).filter((entry) => entry.os || entry.cpu || entry.libc).length;
|
||||
console.log(
|
||||
`Wrote packages/coding-agent/npm-shrinkwrap.json (${packageCount} packages, ${platformPackageCount} platform-specific).`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { getSupportedThinkingLevels } from "../packages/ai/src/models.ts";
|
||||
|
||||
const [catalogPath, outputDir] = process.argv.slice(2);
|
||||
if (!catalogPath || !outputDir) {
|
||||
throw new Error("Usage: node scripts/generate-thinking-capabilities.mjs <catalog-path> <output-dir>");
|
||||
}
|
||||
|
||||
const catalog = JSON.parse(readFileSync(catalogPath, "utf8"));
|
||||
const providersDir = join(outputDir, "providers");
|
||||
mkdirSync(providersDir, { recursive: true });
|
||||
|
||||
for (const [provider, models] of Object.entries(catalog)) {
|
||||
const capabilities = Object.fromEntries(
|
||||
Object.entries(models).map(([id, model]) => {
|
||||
const levels = getSupportedThinkingLevels(model);
|
||||
const values = Object.fromEntries(
|
||||
levels.flatMap((level) => {
|
||||
const value = model.thinkingLevelMap?.[level];
|
||||
return value !== undefined && value !== level ? [[level, value]] : [];
|
||||
}),
|
||||
);
|
||||
return [id, Object.keys(values).length > 0 ? { levels, values } : { levels }];
|
||||
}),
|
||||
);
|
||||
writeFileSync(join(providersDir, `${provider}.json`), JSON.stringify(capabilities));
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { isAbsolute, join, relative, resolve } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
const packages = [
|
||||
{ directory: "packages/ai", name: "@earendil-works/pi-ai" },
|
||||
{ directory: "packages/tui", name: "@earendil-works/pi-tui" },
|
||||
{ directory: "packages/agent", name: "@earendil-works/pi-agent-core" },
|
||||
{ directory: "packages/storage/sqlite-node", name: "@earendil-works/pi-storage-sqlite-node" },
|
||||
{ directory: "packages/coding-agent", name: "@earendil-works/pi-coding-agent" },
|
||||
];
|
||||
|
||||
function printUsage() {
|
||||
console.log(`Usage: node scripts/local-release.mjs [options]
|
||||
|
||||
Builds and packs the publishable packages, then installs the tarballs into an
|
||||
isolated directory outside the repository for local release testing.
|
||||
|
||||
Options:
|
||||
--out <dir> Output directory. Defaults to a new directory under ${tmpdir()}
|
||||
--force Remove --out first if it already exists
|
||||
--skip-check Do not run npm run check before building
|
||||
--skip-test Do not run ./test.sh before building
|
||||
--skip-install Only create tarballs; do not create isolated installs
|
||||
--skip-bun-install Do not create the isolated Bun install
|
||||
--help Show this help
|
||||
`);
|
||||
}
|
||||
|
||||
function parseArgs() {
|
||||
const options = {
|
||||
force: false,
|
||||
outDir: undefined,
|
||||
skipBunInstall: false,
|
||||
skipCheck: false,
|
||||
skipInstall: false,
|
||||
skipTest: false,
|
||||
};
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (arg === "--help") {
|
||||
printUsage();
|
||||
process.exit(0);
|
||||
}
|
||||
if (arg === "--force") {
|
||||
options.force = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--skip-check") {
|
||||
options.skipCheck = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--skip-test") {
|
||||
options.skipTest = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--skip-install") {
|
||||
options.skipInstall = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--skip-bun-install") {
|
||||
options.skipBunInstall = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--out") {
|
||||
const value = args[++i];
|
||||
if (!value) {
|
||||
throw new Error("--out requires a directory");
|
||||
}
|
||||
options.outDir = value;
|
||||
continue;
|
||||
}
|
||||
throw new Error(`Unknown option: ${arg}`);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
console.log(`$ ${[command, ...args].join(" ")}`);
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: options.cwd,
|
||||
encoding: "utf8",
|
||||
shell: process.platform === "win32",
|
||||
stdio: options.capture ? ["inherit", "pipe", "inherit"] : "inherit",
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`Command failed: ${[command, ...args].join(" ")}`);
|
||||
}
|
||||
|
||||
return result.stdout ?? "";
|
||||
}
|
||||
|
||||
function readPackageJson(directory) {
|
||||
return JSON.parse(readFileSync(join(directory, "package.json"), "utf8"));
|
||||
}
|
||||
|
||||
function commandExists(command) {
|
||||
return spawnSync(command, ["--version"], { stdio: "ignore" }).status === 0;
|
||||
}
|
||||
|
||||
function isInsidePath(child, parent) {
|
||||
const relativePath = relative(parent, child);
|
||||
return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath));
|
||||
}
|
||||
|
||||
function prepareOutputDirectory(options, repoRoot) {
|
||||
if (!options.outDir) {
|
||||
return mkdtempSync(join(tmpdir(), "pi-local-release-"));
|
||||
}
|
||||
|
||||
const outDir = resolve(options.outDir);
|
||||
|
||||
if (isInsidePath(outDir, repoRoot)) {
|
||||
throw new Error(`Output directory must be outside the repository: ${outDir}`);
|
||||
}
|
||||
|
||||
if (existsSync(outDir)) {
|
||||
if (!options.force) {
|
||||
throw new Error(`Output directory already exists. Use --force to replace it: ${outDir}`);
|
||||
}
|
||||
rmSync(outDir, { force: true, recursive: true });
|
||||
}
|
||||
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
return outDir;
|
||||
}
|
||||
|
||||
function fileSpecifier(fromDirectory, file) {
|
||||
const relativePath = relative(fromDirectory, file).replaceAll("\\", "/");
|
||||
return `file:${relativePath.startsWith(".") ? relativePath : `./${relativePath}`}`;
|
||||
}
|
||||
|
||||
function currentBinaryPlatform() {
|
||||
if (process.platform === "win32") return process.arch === "arm64" ? "windows-arm64" : "windows-x64";
|
||||
if (process.platform === "darwin") return process.arch === "arm64" ? "darwin-arm64" : "darwin-x64";
|
||||
if (process.platform === "linux") return process.arch === "arm64" ? "linux-arm64" : "linux-x64";
|
||||
throw new Error(`Unsupported binary platform: ${process.platform} ${process.arch}`);
|
||||
}
|
||||
|
||||
function buildBunBinaryRelease(targetDirectory, archiveDirectory) {
|
||||
if (!commandExists("bun")) {
|
||||
throw new Error("Bun is required for the local binary release build.");
|
||||
}
|
||||
const platform = currentBinaryPlatform();
|
||||
const binaryBuildDirectory = join(archiveDirectory, "binary-build");
|
||||
run("./scripts/build-binaries.sh", [
|
||||
"--skip-install",
|
||||
"--skip-deps",
|
||||
"--skip-build",
|
||||
"--platform",
|
||||
platform,
|
||||
"--out",
|
||||
binaryBuildDirectory,
|
||||
]);
|
||||
rmSync(targetDirectory, { force: true, recursive: true });
|
||||
cpSync(join(binaryBuildDirectory, platform), targetDirectory, { recursive: true });
|
||||
const archiveName = platform.startsWith("windows-") ? `pi-${platform}.zip` : `pi-${platform}.tar.gz`;
|
||||
cpSync(join(binaryBuildDirectory, archiveName), join(archiveDirectory, archiveName));
|
||||
return platform;
|
||||
}
|
||||
|
||||
function createPiShim(installDirectory) {
|
||||
const binDirectory = join(installDirectory, "node_modules", ".bin");
|
||||
if (process.platform === "win32") {
|
||||
if (existsSync(join(binDirectory, "pi.cmd"))) {
|
||||
writeFileSync(join(installDirectory, "pi.cmd"), '@ECHO off\r\n"%~dp0node_modules\\.bin\\pi.cmd" %*\r\n');
|
||||
writeFileSync(join(installDirectory, "pi.ps1"), '& "$PSScriptRoot/node_modules/.bin/pi.ps1" @args\n');
|
||||
return;
|
||||
}
|
||||
writeFileSync(join(installDirectory, "pi.cmd"), '@ECHO off\r\n"%~dp0node_modules\\.bin\\pi.exe" %*\r\n');
|
||||
writeFileSync(join(installDirectory, "pi.ps1"), '& "$PSScriptRoot/node_modules/.bin/pi.exe" @args\n');
|
||||
return;
|
||||
}
|
||||
symlinkSync(join("node_modules", ".bin", "pi"), join(installDirectory, "pi"));
|
||||
}
|
||||
|
||||
function packPackage(pkg, tarballDirectory) {
|
||||
const packageJson = readPackageJson(pkg.directory);
|
||||
if (packageJson.name !== pkg.name) {
|
||||
throw new Error(`${pkg.directory}/package.json has name ${packageJson.name}, expected ${pkg.name}`);
|
||||
}
|
||||
|
||||
const output = run("npm", ["pack", "--json", "--pack-destination", tarballDirectory], {
|
||||
capture: true,
|
||||
cwd: pkg.directory,
|
||||
});
|
||||
const packed = JSON.parse(output)[0];
|
||||
return join(tarballDirectory, packed.filename);
|
||||
}
|
||||
|
||||
const options = parseArgs();
|
||||
const repoRoot = process.cwd();
|
||||
const rootPackageJson = readPackageJson(repoRoot);
|
||||
|
||||
if (rootPackageJson.name !== "pi-monorepo") {
|
||||
throw new Error("Run this script from the repository root");
|
||||
}
|
||||
|
||||
const outDir = prepareOutputDirectory(options, repoRoot);
|
||||
const tarballDirectory = join(outDir, "tarballs");
|
||||
const nodeInstallDirectory = join(outDir, "node");
|
||||
const bunInstallDirectory = join(outDir, "bun-install");
|
||||
const binaryDirectory = join(outDir, "bun");
|
||||
mkdirSync(tarballDirectory, { recursive: true });
|
||||
|
||||
// Release artifacts always use a freshly generated, strictly validated catalog,
|
||||
// including when checks or tests are explicitly skipped.
|
||||
run("npm", ["run", "generate:models"], { cwd: repoRoot });
|
||||
|
||||
if (!options.skipCheck) {
|
||||
run("npm", ["run", "check"], { cwd: repoRoot });
|
||||
}
|
||||
|
||||
for (const pkg of packages) {
|
||||
run("npm", ["run", "clean"], { cwd: pkg.directory });
|
||||
run("npm", ["run", pkg.directory === "packages/ai" ? "build:offline" : "build"], { cwd: pkg.directory });
|
||||
}
|
||||
|
||||
if (!options.skipTest) {
|
||||
run("./test.sh", [], { cwd: repoRoot });
|
||||
}
|
||||
|
||||
const tarballs = new Map();
|
||||
for (const pkg of packages) {
|
||||
const tarball = packPackage(pkg, tarballDirectory);
|
||||
tarballs.set(pkg.name, tarball);
|
||||
}
|
||||
|
||||
let binaryPlatform;
|
||||
if (!options.skipInstall) {
|
||||
binaryPlatform = buildBunBinaryRelease(binaryDirectory, outDir);
|
||||
|
||||
mkdirSync(nodeInstallDirectory, { recursive: true });
|
||||
const dependencies = Object.fromEntries(
|
||||
packages.map((pkg) => [pkg.name, fileSpecifier(nodeInstallDirectory, tarballs.get(pkg.name))]),
|
||||
);
|
||||
const installPackageJson = `${JSON.stringify({ private: true, dependencies, overrides: dependencies }, undefined, "\t")}\n`;
|
||||
writeFileSync(join(nodeInstallDirectory, "package.json"), installPackageJson);
|
||||
|
||||
run("npm", ["install", "--omit=dev", "--ignore-scripts"], { cwd: nodeInstallDirectory });
|
||||
createPiShim(nodeInstallDirectory);
|
||||
|
||||
if (!options.skipBunInstall) {
|
||||
if (!commandExists("bun")) {
|
||||
throw new Error("Bun is required for the isolated Bun install. Use --skip-bun-install to skip it.");
|
||||
}
|
||||
mkdirSync(bunInstallDirectory, { recursive: true });
|
||||
const bunDependencies = Object.fromEntries(
|
||||
packages.map((pkg) => [pkg.name, fileSpecifier(bunInstallDirectory, tarballs.get(pkg.name))]),
|
||||
);
|
||||
writeFileSync(join(bunInstallDirectory, "package.json"), `${JSON.stringify({ private: true, dependencies: bunDependencies, overrides: bunDependencies }, undefined, "\t")}\n`);
|
||||
run("bun", ["install", "--production", "--ignore-scripts"], { cwd: bunInstallDirectory });
|
||||
createPiShim(bunInstallDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\nLocal release artifacts created:");
|
||||
console.log(` ${outDir}`);
|
||||
console.log("\nTarballs:");
|
||||
for (const tarball of tarballs.values()) {
|
||||
console.log(` ${tarball}`);
|
||||
}
|
||||
|
||||
if (!options.skipInstall) {
|
||||
console.log("\nLocal Bun binary release:");
|
||||
console.log(` ${binaryDirectory}`);
|
||||
console.log(` ${join(outDir, `pi-${binaryPlatform}.${String(binaryPlatform).startsWith("windows-") ? "zip" : "tar.gz"}`)}`);
|
||||
console.log("\nRun the local Bun binary release from outside the repository:");
|
||||
console.log(` ${join(binaryDirectory, String(binaryPlatform).startsWith("windows-") ? "pi.exe" : "pi")} --help`);
|
||||
|
||||
console.log("\nIsolated npm install:");
|
||||
console.log(` ${nodeInstallDirectory}`);
|
||||
console.log("\nRun the locally packed npm CLI from outside the repository:");
|
||||
console.log(` ${join(nodeInstallDirectory, process.platform === "win32" ? "pi.cmd" : "pi")} --help`);
|
||||
|
||||
if (!options.skipBunInstall) {
|
||||
console.log("\nIsolated Bun package install:");
|
||||
console.log(` ${bunInstallDirectory}`);
|
||||
console.log("\nRun the locally packed Bun package CLI from outside the repository:");
|
||||
console.log(` ${join(bunInstallDirectory, process.platform === "win32" ? "pi.cmd" : "pi")} --help`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { existsSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const SKIPPED_DIRECTORIES = new Set(["dist", "node_modules"]);
|
||||
|
||||
export function findPackageDirectories(root = "packages") {
|
||||
const packageDirectories = [];
|
||||
|
||||
function visit(directory) {
|
||||
if (existsSync(join(directory, "package.json"))) {
|
||||
packageDirectories.push(directory);
|
||||
}
|
||||
|
||||
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory() || SKIPPED_DIRECTORIES.has(entry.name)) {
|
||||
continue;
|
||||
}
|
||||
visit(join(directory, entry.name));
|
||||
}
|
||||
}
|
||||
|
||||
visit(root);
|
||||
return packageDirectories.sort();
|
||||
}
|
||||
@@ -0,0 +1,639 @@
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
|
||||
import { spawn } from "node:child_process";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, join, relative, resolve } from "node:path";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = resolve(__dirname, "..");
|
||||
const packageDir = join(repoRoot, "packages", "coding-agent");
|
||||
const distCliPath = join(packageDir, "dist", "cli.js");
|
||||
const srcCliPath = join(packageDir, "src", "cli.ts");
|
||||
const defaultNodeProfileDir = join(repoRoot, "profiles-node");
|
||||
const defaultBunProfileDir = join(repoRoot, "profiles-bun");
|
||||
const agentDirEnvName = "PI_CODING_AGENT_DIR";
|
||||
const startupBenchmarkEnvName = "PI_STARTUP_BENCHMARK";
|
||||
|
||||
function printHelp() {
|
||||
console.log(`Usage:
|
||||
node scripts/profile-coding-agent-node.mjs [options]
|
||||
|
||||
Profiles coding-agent startup with the runtime selected below:
|
||||
- npm run profile:tui -> builds packages/coding-agent and profiles TUI startup with Node
|
||||
- npm run profile:rpc -> builds packages/coding-agent and profiles RPC startup with Node
|
||||
- bun run profile:tui -> profiles TUI startup from src/cli.ts directly with Bun
|
||||
- bun run profile:rpc -> profiles RPC startup from src/cli.ts directly with Bun
|
||||
|
||||
Options:
|
||||
--mode <name> tui or rpc (default: tui)
|
||||
--runs <n> Number of measured runs (default: 1)
|
||||
--warmup <n> Number of warmup runs before measurements (default: 0)
|
||||
--profile-dir <dir> CPU profile output directory
|
||||
Default: profiles-node for Node, profiles-bun for Bun
|
||||
--label <name> Profile name prefix (default: <mode>-startup)
|
||||
--runtime <name> node, bun, or auto (default: auto)
|
||||
--agent-dir <dir> Use a specific PI_CODING_AGENT_DIR for the benchmark run
|
||||
--isolated-agent-dir Use a fresh temporary agent dir instead of the normal one
|
||||
--no-offline Do not force PI_OFFLINE=1 / PI_SKIP_VERSION_CHECK=1
|
||||
--skip-build Reuse the current dist/cli.js without rebuilding first (Node only)
|
||||
--cpu-profile Write CPU profiles for benchmark runs
|
||||
--help Show this help
|
||||
|
||||
Notes:
|
||||
- By default the benchmark uses your normal configured agent dir, so global models/auth/settings work.
|
||||
- TUI mode measures startup until the interactive UI reaches first usable state.
|
||||
- RPC mode measures startup until a real get_state request receives a response, then closes stdin to exit cleanly.
|
||||
- CPU profiles are kept in the selected profile directory for later analysis.
|
||||
`);
|
||||
}
|
||||
|
||||
function parseIntegerFlag(value, name) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
throw new Error(`Invalid ${name}: ${value}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseRuntime(value) {
|
||||
if (value === "auto" || value === "node" || value === "bun") {
|
||||
return value;
|
||||
}
|
||||
throw new Error(`Invalid --runtime: ${value}`);
|
||||
}
|
||||
|
||||
function parseMode(value) {
|
||||
if (value === "tui" || value === "rpc") {
|
||||
return value;
|
||||
}
|
||||
throw new Error(`Invalid --mode: ${value}`);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
mode: "tui",
|
||||
runs: 1,
|
||||
warmup: 0,
|
||||
profileDir: undefined,
|
||||
label: undefined,
|
||||
offline: true,
|
||||
build: true,
|
||||
runtime: "auto",
|
||||
agentDir: undefined,
|
||||
isolatedAgentDir: false,
|
||||
cpuProfile: false,
|
||||
};
|
||||
|
||||
for (let index = 0; index < argv.length; index++) {
|
||||
const arg = argv[index];
|
||||
|
||||
if (arg === "--help" || arg === "-h") {
|
||||
options.help = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--no-offline") {
|
||||
options.offline = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--isolated-agent-dir") {
|
||||
options.isolatedAgentDir = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--skip-build") {
|
||||
options.build = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--cpu-profile") {
|
||||
options.cpuProfile = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
(arg === "--mode" ||
|
||||
arg === "--runs" ||
|
||||
arg === "--warmup" ||
|
||||
arg === "--profile-dir" ||
|
||||
arg === "--label" ||
|
||||
arg === "--runtime" ||
|
||||
arg === "--agent-dir") &&
|
||||
index + 1 >= argv.length
|
||||
) {
|
||||
throw new Error(`Missing value for ${arg}`);
|
||||
}
|
||||
|
||||
if (arg === "--mode") {
|
||||
options.mode = parseMode(argv[++index]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--runs") {
|
||||
options.runs = parseIntegerFlag(argv[++index], "--runs");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--warmup") {
|
||||
options.warmup = parseIntegerFlag(argv[++index], "--warmup");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--profile-dir") {
|
||||
options.profileDir = resolve(argv[++index]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--label") {
|
||||
options.label = argv[++index];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--runtime") {
|
||||
options.runtime = parseRuntime(argv[++index]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (arg === "--agent-dir") {
|
||||
options.agentDir = resolve(argv[++index]);
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Error(`Unknown option: ${arg}`);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function detectRuntimeFromPackageManager() {
|
||||
const userAgent = process.env.npm_config_user_agent ?? "";
|
||||
return userAgent.startsWith("bun/") ? "bun" : "node";
|
||||
}
|
||||
|
||||
function resolveRuntime(requestedRuntime) {
|
||||
if (requestedRuntime === "auto") {
|
||||
return detectRuntimeFromPackageManager();
|
||||
}
|
||||
return requestedRuntime;
|
||||
}
|
||||
|
||||
function resolveProfileDir(runtime, requestedProfileDir) {
|
||||
if (requestedProfileDir) {
|
||||
return requestedProfileDir;
|
||||
}
|
||||
return runtime === "bun" ? defaultBunProfileDir : defaultNodeProfileDir;
|
||||
}
|
||||
|
||||
function resolveLabel(mode, requestedLabel) {
|
||||
return requestedLabel ?? `${mode}-startup`;
|
||||
}
|
||||
|
||||
function formatMs(value) {
|
||||
return `${value.toFixed(1)}ms`;
|
||||
}
|
||||
|
||||
function toDisplayPath(path) {
|
||||
const relativePath = relative(repoRoot, path);
|
||||
if (relativePath !== "" && !relativePath.startsWith("..")) {
|
||||
return relativePath.replaceAll("\\", "/");
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
function summarize(values) {
|
||||
const sorted = [...values].sort((a, b) => a - b);
|
||||
const total = sorted.reduce((sum, value) => sum + value, 0);
|
||||
const middle = Math.floor(sorted.length / 2);
|
||||
const median = sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle];
|
||||
return {
|
||||
min: sorted[0],
|
||||
max: sorted[sorted.length - 1],
|
||||
avg: total / sorted.length,
|
||||
median,
|
||||
};
|
||||
}
|
||||
|
||||
function parseStartupTimings(stderr) {
|
||||
const lines = stderr.split(/\r?\n/);
|
||||
const timings = new Map();
|
||||
let inBlock = false;
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.includes("--- Startup Timings ---")) {
|
||||
inBlock = true;
|
||||
continue;
|
||||
}
|
||||
if (!inBlock) {
|
||||
continue;
|
||||
}
|
||||
if (line.includes("------------------------")) {
|
||||
break;
|
||||
}
|
||||
const match = line.match(/^\s+([^:]+):\s+(\d+)ms$/);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
timings.set(match[1], Number.parseInt(match[2], 10));
|
||||
}
|
||||
|
||||
return timings;
|
||||
}
|
||||
|
||||
function summarizeTimingMaps(runs) {
|
||||
const valuesByLabel = new Map();
|
||||
for (const run of runs) {
|
||||
for (const [label, value] of run.timings.entries()) {
|
||||
const values = valuesByLabel.get(label);
|
||||
if (values) {
|
||||
values.push(value);
|
||||
} else {
|
||||
valuesByLabel.set(label, [value]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const summaries = new Map();
|
||||
for (const [label, values] of valuesByLabel.entries()) {
|
||||
summaries.set(label, summarize(values));
|
||||
}
|
||||
return summaries;
|
||||
}
|
||||
|
||||
function toMetricName(label) {
|
||||
return `${label.replaceAll(/[^a-zA-Z0-9]+/g, "_").replaceAll(/^_+|_+$/g, "")}_ms`;
|
||||
}
|
||||
|
||||
async function waitForExit(child, errorPrefix) {
|
||||
return await new Promise((resolve, reject) => {
|
||||
child.once("error", reject);
|
||||
child.once("exit", (code, signal) => {
|
||||
if (signal) {
|
||||
reject(new Error(`${errorPrefix} exited from signal ${signal}`));
|
||||
return;
|
||||
}
|
||||
resolve(code ?? 0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function runBuild() {
|
||||
process.stdout.write("Building packages/tui, packages/ai, packages/agent, and packages/coding-agent...\n");
|
||||
const startedAt = performance.now();
|
||||
const child = spawn(
|
||||
"npm",
|
||||
[
|
||||
"run",
|
||||
"build",
|
||||
"--workspace",
|
||||
"packages/tui",
|
||||
"--workspace",
|
||||
"packages/ai",
|
||||
"--workspace",
|
||||
"packages/agent",
|
||||
"--workspace",
|
||||
"packages/coding-agent",
|
||||
],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
env: process.env,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
shell: process.platform === "win32",
|
||||
},
|
||||
);
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdout += chunk;
|
||||
});
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
|
||||
const exitCode = await waitForExit(child, "Build");
|
||||
if (exitCode !== 0) {
|
||||
if (stdout.trim()) {
|
||||
process.stdout.write(`${stdout}${stdout.endsWith("\n") ? "" : "\n"}`);
|
||||
}
|
||||
if (stderr.trim()) {
|
||||
process.stderr.write(`${stderr}${stderr.endsWith("\n") ? "" : "\n"}`);
|
||||
}
|
||||
throw new Error(`Build failed with exit code ${exitCode}`);
|
||||
}
|
||||
|
||||
process.stdout.write(`Build completed in ${formatMs(performance.now() - startedAt)}\n`);
|
||||
}
|
||||
|
||||
function getRuntimeCommand(runtime, mode, profileDir, profileName, cpuProfile) {
|
||||
const benchmarkArgs = ["--no-session"];
|
||||
if (mode === "rpc") {
|
||||
benchmarkArgs.push("--mode", "rpc");
|
||||
}
|
||||
|
||||
if (runtime === "bun") {
|
||||
const args = [];
|
||||
if (cpuProfile) {
|
||||
args.push("--cpu-prof", `--cpu-prof-dir=${profileDir}`, `--cpu-prof-name=${profileName}`);
|
||||
}
|
||||
args.push(srcCliPath, ...benchmarkArgs);
|
||||
return {
|
||||
executable: "bun",
|
||||
args,
|
||||
};
|
||||
}
|
||||
|
||||
const args = [];
|
||||
if (cpuProfile) {
|
||||
args.push("--cpu-prof", `--cpu-prof-dir=${profileDir}`, `--cpu-prof-name=${profileName}`);
|
||||
}
|
||||
args.push(distCliPath, ...benchmarkArgs);
|
||||
return {
|
||||
executable: process.execPath,
|
||||
args,
|
||||
};
|
||||
}
|
||||
|
||||
function createBenchmarkEnv(options, isolatedAgentDir) {
|
||||
const env = { ...process.env };
|
||||
if (options.agentDir) {
|
||||
env[agentDirEnvName] = options.agentDir;
|
||||
} else if (isolatedAgentDir) {
|
||||
env[agentDirEnvName] = isolatedAgentDir;
|
||||
}
|
||||
if (options.mode === "tui") {
|
||||
env[startupBenchmarkEnvName] = "1";
|
||||
}
|
||||
if (options.offline) {
|
||||
env.PI_OFFLINE = "1";
|
||||
env.PI_SKIP_VERSION_CHECK = "1";
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
async function runTuiBenchmarkRun({ runtime, runIndex, measuredIndex, options, profileDir }) {
|
||||
const runNumber = runIndex + 1;
|
||||
const suffix = String(runNumber).padStart(3, "0");
|
||||
const profileName = `${options.label}-${suffix}.cpuprofile`;
|
||||
const tempRoot = options.isolatedAgentDir ? mkdtempSync(join(tmpdir(), "pi-startup-benchmark-")) : undefined;
|
||||
const isolatedAgentDir = tempRoot ? join(tempRoot, "agent") : undefined;
|
||||
if (isolatedAgentDir) {
|
||||
mkdirSync(isolatedAgentDir, { recursive: true });
|
||||
}
|
||||
|
||||
const command = getRuntimeCommand(runtime, "tui", profileDir, profileName, options.cpuProfile);
|
||||
const child = spawn(command.executable, command.args, {
|
||||
cwd: packageDir,
|
||||
env: createBenchmarkEnv(options, isolatedAgentDir),
|
||||
stdio: ["inherit", "ignore", "pipe"],
|
||||
shell: process.platform === "win32" && runtime === "bun",
|
||||
});
|
||||
|
||||
let stderr = "";
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
|
||||
const startedAt = performance.now();
|
||||
const exitCode = await waitForExit(child, `Benchmark ${measuredIndex === undefined ? `warmup ${runNumber}` : `run ${measuredIndex}`}`);
|
||||
const elapsedMs = performance.now() - startedAt;
|
||||
|
||||
try {
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(stderr.trim() || `Benchmark child exited with code ${exitCode}`);
|
||||
}
|
||||
|
||||
const profilePath = options.cpuProfile ? join(profileDir, profileName) : undefined;
|
||||
if (profilePath && !existsSync(profilePath)) {
|
||||
throw new Error(`CPU profile was not written: ${profilePath}`);
|
||||
}
|
||||
|
||||
return { elapsedMs, profilePath, timings: parseStartupTimings(stderr) };
|
||||
} finally {
|
||||
if (tempRoot) {
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function splitJsonLines(buffer, onLine) {
|
||||
let remaining = buffer;
|
||||
while (true) {
|
||||
const newlineIndex = remaining.indexOf("\n");
|
||||
if (newlineIndex === -1) {
|
||||
return remaining;
|
||||
}
|
||||
const line = remaining.slice(0, newlineIndex);
|
||||
onLine(line.endsWith("\r") ? line.slice(0, -1) : line);
|
||||
remaining = remaining.slice(newlineIndex + 1);
|
||||
}
|
||||
}
|
||||
|
||||
async function runRpcBenchmarkRun({ runtime, runIndex, measuredIndex, options, profileDir }) {
|
||||
const runNumber = runIndex + 1;
|
||||
const suffix = String(runNumber).padStart(3, "0");
|
||||
const profileName = `${options.label}-${suffix}.cpuprofile`;
|
||||
const tempRoot = options.isolatedAgentDir ? mkdtempSync(join(tmpdir(), "pi-startup-benchmark-")) : undefined;
|
||||
const isolatedAgentDir = tempRoot ? join(tempRoot, "agent") : undefined;
|
||||
if (isolatedAgentDir) {
|
||||
mkdirSync(isolatedAgentDir, { recursive: true });
|
||||
}
|
||||
|
||||
const command = getRuntimeCommand(runtime, "rpc", profileDir, profileName, options.cpuProfile);
|
||||
const child = spawn(command.executable, command.args, {
|
||||
cwd: packageDir,
|
||||
env: createBenchmarkEnv(options, isolatedAgentDir),
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
shell: process.platform === "win32" && runtime === "bun",
|
||||
});
|
||||
|
||||
let stdoutBuffer = "";
|
||||
let stderr = "";
|
||||
let readyElapsedMs;
|
||||
let responseError;
|
||||
const requestId = `startup-benchmark-${runNumber}`;
|
||||
const startedAt = performance.now();
|
||||
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stdout.on("data", (chunk) => {
|
||||
stdoutBuffer = splitJsonLines(stdoutBuffer + chunk, (line) => {
|
||||
if (line.trim() === "") {
|
||||
return;
|
||||
}
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(line);
|
||||
} catch (error) {
|
||||
responseError = error instanceof Error ? error.message : String(error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed?.type !== "response" || parsed.id !== requestId || parsed.command !== "get_state") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (parsed.success !== true) {
|
||||
responseError = typeof parsed.error === "string" ? parsed.error : "get_state failed";
|
||||
return;
|
||||
}
|
||||
|
||||
if (readyElapsedMs === undefined) {
|
||||
readyElapsedMs = performance.now() - startedAt;
|
||||
child.stdin.end();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
child.stderr.setEncoding("utf8");
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
|
||||
child.stdin.setDefaultEncoding("utf8");
|
||||
child.stdin.write(`${JSON.stringify({ id: requestId, type: "get_state" })}\n`);
|
||||
|
||||
const exitCode = await waitForExit(child, `Benchmark ${measuredIndex === undefined ? `warmup ${runNumber}` : `run ${measuredIndex}`}`);
|
||||
|
||||
try {
|
||||
if (responseError) {
|
||||
throw new Error(responseError);
|
||||
}
|
||||
if (readyElapsedMs === undefined) {
|
||||
throw new Error(stderr.trim() || "RPC benchmark did not receive get_state response");
|
||||
}
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(stderr.trim() || `Benchmark child exited with code ${exitCode}`);
|
||||
}
|
||||
|
||||
const profilePath = options.cpuProfile ? join(profileDir, profileName) : undefined;
|
||||
if (profilePath && !existsSync(profilePath)) {
|
||||
throw new Error(`CPU profile was not written: ${profilePath}`);
|
||||
}
|
||||
|
||||
return { elapsedMs: readyElapsedMs, profilePath, timings: parseStartupTimings(stderr) };
|
||||
} finally {
|
||||
if (tempRoot) {
|
||||
rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runBenchmarkRun(params) {
|
||||
if (params.options.mode === "rpc") {
|
||||
return await runRpcBenchmarkRun(params);
|
||||
}
|
||||
return await runTuiBenchmarkRun(params);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (options.help) {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.agentDir && options.isolatedAgentDir) {
|
||||
throw new Error("--agent-dir and --isolated-agent-dir cannot be combined");
|
||||
}
|
||||
|
||||
if (options.mode === "tui" && (!process.stdin.isTTY || !process.stdout.isTTY)) {
|
||||
throw new Error("TUI benchmark must be run from an interactive terminal.");
|
||||
}
|
||||
|
||||
const runtime = resolveRuntime(options.runtime);
|
||||
options.label = resolveLabel(options.mode, options.label);
|
||||
const profileDir = resolveProfileDir(runtime, options.profileDir);
|
||||
|
||||
if (runtime === "node" && options.build) {
|
||||
await runBuild();
|
||||
}
|
||||
if (runtime === "bun") {
|
||||
process.stdout.write(
|
||||
`Using Bun runtime with ${options.mode === "rpc" ? "packages/coding-agent/src/cli.ts --mode rpc" : "packages/coding-agent/src/cli.ts"}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
const entryPath = runtime === "bun" ? srcCliPath : distCliPath;
|
||||
if (!existsSync(entryPath)) {
|
||||
throw new Error(`CLI entrypoint not found: ${entryPath}`);
|
||||
}
|
||||
|
||||
mkdirSync(profileDir, { recursive: true });
|
||||
|
||||
const measuredRuns = [];
|
||||
const totalRuns = options.warmup + options.runs;
|
||||
for (let runIndex = 0; runIndex < totalRuns; runIndex++) {
|
||||
const measuredIndex = runIndex >= options.warmup ? runIndex - options.warmup + 1 : undefined;
|
||||
const result = await runBenchmarkRun({
|
||||
runtime,
|
||||
runIndex,
|
||||
measuredIndex,
|
||||
options,
|
||||
profileDir,
|
||||
});
|
||||
|
||||
process.stdout.write(
|
||||
`[${measuredIndex === undefined ? `warmup ${runIndex + 1}` : `run ${measuredIndex}`}] elapsed=${formatMs(result.elapsedMs)}\n`,
|
||||
);
|
||||
|
||||
if (measuredIndex !== undefined) {
|
||||
measuredRuns.push(result);
|
||||
}
|
||||
}
|
||||
|
||||
if (measuredRuns.length === 0) {
|
||||
process.stdout.write("\nNo measured runs requested.\n");
|
||||
return;
|
||||
}
|
||||
|
||||
const elapsedSummary = summarize(measuredRuns.map((run) => run.elapsedMs));
|
||||
const timingSummaries = summarizeTimingMaps(measuredRuns);
|
||||
const maxElapsedRun = measuredRuns.reduce((slowest, run) => (run.elapsedMs > slowest.elapsedMs ? run : slowest));
|
||||
if (measuredRuns.length === 1) {
|
||||
process.stdout.write("\nResult\n");
|
||||
process.stdout.write(` runtime: ${runtime}\n`);
|
||||
process.stdout.write(` mode: ${options.mode}\n`);
|
||||
process.stdout.write(` elapsed: ${formatMs(measuredRuns[0].elapsedMs)}\n`);
|
||||
for (const [label, summary] of timingSummaries.entries()) {
|
||||
process.stdout.write(` ${label}: ${formatMs(summary.median)}\n`);
|
||||
}
|
||||
if (options.cpuProfile && maxElapsedRun.profilePath) {
|
||||
process.stdout.write(` selected profile: ${toDisplayPath(maxElapsedRun.profilePath)}\n`);
|
||||
process.stdout.write(` profiles dir: ${toDisplayPath(profileDir)}\n`);
|
||||
}
|
||||
process.stdout.write(`METRIC startup_time_ms=${measuredRuns[0].elapsedMs.toFixed(1)}\n`);
|
||||
for (const [label, summary] of timingSummaries.entries()) {
|
||||
process.stdout.write(`METRIC ${toMetricName(label)}=${summary.median.toFixed(1)}\n`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
process.stdout.write("\nSummary\n");
|
||||
process.stdout.write(` runtime: ${runtime}\n`);
|
||||
process.stdout.write(` mode: ${options.mode}\n`);
|
||||
process.stdout.write(` elapsed min: ${formatMs(elapsedSummary.min)}\n`);
|
||||
process.stdout.write(` elapsed median: ${formatMs(elapsedSummary.median)}\n`);
|
||||
process.stdout.write(` elapsed avg: ${formatMs(elapsedSummary.avg)}\n`);
|
||||
process.stdout.write(` elapsed max: ${formatMs(elapsedSummary.max)}\n`);
|
||||
for (const [label, summary] of timingSummaries.entries()) {
|
||||
process.stdout.write(` ${label} median: ${formatMs(summary.median)}\n`);
|
||||
}
|
||||
if (options.cpuProfile && maxElapsedRun.profilePath) {
|
||||
process.stdout.write(` selected profile: ${toDisplayPath(maxElapsedRun.profilePath)}\n`);
|
||||
process.stdout.write(` profiles dir: ${toDisplayPath(profileDir)}\n`);
|
||||
}
|
||||
process.stdout.write(`METRIC startup_time_ms=${elapsedSummary.median.toFixed(1)}\n`);
|
||||
for (const [label, summary] of timingSummaries.entries()) {
|
||||
process.stdout.write(`METRIC ${toMetricName(label)}=${summary.median.toFixed(1)}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error(message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,308 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
|
||||
const CATALOG_SCHEMA_VERSION = 1;
|
||||
const CATALOG_PREFIX = `models/v${CATALOG_SCHEMA_VERSION}`;
|
||||
const CATALOG_INDEX_KEY = `${CATALOG_PREFIX}/index.json`;
|
||||
// Bump this only when generated model metadata requires behavior unavailable in older pi clients.
|
||||
const MINIMUM_PI_VERSION = "0.80.7";
|
||||
const JSON_CONTENT_TYPE = "application/json; charset=utf-8";
|
||||
const IMMUTABLE_CACHE_CONTROL = "public, max-age=31536000, immutable";
|
||||
const INDEX_CACHE_CONTROL = "no-store";
|
||||
const REQUIRED_PROVIDERS = ["anthropic", "openai", "openrouter"];
|
||||
const MINIMUM_MODEL_COUNT = 500;
|
||||
|
||||
function parseArgs(args) {
|
||||
const options = {
|
||||
input: undefined,
|
||||
bucket: undefined,
|
||||
endpoint: undefined,
|
||||
sourceCommit: undefined,
|
||||
dryRun: false,
|
||||
};
|
||||
|
||||
for (let index = 0; index < args.length; index++) {
|
||||
const arg = args[index];
|
||||
if (arg === "--dry-run") {
|
||||
options.dryRun = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--input" || arg === "--bucket" || arg === "--endpoint" || arg === "--source-commit") {
|
||||
const value = args[++index];
|
||||
if (!value) throw new Error(`${arg} requires a value`);
|
||||
options[
|
||||
{
|
||||
"--input": "input",
|
||||
"--bucket": "bucket",
|
||||
"--endpoint": "endpoint",
|
||||
"--source-commit": "sourceCommit",
|
||||
}[arg]
|
||||
] = value;
|
||||
continue;
|
||||
}
|
||||
throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
|
||||
if (!options.input) throw new Error("--input is required");
|
||||
if (!options.dryRun && !options.bucket) throw new Error("--bucket is required when publishing");
|
||||
if (!options.dryRun && !options.endpoint) throw new Error("--endpoint is required when publishing");
|
||||
return options;
|
||||
}
|
||||
|
||||
function readJson(path) {
|
||||
return JSON.parse(readFileSync(path, "utf8"));
|
||||
}
|
||||
|
||||
function validateBundle(inputDir) {
|
||||
const modelsPath = join(inputDir, "models.json");
|
||||
const providerIndexPath = join(inputDir, "providers.json");
|
||||
const providersDir = join(inputDir, "providers");
|
||||
const modelsBytes = readFileSync(modelsPath);
|
||||
const models = JSON.parse(modelsBytes.toString("utf8"));
|
||||
const providerIds = readJson(providerIndexPath);
|
||||
|
||||
if (typeof models !== "object" || models === null || Array.isArray(models)) {
|
||||
throw new Error("models.json must contain an object");
|
||||
}
|
||||
if (!Array.isArray(providerIds) || !providerIds.every((value) => typeof value === "string")) {
|
||||
throw new Error("providers.json must contain an array of provider IDs");
|
||||
}
|
||||
|
||||
const expectedProviderIds = Object.keys(models).sort();
|
||||
if (!isDeepStrictEqual(providerIds, expectedProviderIds)) {
|
||||
throw new Error("providers.json does not match the sorted providers in models.json");
|
||||
}
|
||||
for (const providerId of REQUIRED_PROVIDERS) {
|
||||
if (!Object.hasOwn(models, providerId)) throw new Error(`Required provider is missing: ${providerId}`);
|
||||
}
|
||||
|
||||
let modelCount = 0;
|
||||
for (const providerId of providerIds) {
|
||||
const providerModels = models[providerId];
|
||||
if (typeof providerModels !== "object" || providerModels === null || Array.isArray(providerModels)) {
|
||||
throw new Error(`Provider catalog must be an object: ${providerId}`);
|
||||
}
|
||||
const providerFile = readJson(join(providersDir, `${providerId}.json`));
|
||||
if (!isDeepStrictEqual(providerFile, providerModels)) {
|
||||
throw new Error(`Provider shard does not match models.json: ${providerId}`);
|
||||
}
|
||||
for (const [modelId, model] of Object.entries(providerModels)) {
|
||||
if (
|
||||
typeof model !== "object" ||
|
||||
model === null ||
|
||||
Array.isArray(model) ||
|
||||
model.id !== modelId ||
|
||||
model.provider !== providerId
|
||||
) {
|
||||
throw new Error(`Invalid model entry: ${providerId}/${modelId}`);
|
||||
}
|
||||
modelCount++;
|
||||
}
|
||||
}
|
||||
|
||||
const shardFiles = readdirSync(providersDir).filter((name) => name.endsWith(".json")).sort();
|
||||
const expectedShardFiles = providerIds.map((providerId) => `${providerId}.json`).sort();
|
||||
if (!isDeepStrictEqual(shardFiles, expectedShardFiles)) {
|
||||
throw new Error("Provider shard files do not match providers.json");
|
||||
}
|
||||
if (modelCount < MINIMUM_MODEL_COUNT) {
|
||||
throw new Error(`Refusing to publish only ${modelCount} models; expected at least ${MINIMUM_MODEL_COUNT}`);
|
||||
}
|
||||
|
||||
const digest = createHash("sha256").update(modelsBytes).digest("hex");
|
||||
return {
|
||||
modelsPath,
|
||||
providerIndexPath,
|
||||
providersDir,
|
||||
providerIds,
|
||||
providerCount: providerIds.length,
|
||||
modelCount,
|
||||
revision: `sha256-${digest}`,
|
||||
};
|
||||
}
|
||||
|
||||
function gitSourceCommit() {
|
||||
const result = spawnSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" });
|
||||
if (result.status !== 0) throw new Error(`Unable to determine source commit: ${result.stderr.trim()}`);
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
function aws(args, { allowNotFound = false } = {}) {
|
||||
const result = spawnSync("aws", args, {
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
AWS_DEFAULT_REGION: process.env.AWS_DEFAULT_REGION || "auto",
|
||||
AWS_EC2_METADATA_DISABLED: "true",
|
||||
},
|
||||
});
|
||||
if (result.error) throw result.error;
|
||||
if (result.status === 0) return true;
|
||||
const message = `${result.stdout}\n${result.stderr}`.trim();
|
||||
if (allowNotFound && /(?:404|NoSuchKey|Not Found)/i.test(message)) return false;
|
||||
throw new Error(`aws ${args.slice(0, 2).join(" ")} failed:\n${message}`);
|
||||
}
|
||||
|
||||
function downloadIndex(bucket, endpoint, outputPath) {
|
||||
return aws(
|
||||
[
|
||||
"s3",
|
||||
"cp",
|
||||
`s3://${bucket}/${CATALOG_INDEX_KEY}`,
|
||||
outputPath,
|
||||
"--endpoint-url",
|
||||
endpoint,
|
||||
"--only-show-errors",
|
||||
],
|
||||
{ allowNotFound: true },
|
||||
);
|
||||
}
|
||||
|
||||
function uploadJson(bucket, endpoint, sourcePath, key, cacheControl) {
|
||||
aws([
|
||||
"s3",
|
||||
"cp",
|
||||
sourcePath,
|
||||
`s3://${bucket}/${key}`,
|
||||
"--endpoint-url",
|
||||
endpoint,
|
||||
"--content-type",
|
||||
JSON_CONTENT_TYPE,
|
||||
"--cache-control",
|
||||
cacheControl,
|
||||
"--only-show-errors",
|
||||
]);
|
||||
}
|
||||
|
||||
function validateIndex(index) {
|
||||
if (
|
||||
typeof index !== "object" ||
|
||||
index === null ||
|
||||
Array.isArray(index) ||
|
||||
index.schemaVersion !== CATALOG_SCHEMA_VERSION
|
||||
) {
|
||||
throw new Error(`Existing ${CATALOG_INDEX_KEY} has an unsupported schema`);
|
||||
}
|
||||
if (!Array.isArray(index.catalogs)) throw new Error(`Existing ${CATALOG_INDEX_KEY} has no catalogs array`);
|
||||
for (const catalog of index.catalogs) {
|
||||
if (
|
||||
typeof catalog !== "object" ||
|
||||
catalog === null ||
|
||||
Array.isArray(catalog) ||
|
||||
typeof catalog.minimumPiVersion !== "string" ||
|
||||
typeof catalog.revision !== "string"
|
||||
) {
|
||||
throw new Error(`Existing ${CATALOG_INDEX_KEY} contains an invalid catalog entry`);
|
||||
}
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
function comparePiVersions(left, right) {
|
||||
const leftParts = left.split(".").map(Number);
|
||||
const rightParts = right.split(".").map(Number);
|
||||
for (let index = 0; index < Math.max(leftParts.length, rightParts.length); index++) {
|
||||
const difference = (leftParts[index] || 0) - (rightParts[index] || 0);
|
||||
if (difference !== 0) return difference;
|
||||
}
|
||||
return left.localeCompare(right);
|
||||
}
|
||||
|
||||
function buildIndex(existingIndex, publication) {
|
||||
const entry = {
|
||||
minimumPiVersion: MINIMUM_PI_VERSION,
|
||||
revision: publication.revision,
|
||||
sourceCommit: publication.sourceCommit,
|
||||
publishedAt: new Date().toISOString(),
|
||||
providerCount: publication.providerCount,
|
||||
modelCount: publication.modelCount,
|
||||
};
|
||||
const catalogs = (existingIndex?.catalogs || [])
|
||||
.filter((catalog) => catalog.minimumPiVersion !== MINIMUM_PI_VERSION)
|
||||
.concat(entry)
|
||||
.sort((left, right) => comparePiVersions(left.minimumPiVersion, right.minimumPiVersion));
|
||||
return {
|
||||
schemaVersion: CATALOG_SCHEMA_VERSION,
|
||||
defaultRevision: publication.revision,
|
||||
catalogs,
|
||||
};
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const inputDir = resolve(options.input);
|
||||
const bundle = validateBundle(inputDir);
|
||||
const publication = {
|
||||
schemaVersion: CATALOG_SCHEMA_VERSION,
|
||||
minimumPiVersion: MINIMUM_PI_VERSION,
|
||||
revision: bundle.revision,
|
||||
sourceCommit: options.sourceCommit || gitSourceCommit(),
|
||||
providerCount: bundle.providerCount,
|
||||
modelCount: bundle.modelCount,
|
||||
};
|
||||
writeFileSync(join(inputDir, "publication.json"), `${JSON.stringify(publication, null, 2)}\n`);
|
||||
|
||||
console.log(JSON.stringify(publication, null, 2));
|
||||
if (options.dryRun) {
|
||||
console.log(`Validated model catalog at ${inputDir}; no objects uploaded.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const temporaryDir = mkdtempSync(join(tmpdir(), "pi-model-catalog-"));
|
||||
try {
|
||||
const currentIndexPath = join(temporaryDir, "index-current.json");
|
||||
const hasCurrentIndex = downloadIndex(options.bucket, options.endpoint, currentIndexPath);
|
||||
const currentIndex = hasCurrentIndex ? validateIndex(readJson(currentIndexPath)) : undefined;
|
||||
const currentEntry = currentIndex?.catalogs.find(
|
||||
(catalog) => catalog.minimumPiVersion === MINIMUM_PI_VERSION,
|
||||
);
|
||||
if (currentIndex?.defaultRevision === bundle.revision && currentEntry?.revision === bundle.revision) {
|
||||
console.log(`Model catalog ${bundle.revision} is already current; no objects uploaded.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const revisionPrefix = `${CATALOG_PREFIX}/revisions/${bundle.revision}`;
|
||||
uploadJson(options.bucket, options.endpoint, bundle.modelsPath, `${revisionPrefix}/models.json`, IMMUTABLE_CACHE_CONTROL);
|
||||
uploadJson(
|
||||
options.bucket,
|
||||
options.endpoint,
|
||||
bundle.providerIndexPath,
|
||||
`${revisionPrefix}/providers.json`,
|
||||
IMMUTABLE_CACHE_CONTROL,
|
||||
);
|
||||
for (const providerId of bundle.providerIds) {
|
||||
uploadJson(
|
||||
options.bucket,
|
||||
options.endpoint,
|
||||
join(bundle.providersDir, `${providerId}.json`),
|
||||
`${revisionPrefix}/providers/${providerId}.json`,
|
||||
IMMUTABLE_CACHE_CONTROL,
|
||||
);
|
||||
}
|
||||
|
||||
const nextIndex = buildIndex(currentIndex, publication);
|
||||
const nextIndexPath = join(temporaryDir, "index-next.json");
|
||||
writeFileSync(nextIndexPath, `${JSON.stringify(nextIndex, null, 2)}\n`);
|
||||
uploadJson(options.bucket, options.endpoint, nextIndexPath, CATALOG_INDEX_KEY, INDEX_CACHE_CONTROL);
|
||||
console.log(`Published ${bundle.revision} to s3://${options.bucket}/${revisionPrefix}`);
|
||||
} finally {
|
||||
rmSync(temporaryDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const packages = [
|
||||
{ directory: "packages/ai", name: "@earendil-works/pi-ai" },
|
||||
{ directory: "packages/agent", name: "@earendil-works/pi-agent-core" },
|
||||
{ directory: "packages/storage/sqlite-node", name: "@earendil-works/pi-storage-sqlite-node" },
|
||||
{ directory: "packages/tui", name: "@earendil-works/pi-tui" },
|
||||
{ directory: "packages/coding-agent", name: "@earendil-works/pi-coding-agent" },
|
||||
];
|
||||
|
||||
const dryRun = process.argv.includes("--dry-run");
|
||||
const unknownArgs = process.argv.slice(2).filter((arg) => arg !== "--dry-run");
|
||||
|
||||
if (unknownArgs.length > 0) {
|
||||
console.error(`Usage: node scripts/publish.mjs [--dry-run]`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function commandForPlatform(command) {
|
||||
return process.platform === "win32" ? `${command}.cmd` : command;
|
||||
}
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
console.log(`$ ${[command, ...args].join(" ")}`);
|
||||
const result = spawnSync(commandForPlatform(command), args, {
|
||||
cwd: options.cwd,
|
||||
encoding: "utf8",
|
||||
stdio: options.capture ? ["inherit", "pipe", "pipe"] : "inherit",
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
const output = [result.stdout, result.stderr].filter(Boolean).join("\n");
|
||||
throw new Error(output ? `Command failed: ${command} ${args.join(" ")}\n${output}` : `Command failed: ${command} ${args.join(" ")}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function readPackageJson(directory) {
|
||||
return JSON.parse(readFileSync(join(directory, "package.json"), "utf8"));
|
||||
}
|
||||
|
||||
function assertBuildOutputExists(directory) {
|
||||
if (!existsSync(join(directory, "dist"))) {
|
||||
throw new Error(`${directory}/dist does not exist. Run npm run build before publishing.`);
|
||||
}
|
||||
}
|
||||
|
||||
function validatePack(directory) {
|
||||
const result = run("npm", ["pack", "--dry-run", "--ignore-scripts", "--json"], { capture: true, cwd: directory });
|
||||
const packed = JSON.parse(result.stdout)[0];
|
||||
console.log(` ${packed.filename}: ${packed.files.length} files, ${packed.size} bytes packed, ${packed.unpackedSize} bytes unpacked`);
|
||||
}
|
||||
|
||||
function isPublished(name, version) {
|
||||
const result = spawnSync(commandForPlatform("npm"), ["view", `${name}@${version}`, "version", "--json"], {
|
||||
encoding: "utf8",
|
||||
stdio: ["inherit", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
if (result.status === 0 && result.stdout.trim()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const output = [result.stdout, result.stderr].filter(Boolean).join("\n");
|
||||
if (result.status !== 0 && (output.includes("E404") || output.includes("404 Not Found"))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
throw new Error(output ? `Failed to query ${name}@${version}\n${output}` : `Failed to query ${name}@${version}`);
|
||||
}
|
||||
|
||||
const packageVersions = new Map();
|
||||
for (const pkg of packages) {
|
||||
const packageJson = readPackageJson(pkg.directory);
|
||||
if (packageJson.name !== pkg.name) {
|
||||
throw new Error(`${pkg.directory}/package.json has name ${packageJson.name}, expected ${pkg.name}`);
|
||||
}
|
||||
packageVersions.set(pkg.name, packageJson.version);
|
||||
}
|
||||
|
||||
const versions = [...new Set(packageVersions.values())];
|
||||
if (versions.length !== 1) {
|
||||
throw new Error(`Publish packages are not lockstep versioned: ${versions.join(", ")}`);
|
||||
}
|
||||
|
||||
console.log(`Publishing pi packages at ${versions[0]}${dryRun ? " (dry run)" : ""}\n`);
|
||||
|
||||
const packageStates = packages.map((pkg) => ({
|
||||
...pkg,
|
||||
published: false,
|
||||
version: packageVersions.get(pkg.name),
|
||||
}));
|
||||
|
||||
for (const pkg of packageStates) {
|
||||
assertBuildOutputExists(pkg.directory);
|
||||
pkg.published = isPublished(pkg.name, pkg.version);
|
||||
|
||||
if (pkg.published) {
|
||||
console.log(`${pkg.name}@${pkg.version} is already published; validating package contents only.`);
|
||||
} else {
|
||||
console.log(`${pkg.name}@${pkg.version} is not published; validating package contents before publish.`);
|
||||
}
|
||||
validatePack(pkg.directory);
|
||||
console.log();
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log("All packages validated; starting publication.\n");
|
||||
|
||||
for (const pkg of packageStates) {
|
||||
if (pkg.published) {
|
||||
console.log(`Skipping ${pkg.name}@${pkg.version}: already published\n`);
|
||||
continue;
|
||||
}
|
||||
|
||||
run("npm", ["publish", "--access", "public", "--provenance", "--ignore-scripts"], { cwd: pkg.directory });
|
||||
console.log();
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { createReadStream } from "node:fs";
|
||||
import { promises as fs } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { createInterface } from "node:readline";
|
||||
|
||||
const DEFAULT_SESSIONS_DIR = path.join(homedir(), ".pi/agent/sessions");
|
||||
const DEFAULT_ACTIVE_READ_TOOL_PATH = path.join(process.cwd(), "packages/coding-agent/src/core/tools/read.ts");
|
||||
const DEFAULT_TOP = 20;
|
||||
const CHART_WIDTH = 40;
|
||||
const REPORT_TIME_ZONE = "Europe/Berlin";
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
sessionsDir: DEFAULT_SESSIONS_DIR,
|
||||
json: false,
|
||||
text: false,
|
||||
includeRecords: false,
|
||||
modelFilter: undefined,
|
||||
top: DEFAULT_TOP,
|
||||
help: false,
|
||||
allSessions: false,
|
||||
since: undefined,
|
||||
autoSincePath: DEFAULT_ACTIVE_READ_TOOL_PATH,
|
||||
bucket: "week",
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg === "--help" || arg === "-h") options.help = true;
|
||||
else if (arg === "--json") options.json = true;
|
||||
else if (arg === "--text") options.text = true;
|
||||
else if (arg === "--include-records") options.includeRecords = true;
|
||||
else if (arg === "--model") options.modelFilter = argv[++i];
|
||||
else if (arg === "--top") {
|
||||
const value = Number.parseInt(argv[++i] ?? "", 10);
|
||||
if (!Number.isFinite(value) || value <= 0) throw new Error("--top must be a positive integer");
|
||||
options.top = value;
|
||||
} else if (arg === "--sessions-dir") options.sessionsDir = argv[++i];
|
||||
else if (arg === "--all-sessions") options.allSessions = true;
|
||||
else if (arg === "--since") options.since = argv[++i];
|
||||
else if (arg === "--auto-since-path") options.autoSincePath = argv[++i];
|
||||
else if (arg === "--bucket") {
|
||||
const value = argv[++i];
|
||||
if (value !== "day" && value !== "week") throw new Error("--bucket must be day or week");
|
||||
options.bucket = value;
|
||||
} else throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`Usage: node scripts/read-tool-stats.mjs [options]
|
||||
|
||||
Options:
|
||||
--sessions-dir <path> Sessions directory (default: ~/.pi/agent/sessions)
|
||||
--model <substring> Filter provider/model by substring
|
||||
--top <n> Number of examples to show (default: ${DEFAULT_TOP})
|
||||
--since <iso> Only scan session files created at or after this ISO time
|
||||
--all-sessions Disable the automatic since filter
|
||||
--auto-since-path <p> Use birth time of this file for the automatic since filter
|
||||
--bucket <day|week> Time bucket for trend chart (default: week)
|
||||
--json Print JSON summary instead of HTML report
|
||||
--text Print plain text report instead of HTML
|
||||
--include-records Include raw records in JSON output
|
||||
-h, --help Show this help
|
||||
`);
|
||||
}
|
||||
|
||||
function parseSessionFileTimestamp(sessionFile) {
|
||||
const base = path.basename(sessionFile);
|
||||
const rawTimestamp = base.split("_")[0];
|
||||
if (!rawTimestamp) return null;
|
||||
const isoTimestamp = rawTimestamp.replace(/T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z$/, "T$1:$2:$3.$4Z");
|
||||
const ms = Date.parse(isoTimestamp);
|
||||
return Number.isFinite(ms) ? ms : null;
|
||||
}
|
||||
|
||||
function formatIso(ms) {
|
||||
return new Date(ms).toISOString();
|
||||
}
|
||||
|
||||
function getTimeZoneParts(ms) {
|
||||
const parts = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: REPORT_TIME_ZONE,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
hourCycle: "h23",
|
||||
weekday: "short",
|
||||
}).formatToParts(new Date(ms));
|
||||
return Object.fromEntries(parts.filter((part) => part.type !== "literal").map((part) => [part.type, part.value]));
|
||||
}
|
||||
|
||||
function formatDay(ms) {
|
||||
const parts = getTimeZoneParts(ms);
|
||||
return `${parts.year}-${parts.month}-${parts.day}`;
|
||||
}
|
||||
|
||||
function startOfReportTimeZoneWeek(ms) {
|
||||
const parts = getTimeZoneParts(ms);
|
||||
const dayIndex = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"].indexOf(parts.weekday ?? "Mon");
|
||||
const localMidnightAsUtc = Date.UTC(Number(parts.year), Number(parts.month) - 1, Number(parts.day));
|
||||
return localMidnightAsUtc - Math.max(dayIndex, 0) * 24 * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
function getTimeBucket(ms, bucket) {
|
||||
if (!Number.isFinite(ms)) return "[unknown]";
|
||||
if (bucket === "day") return formatDay(ms);
|
||||
return formatDay(startOfReportTimeZoneWeek(ms));
|
||||
}
|
||||
|
||||
function getHourOfDayBucket(ms) {
|
||||
if (!Number.isFinite(ms)) return "[unknown]";
|
||||
return `${getTimeZoneParts(ms).hour}:00`;
|
||||
}
|
||||
|
||||
async function resolveAutoSinceMs(options) {
|
||||
if (options.allSessions) return null;
|
||||
if (options.since) {
|
||||
const ms = Date.parse(options.since);
|
||||
if (!Number.isFinite(ms)) throw new Error(`Invalid --since value: ${options.since}`);
|
||||
return { ms, source: `--since ${options.since}` };
|
||||
}
|
||||
if (!options.autoSincePath) return null;
|
||||
try {
|
||||
const stats = await fs.stat(options.autoSincePath);
|
||||
const ms = Number.isFinite(stats.birthtimeMs) && stats.birthtimeMs > 0 ? stats.birthtimeMs : stats.mtimeMs;
|
||||
return Number.isFinite(ms) && ms > 0 ? { ms, source: `birth time of ${options.autoSincePath}` } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function* walkJsonlFiles(dir) {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
entries.sort((a, b) => a.name.localeCompare(b.name));
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) yield* walkJsonlFiles(fullPath);
|
||||
else if (entry.isFile() && entry.name.endsWith(".jsonl")) yield fullPath;
|
||||
}
|
||||
}
|
||||
|
||||
function formatInt(value) {
|
||||
return new Intl.NumberFormat("en-US").format(value);
|
||||
}
|
||||
|
||||
function formatPercent(part, total) {
|
||||
return total === 0 ? "n/a" : `${((part / total) * 100).toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function formatRate(value) {
|
||||
return Number.isFinite(value) ? value.toFixed(2) : "n/a";
|
||||
}
|
||||
|
||||
function median(numbers) {
|
||||
const finite = numbers.filter((value) => Number.isFinite(value)).sort((a, b) => a - b);
|
||||
if (finite.length === 0) return null;
|
||||
const middle = Math.floor(finite.length / 2);
|
||||
return finite.length % 2 === 0 ? (finite[middle - 1] + finite[middle]) / 2 : finite[middle];
|
||||
}
|
||||
|
||||
function bar(part, total) {
|
||||
const filled = total === 0 ? 0 : Math.round((part / total) * CHART_WIDTH);
|
||||
return `${"█".repeat(filled)}${"░".repeat(CHART_WIDTH - filled)}`;
|
||||
}
|
||||
|
||||
function extractTextContent(content) {
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
return content
|
||||
.filter((block) => block?.type === "text" && typeof block.text === "string")
|
||||
.map((block) => block.text)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function classifyRead(args) {
|
||||
const normalizedArgs = args && typeof args === "object" ? args : {};
|
||||
const hasOffset = Object.hasOwn(normalizedArgs, "offset") && normalizedArgs.offset !== undefined && normalizedArgs.offset !== null;
|
||||
const hasLimit = Object.hasOwn(normalizedArgs, "limit") && normalizedArgs.limit !== undefined && normalizedArgs.limit !== null;
|
||||
return {
|
||||
path: typeof normalizedArgs.path === "string" ? normalizedArgs.path : "",
|
||||
offset: hasOffset ? normalizedArgs.offset : null,
|
||||
limit: hasLimit ? normalizedArgs.limit : null,
|
||||
mode: hasOffset || hasLimit ? "partial" : "full",
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeTimeBuckets(records, bucket) {
|
||||
return summarizeGroups(records, (record) => getTimeBucket(record.timestampMs, bucket)).sort((a, b) => a.key.localeCompare(b.key));
|
||||
}
|
||||
|
||||
function summarizeNormalizedTimeBuckets(records, bucket) {
|
||||
return summarizeNormalizedTimeBucketsByKey(records, (record) => getTimeBucket(record.timestampMs, bucket));
|
||||
}
|
||||
|
||||
function summarizeNormalizedTimeBucketsByKey(records, keyFn) {
|
||||
const bucketGroups = new Map();
|
||||
for (const record of records) {
|
||||
const bucketKey = keyFn(record);
|
||||
if (!bucketGroups.has(bucketKey)) bucketGroups.set(bucketKey, []);
|
||||
bucketGroups.get(bucketKey).push(record);
|
||||
}
|
||||
|
||||
return [...bucketGroups.entries()]
|
||||
.map(([key, bucketRecords]) => {
|
||||
const sessionGroups = new Map();
|
||||
for (const record of bucketRecords) {
|
||||
if (!sessionGroups.has(record.sessionFile)) sessionGroups.set(record.sessionFile, []);
|
||||
sessionGroups.get(record.sessionFile).push(record);
|
||||
}
|
||||
const sessions = [...sessionGroups.values()].map((sessionRecords) => {
|
||||
const full = sessionRecords.filter((record) => record.mode === "full").length;
|
||||
const partial = sessionRecords.length - full;
|
||||
return { reads: sessionRecords.length, full, partial, partialRate: sessionRecords.length === 0 ? null : partial / sessionRecords.length };
|
||||
});
|
||||
const reads = bucketRecords.length;
|
||||
const full = bucketRecords.filter((record) => record.mode === "full").length;
|
||||
const partial = reads - full;
|
||||
const sessionCount = sessions.length;
|
||||
const medianSessionPartialRate = median(sessions.map((session) => session.partialRate));
|
||||
return {
|
||||
key,
|
||||
sessions: sessionCount,
|
||||
reads,
|
||||
full,
|
||||
partial,
|
||||
readsPerSession: sessionCount === 0 ? null : reads / sessionCount,
|
||||
fullPerSession: sessionCount === 0 ? null : full / sessionCount,
|
||||
partialPerSession: sessionCount === 0 ? null : partial / sessionCount,
|
||||
medianSessionPartialRate,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a.key.localeCompare(b.key));
|
||||
}
|
||||
|
||||
function summarizeGroups(records, keyFn) {
|
||||
const groups = new Map();
|
||||
for (const record of records) {
|
||||
const key = keyFn(record);
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key).push(record);
|
||||
}
|
||||
return [...groups.entries()]
|
||||
.map(([key, group]) => {
|
||||
const full = group.filter((record) => record.mode === "full").length;
|
||||
const partial = group.length - full;
|
||||
const assistantMessages = new Set(group.map((record) => `${record.sessionFile}::${record.assistantEntryId}`)).size;
|
||||
return { key, reads: group.length, assistantMessages, full, partial, fullRate: group.length === 0 ? null : full / group.length, partialRate: group.length === 0 ? null : partial / group.length };
|
||||
})
|
||||
.sort((a, b) => b.reads - a.reads || a.key.localeCompare(b.key));
|
||||
}
|
||||
|
||||
function buildSummary(records, meta, options) {
|
||||
const full = records.filter((record) => record.mode === "full").length;
|
||||
const partial = records.length - full;
|
||||
const providerStats = summarizeGroups(records, (record) => record.providerModel);
|
||||
const timeStats = summarizeTimeBuckets(records, options.bucket);
|
||||
const normalizedTimeStats = summarizeNormalizedTimeBuckets(records, options.bucket);
|
||||
const timeOfDayStats = summarizeGroups(records, (record) => getHourOfDayBucket(record.timestampMs)).sort((a, b) => a.key.localeCompare(b.key));
|
||||
const normalizedTimeOfDayStats = summarizeNormalizedTimeBucketsByKey(records, (record) => getHourOfDayBucket(record.timestampMs));
|
||||
const timeStatsByProvider = providerStats.map((provider) => ({
|
||||
providerModel: provider.key,
|
||||
...provider,
|
||||
timeStats: summarizeTimeBuckets(
|
||||
records.filter((record) => record.providerModel === provider.key),
|
||||
options.bucket
|
||||
),
|
||||
normalizedTimeStats: summarizeNormalizedTimeBuckets(
|
||||
records.filter((record) => record.providerModel === provider.key),
|
||||
options.bucket
|
||||
),
|
||||
timeOfDayStats: summarizeGroups(
|
||||
records.filter((record) => record.providerModel === provider.key),
|
||||
(record) => getHourOfDayBucket(record.timestampMs)
|
||||
).sort((a, b) => a.key.localeCompare(b.key)),
|
||||
normalizedTimeOfDayStats: summarizeNormalizedTimeBucketsByKey(
|
||||
records.filter((record) => record.providerModel === provider.key),
|
||||
(record) => getHourOfDayBucket(record.timestampMs)
|
||||
),
|
||||
}));
|
||||
return {
|
||||
filters: { model: options.modelFilter ?? null, bucket: options.bucket },
|
||||
scan: {
|
||||
sessionsDir: meta.sessionsDir,
|
||||
sessionFilesScanned: meta.sessionFilesScanned,
|
||||
sessionFilesIncluded: meta.sessionFilesIncluded,
|
||||
sessionFilesSkippedOlderThanSince: meta.sessionFilesSkippedOlderThanSince,
|
||||
sessionFilesWithReadCalls: meta.sessionFilesWithReadCalls,
|
||||
since: meta.since ? { ms: meta.since.ms, iso: formatIso(meta.since.ms), source: meta.since.source } : null,
|
||||
malformedLines: meta.malformedLines,
|
||||
},
|
||||
counts: {
|
||||
assistantMessagesWithReadCalls: new Set(records.map((record) => `${record.sessionFile}::${record.assistantEntryId}`)).size,
|
||||
totalReadCalls: records.length,
|
||||
full,
|
||||
partial,
|
||||
fullRate: records.length === 0 ? null : full / records.length,
|
||||
partialRate: records.length === 0 ? null : partial / records.length,
|
||||
},
|
||||
providerStats,
|
||||
timeStats,
|
||||
normalizedTimeStats,
|
||||
timeOfDayStats,
|
||||
normalizedTimeOfDayStats,
|
||||
timeStatsByProvider,
|
||||
examples: records.slice(0, options.top),
|
||||
};
|
||||
}
|
||||
|
||||
function buildHumanReport(summary) {
|
||||
const lines = [];
|
||||
const originalLog = console.log;
|
||||
console.log = (line = "") => lines.push(String(line));
|
||||
try {
|
||||
printHumanReport(summary);
|
||||
} finally {
|
||||
console.log = originalLog;
|
||||
}
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
return text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
||||
}
|
||||
|
||||
function printHtmlReport(summary) {
|
||||
const text = buildHumanReport(summary);
|
||||
console.log(`<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>Read tool stats</title>
|
||||
<style>
|
||||
body { margin: 24px; background: #fff; color: #111; }
|
||||
pre { font: 13px/1.35 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: pre; }
|
||||
</style>
|
||||
<pre>${escapeHtml(text)}</pre>`);
|
||||
}
|
||||
|
||||
function printHumanReport(summary) {
|
||||
const { scan, counts, timeStats, normalizedTimeStats, timeOfDayStats, normalizedTimeOfDayStats, timeStatsByProvider, filters } = summary;
|
||||
console.log(`Scanned ${formatInt(scan.sessionFilesIncluded)} session files in ${scan.sessionsDir}`);
|
||||
console.log(`Report timezone: ${REPORT_TIME_ZONE} (CET/CEST)`);
|
||||
if (scan.since) {
|
||||
console.log(`Session filter: files created at or after ${scan.since.iso} (${scan.since.source})`);
|
||||
console.log(`Skipped older session files: ${formatInt(scan.sessionFilesSkippedOlderThanSince)} of ${formatInt(scan.sessionFilesScanned)}`);
|
||||
}
|
||||
console.log(`Found ${formatInt(counts.totalReadCalls)} read tool calls in ${formatInt(counts.assistantMessagesWithReadCalls)} assistant messages`);
|
||||
if (filters.model) console.log(`Filters: model contains "${filters.model}"`);
|
||||
|
||||
console.log("\nFull vs partial reads");
|
||||
console.log(` full: ${formatInt(counts.full).padStart(8)} ${formatPercent(counts.full, counts.totalReadCalls).padStart(6)} ${bar(counts.full, counts.totalReadCalls)}`);
|
||||
console.log(` partial: ${formatInt(counts.partial).padStart(8)} ${formatPercent(counts.partial, counts.totalReadCalls).padStart(6)} ${bar(counts.partial, counts.totalReadCalls)}`);
|
||||
|
||||
console.log(`\nBy ${filters.bucket}`);
|
||||
for (const group of timeStats) {
|
||||
console.log(
|
||||
` ${group.key} reads=${formatInt(group.reads).padStart(5)} full=${formatPercent(group.full, group.reads).padStart(6)} partial=${formatPercent(group.partial, group.reads).padStart(6)} ${bar(group.partial, group.reads)}`
|
||||
);
|
||||
}
|
||||
|
||||
console.log("\nBy time of day");
|
||||
for (const group of timeOfDayStats) {
|
||||
console.log(
|
||||
` ${group.key} reads=${formatInt(group.reads).padStart(5)} full=${formatPercent(group.full, group.reads).padStart(6)} partial=${formatPercent(group.partial, group.reads).padStart(6)} ${bar(group.partial, group.reads)}`
|
||||
);
|
||||
}
|
||||
|
||||
console.log("\nBy time of day, session-normalized");
|
||||
for (const group of normalizedTimeOfDayStats) {
|
||||
console.log(
|
||||
` ${group.key} sessions=${formatInt(group.sessions).padStart(4)} reads/session=${formatRate(group.readsPerSession).padStart(5)} full/session=${formatRate(group.fullPerSession).padStart(5)} partial/session=${formatRate(group.partialPerSession).padStart(5)} medianSessionPartial=${group.medianSessionPartialRate === null ? "n/a" : formatPercent(group.medianSessionPartialRate, 1).padStart(6)} ${bar(group.medianSessionPartialRate ?? 0, 1)}`
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`\nBy ${filters.bucket}, session-normalized`);
|
||||
for (const group of normalizedTimeStats) {
|
||||
console.log(
|
||||
` ${group.key} sessions=${formatInt(group.sessions).padStart(4)} reads/session=${formatRate(group.readsPerSession).padStart(5)} full/session=${formatRate(group.fullPerSession).padStart(5)} partial/session=${formatRate(group.partialPerSession).padStart(5)} medianSessionPartial=${group.medianSessionPartialRate === null ? "n/a" : formatPercent(group.medianSessionPartialRate, 1).padStart(6)} ${bar(group.medianSessionPartialRate ?? 0, 1)}`
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`\nBy provider/model, then by ${filters.bucket}`);
|
||||
for (const group of timeStatsByProvider) {
|
||||
console.log(`\n${group.providerModel}`);
|
||||
console.log(` total reads=${formatInt(group.reads)} assistantMessages=${formatInt(group.assistantMessages)}`);
|
||||
console.log(` total full ${formatInt(group.full).padStart(8)} ${formatPercent(group.full, group.reads).padStart(6)} ${bar(group.full, group.reads)}`);
|
||||
console.log(` total partial ${formatInt(group.partial).padStart(8)} ${formatPercent(group.partial, group.reads).padStart(6)} ${bar(group.partial, group.reads)}`);
|
||||
console.log(` By ${filters.bucket}`);
|
||||
for (const bucket of group.timeStats) {
|
||||
console.log(
|
||||
` ${bucket.key} reads=${formatInt(bucket.reads).padStart(5)} full=${formatPercent(bucket.full, bucket.reads).padStart(6)} partial=${formatPercent(bucket.partial, bucket.reads).padStart(6)} ${bar(bucket.partial, bucket.reads)}`
|
||||
);
|
||||
}
|
||||
console.log(` By ${filters.bucket}, session-normalized`);
|
||||
for (const bucket of group.normalizedTimeStats) {
|
||||
console.log(
|
||||
` ${bucket.key} sessions=${formatInt(bucket.sessions).padStart(4)} reads/session=${formatRate(bucket.readsPerSession).padStart(5)} full/session=${formatRate(bucket.fullPerSession).padStart(5)} partial/session=${formatRate(bucket.partialPerSession).padStart(5)} medianSessionPartial=${bucket.medianSessionPartialRate === null ? "n/a" : formatPercent(bucket.medianSessionPartialRate, 1).padStart(6)} ${bar(bucket.medianSessionPartialRate ?? 0, 1)}`
|
||||
);
|
||||
}
|
||||
console.log(" By time of day");
|
||||
for (const bucket of group.timeOfDayStats) {
|
||||
console.log(
|
||||
` ${bucket.key} reads=${formatInt(bucket.reads).padStart(5)} full=${formatPercent(bucket.full, bucket.reads).padStart(6)} partial=${formatPercent(bucket.partial, bucket.reads).padStart(6)} ${bar(bucket.partial, bucket.reads)}`
|
||||
);
|
||||
}
|
||||
console.log(" By time of day, session-normalized");
|
||||
for (const bucket of group.normalizedTimeOfDayStats) {
|
||||
console.log(
|
||||
` ${bucket.key} sessions=${formatInt(bucket.sessions).padStart(4)} reads/session=${formatRate(bucket.readsPerSession).padStart(5)} full/session=${formatRate(bucket.fullPerSession).padStart(5)} partial/session=${formatRate(bucket.partialPerSession).padStart(5)} medianSessionPartial=${bucket.medianSessionPartialRate === null ? "n/a" : formatPercent(bucket.medianSessionPartialRate, 1).padStart(6)} ${bar(bucket.medianSessionPartialRate ?? 0, 1)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (scan.malformedLines > 0) {
|
||||
console.log("\nParser notes");
|
||||
console.log(` malformed lines skipped: ${formatInt(scan.malformedLines)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function scanSessions(sessionsDir, since) {
|
||||
const records = [];
|
||||
const meta = { sessionsDir, sessionFilesScanned: 0, sessionFilesIncluded: 0, sessionFilesSkippedOlderThanSince: 0, sessionFilesWithReadCalls: 0, since, malformedLines: 0 };
|
||||
|
||||
for await (const sessionFile of walkJsonlFiles(sessionsDir)) {
|
||||
meta.sessionFilesScanned++;
|
||||
const sessionTimestampMs = parseSessionFileTimestamp(sessionFile);
|
||||
if (since && sessionTimestampMs !== null && sessionTimestampMs < since.ms) {
|
||||
meta.sessionFilesSkippedOlderThanSince++;
|
||||
continue;
|
||||
}
|
||||
meta.sessionFilesIncluded++;
|
||||
let fileHadReadCall = false;
|
||||
const input = createReadStream(sessionFile, { encoding: "utf8" });
|
||||
const rl = createInterface({ input, crlfDelay: Infinity });
|
||||
|
||||
for await (const line of rl) {
|
||||
if (!line.trim()) continue;
|
||||
let entry;
|
||||
try {
|
||||
entry = JSON.parse(line);
|
||||
} catch {
|
||||
meta.malformedLines++;
|
||||
continue;
|
||||
}
|
||||
if (entry?.type !== "message" || !entry.message) continue;
|
||||
const message = entry.message;
|
||||
if (message.role !== "assistant" || !Array.isArray(message.content)) continue;
|
||||
for (const block of message.content) {
|
||||
if (block?.type !== "toolCall" || block.name !== "read") continue;
|
||||
fileHadReadCall = true;
|
||||
records.push({
|
||||
sessionFile,
|
||||
assistantEntryId: entry.id,
|
||||
toolCallId: typeof block.id === "string" ? block.id : "",
|
||||
timestamp: entry.timestamp,
|
||||
timestampMs: Date.parse(entry.timestamp) || sessionTimestampMs || 0,
|
||||
api: typeof message.api === "string" ? message.api : null,
|
||||
provider: typeof message.provider === "string" ? message.provider : "[unknown]",
|
||||
model: typeof message.model === "string" ? message.model : "[unknown]",
|
||||
providerModel: `${typeof message.provider === "string" ? message.provider : "[unknown]"}/${typeof message.model === "string" ? message.model : "[unknown]"}`,
|
||||
...classifyRead(block.arguments),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (fileHadReadCall) meta.sessionFilesWithReadCalls++;
|
||||
}
|
||||
return { records, meta };
|
||||
}
|
||||
|
||||
function applyFilters(records, options) {
|
||||
return records.filter((record) => !options.modelFilter || record.providerModel.toLowerCase().includes(options.modelFilter.toLowerCase()));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (options.help) {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
const sessionsDir = path.resolve(options.sessionsDir);
|
||||
await fs.access(sessionsDir);
|
||||
const since = await resolveAutoSinceMs(options);
|
||||
const { records, meta } = await scanSessions(sessionsDir, since);
|
||||
const filteredRecords = applyFilters(records, options);
|
||||
const summary = buildSummary(filteredRecords, meta, options);
|
||||
if (options.json) {
|
||||
console.log(JSON.stringify(options.includeRecords ? { summary, records: filteredRecords } : { summary }, null, 2));
|
||||
return;
|
||||
}
|
||||
if (options.text) {
|
||||
printHumanReport(summary);
|
||||
return;
|
||||
}
|
||||
printHtmlReport(summary);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,364 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const DEFAULT_REPO = "earendil-works/pi";
|
||||
const DEFAULT_BASE_PATH = "packages/coding-agent";
|
||||
const DEFAULT_CHANGELOG = "packages/coding-agent/CHANGELOG.md";
|
||||
const DEFAULT_FIX_SINCE_TAG = "v0.74.0";
|
||||
const LEGACY_REPO_RE = /^https:\/\/github\.com\/(?:badlogic|earendil-works)\/pi-mono(?=\/|$)/;
|
||||
const URL_SCHEME_RE = /^[a-z][a-z0-9+.-]*:/i;
|
||||
const INLINE_MARKDOWN_LINK_RE = /(!?\[[^\]\n]+\]\()([^\s)]+)((?:\s+[^)]*)?\))/g;
|
||||
|
||||
function printUsage() {
|
||||
console.log(`Usage: node scripts/release-notes.mjs <command> [options]
|
||||
|
||||
Commands:
|
||||
extract Extract release notes from the coding-agent changelog
|
||||
fix-github-releases Rewrite existing GitHub release note links in place
|
||||
|
||||
extract options:
|
||||
--version <x.y.z> Version to extract
|
||||
--tag <vX.Y.Z> Release tag used for repository links (defaults to v<version>)
|
||||
--changelog <path> Changelog path (default: ${DEFAULT_CHANGELOG})
|
||||
--out <path> Output file (default: stdout)
|
||||
--repo <owner/repo> GitHub repository for generated links (default: ${DEFAULT_REPO})
|
||||
--base-path <path> Base path for relative changelog links (default: ${DEFAULT_BASE_PATH})
|
||||
|
||||
fix-github-releases options:
|
||||
--repo <owner/repo> GitHub repository to patch (default: ${DEFAULT_REPO})
|
||||
--tag <vX.Y.Z> Patch only one release tag
|
||||
--since-tag <vX.Y.Z> Oldest release tag to patch (default: ${DEFAULT_FIX_SINCE_TAG})
|
||||
--base-path <path> Base path for relative changelog links (default: ${DEFAULT_BASE_PATH})
|
||||
--dry-run Print releases that would change without updating GitHub
|
||||
`);
|
||||
}
|
||||
|
||||
function commandForPlatform(command) {
|
||||
return process.platform === "win32" ? `${command}.cmd` : command;
|
||||
}
|
||||
|
||||
function run(command, args, options = {}) {
|
||||
const result = spawnSync(commandForPlatform(command), args, {
|
||||
cwd: options.cwd,
|
||||
encoding: "utf8",
|
||||
maxBuffer: options.maxBuffer ?? 20 * 1024 * 1024,
|
||||
stdio: options.capture ? ["inherit", "pipe", "pipe"] : "inherit",
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
const output = [result.stdout, result.stderr].filter(Boolean).join("\n");
|
||||
throw new Error(output ? `Command failed: ${command} ${args.join(" ")}\n${output}` : `Command failed: ${command} ${args.join(" ")}`);
|
||||
}
|
||||
|
||||
return result.stdout ?? "";
|
||||
}
|
||||
|
||||
function parseOptions(args) {
|
||||
const options = {
|
||||
basePath: DEFAULT_BASE_PATH,
|
||||
changelog: DEFAULT_CHANGELOG,
|
||||
dryRun: false,
|
||||
out: undefined,
|
||||
repo: DEFAULT_REPO,
|
||||
sinceTag: DEFAULT_FIX_SINCE_TAG,
|
||||
tag: undefined,
|
||||
version: undefined,
|
||||
};
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (arg === "--help") {
|
||||
printUsage();
|
||||
process.exit(0);
|
||||
}
|
||||
if (arg === "--dry-run") {
|
||||
options.dryRun = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const optionNames = new Set(["--base-path", "--changelog", "--out", "--repo", "--since-tag", "--tag", "--version"]);
|
||||
if (!optionNames.has(arg)) {
|
||||
throw new Error(`Unknown option: ${arg}`);
|
||||
}
|
||||
|
||||
const value = args[++i];
|
||||
if (!value) {
|
||||
throw new Error(`${arg} requires a value`);
|
||||
}
|
||||
|
||||
if (arg === "--base-path") options.basePath = value;
|
||||
if (arg === "--changelog") options.changelog = value;
|
||||
if (arg === "--out") options.out = value;
|
||||
if (arg === "--repo") options.repo = value;
|
||||
if (arg === "--since-tag") options.sinceTag = value;
|
||||
if (arg === "--tag") options.tag = value;
|
||||
if (arg === "--version") options.version = value;
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function normalizeTag(tagOrVersion) {
|
||||
if (!tagOrVersion) {
|
||||
return undefined;
|
||||
}
|
||||
return tagOrVersion.startsWith("v") ? tagOrVersion : `v${tagOrVersion}`;
|
||||
}
|
||||
|
||||
function versionFromTag(tag) {
|
||||
return tag.startsWith("v") ? tag.slice(1) : tag;
|
||||
}
|
||||
|
||||
function compareVersions(a, b) {
|
||||
const aParts = versionFromTag(a).split(".").map(Number);
|
||||
const bParts = versionFromTag(b).split(".").map(Number);
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const diff = (aParts[i] || 0) - (bParts[i] || 0);
|
||||
if (diff !== 0) {
|
||||
return diff;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function extractChangelogSection(changelog, version) {
|
||||
const headingRe = new RegExp(`^## \\[${escapeRegExp(version)}\\](?:\\s+-\\s+\\d{4}-\\d{2}-\\d{2})?\\s*$`, "m");
|
||||
const heading = headingRe.exec(changelog);
|
||||
|
||||
if (!heading) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const sectionStart = heading.index + heading[0].length;
|
||||
const rest = changelog.slice(sectionStart);
|
||||
const nextHeading = rest.search(/^## \[/m);
|
||||
const section = nextHeading === -1 ? rest : rest.slice(0, nextHeading);
|
||||
return section.trim();
|
||||
}
|
||||
|
||||
function splitLocalTarget(target) {
|
||||
const hashIndex = target.indexOf("#");
|
||||
const beforeHash = hashIndex === -1 ? target : target.slice(0, hashIndex);
|
||||
const fragment = hashIndex === -1 ? "" : target.slice(hashIndex);
|
||||
const queryIndex = beforeHash.indexOf("?");
|
||||
|
||||
if (queryIndex === -1) {
|
||||
return { fragment, pathPart: beforeHash, query: "" };
|
||||
}
|
||||
|
||||
return {
|
||||
fragment,
|
||||
pathPart: beforeHash.slice(0, queryIndex),
|
||||
query: beforeHash.slice(queryIndex),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePathPart(value) {
|
||||
return value.replaceAll("\\", "/");
|
||||
}
|
||||
|
||||
function normalizeBasePath(basePath) {
|
||||
const normalized = path.posix.normalize(normalizePathPart(basePath)).replace(/\/+$/, "");
|
||||
return normalized === "." ? "" : normalized;
|
||||
}
|
||||
|
||||
function resolveRepositoryPath(targetPath, basePath) {
|
||||
const normalizedTarget = normalizePathPart(targetPath);
|
||||
const joined = normalizedTarget.startsWith("/")
|
||||
? path.posix.normalize(normalizedTarget.replace(/^\/+/, ""))
|
||||
: path.posix.normalize(path.posix.join(normalizeBasePath(basePath), normalizedTarget));
|
||||
|
||||
if (joined === "." || joined.startsWith("../") || joined === "..") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return joined;
|
||||
}
|
||||
|
||||
function isDirectoryTarget(originalPath, repositoryPath) {
|
||||
if (originalPath.endsWith("/")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const basename = path.posix.basename(repositoryPath);
|
||||
return !basename.includes(".");
|
||||
}
|
||||
|
||||
function normalizeLinkTarget(target, options) {
|
||||
let canonicalTarget = target.replace(LEGACY_REPO_RE, `https://github.com/${options.repo}`);
|
||||
const repoUrl = `https://github.com/${options.repo}`;
|
||||
|
||||
for (const route of ["blob", "tree"]) {
|
||||
for (const branch of ["main", "master"]) {
|
||||
const floatingRefPrefix = `${repoUrl}/${route}/${branch}/`;
|
||||
if (canonicalTarget.startsWith(floatingRefPrefix)) {
|
||||
canonicalTarget = `${repoUrl}/${route}/${options.tag}/${canonicalTarget.slice(floatingRefPrefix.length)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (canonicalTarget.startsWith("#") || canonicalTarget.startsWith("//") || URL_SCHEME_RE.test(canonicalTarget)) {
|
||||
return canonicalTarget;
|
||||
}
|
||||
|
||||
const { fragment, pathPart, query } = splitLocalTarget(canonicalTarget);
|
||||
if (!pathPart) {
|
||||
return canonicalTarget;
|
||||
}
|
||||
|
||||
const repositoryPath = resolveRepositoryPath(pathPart, options.basePath);
|
||||
if (!repositoryPath) {
|
||||
return canonicalTarget;
|
||||
}
|
||||
|
||||
const route = isDirectoryTarget(pathPart, repositoryPath) ? "tree" : "blob";
|
||||
return `https://github.com/${options.repo}/${route}/${options.tag}/${encodeURI(repositoryPath)}${query}${fragment}`;
|
||||
}
|
||||
|
||||
function normalizeReleaseNoteLinks(markdown, options) {
|
||||
const changes = [];
|
||||
const normalized = markdown.replace(INLINE_MARKDOWN_LINK_RE, (match, prefix, target, suffix) => {
|
||||
const normalizedTarget = normalizeLinkTarget(target, options);
|
||||
if (normalizedTarget !== target) {
|
||||
changes.push({ from: target, to: normalizedTarget });
|
||||
}
|
||||
return `${prefix}${normalizedTarget}${suffix}`;
|
||||
});
|
||||
|
||||
return { changes, markdown: normalized };
|
||||
}
|
||||
|
||||
function writeOutput(content, outPath) {
|
||||
if (outPath) {
|
||||
writeFileSync(outPath, content);
|
||||
return;
|
||||
}
|
||||
|
||||
process.stdout.write(content);
|
||||
}
|
||||
|
||||
function extractReleaseNotes(options) {
|
||||
const version = options.version ?? (options.tag ? versionFromTag(options.tag) : undefined);
|
||||
if (!version) {
|
||||
throw new Error("extract requires --version or --tag");
|
||||
}
|
||||
|
||||
if (!existsSync(options.changelog)) {
|
||||
throw new Error(`Changelog does not exist: ${options.changelog}`);
|
||||
}
|
||||
|
||||
const tag = normalizeTag(options.tag ?? version);
|
||||
const changelog = readFileSync(options.changelog, "utf8");
|
||||
const section = extractChangelogSection(changelog, version);
|
||||
const rawNotes = section ? `${section}\n` : `Release ${version}\n`;
|
||||
const { markdown } = normalizeReleaseNoteLinks(rawNotes, { basePath: options.basePath, repo: options.repo, tag });
|
||||
writeOutput(markdown, options.out);
|
||||
}
|
||||
|
||||
function listGithubReleases(repo) {
|
||||
const output = run("gh", ["api", `repos/${repo}/releases`, "--paginate", "--jq", ".[] | {id, tag_name, body} | @json"], {
|
||||
capture: true,
|
||||
});
|
||||
return output
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => JSON.parse(line));
|
||||
}
|
||||
|
||||
function uniqueChanges(changes) {
|
||||
const seen = new Set();
|
||||
const unique = [];
|
||||
for (const change of changes) {
|
||||
const key = `${change.from}\n${change.to}`;
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
unique.push(change);
|
||||
}
|
||||
return unique;
|
||||
}
|
||||
|
||||
function updateGithubRelease(repo, tag, body) {
|
||||
const tempDir = mkdtempSync(path.join(tmpdir(), "pi-release-notes-"));
|
||||
try {
|
||||
const notesPath = path.join(tempDir, "notes.md");
|
||||
writeFileSync(notesPath, body);
|
||||
run("gh", ["release", "edit", tag, "--repo", repo, "--notes-file", notesPath], { capture: true });
|
||||
} finally {
|
||||
rmSync(tempDir, { force: true, recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function fixGithubReleases(options) {
|
||||
const tagFilter = normalizeTag(options.tag);
|
||||
const sinceTag = normalizeTag(options.sinceTag);
|
||||
const matchingReleases = listGithubReleases(options.repo).filter((release) => !tagFilter || release.tag_name === tagFilter);
|
||||
|
||||
if (tagFilter && matchingReleases.length === 0) {
|
||||
throw new Error(`Release not found: ${tagFilter}`);
|
||||
}
|
||||
|
||||
const releases = matchingReleases.filter((release) => compareVersions(release.tag_name, sinceTag) >= 0);
|
||||
if (tagFilter && releases.length === 0) {
|
||||
console.log(`Skipping ${tagFilter}: older than ${sinceTag}.`);
|
||||
console.log(`${options.dryRun ? "Would update" : "Updated"} 0 releases.`);
|
||||
return;
|
||||
}
|
||||
|
||||
let changedCount = 0;
|
||||
for (const release of releases) {
|
||||
const tag = release.tag_name;
|
||||
const body = release.body ?? "";
|
||||
const result = normalizeReleaseNoteLinks(body, { basePath: options.basePath, repo: options.repo, tag });
|
||||
if (result.markdown === body) {
|
||||
continue;
|
||||
}
|
||||
|
||||
changedCount++;
|
||||
const unique = uniqueChanges(result.changes);
|
||||
console.log(`${options.dryRun ? "Would update" : "Updating"} ${tag} (${unique.length} link${unique.length === 1 ? "" : "s"})`);
|
||||
for (const change of unique) {
|
||||
console.log(` ${change.from}`);
|
||||
console.log(` -> ${change.to}`);
|
||||
}
|
||||
|
||||
if (!options.dryRun) {
|
||||
updateGithubRelease(options.repo, tag, result.markdown);
|
||||
}
|
||||
}
|
||||
|
||||
const prefix = options.dryRun ? "Would update" : "Updated";
|
||||
console.log(`${prefix} ${changedCount} release${changedCount === 1 ? "" : "s"}.`);
|
||||
}
|
||||
|
||||
try {
|
||||
const [command, ...args] = process.argv.slice(2);
|
||||
if (!command || command === "--help") {
|
||||
printUsage();
|
||||
process.exit(command ? 0 : 1);
|
||||
}
|
||||
|
||||
const options = parseOptions(args);
|
||||
if (command === "extract") {
|
||||
extractReleaseNotes(options);
|
||||
} else if (command === "fix-github-releases") {
|
||||
fixGithubReleases(options);
|
||||
} else {
|
||||
throw new Error(`Unknown command: ${command}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error instanceof Error ? error.message : error);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Release script for pi-mono
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/release.mjs <major|minor|patch>
|
||||
* node scripts/release.mjs <x.y.z>
|
||||
*
|
||||
* Steps:
|
||||
* 1. Check for uncommitted changes
|
||||
* 2. Bump version via npm run version:xxx or set an explicit version
|
||||
* 3. Update CHANGELOG.md files: [Unreleased] -> [version] - date
|
||||
* 4. Regenerate release artifacts
|
||||
* 5. Run checks and tests
|
||||
* 6. Commit and tag the release
|
||||
* 7. Add new [Unreleased] section to changelogs
|
||||
* 8. Commit next-cycle changelog updates
|
||||
* 9. Push main and the tag to trigger CI publishing
|
||||
*/
|
||||
|
||||
import { execSync } from "node:child_process";
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { findPackageDirectories } from "./package-workspaces.mjs";
|
||||
|
||||
const RELEASE_TARGET = process.argv[2];
|
||||
const BUMP_TYPES = new Set(["major", "minor", "patch"]);
|
||||
const SEMVER_RE = /^\d+\.\d+\.\d+$/;
|
||||
|
||||
if (!RELEASE_TARGET || (!BUMP_TYPES.has(RELEASE_TARGET) && !SEMVER_RE.test(RELEASE_TARGET))) {
|
||||
console.error("Usage: node scripts/release.mjs <major|minor|patch|x.y.z>");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function run(cmd, options = {}) {
|
||||
console.log(`$ ${cmd}`);
|
||||
try {
|
||||
return execSync(cmd, { encoding: "utf-8", stdio: options.silent ? "pipe" : "inherit", ...options });
|
||||
} catch (e) {
|
||||
if (!options.ignoreError) {
|
||||
console.error(`Command failed: ${cmd}`);
|
||||
process.exit(1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getVersion() {
|
||||
const pkg = JSON.parse(readFileSync("packages/ai/package.json", "utf-8"));
|
||||
return pkg.version;
|
||||
}
|
||||
|
||||
function compareVersions(a, b) {
|
||||
const aParts = a.split(".").map(Number);
|
||||
const bParts = b.split(".").map(Number);
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const diff = (aParts[i] || 0) - (bParts[i] || 0);
|
||||
if (diff !== 0) {
|
||||
return diff;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function shellQuote(value) {
|
||||
return `'${value.replace(/'/g, `'\\''`)}'`;
|
||||
}
|
||||
|
||||
function removeStaleWorkspaceLockEntries() {
|
||||
const workspaceVersions = new Map(
|
||||
findPackageDirectories()
|
||||
.map((directory) => JSON.parse(readFileSync(join(directory, "package.json"), "utf8")))
|
||||
.filter((pkg) => pkg.private !== true)
|
||||
.map((pkg) => [pkg.name, pkg.version]),
|
||||
);
|
||||
const lockPath = "package-lock.json";
|
||||
const lock = JSON.parse(readFileSync(lockPath, "utf8"));
|
||||
let removed = 0;
|
||||
|
||||
for (const [path, pkg] of Object.entries(lock.packages)) {
|
||||
if (!path.startsWith("packages/") || pkg.link === true) {
|
||||
continue;
|
||||
}
|
||||
for (const [name, version] of workspaceVersions) {
|
||||
if (path.endsWith(`/node_modules/${name}`) && pkg.version !== version) {
|
||||
delete lock.packages[path];
|
||||
removed++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (removed > 0) {
|
||||
writeFileSync(lockPath, `${JSON.stringify(lock, null, "\t")}\n`);
|
||||
console.log(`Removed ${removed} stale workspace package lock ${removed === 1 ? "entry" : "entries"}.`);
|
||||
}
|
||||
}
|
||||
|
||||
function stageChangedFiles() {
|
||||
const output = run("git ls-files -m -o -d --exclude-standard", { silent: true });
|
||||
const paths = [...new Set((output || "").split("\n").map((line) => line.trim()).filter(Boolean))];
|
||||
if (paths.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
run(`git add -- ${paths.map(shellQuote).join(" ")}`);
|
||||
}
|
||||
|
||||
function bumpOrSetVersion(target) {
|
||||
const currentVersion = getVersion();
|
||||
|
||||
if (BUMP_TYPES.has(target)) {
|
||||
console.log(`Bumping version (${target})...`);
|
||||
run(`npm run version:${target}`);
|
||||
} else {
|
||||
if (compareVersions(target, currentVersion) <= 0) {
|
||||
console.error(`Error: explicit version ${target} must be greater than current version ${currentVersion}.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Setting explicit version (${target})...`);
|
||||
run(`npm version ${target} -ws --no-git-tag-version && node scripts/sync-versions.js && npm install --package-lock-only --ignore-scripts`);
|
||||
}
|
||||
|
||||
// npm version can temporarily install the previous workspace versions before
|
||||
// sync-versions updates inter-package ranges. Remove those stale lock entries,
|
||||
// refresh the lockfile, then hydrate from the final dependency graph.
|
||||
removeStaleWorkspaceLockEntries();
|
||||
run("npm install --package-lock-only --ignore-scripts");
|
||||
run("npm ci --ignore-scripts");
|
||||
return getVersion();
|
||||
}
|
||||
|
||||
function getChangelogs() {
|
||||
return findPackageDirectories()
|
||||
.map((directory) => join(directory, "CHANGELOG.md"))
|
||||
.filter((path) => existsSync(path));
|
||||
}
|
||||
|
||||
function updateChangelogsForRelease(version) {
|
||||
const date = new Date().toISOString().split("T")[0];
|
||||
const changelogs = getChangelogs();
|
||||
|
||||
for (const changelog of changelogs) {
|
||||
const content = readFileSync(changelog, "utf-8");
|
||||
|
||||
if (!content.includes("## [Unreleased]")) {
|
||||
console.log(` Skipping ${changelog}: no [Unreleased] section`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const updated = content.replace(
|
||||
"## [Unreleased]",
|
||||
`## [${version}] - ${date}`
|
||||
);
|
||||
writeFileSync(changelog, updated);
|
||||
console.log(` Updated ${changelog}`);
|
||||
}
|
||||
}
|
||||
|
||||
function addUnreleasedSection() {
|
||||
const changelogs = getChangelogs();
|
||||
const unreleasedSection = "## [Unreleased]\n\n";
|
||||
|
||||
for (const changelog of changelogs) {
|
||||
const content = readFileSync(changelog, "utf-8");
|
||||
|
||||
// Insert after "# Changelog\n\n"
|
||||
const updated = content.replace(
|
||||
/^(# Changelog\n\n)/,
|
||||
`$1${unreleasedSection}`
|
||||
);
|
||||
writeFileSync(changelog, updated);
|
||||
console.log(` Added [Unreleased] to ${changelog}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Main flow
|
||||
console.log("\n=== Release Script ===\n");
|
||||
|
||||
// 1. Check for uncommitted changes
|
||||
console.log("Checking for uncommitted changes...");
|
||||
const status = run("git status --porcelain", { silent: true });
|
||||
if (status && status.trim()) {
|
||||
console.error("Error: Uncommitted changes detected. Commit or stash first.");
|
||||
console.error(status);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(" Working directory clean\n");
|
||||
|
||||
// 2. Bump or set version
|
||||
const version = bumpOrSetVersion(RELEASE_TARGET);
|
||||
console.log(` New version: ${version}\n`);
|
||||
|
||||
// 3. Update changelogs
|
||||
console.log("Updating CHANGELOG.md files...");
|
||||
updateChangelogsForRelease(version);
|
||||
console.log();
|
||||
|
||||
// 4. Regenerate release artifacts
|
||||
console.log("Regenerating release artifacts...");
|
||||
run("npm run generate:models");
|
||||
run("npm run check:model-data");
|
||||
run("npm run shrinkwrap:coding-agent");
|
||||
run("npm run install-lock:coding-agent");
|
||||
console.log();
|
||||
|
||||
// 5. Run checks and tests
|
||||
console.log("Running checks...");
|
||||
run("npm run check");
|
||||
console.log();
|
||||
|
||||
console.log("Building packages for tests...");
|
||||
run("npm run build:offline");
|
||||
console.log();
|
||||
|
||||
console.log("Running tests...");
|
||||
run("./test.sh");
|
||||
console.log();
|
||||
|
||||
// 6. Commit and tag
|
||||
console.log("Committing and tagging...");
|
||||
stageChangedFiles();
|
||||
run(`git commit -m "Release v${version}"`);
|
||||
run(`git tag v${version}`);
|
||||
console.log();
|
||||
|
||||
// 7. Add new [Unreleased] sections
|
||||
console.log("Adding [Unreleased] sections for next cycle...");
|
||||
addUnreleasedSection();
|
||||
console.log();
|
||||
|
||||
// 8. Commit
|
||||
console.log("Committing changelog updates...");
|
||||
stageChangedFiles();
|
||||
run(`git commit -m "Add [Unreleased] section for next cycle"`);
|
||||
console.log();
|
||||
|
||||
// 9. Push
|
||||
console.log("Pushing to remote...");
|
||||
run("git push origin main");
|
||||
run(`git push origin v${version}`);
|
||||
console.log();
|
||||
|
||||
console.log(`=== Prepared release v${version}; CI publishing starts after the tag push ===`);
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env node
|
||||
import { existsSync } from "node:fs";
|
||||
import { createBashTool } from "../packages/coding-agent/src/core/tools/bash.ts";
|
||||
|
||||
const shellPath = "C:\\Windows\\System32\\bash.exe";
|
||||
const nameExpansion = "$" + "{name}";
|
||||
const countExpansion = "$" + "{count}";
|
||||
const iExpansion = "$" + "{i}";
|
||||
|
||||
function getTextOutput(result) {
|
||||
return result.content
|
||||
.filter((content) => content.type === "text")
|
||||
.map((content) => content.text ?? "")
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
async function runCase(label, command, expectedOutput) {
|
||||
const tool = createBashTool(process.cwd(), { shellPath });
|
||||
const result = await tool.execute(label, { command });
|
||||
const output = getTextOutput(result).trimEnd();
|
||||
if (output !== expectedOutput) {
|
||||
throw new Error(
|
||||
[
|
||||
`${label} failed`,
|
||||
"Expected:",
|
||||
expectedOutput,
|
||||
"Actual:",
|
||||
output,
|
||||
].join("\n"),
|
||||
);
|
||||
}
|
||||
console.log(output);
|
||||
}
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
throw new Error("This repro must run from Windows PowerShell/CMD, not macOS/Linux or inside WSL.");
|
||||
}
|
||||
|
||||
if (!existsSync(shellPath)) {
|
||||
throw new Error(`WSL bash launcher not found at ${shellPath}. Install/enable WSL first.`);
|
||||
}
|
||||
|
||||
await runCase(
|
||||
"issue-5893-simple-variable",
|
||||
`name='World'; echo "Hello, ${nameExpansion}!"`,
|
||||
"Hello, World!",
|
||||
);
|
||||
|
||||
await runCase(
|
||||
"issue-5893-loop-variable",
|
||||
`count=3; for i in $(seq 1 ${countExpansion}); do echo "Iteration ${iExpansion} of ${countExpansion}"; done`,
|
||||
"Iteration 1 of 3\nIteration 2 of 3\nIteration 3 of 3",
|
||||
);
|
||||
|
||||
console.log("issue #5893 WSL bash repro passed");
|
||||
@@ -0,0 +1,405 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { createReadStream } from "node:fs";
|
||||
import { promises as fs } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { createInterface } from "node:readline";
|
||||
|
||||
const DEFAULT_SESSIONS_DIR = path.join(homedir(), ".pi/agent/sessions");
|
||||
const MODELS_GENERATED_PATH = path.join(process.cwd(), "packages/ai/src/models.generated.ts");
|
||||
const MODELS_CONFIG_PATH = path.join(homedir(), ".pi/agent/models.json");
|
||||
const REPORT_TIME_ZONE = "Europe/Berlin";
|
||||
const CHART_WIDTH = 40;
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = { sessionsDir: DEFAULT_SESSIONS_DIR, json: false, text: false, allSessions: false, since: undefined, modelFilter: undefined, modelPrefixes: [], bashContains: [], cwd: process.cwd(), help: false };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const arg = argv[i];
|
||||
if (arg === "--help" || arg === "-h") options.help = true;
|
||||
else if (arg === "--json") options.json = true;
|
||||
else if (arg === "--text") options.text = true;
|
||||
else if (arg === "--sessions-dir") options.sessionsDir = argv[++i];
|
||||
else if (arg === "--since") options.since = argv[++i];
|
||||
else if (arg === "--all-sessions") options.allSessions = true;
|
||||
else if (arg === "--model") options.modelFilter = argv[++i];
|
||||
else if (arg === "--model-prefix") options.modelPrefixes.push(argv[++i]);
|
||||
else if (arg === "--bash-contains") options.bashContains.push(argv[++i]);
|
||||
else if (arg === "--git-commit-or-push") options.bashContains.push("git commit", "git push");
|
||||
else if (arg === "--cwd") options.cwd = argv[++i];
|
||||
else if (arg === "--all-cwds") options.cwd = undefined;
|
||||
else throw new Error(`Unknown argument: ${arg}`);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function printHelp() {
|
||||
console.log(`Usage: node scripts/session-context-stats.mjs [options]
|
||||
|
||||
Options:
|
||||
--sessions-dir <path> Sessions directory (default: ~/.pi/agent/sessions)
|
||||
--model <substring> Filter provider/model by substring
|
||||
--model-prefix <p> Include provider/model prefixes, repeatable, e.g. openai-codex/
|
||||
--bash-contains <text> Include only sessions with bash tool calls containing text, repeatable
|
||||
--git-commit-or-push Shortcut for --bash-contains "git commit" --bash-contains "git push"
|
||||
--cwd <path> Include only sessions whose cwd is this path (default: current cwd)
|
||||
--all-cwds Include sessions from all cwd values
|
||||
--since <iso> Only scan session files created at or after this ISO time
|
||||
--all-sessions Scan all sessions (default already scans all)
|
||||
--json Print JSON instead of HTML report
|
||||
--text Print plain text instead of HTML report
|
||||
-h, --help Show this help
|
||||
`);
|
||||
}
|
||||
|
||||
function parseSessionFileTimestamp(sessionFile) {
|
||||
const rawTimestamp = path.basename(sessionFile).split("_")[0];
|
||||
if (!rawTimestamp) return null;
|
||||
const ms = Date.parse(rawTimestamp.replace(/T(\d{2})-(\d{2})-(\d{2})-(\d{3})Z$/, "T$1:$2:$3.$4Z"));
|
||||
return Number.isFinite(ms) ? ms : null;
|
||||
}
|
||||
|
||||
function getTimeZoneParts(ms) {
|
||||
const parts = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: REPORT_TIME_ZONE,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).formatToParts(new Date(ms));
|
||||
return Object.fromEntries(parts.filter((part) => part.type !== "literal").map((part) => [part.type, part.value]));
|
||||
}
|
||||
|
||||
function formatDay(ms) {
|
||||
const parts = getTimeZoneParts(ms);
|
||||
return `${parts.year}-${parts.month}-${parts.day}`;
|
||||
}
|
||||
|
||||
function formatInt(value) {
|
||||
return new Intl.NumberFormat("en-US").format(Math.round(value));
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
return Number.isFinite(value) ? value.toFixed(2) : "n/a";
|
||||
}
|
||||
|
||||
function formatPercent(value) {
|
||||
return Number.isFinite(value) ? `${value.toFixed(1)}%` : "n/a";
|
||||
}
|
||||
|
||||
function bar(percent) {
|
||||
const clamped = Number.isFinite(percent) ? Math.max(0, Math.min(100, percent)) : 0;
|
||||
const filled = Math.round((clamped / 100) * CHART_WIDTH);
|
||||
return `${"█".repeat(filled)}${"░".repeat(CHART_WIDTH - filled)}`;
|
||||
}
|
||||
|
||||
function median(values) {
|
||||
const finite = values.filter((value) => Number.isFinite(value)).sort((a, b) => a - b);
|
||||
if (finite.length === 0) return null;
|
||||
const middle = Math.floor(finite.length / 2);
|
||||
return finite.length % 2 === 0 ? (finite[middle - 1] + finite[middle]) / 2 : finite[middle];
|
||||
}
|
||||
|
||||
async function* walkJsonlFiles(dir) {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
entries.sort((a, b) => a.name.localeCompare(b.name));
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) yield* walkJsonlFiles(fullPath);
|
||||
else if (entry.isFile() && entry.name.endsWith(".jsonl")) yield fullPath;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadContextWindows() {
|
||||
const windows = new Map();
|
||||
const sources = [];
|
||||
let text = "";
|
||||
try {
|
||||
text = await fs.readFile(MODELS_GENERATED_PATH, "utf8");
|
||||
sources.push(MODELS_GENERATED_PATH);
|
||||
} catch {
|
||||
// Optional in non-repo usage.
|
||||
}
|
||||
const providerRegex = /\n\t"([^"]+)": \{([\s\S]*?\n\t)\},/g;
|
||||
let providerMatch;
|
||||
while ((providerMatch = providerRegex.exec(text)) !== null) {
|
||||
const provider = providerMatch[1];
|
||||
const body = providerMatch[2];
|
||||
const modelRegex = /\n\t\t"([^"]+)": \{[\s\S]*?contextWindow: (\d+),/g;
|
||||
let modelMatch;
|
||||
while ((modelMatch = modelRegex.exec(body)) !== null) {
|
||||
windows.set(`${provider}/${modelMatch[1]}`, Number(modelMatch[2]));
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const config = JSON.parse(await fs.readFile(MODELS_CONFIG_PATH, "utf8"));
|
||||
sources.push(MODELS_CONFIG_PATH);
|
||||
const providers = config?.providers && typeof config.providers === "object" ? config.providers : {};
|
||||
for (const [providerName, provider] of Object.entries(providers)) {
|
||||
const overrides = provider?.modelOverrides && typeof provider.modelOverrides === "object" ? provider.modelOverrides : {};
|
||||
for (const [modelId, override] of Object.entries(overrides)) {
|
||||
if (typeof override?.contextWindow === "number") windows.set(`${providerName}/${modelId}`, override.contextWindow);
|
||||
}
|
||||
if (Array.isArray(provider?.models)) {
|
||||
for (const model of provider.models) {
|
||||
if (typeof model?.id === "string" && typeof model.contextWindow === "number") windows.set(`${providerName}/${model.id}`, model.contextWindow);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Optional user config.
|
||||
}
|
||||
|
||||
return { windows, sources };
|
||||
}
|
||||
|
||||
function contextTokens(usage) {
|
||||
if (!usage || typeof usage !== "object") return null;
|
||||
const totalTokens = Number(usage.totalTokens ?? 0);
|
||||
if (Number.isFinite(totalTokens) && totalTokens > 0) return totalTokens;
|
||||
const input = Number(usage.input ?? 0);
|
||||
const output = Number(usage.output ?? 0);
|
||||
const cacheRead = Number(usage.cacheRead ?? 0);
|
||||
const cacheWrite = Number(usage.cacheWrite ?? 0);
|
||||
const value = input + output + cacheRead + cacheWrite;
|
||||
return Number.isFinite(value) && value > 0 ? value : null;
|
||||
}
|
||||
|
||||
async function scanSessions(sessionsDir, sinceMs, contextWindows, cwdFilter) {
|
||||
const windows = contextWindows.windows;
|
||||
const sessions = [];
|
||||
const meta = { sessionsDir, sessionFilesScanned: 0, sessionFilesIncluded: 0, sessionFilesSkippedOlderThanSince: 0, malformedLines: 0 };
|
||||
for await (const sessionFile of walkJsonlFiles(sessionsDir)) {
|
||||
meta.sessionFilesScanned++;
|
||||
const fileTimestampMs = parseSessionFileTimestamp(sessionFile);
|
||||
if (sinceMs !== null && fileTimestampMs !== null && fileTimestampMs < sinceMs) {
|
||||
meta.sessionFilesSkippedOlderThanSince++;
|
||||
continue;
|
||||
}
|
||||
meta.sessionFilesIncluded++;
|
||||
const session = {
|
||||
sessionFile,
|
||||
cwd: null,
|
||||
startMs: fileTimestampMs ?? 0,
|
||||
endMs: fileTimestampMs ?? 0,
|
||||
providerModel: "[unknown]/[unknown]",
|
||||
assistantMessages: 0,
|
||||
userMessages: 0,
|
||||
compactions: 0,
|
||||
seenCompaction: false,
|
||||
maxPromptTokens: null,
|
||||
preFirstCompactionTokens: null,
|
||||
maxContextUsagePercent: null,
|
||||
preFirstCompactionUsagePercent: null,
|
||||
contextWindow: null,
|
||||
bashCommands: [],
|
||||
over80: false,
|
||||
over90: false,
|
||||
over100: false,
|
||||
};
|
||||
const input = createReadStream(sessionFile, { encoding: "utf8" });
|
||||
const rl = createInterface({ input, crlfDelay: Infinity });
|
||||
for await (const line of rl) {
|
||||
if (!line.trim()) continue;
|
||||
let entry;
|
||||
try {
|
||||
entry = JSON.parse(line);
|
||||
} catch {
|
||||
meta.malformedLines++;
|
||||
continue;
|
||||
}
|
||||
if (entry.type === "session" && typeof entry.cwd === "string") session.cwd = entry.cwd;
|
||||
const entryMs = Date.parse(entry.timestamp ?? "");
|
||||
if (Number.isFinite(entryMs)) {
|
||||
if (!session.startMs || entryMs < session.startMs) session.startMs = entryMs;
|
||||
if (!session.endMs || entryMs > session.endMs) session.endMs = entryMs;
|
||||
}
|
||||
if (entry.type === "compaction") {
|
||||
session.compactions++;
|
||||
if (typeof entry.tokensBefore === "number") {
|
||||
session.maxPromptTokens = Math.max(session.maxPromptTokens ?? 0, entry.tokensBefore);
|
||||
if (!session.seenCompaction) session.preFirstCompactionTokens = entry.tokensBefore;
|
||||
}
|
||||
session.seenCompaction = true;
|
||||
continue;
|
||||
}
|
||||
if (entry.type !== "message" || !entry.message) continue;
|
||||
const message = entry.message;
|
||||
if (message.role === "assistant" && Array.isArray(message.content)) {
|
||||
for (const block of message.content) {
|
||||
if (block?.type !== "toolCall" || block.name !== "bash") continue;
|
||||
const command = typeof block.arguments?.command === "string" ? block.arguments.command : "";
|
||||
if (command) session.bashCommands.push(command);
|
||||
}
|
||||
}
|
||||
if (message.role === "user") session.userMessages++;
|
||||
if (message.role !== "assistant") continue;
|
||||
session.assistantMessages++;
|
||||
const provider = typeof message.provider === "string" ? message.provider : "[unknown]";
|
||||
const model = typeof message.model === "string" ? message.model : "[unknown]";
|
||||
session.providerModel = `${provider}/${model}`;
|
||||
const contextWindow = windows.get(session.providerModel) ?? null;
|
||||
if (contextWindow !== null) session.contextWindow = contextWindow;
|
||||
const tokens = contextTokens(message.usage);
|
||||
if (tokens !== null) {
|
||||
session.maxPromptTokens = Math.max(session.maxPromptTokens ?? 0, tokens);
|
||||
if (!session.seenCompaction) session.preFirstCompactionTokens = tokens;
|
||||
}
|
||||
}
|
||||
if (session.maxPromptTokens !== null && session.contextWindow !== null) {
|
||||
session.maxContextUsagePercent = (session.maxPromptTokens / session.contextWindow) * 100;
|
||||
session.over80 = session.maxContextUsagePercent >= 80;
|
||||
session.over90 = session.maxContextUsagePercent >= 90;
|
||||
session.over100 = session.maxContextUsagePercent >= 100;
|
||||
}
|
||||
if (session.preFirstCompactionTokens !== null && session.contextWindow !== null) {
|
||||
session.preFirstCompactionUsagePercent = (session.preFirstCompactionTokens / session.contextWindow) * 100;
|
||||
}
|
||||
if (!cwdFilter || path.resolve(session.cwd ?? "") === cwdFilter) sessions.push(session);
|
||||
}
|
||||
return { sessions, meta };
|
||||
}
|
||||
|
||||
function summarizeGroups(sessions, keyFn) {
|
||||
const groups = new Map();
|
||||
for (const session of sessions) {
|
||||
const key = keyFn(session);
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key).push(session);
|
||||
}
|
||||
return [...groups.entries()].map(([key, group]) => summarizeSessionGroup(key, group)).sort((a, b) => a.key.localeCompare(b.key));
|
||||
}
|
||||
|
||||
function summarizeSessionGroup(key, group) {
|
||||
const withUsage = group.filter((session) => session.maxContextUsagePercent !== null);
|
||||
const withTokens = group.filter((session) => session.maxPromptTokens !== null);
|
||||
const withPreCompactionUsage = group.filter((session) => session.preFirstCompactionUsagePercent !== null);
|
||||
const compactions = group.filter((session) => session.compactions > 0).length;
|
||||
const totalCompactions = group.reduce((sum, session) => sum + session.compactions, 0);
|
||||
const contextWindows = [...new Set(group.map((session) => session.contextWindow).filter((value) => value !== null))].sort((a, b) => a - b);
|
||||
return {
|
||||
key,
|
||||
sessions: group.length,
|
||||
contextWindows,
|
||||
assistantMessages: group.reduce((sum, session) => sum + session.assistantMessages, 0),
|
||||
avgTurns: group.reduce((sum, session) => sum + session.userMessages, 0) / group.length,
|
||||
avgDurationMinutes: group.reduce((sum, session) => sum + Math.max(0, session.endMs - session.startMs) / 60000, 0) / group.length,
|
||||
avgMaxPromptTokens: withTokens.length === 0 ? null : withTokens.reduce((sum, session) => sum + (session.maxPromptTokens ?? 0), 0) / withTokens.length,
|
||||
medianMaxPromptTokens: median(withTokens.map((session) => session.maxPromptTokens)),
|
||||
avgMaxContextUsagePercent: withUsage.length === 0 ? null : withUsage.reduce((sum, session) => sum + (session.maxContextUsagePercent ?? 0), 0) / withUsage.length,
|
||||
medianMaxContextUsagePercent: median(withUsage.map((session) => session.maxContextUsagePercent)),
|
||||
medianPreFirstCompactionUsagePercent: median(withPreCompactionUsage.map((session) => session.preFirstCompactionUsagePercent)),
|
||||
contextKnownSessions: withUsage.length,
|
||||
sessionsWithCompaction: compactions,
|
||||
totalCompactions,
|
||||
compactionRate: (compactions / group.length) * 100,
|
||||
over80: group.filter((session) => session.over80).length,
|
||||
over90: group.filter((session) => session.over90).length,
|
||||
over100: group.filter((session) => session.over100).length,
|
||||
};
|
||||
}
|
||||
|
||||
function buildSummary(sessions, meta, options) {
|
||||
const lowerPrefixes = options.modelPrefixes.map((prefix) => prefix.toLowerCase());
|
||||
const bashContains = options.bashContains.map((text) => text.toLowerCase());
|
||||
const filtered = sessions.filter((session) => {
|
||||
const providerModel = session.providerModel.toLowerCase();
|
||||
if (options.modelFilter && !providerModel.includes(options.modelFilter.toLowerCase())) return false;
|
||||
if (lowerPrefixes.length > 0 && !lowerPrefixes.some((prefix) => providerModel.startsWith(prefix))) return false;
|
||||
if (bashContains.length > 0) {
|
||||
const commands = session.bashCommands.map((command) => command.toLowerCase());
|
||||
if (!bashContains.some((text) => commands.some((command) => command.includes(text)))) return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return {
|
||||
filters: { model: options.modelFilter ?? null, modelPrefixes: options.modelPrefixes, bashContains: options.bashContains, cwd: options.cwd ? path.resolve(options.cwd) : null },
|
||||
scan: { ...meta, timezone: REPORT_TIME_ZONE },
|
||||
totals: summarizeSessionGroup("total", filtered),
|
||||
byDay: summarizeGroups(filtered, (session) => formatDay(session.startMs)),
|
||||
byModel: summarizeGroups(filtered, (session) => session.providerModel).sort((a, b) => b.sessions - a.sessions || a.key.localeCompare(b.key)),
|
||||
byModelDay: summarizeGroups(filtered, (session) => session.providerModel).sort((a, b) => b.sessions - a.sessions || a.key.localeCompare(b.key)).map((model) => ({
|
||||
...model,
|
||||
byDay: summarizeGroups(
|
||||
filtered.filter((session) => session.providerModel === model.key),
|
||||
(session) => formatDay(session.startMs)
|
||||
),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function lineForGroup(group, indent = " ") {
|
||||
return `${indent}${group.key} sessions=${formatInt(group.sessions).padStart(4)} avgTurns=${formatNumber(group.avgTurns).padStart(5)} avgMin=${formatNumber(group.avgDurationMinutes).padStart(6)} avgMaxTok=${group.avgMaxPromptTokens === null ? "n/a" : formatInt(group.avgMaxPromptTokens).padStart(7)} medMaxCtx=${group.medianMaxContextUsagePercent === null ? "n/a" : formatPercent(group.medianMaxContextUsagePercent).padStart(6)} medPreCompactCtx=${group.medianPreFirstCompactionUsagePercent === null ? "n/a" : formatPercent(group.medianPreFirstCompactionUsagePercent).padStart(6)} avgCtx=${group.avgMaxContextUsagePercent === null ? "n/a" : formatPercent(group.avgMaxContextUsagePercent).padStart(6)} compact=${formatPercent(group.compactionRate).padStart(6)} over90=${formatInt(group.over90).padStart(3)} ${bar(group.medianMaxContextUsagePercent ?? 0)}`;
|
||||
}
|
||||
|
||||
function buildTextReport(summary) {
|
||||
const lines = [];
|
||||
lines.push(`Scanned ${formatInt(summary.scan.sessionFilesIncluded)} session files in ${summary.scan.sessionsDir}`);
|
||||
lines.push(`Report timezone: ${summary.scan.timezone} (CET/CEST)`);
|
||||
lines.push(`Context window sources: ${summary.scan.contextWindowSources.join(", ") || "none"}`);
|
||||
if (summary.filters.model) lines.push(`Filters: model contains "${summary.filters.model}"`);
|
||||
if (summary.filters.modelPrefixes.length > 0) lines.push(`Filters: model prefixes = ${summary.filters.modelPrefixes.join(", ")}`);
|
||||
if (summary.filters.bashContains.length > 0) lines.push(`Filters: bash contains any of = ${summary.filters.bashContains.join(", ")}`);
|
||||
if (summary.filters.cwd) lines.push(`Filters: cwd = ${summary.filters.cwd}`);
|
||||
lines.push("Context usage parses full session JSONL files. max context uses max assistant usage.totalTokens per session, falling back to input + output + cacheRead + cacheWrite, plus compaction tokensBefore. medPreCompactCtx uses the last assistant usage before the first compaction, or the first compaction tokensBefore when present, divided by model contextWindow from packages/ai/src/models.generated.ts when known.");
|
||||
lines.push("");
|
||||
lines.push("Totals");
|
||||
lines.push(lineForGroup(summary.totals));
|
||||
lines.push("");
|
||||
lines.push("By day");
|
||||
for (const group of summary.byDay) lines.push(lineForGroup(group));
|
||||
lines.push("");
|
||||
lines.push("By model");
|
||||
for (const group of summary.byModel) lines.push(lineForGroup(group));
|
||||
lines.push("");
|
||||
lines.push("By model, then by day");
|
||||
for (const model of summary.byModelDay) {
|
||||
lines.push("");
|
||||
const contextWindowLabel = model.contextWindows.length === 0 ? "unknown" : model.contextWindows.map((value) => formatInt(value)).join(", ");
|
||||
lines.push(`${model.key} contextWindow=${contextWindowLabel}`);
|
||||
lines.push(lineForGroup(model, " total "));
|
||||
for (const group of model.byDay) lines.push(lineForGroup(group, " "));
|
||||
}
|
||||
if (summary.scan.malformedLines > 0) {
|
||||
lines.push("");
|
||||
lines.push(`Malformed lines skipped: ${formatInt(summary.scan.malformedLines)}`);
|
||||
}
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
return text.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
||||
}
|
||||
|
||||
function printHtmlReport(summary) {
|
||||
console.log(`<!doctype html>
|
||||
<meta charset="utf-8">
|
||||
<title>Session context stats</title>
|
||||
<style>
|
||||
body { margin: 24px; background: #fff; color: #111; }
|
||||
pre { font: 13px/1.35 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: pre; }
|
||||
</style>
|
||||
<pre>${escapeHtml(buildTextReport(summary))}</pre>`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
if (options.help) {
|
||||
printHelp();
|
||||
return;
|
||||
}
|
||||
const sinceMs = options.since ? Date.parse(options.since) : null;
|
||||
if (options.since && !Number.isFinite(sinceMs)) throw new Error(`Invalid --since value: ${options.since}`);
|
||||
const contextWindows = await loadContextWindows();
|
||||
const cwdFilter = options.cwd ? path.resolve(options.cwd) : undefined;
|
||||
const { sessions, meta } = await scanSessions(path.resolve(options.sessionsDir), sinceMs, contextWindows, cwdFilter);
|
||||
const summary = buildSummary(sessions, { ...meta, contextWindowSources: contextWindows.sources }, options);
|
||||
if (options.json) console.log(JSON.stringify(summary, null, 2));
|
||||
else if (options.text) console.log(buildTextReport(summary));
|
||||
else printHtmlReport(summary);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,406 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Extracts session transcripts for a given cwd, splits into context-sized files,
|
||||
* optionally spawns subagents to analyze patterns.
|
||||
*
|
||||
* Usage: node scripts/session-transcripts.ts [--analyze] [--output <dir>] [cwd]
|
||||
* --analyze Spawn pi subagents to analyze each transcript file
|
||||
* --output <dir> Output directory for transcript files (defaults to ./session-transcripts)
|
||||
* cwd Working directory to extract sessions for (defaults to current)
|
||||
*/
|
||||
|
||||
import { readFileSync, readdirSync, writeFileSync, existsSync, mkdirSync } from "fs";
|
||||
import { spawn } from "child_process";
|
||||
import { createInterface } from "node:readline";
|
||||
import { homedir } from "os";
|
||||
import { join, resolve } from "path";
|
||||
import { parseSessionEntries, type SessionMessageEntry } from "../packages/coding-agent/src/core/session-manager.ts";
|
||||
import chalk from "chalk";
|
||||
|
||||
const MAX_CHARS_PER_FILE = 100_000; // ~20k tokens, leaving room for prompt + analysis + output
|
||||
|
||||
function cwdToSessionDir(cwd: string): string {
|
||||
const normalized = resolve(cwd).replace(/\//g, "-");
|
||||
return `--${normalized.slice(1)}--`; // Remove leading slash, wrap with --
|
||||
}
|
||||
|
||||
function extractTextContent(content: string | Array<{ type: string; text?: string }>): string {
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
|
||||
return content
|
||||
.filter((c) => c.type === "text" && c.text)
|
||||
.map((c) => c.text!)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function parseSession(filePath: string): string[] {
|
||||
const content = readFileSync(filePath, "utf8");
|
||||
const entries = parseSessionEntries(content);
|
||||
const messages: string[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.type !== "message") continue;
|
||||
const msgEntry = entry as SessionMessageEntry;
|
||||
const { role, content } = msgEntry.message;
|
||||
|
||||
if (role !== "user" && role !== "assistant") continue;
|
||||
|
||||
const text = extractTextContent(content as string | Array<{ type: string; text?: string }>);
|
||||
if (!text.trim()) continue;
|
||||
|
||||
messages.push(`[${role.toUpperCase()}]\n${text}`);
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
const MAX_DISPLAY_WIDTH = 100;
|
||||
|
||||
function truncateLine(text: string, maxWidth: number): string {
|
||||
const singleLine = text.replace(/\n/g, " ").replace(/\s+/g, " ").trim();
|
||||
if (singleLine.length <= maxWidth) return singleLine;
|
||||
return singleLine.slice(0, maxWidth - 3) + "...";
|
||||
}
|
||||
|
||||
interface JsonEvent {
|
||||
type: string;
|
||||
assistantMessageEvent?: { type: string; delta?: string };
|
||||
toolName?: string;
|
||||
args?: {
|
||||
path?: string;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
content?: string;
|
||||
};
|
||||
}
|
||||
|
||||
function runSubagent(prompt: string, cwd: string): Promise<{ success: boolean }> {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn("pi", ["--mode", "json", "--tools", "read,write", "-p", prompt], {
|
||||
cwd,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
let textBuffer = "";
|
||||
|
||||
const rl = createInterface({ input: child.stdout });
|
||||
|
||||
rl.on("line", (line) => {
|
||||
try {
|
||||
const event: JsonEvent = JSON.parse(line);
|
||||
|
||||
if (event.type === "message_update" && event.assistantMessageEvent) {
|
||||
const msgEvent = event.assistantMessageEvent;
|
||||
if (msgEvent.type === "text_delta" && msgEvent.delta) {
|
||||
textBuffer += msgEvent.delta;
|
||||
}
|
||||
} else if (event.type === "tool_execution_start" && event.toolName) {
|
||||
// Print accumulated text before tool starts
|
||||
if (textBuffer.trim()) {
|
||||
console.log(chalk.dim(" " + truncateLine(textBuffer, MAX_DISPLAY_WIDTH)));
|
||||
textBuffer = "";
|
||||
}
|
||||
// Format tool call with args
|
||||
let argsStr = "";
|
||||
if (event.args) {
|
||||
if (event.toolName === "read") {
|
||||
argsStr = event.args.path || "";
|
||||
if (event.args.offset) argsStr += ` offset=${event.args.offset}`;
|
||||
if (event.args.limit) argsStr += ` limit=${event.args.limit}`;
|
||||
} else if (event.toolName === "write") {
|
||||
argsStr = event.args.path || "";
|
||||
}
|
||||
}
|
||||
console.log(chalk.cyan(` [${event.toolName}] ${argsStr}`));
|
||||
} else if (event.type === "turn_end") {
|
||||
// Print any remaining text at turn end
|
||||
if (textBuffer.trim()) {
|
||||
console.log(chalk.dim(" " + truncateLine(textBuffer, MAX_DISPLAY_WIDTH)));
|
||||
}
|
||||
textBuffer = "";
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed JSON
|
||||
}
|
||||
});
|
||||
|
||||
child.stderr.on("data", (data) => {
|
||||
process.stderr.write(chalk.red(data.toString()));
|
||||
});
|
||||
|
||||
child.on("close", (code) => {
|
||||
resolve({ success: code === 0 });
|
||||
});
|
||||
|
||||
child.on("error", (err) => {
|
||||
console.error(chalk.red(` Failed to spawn pi: ${err.message}`));
|
||||
resolve({ success: false });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const analyzeFlag = args.includes("--analyze");
|
||||
|
||||
// Parse --output <dir>
|
||||
const outputIdx = args.indexOf("--output");
|
||||
let outputDir = resolve("./session-transcripts");
|
||||
if (outputIdx !== -1 && args[outputIdx + 1]) {
|
||||
outputDir = resolve(args[outputIdx + 1]);
|
||||
}
|
||||
|
||||
// Find cwd (positional arg that's not a flag or flag value)
|
||||
const flagIndices = new Set<number>();
|
||||
flagIndices.add(args.indexOf("--analyze"));
|
||||
if (outputIdx !== -1) {
|
||||
flagIndices.add(outputIdx);
|
||||
flagIndices.add(outputIdx + 1);
|
||||
}
|
||||
const cwdArg = args.find((a, i) => !flagIndices.has(i) && !a.startsWith("--"));
|
||||
const cwd = resolve(cwdArg || process.cwd());
|
||||
|
||||
mkdirSync(outputDir, { recursive: true });
|
||||
const sessionsBase = join(homedir(), ".pi/agent/sessions");
|
||||
const sessionDirName = cwdToSessionDir(cwd);
|
||||
const sessionDir = join(sessionsBase, sessionDirName);
|
||||
|
||||
if (!existsSync(sessionDir)) {
|
||||
console.error(`No sessions found for ${cwd}`);
|
||||
console.error(`Expected: ${sessionDir}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const sessionFiles = readdirSync(sessionDir)
|
||||
.filter((f) => f.endsWith(".jsonl"))
|
||||
.sort();
|
||||
|
||||
console.log(`Found ${sessionFiles.length} session files in ${sessionDir}`);
|
||||
|
||||
// Collect all transcripts
|
||||
const allTranscripts: string[] = [];
|
||||
for (const file of sessionFiles) {
|
||||
const filePath = join(sessionDir, file);
|
||||
const messages = parseSession(filePath);
|
||||
if (messages.length > 0) {
|
||||
allTranscripts.push(`=== SESSION: ${file} ===\n${messages.join("\n---\n")}\n=== END SESSION ===`);
|
||||
}
|
||||
}
|
||||
|
||||
if (allTranscripts.length === 0) {
|
||||
console.error("No transcripts found");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Split into files respecting MAX_CHARS_PER_FILE
|
||||
const outputFiles: string[] = [];
|
||||
let currentContent = "";
|
||||
let fileIndex = 0;
|
||||
|
||||
for (const transcript of allTranscripts) {
|
||||
// If adding this transcript would exceed limit, write current and start new
|
||||
if (currentContent.length > 0 && currentContent.length + transcript.length + 2 > MAX_CHARS_PER_FILE) {
|
||||
const filename = `session-transcripts-${String(fileIndex).padStart(3, "0")}.txt`;
|
||||
writeFileSync(join(outputDir, filename), currentContent);
|
||||
outputFiles.push(filename);
|
||||
console.log(`Wrote ${filename} (${currentContent.length} chars)`);
|
||||
currentContent = "";
|
||||
fileIndex++;
|
||||
}
|
||||
|
||||
// If this single transcript exceeds limit, write it to its own file
|
||||
if (transcript.length > MAX_CHARS_PER_FILE) {
|
||||
// Write any pending content first
|
||||
if (currentContent.length > 0) {
|
||||
const filename = `session-transcripts-${String(fileIndex).padStart(3, "0")}.txt`;
|
||||
writeFileSync(join(outputDir, filename), currentContent);
|
||||
outputFiles.push(filename);
|
||||
console.log(`Wrote ${filename} (${currentContent.length} chars)`);
|
||||
currentContent = "";
|
||||
fileIndex++;
|
||||
}
|
||||
// Write the large transcript to its own file
|
||||
const filename = `session-transcripts-${String(fileIndex).padStart(3, "0")}.txt`;
|
||||
writeFileSync(join(outputDir, filename), transcript);
|
||||
outputFiles.push(filename);
|
||||
console.log(chalk.yellow(`Wrote ${filename} (${transcript.length} chars) - oversized`));
|
||||
fileIndex++;
|
||||
continue;
|
||||
}
|
||||
|
||||
currentContent += (currentContent ? "\n\n" : "") + transcript;
|
||||
}
|
||||
|
||||
// Write remaining content
|
||||
if (currentContent.length > 0) {
|
||||
const filename = `session-transcripts-${String(fileIndex).padStart(3, "0")}.txt`;
|
||||
writeFileSync(join(outputDir, filename), currentContent);
|
||||
outputFiles.push(filename);
|
||||
console.log(`Wrote ${filename} (${currentContent.length} chars)`);
|
||||
}
|
||||
|
||||
console.log(`\nCreated ${outputFiles.length} transcript file(s) in ${outputDir}`);
|
||||
|
||||
if (!analyzeFlag) {
|
||||
console.log("\nRun with --analyze to spawn pi subagents for pattern analysis.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Find AGENTS.md files to compare against
|
||||
const globalAgentsMd = join(homedir(), ".pi/agent/AGENTS.md");
|
||||
const localAgentsMd = join(cwd, "AGENTS.md");
|
||||
const agentsMdFiles = [globalAgentsMd, localAgentsMd].filter(existsSync);
|
||||
const agentsMdSection =
|
||||
agentsMdFiles.length > 0
|
||||
? `STEP 1: Read the existing AGENTS.md file(s) to see what's already encoded:\n${agentsMdFiles.join("\n")}\n\nSTEP 2: `
|
||||
: "";
|
||||
|
||||
// Spawn subagents to analyze each file
|
||||
const analysisPrompt = `You are analyzing session transcripts to identify recurring user instructions that could be automated.
|
||||
|
||||
${agentsMdSection}READING THE TRANSCRIPT:
|
||||
The transcript file is large. Read it in chunks of 1000 lines using offset/limit parameters:
|
||||
1. First: read with limit=1000 (lines 1-1000)
|
||||
2. Then: read with offset=1001, limit=1000 (lines 1001-2000)
|
||||
3. Continue incrementing offset by 1000 until you reach the end
|
||||
4. Only after reading the ENTIRE file, perform the analysis and write the summary
|
||||
|
||||
ANALYSIS TASK:
|
||||
Look for patterns where the user repeatedly gives similar instructions. These could become:
|
||||
- AGENTS.md entries: coding style rules, behavior guidelines, project conventions
|
||||
- Skills: multi-step workflows with external tools (search, browser, APIs)
|
||||
- Prompt templates: reusable prompts for common tasks
|
||||
|
||||
Compare each pattern against the existing AGENTS.md content to determine if it's NEW or EXISTING.
|
||||
|
||||
OUTPUT FORMAT (strict):
|
||||
Write a file with exactly this structure. Use --- as separator between patterns.
|
||||
|
||||
PATTERN: <short descriptive name>
|
||||
STATUS: NEW | EXISTING
|
||||
TYPE: agents-md | skill | prompt-template
|
||||
FREQUENCY: <number of times observed>
|
||||
EVIDENCE:
|
||||
- "<exact quote 1>"
|
||||
- "<exact quote 2>"
|
||||
- "<exact quote 3>"
|
||||
DRAFT:
|
||||
<proposed content for AGENTS.md entry, SKILL.md, or prompt template>
|
||||
---
|
||||
|
||||
Rules:
|
||||
- Only include patterns that appear 2+ times
|
||||
- STATUS is NEW if not in AGENTS.md, EXISTING if already covered
|
||||
- EVIDENCE must contain exact quotes from the transcripts
|
||||
- DRAFT must be ready-to-use content
|
||||
- If no patterns found, write "NO PATTERNS FOUND"
|
||||
- Do not include any other text outside this format`;
|
||||
|
||||
console.log("\nSpawning subagents for analysis...");
|
||||
for (const file of outputFiles) {
|
||||
const summaryFile = file.replace(".txt", ".summary.txt");
|
||||
const filePath = join(outputDir, file);
|
||||
const summaryPath = join(outputDir, summaryFile);
|
||||
|
||||
const fileContent = readFileSync(filePath, "utf8");
|
||||
const fileSize = fileContent.length;
|
||||
|
||||
console.log(`Analyzing ${file} (${fileSize} chars)...`);
|
||||
|
||||
const lineCount = fileContent.split("\n").length;
|
||||
const fullPrompt = `${analysisPrompt}\n\nThe file ${filePath} has ${lineCount} lines. Read it in full using chunked reads, then write your analysis to ${summaryPath}`;
|
||||
|
||||
const result = await runSubagent(fullPrompt, outputDir);
|
||||
|
||||
if (result.success && existsSync(summaryPath)) {
|
||||
console.log(chalk.green(` -> ${summaryFile}`));
|
||||
} else if (result.success) {
|
||||
console.error(chalk.yellow(` Agent finished but did not write ${summaryFile}`));
|
||||
} else {
|
||||
console.error(chalk.red(` Failed to analyze ${file}`));
|
||||
}
|
||||
}
|
||||
|
||||
// Collect all created summary files
|
||||
const summaryFiles = readdirSync(outputDir)
|
||||
.filter((f) => f.endsWith(".summary.txt"))
|
||||
.sort();
|
||||
|
||||
console.log(`\n=== Individual Analysis Complete ===`);
|
||||
console.log(`Created ${summaryFiles.length} summary files`);
|
||||
|
||||
if (summaryFiles.length === 0) {
|
||||
console.log(chalk.yellow("No summary files created. Nothing to aggregate."));
|
||||
return;
|
||||
}
|
||||
|
||||
// Final aggregation step
|
||||
console.log("\nAggregating findings into final summary...");
|
||||
|
||||
const summaryPaths = summaryFiles.map((f) => join(outputDir, f)).join("\n");
|
||||
const finalSummaryPath = join(outputDir, "FINAL-SUMMARY.txt");
|
||||
|
||||
const aggregationPrompt = `You are aggregating pattern analysis results from multiple summary files.
|
||||
|
||||
STEP 1: Read the existing AGENTS.md file(s) to understand what patterns are already encoded:
|
||||
${agentsMdFiles.length > 0 ? agentsMdFiles.join("\n") : "(no AGENTS.md files found)"}
|
||||
|
||||
STEP 2: Read ALL of the following summary files:
|
||||
${summaryPaths}
|
||||
|
||||
STEP 3: Create a consolidated final summary that:
|
||||
1. Merges duplicate patterns (same pattern found in multiple files)
|
||||
2. Ranks patterns by total frequency across all files
|
||||
3. Groups by status (NEW first, then EXISTING) and type
|
||||
4. Provides the best/most complete DRAFT for each unique pattern
|
||||
5. Verify STATUS against AGENTS.md content (pattern may be marked NEW in summaries but actually exists)
|
||||
|
||||
OUTPUT FORMAT (strict):
|
||||
Write the final summary with this structure:
|
||||
|
||||
# NEW PATTERNS (not yet in AGENTS.md)
|
||||
|
||||
## AGENTS.MD: <pattern name>
|
||||
Total Frequency: <sum across all files>
|
||||
Evidence:
|
||||
- "<best quotes>"
|
||||
Draft:
|
||||
<consolidated draft>
|
||||
|
||||
## SKILL: <pattern name>
|
||||
...
|
||||
|
||||
## PROMPT-TEMPLATE: <pattern name>
|
||||
...
|
||||
|
||||
---
|
||||
|
||||
# EXISTING PATTERNS (already in AGENTS.md, for reference)
|
||||
|
||||
## <pattern name>
|
||||
Total Frequency: <N>
|
||||
Already covered by: <quote relevant section from AGENTS.md>
|
||||
|
||||
---
|
||||
|
||||
# SUMMARY
|
||||
- New patterns to add: <N>
|
||||
- Already covered: <N>
|
||||
- Top 3 new patterns by frequency: <list>
|
||||
|
||||
Write the final summary to ${finalSummaryPath}`;
|
||||
|
||||
const aggregateResult = await runSubagent(aggregationPrompt, outputDir);
|
||||
|
||||
if (aggregateResult.success && existsSync(finalSummaryPath)) {
|
||||
console.log(chalk.green(`\n=== Final Summary Created ===`));
|
||||
console.log(chalk.green(` ${finalSummaryPath}`));
|
||||
} else if (aggregateResult.success) {
|
||||
console.error(chalk.yellow(`Agent finished but did not write final summary`));
|
||||
} else {
|
||||
console.error(chalk.red(`Failed to create final summary`));
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
@@ -0,0 +1,233 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
interface UsageCost {
|
||||
input?: number;
|
||||
output?: number;
|
||||
cacheRead?: number;
|
||||
cacheWrite?: number;
|
||||
total?: number;
|
||||
}
|
||||
|
||||
interface Usage {
|
||||
input?: number;
|
||||
output?: number;
|
||||
cacheRead?: number;
|
||||
cacheWrite?: number;
|
||||
totalTokens?: number;
|
||||
cost?: UsageCost;
|
||||
}
|
||||
|
||||
interface AssistantMessage {
|
||||
role?: string;
|
||||
provider?: string;
|
||||
usage?: Usage;
|
||||
timestamp?: number;
|
||||
}
|
||||
|
||||
interface SessionEntry {
|
||||
type?: string;
|
||||
timestamp?: string;
|
||||
message?: AssistantMessage;
|
||||
}
|
||||
|
||||
interface Totals {
|
||||
input: number;
|
||||
output: number;
|
||||
cacheRead: number;
|
||||
cacheWrite: number;
|
||||
totalTokens: number;
|
||||
costInput: number;
|
||||
costOutput: number;
|
||||
costCacheRead: number;
|
||||
costCacheWrite: number;
|
||||
costTotal: number;
|
||||
assistantMessages: number;
|
||||
sessions: Set<string>;
|
||||
}
|
||||
|
||||
interface DayStats extends Totals {
|
||||
providers: Map<string, Totals>;
|
||||
}
|
||||
|
||||
interface Args {
|
||||
days: number;
|
||||
cwd: string;
|
||||
sessionsBase: string;
|
||||
}
|
||||
|
||||
function createTotals(): Totals {
|
||||
return {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
costInput: 0,
|
||||
costOutput: 0,
|
||||
costCacheRead: 0,
|
||||
costCacheWrite: 0,
|
||||
costTotal: 0,
|
||||
assistantMessages: 0,
|
||||
sessions: new Set<string>(),
|
||||
};
|
||||
}
|
||||
|
||||
function createDayStats(): DayStats {
|
||||
return {
|
||||
...createTotals(),
|
||||
providers: new Map<string, Totals>(),
|
||||
};
|
||||
}
|
||||
|
||||
function addUsage(totals: Totals, usage: Usage, sessionFile: string): void {
|
||||
const cost = usage.cost ?? {};
|
||||
totals.input += usage.input ?? 0;
|
||||
totals.output += usage.output ?? 0;
|
||||
totals.cacheRead += usage.cacheRead ?? 0;
|
||||
totals.cacheWrite += usage.cacheWrite ?? 0;
|
||||
totals.totalTokens += usage.totalTokens ?? 0;
|
||||
totals.costInput += cost.input ?? 0;
|
||||
totals.costOutput += cost.output ?? 0;
|
||||
totals.costCacheRead += cost.cacheRead ?? 0;
|
||||
totals.costCacheWrite += cost.cacheWrite ?? 0;
|
||||
totals.costTotal += cost.total ?? 0;
|
||||
totals.assistantMessages += 1;
|
||||
totals.sessions.add(sessionFile);
|
||||
}
|
||||
|
||||
function encodeSessionDir(cwd: string): string {
|
||||
const normalized = cwd.startsWith("/") ? cwd.slice(1) : cwd;
|
||||
return `--${normalized.replace(/\//g, "-")}--`;
|
||||
}
|
||||
|
||||
function localDayKey(date: Date): string {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
function parseArgs(): Args {
|
||||
const args = process.argv.slice(2);
|
||||
let days = 7;
|
||||
let cwd = process.cwd();
|
||||
let sessionsBase = join(homedir(), ".pi", "agent", "sessions");
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if ((arg === "--days" || arg === "-n") && args[i + 1]) {
|
||||
days = Number.parseInt(args[++i], 10);
|
||||
} else if ((arg === "--cwd" || arg === "--dir" || arg === "-d") && args[i + 1]) {
|
||||
cwd = resolve(args[++i]);
|
||||
} else if (arg === "--sessions-base" && args[i + 1]) {
|
||||
sessionsBase = resolve(args[++i]);
|
||||
} else if (arg === "--help" || arg === "-h") {
|
||||
console.log(`Usage: scripts/stats.ts [options]
|
||||
|
||||
Options:
|
||||
-n, --days <days> Number of local calendar days to include (default: 7)
|
||||
-d, --dir, --cwd <path> Project cwd to inspect (default: current cwd)
|
||||
--sessions-base <path> Sessions base directory (default: ~/.pi/agent/sessions)
|
||||
-h, --help Show this help`);
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
if (!Number.isInteger(days) || days <= 0) {
|
||||
throw new Error("--days must be a positive integer");
|
||||
}
|
||||
|
||||
return { days, cwd: resolve(cwd), sessionsBase };
|
||||
}
|
||||
|
||||
function formatInt(value: number): string {
|
||||
return Math.round(value).toLocaleString("en-US");
|
||||
}
|
||||
|
||||
function formatCost(value: number): string {
|
||||
return `$${value.toFixed(4)}`;
|
||||
}
|
||||
|
||||
function printTotals(label: string, totals: Totals): void {
|
||||
console.log(
|
||||
`${label.padEnd(16)} messages: ${String(totals.assistantMessages).padStart(5)} sessions: ${String(totals.sessions.size).padStart(3)} ` +
|
||||
`input: ${formatInt(totals.input).padStart(12)} output: ${formatInt(totals.output).padStart(10)} ` +
|
||||
`cache read: ${formatInt(totals.cacheRead).padStart(13)} cache write: ${formatInt(totals.cacheWrite).padStart(10)} ` +
|
||||
`total: ${formatInt(totals.totalTokens).padStart(13)} cost: ${formatCost(totals.costTotal).padStart(10)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const { days, cwd, sessionsBase } = parseArgs();
|
||||
const sessionsDir = join(sessionsBase, encodeSessionDir(cwd));
|
||||
|
||||
if (!existsSync(sessionsDir)) {
|
||||
throw new Error(`Sessions directory not found: ${sessionsDir}`);
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
start.setDate(start.getDate() - days + 1);
|
||||
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1);
|
||||
|
||||
const stats = new Map<string, DayStats>();
|
||||
const grandTotal = createTotals();
|
||||
|
||||
for (const file of readdirSync(sessionsDir)) {
|
||||
if (!file.endsWith(".jsonl")) continue;
|
||||
|
||||
const path = join(sessionsDir, file);
|
||||
const lines = readFileSync(path, "utf8").split("\n");
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
|
||||
let entry: SessionEntry;
|
||||
try {
|
||||
entry = JSON.parse(line) as SessionEntry;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.type !== "message" || entry.message?.role !== "assistant" || !entry.message.usage) continue;
|
||||
|
||||
const timestamp = entry.message.timestamp !== undefined ? new Date(entry.message.timestamp) : new Date(entry.timestamp ?? 0);
|
||||
if (timestamp < start || timestamp >= end) continue;
|
||||
|
||||
const dayKey = localDayKey(timestamp);
|
||||
let dayStats = stats.get(dayKey);
|
||||
if (!dayStats) {
|
||||
dayStats = createDayStats();
|
||||
stats.set(dayKey, dayStats);
|
||||
}
|
||||
|
||||
const provider = entry.message.provider ?? "unknown";
|
||||
let providerStats = dayStats.providers.get(provider);
|
||||
if (!providerStats) {
|
||||
providerStats = createTotals();
|
||||
dayStats.providers.set(provider, providerStats);
|
||||
}
|
||||
|
||||
addUsage(dayStats, entry.message.usage, file);
|
||||
addUsage(providerStats, entry.message.usage, file);
|
||||
addUsage(grandTotal, entry.message.usage, file);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Usage for ${cwd}`);
|
||||
console.log(`Sessions: ${sessionsDir}`);
|
||||
console.log(`Period: ${localDayKey(start)} through ${localDayKey(new Date(end.getTime() - 1))} (${days} local days)`);
|
||||
console.log("".padEnd(160, "="));
|
||||
|
||||
for (const day of [...stats.keys()].sort()) {
|
||||
const dayStats = stats.get(day)!;
|
||||
printTotals(day, dayStats);
|
||||
for (const provider of [...dayStats.providers.keys()].sort()) {
|
||||
printTotals(` ${provider}`, dayStats.providers.get(provider)!);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("".padEnd(160, "-"));
|
||||
printTotals("TOTAL", grandTotal);
|
||||
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Validates lockstep versions for published packages, then synchronizes
|
||||
* internal dependency versions in all workspace packages, including private ones.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { findPackageDirectories } from "./package-workspaces.mjs";
|
||||
|
||||
const GENERATED_PACKAGE_SUFFIXES = [join("coding-agent", "install-lock")];
|
||||
|
||||
const packageRoot = process.argv[2] ?? "packages";
|
||||
const workspacePackages = findPackageDirectories(packageRoot)
|
||||
.filter((directory) => !GENERATED_PACKAGE_SUFFIXES.some((suffix) => directory.endsWith(suffix)))
|
||||
.map((directory) => {
|
||||
const path = join(directory, "package.json");
|
||||
return { data: JSON.parse(readFileSync(path, "utf8")), path };
|
||||
});
|
||||
const publishedPackages = workspacePackages.filter((pkg) => pkg.data.private !== true);
|
||||
const versionMap = new Map(workspacePackages.map((pkg) => [pkg.data.name, pkg.data.version]));
|
||||
|
||||
console.log("Current versions:");
|
||||
for (const pkg of [...publishedPackages].sort((a, b) => a.data.name.localeCompare(b.data.name))) {
|
||||
console.log(` ${pkg.data.name}: ${pkg.data.version}`);
|
||||
}
|
||||
|
||||
const versions = new Set(publishedPackages.map((pkg) => pkg.data.version));
|
||||
if (versions.size > 1) {
|
||||
console.error("\nERROR: Not all non-private packages have the same version.");
|
||||
console.error("Expected lockstep versioning. Run one of:");
|
||||
console.error(" npm run version:patch");
|
||||
console.error(" npm run version:minor");
|
||||
console.error(" npm run version:major");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("\nAll non-private packages are at the same version (lockstep).");
|
||||
|
||||
let totalUpdates = 0;
|
||||
const updatedPackages = new Set();
|
||||
for (const pkg of workspacePackages) {
|
||||
for (const dependencyType of ["dependencies", "devDependencies"]) {
|
||||
const dependencies = pkg.data[dependencyType];
|
||||
if (!dependencies) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const [dependencyName, currentSpecifier] of Object.entries(dependencies)) {
|
||||
// Registry aliases such as `npm:@earendil-works/pi-ai@0.1.2` are never workspace-linked,
|
||||
// so lockstep bumping them would point at a version that is not published yet.
|
||||
const version = versionMap.get(dependencyName);
|
||||
const newSpecifier = version ? `^${version}` : null;
|
||||
if (!newSpecifier || currentSpecifier === newSpecifier) {
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`\n${pkg.data.name}:`);
|
||||
console.log(
|
||||
` ${dependencyName}: ${currentSpecifier} → ${newSpecifier}${dependencyType === "devDependencies" ? " (devDependencies)" : ""}`,
|
||||
);
|
||||
dependencies[dependencyName] = newSpecifier;
|
||||
updatedPackages.add(pkg);
|
||||
totalUpdates++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const pkg of updatedPackages) {
|
||||
writeFileSync(pkg.path, `${JSON.stringify(pkg.data, null, "\t")}\n`);
|
||||
}
|
||||
|
||||
if (totalUpdates === 0) {
|
||||
console.log("\nAll inter-package dependencies are already in sync.");
|
||||
} else {
|
||||
console.log(`\nUpdated ${totalUpdates} dependency version(s).`);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
|
||||
const syncVersionsScript = fileURLToPath(new URL("./sync-versions.js", import.meta.url));
|
||||
|
||||
async function writeManifest(root, relativeDirectory, manifest) {
|
||||
const directory = join(root, relativeDirectory);
|
||||
await mkdir(directory, { recursive: true });
|
||||
await writeFile(join(directory, "package.json"), `${JSON.stringify(manifest, null, "\t")}\n`);
|
||||
}
|
||||
|
||||
async function readManifest(root, relativeDirectory) {
|
||||
return JSON.parse(await readFile(join(root, relativeDirectory, "package.json"), "utf8"));
|
||||
}
|
||||
|
||||
function runSyncVersions(root) {
|
||||
return spawnSync(process.execPath, [syncVersionsScript, join(root, "packages")], {
|
||||
cwd: root,
|
||||
encoding: "utf8",
|
||||
});
|
||||
}
|
||||
|
||||
test("synchronizes private dependencies without touching registry aliases, generated manifests, or published lockstep", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "pi-sync-versions-"));
|
||||
try {
|
||||
await writeManifest(root, "packages/ai", {
|
||||
name: "@earendil-works/pi-ai",
|
||||
version: "2.0.0",
|
||||
});
|
||||
await writeManifest(root, "packages/coding-agent", {
|
||||
name: "@earendil-works/pi-coding-agent",
|
||||
version: "2.0.0",
|
||||
});
|
||||
await writeManifest(root, "packages/evals", {
|
||||
name: "@earendil-works/pi-evals",
|
||||
version: "9.9.9",
|
||||
private: true,
|
||||
dependencies: {
|
||||
"@earendil-works/pi-coding-agent": "^1.0.0",
|
||||
"@mariozechner/pi-ai": "npm:@earendil-works/pi-ai@1.0.0",
|
||||
},
|
||||
});
|
||||
await writeManifest(root, "packages/coding-agent/install-lock", {
|
||||
name: "generated-install-lock",
|
||||
version: "0.0.0",
|
||||
private: true,
|
||||
dependencies: {
|
||||
"@earendil-works/pi-coding-agent": "^1.0.0",
|
||||
},
|
||||
});
|
||||
|
||||
const result = runSyncVersions(root);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
|
||||
const evalsManifest = await readManifest(root, "packages/evals");
|
||||
assert.equal(evalsManifest.dependencies["@earendil-works/pi-coding-agent"], "^2.0.0");
|
||||
assert.equal(evalsManifest.dependencies["@mariozechner/pi-ai"], "npm:@earendil-works/pi-ai@1.0.0");
|
||||
const generatedManifest = await readManifest(root, "packages/coding-agent/install-lock");
|
||||
assert.equal(generatedManifest.dependencies["@earendil-works/pi-coding-agent"], "^1.0.0");
|
||||
|
||||
await writeManifest(root, "packages/ai", {
|
||||
name: "@earendil-works/pi-ai",
|
||||
version: "3.0.0",
|
||||
});
|
||||
const lockstepFailure = runSyncVersions(root);
|
||||
assert.equal(lockstepFailure.status, 1, lockstepFailure.stderr);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { homedir, tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import { openBrowser } from "../packages/coding-agent/src/utils/open-browser.ts";
|
||||
|
||||
interface TextContent { type: "text"; text: string }
|
||||
interface ImageContent { type: "image"; data: string; mimeType?: string }
|
||||
interface ToolCallContent { type: "toolCall"; id: string; name: string; arguments?: Record<string, unknown> }
|
||||
type Content = TextContent | ImageContent | ToolCallContent | { type: string; [key: string]: unknown };
|
||||
interface Message { role?: string; content?: string | Content[]; toolCallId?: string; toolName?: string; details?: unknown }
|
||||
interface Entry { type?: string; message?: Message }
|
||||
interface ToolStats { calls: number; results: number; estimatedTokens: number; samples: number[]; errors: number }
|
||||
interface BashCommandStats { calls: number; estimatedTokens: number; samples: number[] }
|
||||
interface ToolCallInfo { toolName: string; bashCommand?: string }
|
||||
|
||||
const BUCKETS = [0, 50, 100, 250, 500, 1000, 2000, 4000, 8000, 16000, 32000, Number.POSITIVE_INFINITY];
|
||||
|
||||
function parseArgs(): { sessionsDir: string; output: string } {
|
||||
let sessionsDir = join(homedir(), ".pi", "agent", "sessions");
|
||||
let output = join(tmpdir(), "pi-tool-stats.html");
|
||||
const args = process.argv.slice(2);
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (arg === "--sessions-dir" && args[i + 1]) sessionsDir = resolve(args[++i]);
|
||||
else if ((arg === "--output" || arg === "-o") && args[i + 1]) output = resolve(args[++i]);
|
||||
else if (arg === "--help" || arg === "-h") {
|
||||
console.log(`Usage: scripts/tool-stats.ts [--sessions-dir <dir>] [--output <file.html>]`);
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
return { sessionsDir, output };
|
||||
}
|
||||
|
||||
function jsonlFiles(dir: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const path = join(dir, entry.name);
|
||||
if (entry.isDirectory()) out.push(...jsonlFiles(path));
|
||||
else if (entry.isFile() && entry.name.endsWith(".jsonl")) out.push(path);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function getStats<T>(map: Map<string, T>, key: string, create: () => T): T {
|
||||
let stats = map.get(key);
|
||||
if (!stats) {
|
||||
stats = create();
|
||||
map.set(key, stats);
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
|
||||
function createToolStats(): ToolStats {
|
||||
return { calls: 0, results: 0, estimatedTokens: 0, samples: [], errors: 0 };
|
||||
}
|
||||
|
||||
function createBashStats(): BashCommandStats {
|
||||
return { calls: 0, estimatedTokens: 0, samples: [] };
|
||||
}
|
||||
|
||||
function estimateTokenCount(text: string): number {
|
||||
return Math.ceil(text.length / 4);
|
||||
}
|
||||
|
||||
function contentText(content: Message["content"]): string {
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
return content.map((block) => {
|
||||
if (block.type === "text" && "text" in block && typeof block.text === "string") return block.text;
|
||||
if (block.type === "image" && "data" in block && typeof block.data === "string") return block.data;
|
||||
return JSON.stringify(block);
|
||||
}).join("\n");
|
||||
}
|
||||
|
||||
function getBashCommand(args: Record<string, unknown> | undefined): string | undefined {
|
||||
const command = args?.command;
|
||||
return typeof command === "string" ? command : undefined;
|
||||
}
|
||||
|
||||
function commandKey(command: string): string {
|
||||
const first = command.split(/\n|&&|\|\||;|\|/)[0]?.trim() ?? command.trim();
|
||||
const match = first.match(/^(?:\w+=\S+\s+)*(?:sudo\s+)?([^\s]+)(?:\s+([^\s]+))?/);
|
||||
if (!match) return "unknown";
|
||||
const bin = match[1] ?? "unknown";
|
||||
const sub = match[2] && !match[2].startsWith("-") ? ` ${match[2]}` : "";
|
||||
return `${bin}${sub}`;
|
||||
}
|
||||
|
||||
function bucketCounts(samples: number[]): number[] {
|
||||
const counts = new Array(BUCKETS.length - 1).fill(0) as number[];
|
||||
for (const sample of samples) {
|
||||
const index = BUCKETS.findIndex((max, i) => i > 0 && sample <= max) - 1;
|
||||
counts[Math.max(0, index)]++;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
function bucketLabels(): string[] {
|
||||
return BUCKETS.slice(0, -1).map((min, i) => {
|
||||
const max = BUCKETS[i + 1];
|
||||
return Number.isFinite(max) ? `${min}-${max}` : `${min}+`;
|
||||
});
|
||||
}
|
||||
|
||||
const { sessionsDir, output } = parseArgs();
|
||||
if (!existsSync(sessionsDir)) throw new Error(`Sessions directory not found: ${sessionsDir}`);
|
||||
|
||||
const tools = new Map<string, ToolStats>();
|
||||
const bashCommands = new Map<string, BashCommandStats>();
|
||||
const callsById = new Map<string, ToolCallInfo>();
|
||||
let parseErrors = 0;
|
||||
const files = jsonlFiles(sessionsDir);
|
||||
|
||||
for (const file of files) {
|
||||
for (const line of readFileSync(file, "utf8").split("\n")) {
|
||||
if (!line.trim()) continue;
|
||||
let entry: Entry;
|
||||
try { entry = JSON.parse(line) as Entry; } catch { parseErrors++; continue; }
|
||||
if (entry.type !== "message") continue;
|
||||
const message = entry.message;
|
||||
if (!message) continue;
|
||||
if (message.role === "assistant" && Array.isArray(message.content)) {
|
||||
for (const block of message.content) {
|
||||
if (block.type !== "toolCall" || !("name" in block) || typeof block.name !== "string") continue;
|
||||
const stats = getStats(tools, block.name, createToolStats);
|
||||
stats.calls++;
|
||||
const bashCommand = block.name === "bash" ? getBashCommand(block.arguments) : undefined;
|
||||
callsById.set(block.id, { toolName: block.name, bashCommand });
|
||||
if (bashCommand) getStats(bashCommands, commandKey(bashCommand), createBashStats).calls++;
|
||||
}
|
||||
} else if (message.role === "toolResult" && message.toolName) {
|
||||
const text = contentText(message.content);
|
||||
const tokens = estimateTokenCount(text);
|
||||
const stats = getStats(tools, message.toolName, createToolStats);
|
||||
stats.results++;
|
||||
stats.estimatedTokens += tokens;
|
||||
stats.samples.push(tokens);
|
||||
if ("isError" in message && message.isError === true) stats.errors++;
|
||||
const call = message.toolCallId ? callsById.get(message.toolCallId) : undefined;
|
||||
if (call?.bashCommand) {
|
||||
const bash = getStats(bashCommands, commandKey(call.bashCommand), createBashStats);
|
||||
bash.estimatedTokens += tokens;
|
||||
bash.samples.push(tokens);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const toolRows = [...tools.entries()].map(([name, s]) => ({ name, ...s, avg: s.results ? s.estimatedTokens / s.results : 0, histogram: bucketCounts(s.samples) })).sort((a, b) => b.estimatedTokens - a.estimatedTokens);
|
||||
const bashRows = [...bashCommands.entries()].map(([name, s]) => ({ name, ...s, avg: s.samples.length ? s.estimatedTokens / s.samples.length : 0, histogram: bucketCounts(s.samples) })).sort((a, b) => b.estimatedTokens - a.estimatedTokens).slice(0, 50);
|
||||
const data = { generatedAt: new Date().toISOString(), sessionsDir, files: files.length, parseErrors, bucketLabels: bucketLabels(), tools: toolRows, bashCommands: bashRows };
|
||||
|
||||
const html = `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Pi Tool Stats</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.9/dist/chart.umd.min.js"></script>
|
||||
</head>
|
||||
<body class="bg-zinc-950 text-zinc-100 p-6">
|
||||
<main class="max-w-7xl mx-auto space-y-6">
|
||||
<h1 class="text-3xl font-bold">Pi Tool Stats</h1>
|
||||
<p class="text-zinc-400">${data.files} session files from <code>${sessionsDir}</code>. Generated ${data.generatedAt}.</p>
|
||||
<section class="grid md:grid-cols-2 gap-6">
|
||||
<div class="bg-zinc-900 rounded p-4"><h2 class="font-semibold mb-3">Estimated result tokens by tool</h2><canvas id="tokens"></canvas></div>
|
||||
<div class="bg-zinc-900 rounded p-4"><h2 class="font-semibold mb-3">Tool calls</h2><canvas id="calls"></canvas></div>
|
||||
</section>
|
||||
<section class="grid md:grid-cols-2 gap-6">
|
||||
<div class="bg-zinc-900 rounded p-4">
|
||||
<div class="flex items-center justify-between gap-4 mb-3">
|
||||
<h2 class="font-semibold">Tool result token histogram</h2>
|
||||
<select id="toolSelect" class="bg-zinc-800 rounded px-2 py-1 text-sm"></select>
|
||||
</div>
|
||||
<p id="toolSummary" class="text-sm text-zinc-400 mb-3"></p>
|
||||
<canvas id="toolHistogram" height="120"></canvas>
|
||||
</div>
|
||||
<div class="bg-zinc-900 rounded p-4">
|
||||
<div class="flex items-center justify-between gap-4 mb-3">
|
||||
<h2 class="font-semibold">Bash result token histogram</h2>
|
||||
<select id="bashSelect" class="bg-zinc-800 rounded px-2 py-1 text-sm"></select>
|
||||
</div>
|
||||
<p id="bashSummary" class="text-sm text-zinc-400 mb-3"></p>
|
||||
<canvas id="bashHistogram" height="120"></canvas>
|
||||
</div>
|
||||
</section>
|
||||
<section class="bg-zinc-900 rounded p-4"><h2 class="font-semibold mb-3">Tools</h2><div id="tools"></div></section>
|
||||
<section class="bg-zinc-900 rounded p-4">
|
||||
<h2 class="font-semibold mb-3">Bash common commands (best effort)</h2>
|
||||
<div id="bash" class="mt-4"></div>
|
||||
</section>
|
||||
</main>
|
||||
<script>
|
||||
const data=${JSON.stringify(data)};
|
||||
function fmt(n){return Math.round(n).toLocaleString()}
|
||||
function esc(s){return String(s).replace(/[&<>\"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]))}
|
||||
function table(rows,el){
|
||||
document.getElementById(el).innerHTML='<table class="w-full text-sm"><thead class="text-zinc-400"><tr><th class="text-left p-2">Name</th><th class="text-right p-2">Calls</th><th class="text-right p-2">Results</th><th class="text-right p-2">Est. tokens</th><th class="text-right p-2">Avg/result</th><th class="text-left p-2 w-64">Histogram</th></tr></thead><tbody>'+rows.map((r,i)=>'<tr class="border-t border-zinc-800 hover:bg-zinc-800/50 cursor-pointer" data-row="'+i+'"><td class="p-2 font-mono">'+esc(r.name)+'</td><td class="p-2 text-right">'+fmt(r.calls)+'</td><td class="p-2 text-right">'+fmt(r.results??r.samples.length)+'</td><td class="p-2 text-right">'+fmt(r.estimatedTokens)+'</td><td class="p-2 text-right">'+fmt(r.avg)+'</td><td class="p-2"><canvas id="'+el+'Hist'+i+'" height="34"></canvas></td></tr>').join('')+'</tbody></table>';
|
||||
rows.forEach((r,i)=>new Chart(document.getElementById(el+'Hist'+i),{type:'bar',data:{labels:data.bucketLabels,datasets:[{data:r.histogram,label:r.name,backgroundColor:'#60a5fa'}]},options:{responsive:true,maintainAspectRatio:false,plugins:{legend:{display:false},tooltip:{callbacks:{title:(items)=>data.bucketLabels[items[0].dataIndex]+' tokens'}}},scales:{x:{display:false},y:{display:false}}}}));
|
||||
document.querySelectorAll('#'+el+' tr[data-row]').forEach(row=>row.addEventListener('click',()=>document.getElementById(el==='tools'?'toolSelect':'bashSelect').value=row.dataset.row,document.getElementById(el==='tools'?'toolSelect':'bashSelect').dispatchEvent(new Event('change'))));
|
||||
}
|
||||
function fillSelect(id,rows){document.getElementById(id).innerHTML=rows.map((r,i)=>'<option value="'+i+'">'+esc(r.name)+'</option>').join('')}
|
||||
function summary(r){return fmt(r.calls)+' calls, '+fmt(r.results??r.samples.length)+' results, '+fmt(r.estimatedTokens)+' estimated result tokens, '+fmt(r.avg)+' avg/result'}
|
||||
function singleHistogram(canvasId,summaryId,selectId,rows){
|
||||
let chart;
|
||||
function update(){
|
||||
const row=rows[Number(document.getElementById(selectId).value)]??rows[0];
|
||||
document.getElementById(summaryId).textContent=row?summary(row):'No data';
|
||||
if(chart)chart.destroy();
|
||||
chart=new Chart(document.getElementById(canvasId),{type:'bar',data:{labels:data.bucketLabels,datasets:[{label:row?.name??'',data:row?.histogram??[],backgroundColor:'#60a5fa'}]},options:{plugins:{legend:{display:false},tooltip:{callbacks:{title:(items)=>data.bucketLabels[items[0].dataIndex]+' tokens',label:(item)=>fmt(item.raw)+' results'}}},scales:{y:{beginAtZero:true,title:{display:true,text:'result count'}},x:{title:{display:true,text:'estimated tokens/result'}}}}});
|
||||
}
|
||||
document.getElementById(selectId).addEventListener('change',update);
|
||||
update();
|
||||
}
|
||||
table(data.tools,'tools');
|
||||
table(data.bashCommands,'bash');
|
||||
fillSelect('toolSelect',data.tools);
|
||||
fillSelect('bashSelect',data.bashCommands);
|
||||
singleHistogram('toolHistogram','toolSummary','toolSelect',data.tools);
|
||||
singleHistogram('bashHistogram','bashSummary','bashSelect',data.bashCommands);
|
||||
new Chart(document.getElementById('tokens'),{type:'bar',data:{labels:data.tools.map(r=>r.name),datasets:[{label:'estimated tokens',data:data.tools.map(r=>r.estimatedTokens)}]},options:{plugins:{legend:{display:false}}}});
|
||||
new Chart(document.getElementById('calls'),{type:'bar',data:{labels:data.tools.map(r=>r.name),datasets:[{label:'calls',data:data.tools.map(r=>r.calls)}]},options:{plugins:{legend:{display:false}}}});
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
mkdirSync(resolve(output, ".."), { recursive: true });
|
||||
writeFileSync(output, html);
|
||||
console.log(`Wrote ${output}`);
|
||||
openBrowser(output);
|
||||
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Rewrites relative source import specifiers in package source directories from .js to .ts.
|
||||
# TypeScript's rewriteRelativeImportExtensions option rewrites these back to .js in emitted output.
|
||||
find packages -mindepth 2 -maxdepth 2 -type d -name src -print0 |
|
||||
while IFS= read -r -d '' src_dir; do
|
||||
find "$src_dir" -type f -name '*.ts' -print0
|
||||
done |
|
||||
xargs -0 perl -0pi -e 's/(\b(?:from|import)\b\s*\(?\s*["\x27])(\.{1,2}\/[^"\x27\r\n]+)\.js(["\x27]\s*\)?)/$1$2.ts$3/g; s/(\bdeclare\s+module\s+["\x27])(\.{1,2}\/[^"\x27\r\n]+)\.js(["\x27])/$1$2.ts$3/g; s/(\bimportNodeOnlyProvider\(\s*["\x27])(\.{1,2}\/[^"\x27\r\n]+)\.js(["\x27]\s*\))/$1$2.ts$3/g'
|
||||
Reference in New Issue
Block a user