feat: integrate Pi backend and Pi Web

This commit is contained in:
luckyyzh
2026-07-30 19:37:53 +08:00
commit 7392ab9dd7
1390 changed files with 337197 additions and 0 deletions
+13
View File
@@ -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),
});
+61
View File
@@ -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,
);
+256
View File
@@ -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
+128
View File
@@ -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);
}
+120
View File
@@ -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);
+63
View File
@@ -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);
}
+74
View File
@@ -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);
}
+183
View File
@@ -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();
+150
View File
@@ -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"
+241
View File
@@ -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 });
}
+834
View File
@@ -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));
}
+289
View File
@@ -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`);
}
}
+24
View File
@@ -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();
}
+639
View File
@@ -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);
});
+308
View File
@@ -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;
});
+126
View File
@@ -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();
}
+505
View File
@@ -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("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
}
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);
});
+364
View File
@@ -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);
}
+247
View File
@@ -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 ===`);
+55
View File
@@ -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");
+405
View File
@@ -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("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
}
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);
});
+406
View File
@@ -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);
+233
View File
@@ -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);
+78
View File
@@ -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).`);
}
+75
View File
@@ -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 });
}
});
+232
View File
@@ -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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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);
+10
View File
@@ -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'