chore: drop non-code junk (pi-mono-reference, runtime state, root duplicates)
Remove files that should never have been tracked: - pi-mono-reference/: 688-file copy of an upstream monorepo, zero imports, used only as offline human reference - build/electron-builder.runtime.json: environment-specific build artifact with hardcoded local paths - state/cancel-signal-state.json: runtime state written by the app - prompt.txt, logo.svg (root): exact duplicates of the canonical copies under .planning/archive/ and assets/ Add state/ to .gitignore so future runtime state stays out.
This commit is contained in:
1 parent
34ca5b77f1
commit
1f7230fe87
691 files changed
+3
-190434
No files matched your search
@@ -1,4 +0,0 @@
|
||||
import { complete, getModel } from "@mariozechner/pi-ai";
|
||||
|
||||
const model = getModel("google", "gemini-2.5-flash");
|
||||
console.log(model.id, typeof complete);
|
||||
@@ -1,175 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Build pi binaries for all platforms locally.
|
||||
# Mirrors .github/workflows/build-binaries.yml
|
||||
#
|
||||
# Usage:
|
||||
# ./scripts/build-binaries.sh [--skip-deps] [--platform <platform>]
|
||||
#
|
||||
# Options:
|
||||
# --skip-deps Skip installing cross-platform dependencies
|
||||
# --platform <name> Build only for specified platform (darwin-arm64, darwin-x64, linux-x64, linux-arm64, windows-x64)
|
||||
#
|
||||
# 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
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
SKIP_DEPS=false
|
||||
PLATFORM=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case $1 in
|
||||
--skip-deps)
|
||||
SKIP_DEPS=true
|
||||
shift
|
||||
;;
|
||||
--platform)
|
||||
PLATFORM="$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)
|
||||
;;
|
||||
*)
|
||||
echo "Invalid platform: $PLATFORM"
|
||||
echo "Valid platforms: darwin-arm64, darwin-x64, linux-x64, linux-arm64, windows-x64"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
echo "==> Installing dependencies..."
|
||||
npm ci
|
||||
|
||||
if [[ "$SKIP_DEPS" == "false" ]]; then
|
||||
echo "==> Installing cross-platform native bindings..."
|
||||
# npm ci only installs optional deps for the current platform
|
||||
# We need 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 --no-save --force \
|
||||
@mariozechner/clipboard-darwin-arm64@0.3.0 \
|
||||
@mariozechner/clipboard-darwin-x64@0.3.0 \
|
||||
@mariozechner/clipboard-linux-x64-gnu@0.3.0 \
|
||||
@mariozechner/clipboard-linux-arm64-gnu@0.3.0 \
|
||||
@mariozechner/clipboard-win32-x64-msvc@0.3.0 \
|
||||
@img/sharp-darwin-arm64@0.34.5 \
|
||||
@img/sharp-darwin-x64@0.34.5 \
|
||||
@img/sharp-linux-x64@0.34.5 \
|
||||
@img/sharp-linux-arm64@0.34.5 \
|
||||
@img/sharp-win32-x64@0.34.5 \
|
||||
@img/sharp-libvips-darwin-arm64@1.2.4 \
|
||||
@img/sharp-libvips-darwin-x64@1.2.4 \
|
||||
@img/sharp-libvips-linux-x64@1.2.4 \
|
||||
@img/sharp-libvips-linux-arm64@1.2.4
|
||||
else
|
||||
echo "==> Skipping cross-platform native bindings (--skip-deps)"
|
||||
fi
|
||||
|
||||
echo "==> Building all packages..."
|
||||
npm run build
|
||||
|
||||
echo "==> Building binaries..."
|
||||
cd packages/coding-agent
|
||||
|
||||
# Clean previous builds
|
||||
rm -rf binaries
|
||||
mkdir -p binaries/{darwin-arm64,darwin-x64,linux-x64,linux-arm64,windows-x64}
|
||||
|
||||
# Determine which platforms to build
|
||||
if [[ -n "$PLATFORM" ]]; then
|
||||
PLATFORMS=("$PLATFORM")
|
||||
else
|
||||
PLATFORMS=(darwin-arm64 darwin-x64 linux-x64 linux-arm64 windows-x64)
|
||||
fi
|
||||
|
||||
for platform in "${PLATFORMS[@]}"; do
|
||||
echo "Building for $platform..."
|
||||
# Externalize koffi to avoid embedding all 18 platform .node files (~74MB)
|
||||
# into every binary. Koffi is only used on Windows for VT input and the
|
||||
# call site has a try/catch fallback. For Windows builds, we copy the
|
||||
# appropriate .node file alongside the binary below.
|
||||
if [[ "$platform" == "windows-x64" ]]; then
|
||||
bun build --compile --external koffi --target=bun-$platform ./dist/bun/cli.js --outfile binaries/$platform/pi.exe
|
||||
else
|
||||
bun build --compile --external koffi --target=bun-$platform ./dist/bun/cli.js --outfile binaries/$platform/pi
|
||||
fi
|
||||
done
|
||||
|
||||
echo "==> Creating release archives..."
|
||||
|
||||
# Copy shared files to each platform directory
|
||||
for platform in "${PLATFORMS[@]}"; do
|
||||
cp package.json binaries/$platform/
|
||||
cp README.md binaries/$platform/
|
||||
cp CHANGELOG.md binaries/$platform/
|
||||
cp ../../node_modules/@silvia-odwyer/photon-node/photon_rs_bg.wasm binaries/$platform/
|
||||
mkdir -p binaries/$platform/theme
|
||||
cp dist/modes/interactive/theme/*.json binaries/$platform/theme/
|
||||
mkdir -p binaries/$platform/assets
|
||||
cp dist/modes/interactive/assets/* binaries/$platform/assets/
|
||||
cp -r dist/core/export-html binaries/$platform/
|
||||
cp -r docs binaries/$platform/
|
||||
cp -r examples binaries/$platform/
|
||||
|
||||
# Copy koffi native module for Windows (needed for VT input support)
|
||||
if [[ "$platform" == "windows-x64" ]]; then
|
||||
mkdir -p binaries/$platform/node_modules/koffi/build/koffi/win32_x64
|
||||
cp ../../node_modules/koffi/index.js binaries/$platform/node_modules/koffi/
|
||||
cp ../../node_modules/koffi/package.json binaries/$platform/node_modules/koffi/
|
||||
cp ../../node_modules/koffi/build/koffi/win32_x64/koffi.node binaries/$platform/node_modules/koffi/build/koffi/win32_x64/
|
||||
fi
|
||||
done
|
||||
|
||||
# Create archives
|
||||
cd binaries
|
||||
|
||||
for platform in "${PLATFORMS[@]}"; do
|
||||
if [[ "$platform" == "windows-x64" ]]; 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-x64" ]]; 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 packages/coding-agent/binaries/"
|
||||
ls -lh *.tar.gz *.zip 2>/dev/null || true
|
||||
echo ""
|
||||
echo "Extracted directories for testing:"
|
||||
for platform in "${PLATFORMS[@]}"; do
|
||||
echo " binaries/$platform/pi"
|
||||
done
|
||||
@@ -1,36 +0,0 @@
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { build } from "esbuild";
|
||||
|
||||
const outputPath = join(tmpdir(), "pi-browser-smoke.js");
|
||||
const errorLogPath = join(tmpdir(), "pi-browser-smoke-errors.log");
|
||||
|
||||
try {
|
||||
await build({
|
||||
entryPoints: ["scripts/browser-smoke-entry.ts"],
|
||||
bundle: true,
|
||||
platform: "browser",
|
||||
format: "esm",
|
||||
logLevel: "silent",
|
||||
outfile: outputPath,
|
||||
});
|
||||
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);
|
||||
}
|
||||
@@ -1,183 +0,0 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
|
||||
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();
|
||||
@@ -1,834 +0,0 @@
|
||||
#!/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);
|
||||
});
|
||||
@@ -1,639 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -1,145 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Release script for pi-mono
|
||||
*
|
||||
* Usage: node scripts/release.mjs <major|minor|patch>
|
||||
*
|
||||
* Steps:
|
||||
* 1. Check for uncommitted changes
|
||||
* 2. Bump version via npm run version:xxx
|
||||
* 3. Update CHANGELOG.md files: [Unreleased] -> [version] - date
|
||||
* 4. Commit and tag
|
||||
* 5. Publish to npm
|
||||
* 6. Add new [Unreleased] section to changelogs
|
||||
* 7. Commit
|
||||
*/
|
||||
|
||||
import { execSync } from "child_process";
|
||||
import { readFileSync, writeFileSync, readdirSync, existsSync } from "fs";
|
||||
import { join } from "path";
|
||||
|
||||
const BUMP_TYPE = process.argv[2];
|
||||
|
||||
if (!["major", "minor", "patch"].includes(BUMP_TYPE)) {
|
||||
console.error("Usage: node scripts/release.mjs <major|minor|patch>");
|
||||
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 getChangelogs() {
|
||||
const packagesDir = "packages";
|
||||
const packages = readdirSync(packagesDir);
|
||||
return packages
|
||||
.map((pkg) => join(packagesDir, pkg, "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 version
|
||||
console.log(`Bumping version (${BUMP_TYPE})...`);
|
||||
run(`npm run version:${BUMP_TYPE}`);
|
||||
const version = getVersion();
|
||||
console.log(` New version: ${version}\n`);
|
||||
|
||||
// 3. Update changelogs
|
||||
console.log("Updating CHANGELOG.md files...");
|
||||
updateChangelogsForRelease(version);
|
||||
console.log();
|
||||
|
||||
// 4. Commit and tag
|
||||
console.log("Committing and tagging...");
|
||||
run("git add .");
|
||||
run(`git commit -m "Release v${version}"`);
|
||||
run(`git tag v${version}`);
|
||||
console.log();
|
||||
|
||||
// 5. Publish
|
||||
console.log("Publishing to npm...");
|
||||
run("npm run publish");
|
||||
console.log();
|
||||
|
||||
// 6. Add new [Unreleased] sections
|
||||
console.log("Adding [Unreleased] sections for next cycle...");
|
||||
addUnreleasedSection();
|
||||
console.log();
|
||||
|
||||
// 7. Commit
|
||||
console.log("Committing changelog updates...");
|
||||
run("git add .");
|
||||
run(`git commit -m "Add [Unreleased] section for next cycle"`);
|
||||
console.log();
|
||||
|
||||
// 8. Push
|
||||
console.log("Pushing to remote...");
|
||||
run("git push origin main");
|
||||
run(`git push origin v${version}`);
|
||||
console.log();
|
||||
|
||||
console.log(`=== Released v${version} ===`);
|
||||
@@ -1,406 +0,0 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* Extracts session transcripts for a given cwd, splits into context-sized files,
|
||||
* optionally spawns subagents to analyze patterns.
|
||||
*
|
||||
* Usage: npx tsx 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.js";
|
||||
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);
|
||||
@@ -1,96 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Syncs ALL @mariozechner/* package dependency versions to match their current versions.
|
||||
* This ensures lockstep versioning across the monorepo.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, readdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
|
||||
const packagesDir = join(process.cwd(), 'packages');
|
||||
const packageDirs = readdirSync(packagesDir, { withFileTypes: true })
|
||||
.filter(dirent => dirent.isDirectory())
|
||||
.map(dirent => dirent.name);
|
||||
|
||||
// Read all package.json files and build version map
|
||||
const packages = {};
|
||||
const versionMap = {};
|
||||
|
||||
for (const dir of packageDirs) {
|
||||
const pkgPath = join(packagesDir, dir, 'package.json');
|
||||
try {
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
||||
packages[dir] = { path: pkgPath, data: pkg };
|
||||
versionMap[pkg.name] = pkg.version;
|
||||
} catch (e) {
|
||||
console.error(`Failed to read ${pkgPath}:`, e.message);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Current versions:');
|
||||
for (const [name, version] of Object.entries(versionMap).sort()) {
|
||||
console.log(` ${name}: ${version}`);
|
||||
}
|
||||
|
||||
// Verify all versions are the same (lockstep)
|
||||
const versions = new Set(Object.values(versionMap));
|
||||
if (versions.size > 1) {
|
||||
console.error('\n❌ ERROR: Not all 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('\n✅ All packages at same version (lockstep)');
|
||||
|
||||
// Update all inter-package dependencies
|
||||
let totalUpdates = 0;
|
||||
for (const [dir, pkg] of Object.entries(packages)) {
|
||||
let updated = false;
|
||||
|
||||
// Check dependencies
|
||||
if (pkg.data.dependencies) {
|
||||
for (const [depName, currentVersion] of Object.entries(pkg.data.dependencies)) {
|
||||
if (versionMap[depName]) {
|
||||
const newVersion = `^${versionMap[depName]}`;
|
||||
if (currentVersion !== newVersion) {
|
||||
console.log(`\n${pkg.data.name}:`);
|
||||
console.log(` ${depName}: ${currentVersion} → ${newVersion}`);
|
||||
pkg.data.dependencies[depName] = newVersion;
|
||||
updated = true;
|
||||
totalUpdates++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check devDependencies
|
||||
if (pkg.data.devDependencies) {
|
||||
for (const [depName, currentVersion] of Object.entries(pkg.data.devDependencies)) {
|
||||
if (versionMap[depName]) {
|
||||
const newVersion = `^${versionMap[depName]}`;
|
||||
if (currentVersion !== newVersion) {
|
||||
console.log(`\n${pkg.data.name}:`);
|
||||
console.log(` ${depName}: ${currentVersion} → ${newVersion} (devDependencies)`);
|
||||
pkg.data.devDependencies[depName] = newVersion;
|
||||
updated = true;
|
||||
totalUpdates++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write if updated
|
||||
if (updated) {
|
||||
writeFileSync(pkg.path, JSON.stringify(pkg.data, null, '\t') + '\n');
|
||||
}
|
||||
}
|
||||
|
||||
if (totalUpdates === 0) {
|
||||
console.log('\nAll inter-package dependencies already in sync.');
|
||||
} else {
|
||||
console.log(`\n✅ Updated ${totalUpdates} dependency version(s)`);
|
||||
}
|
||||
Reference in new issue
Block a user