pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
126 lines (125 loc) • 5.19 kB
JavaScript
/**
* NDJSON telemetry for the agent-facing ast_grep_search / ast_grep_replace
* tools. Captures inputs, outcome, and failure category so we can answer:
*
* - How often do agents hit "Multiple AST nodes" with multi-line patterns?
* - Which language emits which failure most often?
* - Do retries (post-skill-read) succeed within the same session?
* - Which patterns keep getting tried and keep failing?
*
* Mirrors `actionable-warnings-logger.ts` for shape + rotation behaviour.
*/
import * as path from "node:path";
import { isTestMode } from "./env-utils.js";
import { getGlobalPiLensDir } from "./file-utils.js";
import { createNdjsonLogger } from "./ndjson-logger.js";
const AG_LOG_DIR = getGlobalPiLensDir();
const AG_LOG_FILE = path.join(AG_LOG_DIR, "ast-grep-tools.log");
const AG_LOG_BACKUP_FILE = path.join(AG_LOG_DIR, "ast-grep-tools.log.1");
const MAX_LOG_BYTES = Math.max(128 * 1024, Number.parseInt(process.env.PI_LENS_AST_GREP_LOG_MAX_BYTES ?? "1048576", 10) || 1048576);
const writer = createNdjsonLogger({
filePath: AG_LOG_FILE,
maxBytes: MAX_LOG_BYTES,
backupPath: AG_LOG_BACKUP_FILE,
});
const PATTERN_TRUNCATE_AT = 500;
const ERROR_TRUNCATE_AT = 300;
function truncate(value, max) {
if (value === undefined)
return undefined;
if (value.length <= max)
return value;
return `${value.slice(0, max)}…[truncated ${value.length - max} chars]`;
}
function countLines(value) {
if (value === undefined)
return undefined;
if (value.length === 0)
return 0;
let lines = 1;
for (let i = 0; i < value.length; i++) {
if (value.charCodeAt(i) === 10)
lines++;
}
return lines;
}
/**
* Categorise an error string from the sg-runner / spawn layer. Looks first
* at the friendly wrappers sg-runner.ts emits (which already contain the
* original stderr); falls back to raw stderr substring checks for codepaths
* that don't go through that wrapper.
*/
export function classifyAstGrepError(errorMessage) {
if (!errorMessage)
return "other";
const lower = errorMessage.toLowerCase();
if (lower.includes("multiple ast nodes are detected") ||
lower.includes("the pattern appears to contain multiple ast nodes")) {
return "multiple_ast_nodes";
}
if (lower.includes("cannot parse query") ||
lower.includes("pattern syntax error") ||
lower.includes("could not be parsed as valid code")) {
return "cannot_parse_query";
}
if (lower.includes("cli not found") ||
lower.includes("enoent") ||
lower.includes("not installed")) {
return "tool_not_found";
}
if (lower.includes("timed out") || lower.includes("timeout")) {
return "timeout";
}
if (lower.includes("failed to parse output")) {
return "json_parse_failed";
}
return "other";
}
/**
* Map a classified error kind to a one-line, agent-facing remediation hint.
*
* The runner layer (sg-runner.ts) already wraps the two highest-frequency
* spawn errors — "Multiple AST nodes" and "Cannot parse query" — with curated
* multi-line guidance, so those return `null` here (don't double up). This
* closes the loop for the categories that otherwise reach the agent as raw
* stderr (`other`/`timeout`/`tool_not_found`/`json_parse_failed`): we already
* classify them for telemetry, so reuse that classification to help the agent
* self-correct on the error path the way getPatternHint() does on zero-matches.
*/
export function astGrepRemediationHint(kind) {
switch (kind) {
case "multiple_ast_nodes":
case "cannot_parse_query":
// Error text already carries curated guidance from sg-runner.ts.
return null;
case "tool_not_found":
return "Hint: the ast-grep CLI is missing or not on PATH — install it with `npm i -D @ast-grep/cli`.";
case "timeout":
return "Hint: the operation timed out — scope `paths` to specific files/folders, or narrow the pattern.";
case "json_parse_failed":
return "Hint: ast-grep produced output that could not be parsed — retry with a simpler pattern; the installed CLI version may be incompatible.";
default:
return "Hint: verify the pattern is a single valid AST node for this `lang` (use ast_grep_dump to discover node kinds), or fall back to grep for plain-text search.";
}
}
export function logAstGrepToolEvent(event) {
if (isTestMode())
return;
const payload = {
...event,
pattern: truncate(event.pattern, PATTERN_TRUNCATE_AT) ?? "",
patternLineCount: event.patternLineCount,
rewrite: truncate(event.rewrite, PATTERN_TRUNCATE_AT),
rewriteLineCount: event.rewriteLineCount,
errorRaw: truncate(event.errorRaw, ERROR_TRUNCATE_AT),
};
writer.log({ ts: new Date().toISOString(), ...payload });
}
export function getAstGrepToolLogPath() {
return AG_LOG_FILE;
}
/** Resolve once all enqueued ast-grep-tool writes are on disk. */
export function flushAstGrepToolLog() {
return writer.flush();
}
export { countLines as _countLinesForTest };