pi-lens
Version:
Real-time code feedback for pi — LSP, linters, formatters, type-checking, structural analysis & booboo
263 lines (262 loc) • 12 kB
JavaScript
import { logLatency } from "./latency-logger.js";
import { normalizeMapKey } from "./path-utils.js";
const BUS_FILES_TOUCHED_EVENT = "pilens:files:touched";
const MAX_NAMES_SHOWN = 5;
// Module-level accumulator: one process/session, so a plain map keyed via
// normalizeMapKey (house style — every map/set key in this module MUST go
// through it; a hand-rolled replace() is the exact trap that cost two red CI
// rounds on #458's reconcile tests, PR #491) is sufficient. Cleared ONLY
// inside consumeAgentNudge() (i.e. only at actual injection into a `context`
// call) — deliberately NOT tied to turn_start/agent_end/agent_settled, so
// entries accumulated during run A's turn_end survive until run B's first
// `context` call in the same session (the cross-run `git status` case).
const _touched = new Map();
// Count of bus-reported paths dropped by the relevance filter (file never
// read/edited this session) since the last consume — drained alongside the
// accumulator so the `agent_nudge` phase can report how much the filter
// actually suppresses, which is the metric that validates (or indicts) the
// "only nudge for files the session saw" rule.
let _relevanceFilteredCount = 0;
/** Test-only: clear accumulator state between test files/cases. */
export function _resetAgentNudgeForTests() {
_touched.clear();
_relevanceFilteredCount = 0;
_enabledCache = undefined;
}
// --- Kill switch (lazy, memoized — house style per clients/quiet-window.ts) ---
let _enabledCache;
/** `PI_LENS_AGENT_NUDGE=0` disables accumulation and injection outright. */
export function isAgentNudgeEnabled() {
if (_enabledCache === undefined) {
_enabledCache = process.env.PI_LENS_AGENT_NUDGE !== "0";
}
return _enabledCache;
}
function isValidPayload(data) {
if (!data || typeof data !== "object")
return false;
const p = data;
return (p.v === 1 &&
p.source === "pi-lens" &&
(p.reason === "autofix" || p.reason === "format") &&
Array.isArray(p.paths));
}
/**
* Record a `pilens:files:touched` event into the accumulator, filtered to
* files the session has actually read or edited (read-guard is the source of
* truth — `getReadHistory`/`getEditHistory` both key internally via
* `normalizeFilePath`, so either separator form on the incoming bus payload
* or the guard's stored records resolves to the same record regardless of
* which form was recorded first).
*/
function recordTouchedEvent(payload, getReadGuard) {
const readGuard = getReadGuard();
if (!readGuard)
return;
for (const rawPath of payload.paths) {
const isRelevant = readGuard.getReadHistory(rawPath).length > 0 ||
readGuard.getEditHistory(rawPath).length > 0;
if (!isRelevant) {
_relevanceFilteredCount++;
continue;
}
const mapKey = normalizeMapKey(rawPath);
const existing = _touched.get(mapKey);
if (existing) {
existing.reasons.add(payload.reason);
// "local" is sticky — see AccumulatedFileOrigin doc. A file already
// recorded via the cross-process feed, now also reported by this
// session's own bus, upgrades to "local".
existing.origin = "local";
}
else {
_touched.set(mapKey, {
displayPath: rawPath,
reasons: new Set([payload.reason]),
origin: "local",
});
}
}
}
/**
* Feed entries read from the cross-process `recent-touches.json` record
* (#492, clients/recent-touches.ts) into the SAME accumulator #485 uses for
* in-process bus events — one accumulator, one `consumeAgentNudge` call, one
* batched context message regardless of how many files came from which
* channel.
*
* Relevance filtering differs by call site (#492 point 6) and is therefore
* the CALLER's responsibility, not this function's:
* - parent at turn_start: read-guard history FIRST (a parent usually has
* one), falling back to recency-only for files it hasn't read (a parent
* about to `git commit` needs attribution even for unread files);
* - child at session_start: recency + file-existence only (no read
* history exists yet this early).
* `readCrossProcessTouchesForTurnStart` / `...ForSessionStart` in
* recent-touches.ts already apply their own recency/existence/self-pid
* filtering before entries reach here — this function does not re-derive
* relevance, it only merges into the accumulator and marks provenance.
*
* Self-exclusion (an entry this process itself published never nudges
* itself) and path+ts dedup across repeated reads of the record are both
* handled upstream in recent-touches.ts (pid filter + last-consumed cursor),
* so entries reaching this function are already the "new, foreign" set.
*/
export function recordCrossProcessTouches(entries) {
if (!isAgentNudgeEnabled())
return;
for (const entry of entries) {
const mapKey = normalizeMapKey(entry.path);
const existing = _touched.get(mapKey);
if (existing) {
existing.reasons.add(entry.reason);
// "local" is sticky (see AccumulatedFileOrigin) — never downgrade an
// already-local entry back to cross-process.
}
else {
_touched.set(mapKey, {
displayPath: entry.path,
reasons: new Set([entry.reason]),
origin: "cross-process",
});
}
}
}
/**
* Subscribe to `pilens:files:touched` on pi's shared event bus. Called once
* at extension factory time from index.ts, mirroring `wireBusEmitter`'s
* placement (clients/bus-publish.ts). No-ops silently when `pi.events` or
* `.on` is unavailable (older pi host) — never throws.
*/
export function wireAgentNudgeSubscriber(args) {
const { events, getReadGuard, dbg } = args;
if (!events?.on)
return;
try {
events.on(BUS_FILES_TOUCHED_EVENT, (data) => {
if (!isAgentNudgeEnabled())
return;
if (!isValidPayload(data))
return;
try {
recordTouchedEvent(data, getReadGuard);
}
catch (err) {
dbg?.(`agent-nudge: failed to record touched event: ${err}`);
}
});
}
catch (err) {
dbg?.(`agent-nudge: subscribe failed (older pi host?): ${err}`);
}
}
/**
* Consume the accumulated touched-file set and produce (at most) one context
* message, e.g.:
* "pi-lens: 2 file(s) were autoformatted after your last turn: a.ts, b.ts —
* working-tree changes to these are expected; re-read before editing."
* The provenance framing ("pi-lens ... expected") matters: the primary pain
* case is an agent running `git status` (often at the START of a brand-new
* run/session, not just mid-run) and burning turns investigating diffs it
* did not knowingly make. Naming pi-lens as the source lets the agent act
* (re-read, proceed) instead of investigating.
*
* #492: attribution is three-way by the batch's origin mix (see
* AccumulatedFileOrigin) — still ONE message total (never split local vs.
* cross-process into two separate injections; the agent gets one coherent
* picture of everything unexplained in the working tree), but the wording
* must never assign a LOCAL file to another instance:
* - all local → "after your last turn" (the original #485 wording,
* unchanged — verified by the pre-existing #485
* tests);
* - all cross-process → "by another pi-lens instance (e.g. a subagent's)";
* - mixed → "after your last turn (N of them by another
* pi-lens instance)" — the base framing stays local
* and the cross-process portion is counted out
* precisely, so no local file is ever misattributed
* to another instance.
*
* Clears the accumulator ONLY here, on actual injection — never on
* agent_end/agent_settled/turn_start. Files formatted at the last turn_end of
* a PREVIOUS run must still nudge at the first turn of the NEXT run in the
* same session: this function is invoked from the `context` extension event
* (index.ts), which fires before every provider/LLM call — including the
* very first call of a fresh `agent_start` — so the accumulator surviving
* across run boundaries is exactly what makes that cross-run delivery work.
* Empty accumulator ⇒ returns undefined ⇒ zero bytes injected.
*/
export function consumeAgentNudge(dbg) {
const entries = Array.from(_touched.values());
_touched.clear();
const filesFiltered = _relevanceFilteredCount;
_relevanceFilteredCount = 0;
if (!isAgentNudgeEnabled())
return undefined;
if (entries.length === 0)
return undefined;
try {
const filesTotal = entries.length;
const shown = entries.slice(0, MAX_NAMES_SHOWN);
const remaining = filesTotal - shown.length;
// Determine a single verb covering every reason seen across all
// accumulated files (not just the shown subset) — most turns will have
// a single uniform reason, so keep that common case terse; a mix of
// autofix + format across the batch falls back to a combined verb.
const allReasons = new Set();
for (const e of entries) {
for (const r of e.reasons)
allReasons.add(r);
}
const verbLabel = allReasons.size > 1
? "autofixed/reformatted"
: allReasons.has("format")
? "reformatted"
: "autofixed";
const names = shown.map((e) => e.displayPath);
const nameList = remaining > 0
? `${names.join(", ")}, and ${remaining} more`
: names.join(", ");
const crossProcessCount = entries.filter((e) => e.origin === "cross-process").length;
const localCount = filesTotal - crossProcessCount;
// Three-way attribution (see the function doc): never assign a local
// file to another instance — a mixed batch keeps the local base framing
// and calls out the cross-process portion by exact count.
const attribution = localCount === 0
? "by another pi-lens instance (e.g. a subagent's)"
: crossProcessCount === 0
? "after your last turn"
: `after your last turn (${crossProcessCount} of them by another pi-lens instance)`;
const message = `pi-lens: ${filesTotal} file(s) were ${verbLabel} ${attribution}: ${nameList} — working-tree changes to these are expected; re-read before editing.`;
logLatency({
type: "phase",
filePath: "<pi-lens>",
phase: "agent_nudge",
durationMs: 0,
metadata: {
filesTotal,
filesShown: shown.length,
// Relevance-filter drops since the last consume (files the session
// never read/edited) — NOT the display overflow, which is
// filesTotal - filesShown.
filesFiltered,
reasonMix: Array.from(allReasons),
// #492: origin mix so cross-process pickup rate is observable
// alongside the existing relevance-filter metric.
originLocal: localCount,
originCrossProcess: crossProcessCount,
},
});
return {
messages: [
{
role: "user",
content: `[pi-lens automated context — not a user request] ${message}`,
},
],
};
}
catch (err) {
dbg?.(`agent-nudge: consume failed: ${err}`);
return undefined;
}
}