pi-lens 3.8.67 → 3.8.69
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +58 -0
- package/README.md +57 -38
- package/dist/clients/agent-nudge.js +262 -0
- package/dist/clients/biome-client.js +2 -2
- package/dist/clients/bus-publish.js +110 -0
- package/dist/clients/deps/ast-grep-napi.js +20 -1
- package/dist/clients/deps/pi-tui.js +4 -1
- package/dist/clients/deps/typebox.js +5 -2
- package/dist/clients/deps/web-tree-sitter.js +22 -1
- package/dist/clients/diagnostic-logger.js +2 -2
- package/dist/clients/diagnostics-publish.js +180 -0
- package/dist/clients/dispatch/dispatcher.js +2 -0
- package/dist/clients/dispatch/integration.js +153 -2
- package/dist/clients/dispatch/runners/ast-grep-napi.js +58 -13
- package/dist/clients/dispatch/runners/yaml-rule-parser.js +68 -19
- package/dist/clients/file-utils.js +20 -7
- package/dist/clients/installer/index.js +32 -1
- package/dist/clients/instance-reaper.js +516 -0
- package/dist/clients/instance-registry.js +238 -0
- package/dist/clients/jscpd-client.js +2 -2
- package/dist/clients/lens-config.js +17 -0
- package/dist/clients/lens-engine.js +44 -10
- package/dist/clients/lsp/cascade-tier.js +254 -0
- package/dist/clients/lsp/client.js +81 -3
- package/dist/clients/lsp/index.js +3 -0
- package/dist/clients/lsp/launch.js +2 -0
- package/dist/clients/lsp/server-strategies.js +71 -0
- package/dist/clients/lsp/server.js +119 -6
- package/dist/clients/mcp/analyze.js +110 -1
- package/dist/clients/module-report.js +163 -18
- package/dist/clients/path-utils.js +25 -0
- package/dist/clients/persist-debounce.js +63 -0
- package/dist/clients/pipeline.js +82 -2
- package/dist/clients/project-diagnostics/extractors.js +30 -4
- package/dist/clients/project-snapshot.js +7 -2
- package/dist/clients/quiet-window.js +168 -0
- package/dist/clients/recent-touches.js +233 -0
- package/dist/clients/review-graph/builder.js +20 -2
- package/dist/clients/runtime-agent-end.js +51 -1
- package/dist/clients/runtime-coordinator.js +21 -0
- package/dist/clients/runtime-session.js +154 -48
- package/dist/clients/runtime-tool-result.js +46 -0
- package/dist/clients/runtime-turn.js +9 -0
- package/dist/clients/session-lifecycle.js +252 -0
- package/dist/clients/sgconfig.js +246 -38
- package/dist/clients/slow-fs.js +137 -0
- package/dist/clients/source-filter.js +62 -14
- package/dist/clients/subagent-mode.js +87 -0
- package/dist/clients/tree-sitter-symbol-extractor.js +108 -0
- package/dist/clients/tui-fit.js +54 -0
- package/dist/clients/turn-summary-render.js +72 -0
- package/dist/clients/turn-summary.js +132 -0
- package/dist/clients/widget-state.js +27 -30
- package/dist/clients/word-index.js +296 -1
- package/dist/index.js +62809 -1633
- package/dist/mcp/build-staleness.js +123 -0
- package/dist/mcp/server.js +377 -43
- package/dist/tools/ast-grep-search.js +1 -1
- package/dist/tools/lens-diagnostics.js +42 -12
- package/dist/tools/lsp-diagnostics.js +117 -4
- package/dist/tools/module-report.js +14 -11
- package/dist/tools/symbol-search.js +110 -0
- package/package.json +3 -2
- package/rules/ast-grep-rules/rule-tests/hardcoded-url-js-test.yml +1 -0
- package/rules/ast-grep-rules/rule-tests/no-typeof-undefined-js-test.yml +8 -0
- package/rules/ast-grep-rules/rule-tests/no-typeof-undefined-test.yml +8 -0
- package/rules/ast-grep-rules/rules/hardcoded-url-js.yml +1 -1
- package/rules/ast-grep-rules/rules/no-typeof-undefined-js.yml +9 -7
- package/rules/ast-grep-rules/rules/no-typeof-undefined.yml +9 -7
- package/skills/{ast-grep → pi-lens-ast-grep}/SKILL.md +1 -1
- package/skills/{lsp-navigation → pi-lens-lsp-navigation}/SKILL.md +1 -1
- package/skills/{write-ast-grep-rule → pi-lens-write-ast-grep-rule}/SKILL.md +1 -1
- package/skills/{write-tree-sitter-rule → pi-lens-write-tree-sitter-rule}/SKILL.md +1 -1
- package/dist/clients/tree-sitter-fixer.js +0 -127
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { logLatency } from "./latency-logger.js";
|
|
2
|
+
import { normalizeMapKey } from "./path-utils.js";
|
|
3
|
+
const BUS_FILES_TOUCHED_EVENT = "pilens:files:touched";
|
|
4
|
+
const MAX_NAMES_SHOWN = 5;
|
|
5
|
+
// Module-level accumulator: one process/session, so a plain map keyed via
|
|
6
|
+
// normalizeMapKey (house style — every map/set key in this module MUST go
|
|
7
|
+
// through it; a hand-rolled replace() is the exact trap that cost two red CI
|
|
8
|
+
// rounds on #458's reconcile tests, PR #491) is sufficient. Cleared ONLY
|
|
9
|
+
// inside consumeAgentNudge() (i.e. only at actual injection into a `context`
|
|
10
|
+
// call) — deliberately NOT tied to turn_start/agent_end/agent_settled, so
|
|
11
|
+
// entries accumulated during run A's turn_end survive until run B's first
|
|
12
|
+
// `context` call in the same session (the cross-run `git status` case).
|
|
13
|
+
const _touched = new Map();
|
|
14
|
+
// Count of bus-reported paths dropped by the relevance filter (file never
|
|
15
|
+
// read/edited this session) since the last consume — drained alongside the
|
|
16
|
+
// accumulator so the `agent_nudge` phase can report how much the filter
|
|
17
|
+
// actually suppresses, which is the metric that validates (or indicts) the
|
|
18
|
+
// "only nudge for files the session saw" rule.
|
|
19
|
+
let _relevanceFilteredCount = 0;
|
|
20
|
+
/** Test-only: clear accumulator state between test files/cases. */
|
|
21
|
+
export function _resetAgentNudgeForTests() {
|
|
22
|
+
_touched.clear();
|
|
23
|
+
_relevanceFilteredCount = 0;
|
|
24
|
+
_enabledCache = undefined;
|
|
25
|
+
}
|
|
26
|
+
// --- Kill switch (lazy, memoized — house style per clients/quiet-window.ts) ---
|
|
27
|
+
let _enabledCache;
|
|
28
|
+
/** `PI_LENS_AGENT_NUDGE=0` disables accumulation and injection outright. */
|
|
29
|
+
export function isAgentNudgeEnabled() {
|
|
30
|
+
if (_enabledCache === undefined) {
|
|
31
|
+
_enabledCache = process.env.PI_LENS_AGENT_NUDGE !== "0";
|
|
32
|
+
}
|
|
33
|
+
return _enabledCache;
|
|
34
|
+
}
|
|
35
|
+
function isValidPayload(data) {
|
|
36
|
+
if (!data || typeof data !== "object")
|
|
37
|
+
return false;
|
|
38
|
+
const p = data;
|
|
39
|
+
return (p.v === 1 &&
|
|
40
|
+
p.source === "pi-lens" &&
|
|
41
|
+
(p.reason === "autofix" || p.reason === "format") &&
|
|
42
|
+
Array.isArray(p.paths));
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Record a `pilens:files:touched` event into the accumulator, filtered to
|
|
46
|
+
* files the session has actually read or edited (read-guard is the source of
|
|
47
|
+
* truth — `getReadHistory`/`getEditHistory` both key internally via
|
|
48
|
+
* `normalizeFilePath`, so either separator form on the incoming bus payload
|
|
49
|
+
* or the guard's stored records resolves to the same record regardless of
|
|
50
|
+
* which form was recorded first).
|
|
51
|
+
*/
|
|
52
|
+
function recordTouchedEvent(payload, getReadGuard) {
|
|
53
|
+
const readGuard = getReadGuard();
|
|
54
|
+
if (!readGuard)
|
|
55
|
+
return;
|
|
56
|
+
for (const rawPath of payload.paths) {
|
|
57
|
+
const isRelevant = readGuard.getReadHistory(rawPath).length > 0 ||
|
|
58
|
+
readGuard.getEditHistory(rawPath).length > 0;
|
|
59
|
+
if (!isRelevant) {
|
|
60
|
+
_relevanceFilteredCount++;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
const mapKey = normalizeMapKey(rawPath);
|
|
64
|
+
const existing = _touched.get(mapKey);
|
|
65
|
+
if (existing) {
|
|
66
|
+
existing.reasons.add(payload.reason);
|
|
67
|
+
// "local" is sticky — see AccumulatedFileOrigin doc. A file already
|
|
68
|
+
// recorded via the cross-process feed, now also reported by this
|
|
69
|
+
// session's own bus, upgrades to "local".
|
|
70
|
+
existing.origin = "local";
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
_touched.set(mapKey, {
|
|
74
|
+
displayPath: rawPath,
|
|
75
|
+
reasons: new Set([payload.reason]),
|
|
76
|
+
origin: "local",
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Feed entries read from the cross-process `recent-touches.json` record
|
|
83
|
+
* (#492, clients/recent-touches.ts) into the SAME accumulator #485 uses for
|
|
84
|
+
* in-process bus events — one accumulator, one `consumeAgentNudge` call, one
|
|
85
|
+
* batched context message regardless of how many files came from which
|
|
86
|
+
* channel.
|
|
87
|
+
*
|
|
88
|
+
* Relevance filtering differs by call site (#492 point 6) and is therefore
|
|
89
|
+
* the CALLER's responsibility, not this function's:
|
|
90
|
+
* - parent at turn_start: read-guard history FIRST (a parent usually has
|
|
91
|
+
* one), falling back to recency-only for files it hasn't read (a parent
|
|
92
|
+
* about to `git commit` needs attribution even for unread files);
|
|
93
|
+
* - child at session_start: recency + file-existence only (no read
|
|
94
|
+
* history exists yet this early).
|
|
95
|
+
* `readCrossProcessTouchesForTurnStart` / `...ForSessionStart` in
|
|
96
|
+
* recent-touches.ts already apply their own recency/existence/self-pid
|
|
97
|
+
* filtering before entries reach here — this function does not re-derive
|
|
98
|
+
* relevance, it only merges into the accumulator and marks provenance.
|
|
99
|
+
*
|
|
100
|
+
* Self-exclusion (an entry this process itself published never nudges
|
|
101
|
+
* itself) and path+ts dedup across repeated reads of the record are both
|
|
102
|
+
* handled upstream in recent-touches.ts (pid filter + last-consumed cursor),
|
|
103
|
+
* so entries reaching this function are already the "new, foreign" set.
|
|
104
|
+
*/
|
|
105
|
+
export function recordCrossProcessTouches(entries) {
|
|
106
|
+
if (!isAgentNudgeEnabled())
|
|
107
|
+
return;
|
|
108
|
+
for (const entry of entries) {
|
|
109
|
+
const mapKey = normalizeMapKey(entry.path);
|
|
110
|
+
const existing = _touched.get(mapKey);
|
|
111
|
+
if (existing) {
|
|
112
|
+
existing.reasons.add(entry.reason);
|
|
113
|
+
// "local" is sticky (see AccumulatedFileOrigin) — never downgrade an
|
|
114
|
+
// already-local entry back to cross-process.
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
_touched.set(mapKey, {
|
|
118
|
+
displayPath: entry.path,
|
|
119
|
+
reasons: new Set([entry.reason]),
|
|
120
|
+
origin: "cross-process",
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Subscribe to `pilens:files:touched` on pi's shared event bus. Called once
|
|
127
|
+
* at extension factory time from index.ts, mirroring `wireBusEmitter`'s
|
|
128
|
+
* placement (clients/bus-publish.ts). No-ops silently when `pi.events` or
|
|
129
|
+
* `.on` is unavailable (older pi host) — never throws.
|
|
130
|
+
*/
|
|
131
|
+
export function wireAgentNudgeSubscriber(args) {
|
|
132
|
+
const { events, getReadGuard, dbg } = args;
|
|
133
|
+
if (!events?.on)
|
|
134
|
+
return;
|
|
135
|
+
try {
|
|
136
|
+
events.on(BUS_FILES_TOUCHED_EVENT, (data) => {
|
|
137
|
+
if (!isAgentNudgeEnabled())
|
|
138
|
+
return;
|
|
139
|
+
if (!isValidPayload(data))
|
|
140
|
+
return;
|
|
141
|
+
try {
|
|
142
|
+
recordTouchedEvent(data, getReadGuard);
|
|
143
|
+
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
dbg?.(`agent-nudge: failed to record touched event: ${err}`);
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
dbg?.(`agent-nudge: subscribe failed (older pi host?): ${err}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Consume the accumulated touched-file set and produce (at most) one context
|
|
155
|
+
* message, e.g.:
|
|
156
|
+
* "pi-lens: 2 file(s) were autoformatted after your last turn: a.ts, b.ts —
|
|
157
|
+
* working-tree changes to these are expected; re-read before editing."
|
|
158
|
+
* The provenance framing ("pi-lens ... expected") matters: the primary pain
|
|
159
|
+
* case is an agent running `git status` (often at the START of a brand-new
|
|
160
|
+
* run/session, not just mid-run) and burning turns investigating diffs it
|
|
161
|
+
* did not knowingly make. Naming pi-lens as the source lets the agent act
|
|
162
|
+
* (re-read, proceed) instead of investigating.
|
|
163
|
+
*
|
|
164
|
+
* #492: attribution is three-way by the batch's origin mix (see
|
|
165
|
+
* AccumulatedFileOrigin) — still ONE message total (never split local vs.
|
|
166
|
+
* cross-process into two separate injections; the agent gets one coherent
|
|
167
|
+
* picture of everything unexplained in the working tree), but the wording
|
|
168
|
+
* must never assign a LOCAL file to another instance:
|
|
169
|
+
* - all local → "after your last turn" (the original #485 wording,
|
|
170
|
+
* unchanged — verified by the pre-existing #485
|
|
171
|
+
* tests);
|
|
172
|
+
* - all cross-process → "by another pi-lens instance (e.g. a subagent's)";
|
|
173
|
+
* - mixed → "after your last turn (N of them by another
|
|
174
|
+
* pi-lens instance)" — the base framing stays local
|
|
175
|
+
* and the cross-process portion is counted out
|
|
176
|
+
* precisely, so no local file is ever misattributed
|
|
177
|
+
* to another instance.
|
|
178
|
+
*
|
|
179
|
+
* Clears the accumulator ONLY here, on actual injection — never on
|
|
180
|
+
* agent_end/agent_settled/turn_start. Files formatted at the last turn_end of
|
|
181
|
+
* a PREVIOUS run must still nudge at the first turn of the NEXT run in the
|
|
182
|
+
* same session: this function is invoked from the `context` extension event
|
|
183
|
+
* (index.ts), which fires before every provider/LLM call — including the
|
|
184
|
+
* very first call of a fresh `agent_start` — so the accumulator surviving
|
|
185
|
+
* across run boundaries is exactly what makes that cross-run delivery work.
|
|
186
|
+
* Empty accumulator ⇒ returns undefined ⇒ zero bytes injected.
|
|
187
|
+
*/
|
|
188
|
+
export function consumeAgentNudge(dbg) {
|
|
189
|
+
const entries = Array.from(_touched.values());
|
|
190
|
+
_touched.clear();
|
|
191
|
+
const filesFiltered = _relevanceFilteredCount;
|
|
192
|
+
_relevanceFilteredCount = 0;
|
|
193
|
+
if (!isAgentNudgeEnabled())
|
|
194
|
+
return undefined;
|
|
195
|
+
if (entries.length === 0)
|
|
196
|
+
return undefined;
|
|
197
|
+
try {
|
|
198
|
+
const filesTotal = entries.length;
|
|
199
|
+
const shown = entries.slice(0, MAX_NAMES_SHOWN);
|
|
200
|
+
const remaining = filesTotal - shown.length;
|
|
201
|
+
// Determine a single verb covering every reason seen across all
|
|
202
|
+
// accumulated files (not just the shown subset) — most turns will have
|
|
203
|
+
// a single uniform reason, so keep that common case terse; a mix of
|
|
204
|
+
// autofix + format across the batch falls back to a combined verb.
|
|
205
|
+
const allReasons = new Set();
|
|
206
|
+
for (const e of entries) {
|
|
207
|
+
for (const r of e.reasons)
|
|
208
|
+
allReasons.add(r);
|
|
209
|
+
}
|
|
210
|
+
const verbLabel = allReasons.size > 1
|
|
211
|
+
? "autofixed/reformatted"
|
|
212
|
+
: allReasons.has("format")
|
|
213
|
+
? "reformatted"
|
|
214
|
+
: "autofixed";
|
|
215
|
+
const names = shown.map((e) => e.displayPath);
|
|
216
|
+
const nameList = remaining > 0
|
|
217
|
+
? `${names.join(", ")}, and ${remaining} more`
|
|
218
|
+
: names.join(", ");
|
|
219
|
+
const crossProcessCount = entries.filter((e) => e.origin === "cross-process").length;
|
|
220
|
+
const localCount = filesTotal - crossProcessCount;
|
|
221
|
+
// Three-way attribution (see the function doc): never assign a local
|
|
222
|
+
// file to another instance — a mixed batch keeps the local base framing
|
|
223
|
+
// and calls out the cross-process portion by exact count.
|
|
224
|
+
const attribution = localCount === 0
|
|
225
|
+
? "by another pi-lens instance (e.g. a subagent's)"
|
|
226
|
+
: crossProcessCount === 0
|
|
227
|
+
? "after your last turn"
|
|
228
|
+
: `after your last turn (${crossProcessCount} of them by another pi-lens instance)`;
|
|
229
|
+
const message = `pi-lens: ${filesTotal} file(s) were ${verbLabel} ${attribution}: ${nameList} — working-tree changes to these are expected; re-read before editing.`;
|
|
230
|
+
logLatency({
|
|
231
|
+
type: "phase",
|
|
232
|
+
filePath: "<pi-lens>",
|
|
233
|
+
phase: "agent_nudge",
|
|
234
|
+
durationMs: 0,
|
|
235
|
+
metadata: {
|
|
236
|
+
filesTotal,
|
|
237
|
+
filesShown: shown.length,
|
|
238
|
+
// Relevance-filter drops since the last consume (files the session
|
|
239
|
+
// never read/edited) — NOT the display overflow, which is
|
|
240
|
+
// filesTotal - filesShown.
|
|
241
|
+
filesFiltered,
|
|
242
|
+
reasonMix: Array.from(allReasons),
|
|
243
|
+
// #492: origin mix so cross-process pickup rate is observable
|
|
244
|
+
// alongside the existing relevance-filter metric.
|
|
245
|
+
originLocal: localCount,
|
|
246
|
+
originCrossProcess: crossProcessCount,
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
return {
|
|
250
|
+
messages: [
|
|
251
|
+
{
|
|
252
|
+
role: "user",
|
|
253
|
+
content: `[pi-lens automated context — not a user request] ${message}`,
|
|
254
|
+
},
|
|
255
|
+
],
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
catch (err) {
|
|
259
|
+
dbg?.(`agent-nudge: consume failed: ${err}`);
|
|
260
|
+
return undefined;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
* Docs: https://biomejs.dev/
|
|
9
9
|
*/
|
|
10
10
|
import * as fs from "node:fs";
|
|
11
|
-
import * as os from "node:os";
|
|
12
11
|
import * as path from "node:path";
|
|
13
12
|
import { isFileKind } from "./file-kinds.js";
|
|
13
|
+
import { getGlobalPiLensDir } from "./file-utils.js";
|
|
14
14
|
import { findGlobalBinary } from "./package-manager.js";
|
|
15
15
|
import { safeSpawnAsync } from "./safe-spawn.js";
|
|
16
16
|
// --- Client ---
|
|
@@ -56,7 +56,7 @@ export class BiomeClient {
|
|
|
56
56
|
// the tool is already installed but not in the project's node_modules.
|
|
57
57
|
// On Windows prefer .cmd (native batch) over the sh wrapper — 2x faster.
|
|
58
58
|
const isWin = process.platform === "win32";
|
|
59
|
-
const piLensBin = path.join(
|
|
59
|
+
const piLensBin = path.join(getGlobalPiLensDir(), "tools", "node_modules", ".bin");
|
|
60
60
|
const candidates = isWin
|
|
61
61
|
? [
|
|
62
62
|
path.join(resolveCwd, "node_modules", ".bin", "biome.cmd"),
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Publishes `pilens:files:touched` on pi's shared `pi.events` bus (#482).
|
|
3
|
+
*
|
|
4
|
+
* This is pi-lens's FIRST `pi.events` broadcast surface: it exists so other
|
|
5
|
+
* extensions in the same session can observe files pi-lens writes
|
|
6
|
+
* autonomously (autofix/format runners) without reverse-engineering us —
|
|
7
|
+
* writes the agent makes itself via its own tool calls are NOT ours to
|
|
8
|
+
* broadcast; the host already knows about those (see the seam audit in
|
|
9
|
+
* AGENTS.md / issue #482).
|
|
10
|
+
*
|
|
11
|
+
* Versioning policy: the payload is frozen-additive. New optional fields may
|
|
12
|
+
* be added under the same `v: 1`; a breaking/incompatible change to an
|
|
13
|
+
* existing field's meaning must bump `v`.
|
|
14
|
+
*
|
|
15
|
+
* Fire-and-forget: publishing must never affect the write path's success or
|
|
16
|
+
* latency. Any failure (bus unavailable, emit throws) is swallowed; a `dbg`
|
|
17
|
+
* callback is invoked at most once on first failure so a wired caller can log
|
|
18
|
+
* it without spamming.
|
|
19
|
+
*/
|
|
20
|
+
import { normalizeFilePath } from "./path-utils.js";
|
|
21
|
+
import { appendRecentTouches } from "./recent-touches.js";
|
|
22
|
+
export const BUS_FILES_TOUCHED_EVENT = "pilens:files:touched";
|
|
23
|
+
export const BUS_FILES_TOUCHED_VERSION = 1;
|
|
24
|
+
let busEmit;
|
|
25
|
+
let hasLoggedFailure = false;
|
|
26
|
+
/**
|
|
27
|
+
* Wire the emit function from pi's `pi.events` bus. Called once at extension
|
|
28
|
+
* factory time from index.ts (module-level singleton, same pattern as
|
|
29
|
+
* `initLensEvents`). Never called ⇒ `publishFilesTouched` no-ops, which is
|
|
30
|
+
* exactly the state unit tests and the MCP server path run in (no pi host,
|
|
31
|
+
* no `pi.events`).
|
|
32
|
+
*/
|
|
33
|
+
export function wireBusEmitter(emitFn) {
|
|
34
|
+
busEmit = emitFn;
|
|
35
|
+
}
|
|
36
|
+
/** Test-only: reset module state between test files. */
|
|
37
|
+
export function _resetForTests() {
|
|
38
|
+
busEmit = undefined;
|
|
39
|
+
hasLoggedFailure = false;
|
|
40
|
+
_envCache = undefined;
|
|
41
|
+
}
|
|
42
|
+
let _envCache;
|
|
43
|
+
/**
|
|
44
|
+
* Lazy env read (house style) so tests can flip `PI_LENS_BUS_PUBLISH` at
|
|
45
|
+
* runtime via `_resetForTests` + re-set the env var. Kill switch: set to `0`
|
|
46
|
+
* to disable publishing outright.
|
|
47
|
+
*/
|
|
48
|
+
export function isBusPublishEnabled() {
|
|
49
|
+
if (_envCache === undefined) {
|
|
50
|
+
_envCache = process.env.PI_LENS_BUS_PUBLISH !== "0";
|
|
51
|
+
}
|
|
52
|
+
return _envCache;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Publish one `pilens:files:touched` event for a logical write batch (one
|
|
56
|
+
* event per call site invocation, not per file). Fire-and-forget: never
|
|
57
|
+
* throws, never awaited by the caller's write path.
|
|
58
|
+
*
|
|
59
|
+
* #492: this is also the producer seam for the cross-process
|
|
60
|
+
* `recent-touches.json` record (clients/recent-touches.ts) — parent and
|
|
61
|
+
* child pi processes run this exact function, so appending here (rather
|
|
62
|
+
* than at each of the several `publishFilesTouched` call sites) guarantees
|
|
63
|
+
* every future call site gets cross-process propagation for free. The
|
|
64
|
+
* append is independent of `busEmit` being wired (a bare/MCP/test host with
|
|
65
|
+
* no `pi.events` still gets a cross-process record — the in-process bus and
|
|
66
|
+
* the on-disk record are two separate deliveries of the same payload, and
|
|
67
|
+
* the record is the ONLY one of the two that survives a process boundary).
|
|
68
|
+
* Fire-and-forget, same as the bus emit: never awaited, failures swallowed
|
|
69
|
+
* and dbg-logged (never break the publish path).
|
|
70
|
+
*/
|
|
71
|
+
export function publishFilesTouched(args) {
|
|
72
|
+
if (args.origin === "bus")
|
|
73
|
+
return;
|
|
74
|
+
if (args.paths.length === 0)
|
|
75
|
+
return;
|
|
76
|
+
if (!isBusPublishEnabled())
|
|
77
|
+
return;
|
|
78
|
+
void appendRecentTouches({
|
|
79
|
+
cwd: args.cwd,
|
|
80
|
+
reason: args.reason,
|
|
81
|
+
paths: args.paths,
|
|
82
|
+
sessionId: args.sessionId,
|
|
83
|
+
}).catch((err) => {
|
|
84
|
+
args.dbg?.(`bus-publish: recent-touches append failed: ${err}`);
|
|
85
|
+
});
|
|
86
|
+
if (!busEmit)
|
|
87
|
+
return;
|
|
88
|
+
try {
|
|
89
|
+
const payload = {
|
|
90
|
+
v: BUS_FILES_TOUCHED_VERSION,
|
|
91
|
+
source: "pi-lens",
|
|
92
|
+
reason: args.reason,
|
|
93
|
+
paths: args.paths.map((p) => normalizeFilePath(p)),
|
|
94
|
+
cwd: normalizeFilePath(args.cwd),
|
|
95
|
+
};
|
|
96
|
+
if (args.fixes && args.fixes.length > 0) {
|
|
97
|
+
payload.fixes = args.fixes.map((f) => ({
|
|
98
|
+
...f,
|
|
99
|
+
path: normalizeFilePath(f.path),
|
|
100
|
+
}));
|
|
101
|
+
}
|
|
102
|
+
busEmit(BUS_FILES_TOUCHED_EVENT, payload);
|
|
103
|
+
}
|
|
104
|
+
catch (err) {
|
|
105
|
+
if (!hasLoggedFailure) {
|
|
106
|
+
hasLoggedFailure = true;
|
|
107
|
+
args.dbg?.(`bus-publish: pilens:files:touched emit failed (further failures suppressed): ${err}`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -1,3 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralized LAZY accessor for `@ast-grep/napi` (a native addon — loaded on
|
|
3
|
+
* demand, never at module-eval). See ./typescript.ts for the rationale.
|
|
4
|
+
* Types are re-exported; the module itself is fetched via `loadAstGrepNapi()`.
|
|
5
|
+
*
|
|
6
|
+
* Resolved to an absolute `file://` URL via `createRequire` before importing: an
|
|
7
|
+
* absolute-path dynamic import works under pi's bundled host, a bare specifier
|
|
8
|
+
* does not. The path is converted to a `file://` URL (a raw Windows path is not
|
|
9
|
+
* a valid import specifier); bare import kept as a fallback.
|
|
10
|
+
*/
|
|
11
|
+
import { createRequire } from "node:module";
|
|
12
|
+
import { pathToFileURL } from "node:url";
|
|
13
|
+
const _require = createRequire(import.meta.url);
|
|
1
14
|
export function loadAstGrepNapi() {
|
|
2
|
-
|
|
15
|
+
try {
|
|
16
|
+
const entry = _require.resolve("@ast-grep/napi");
|
|
17
|
+
return import(pathToFileURL(entry).href);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return import("@ast-grep/napi");
|
|
21
|
+
}
|
|
3
22
|
}
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Centralized accessor for `@earendil-works/pi-tui`. See ./typescript.ts for the
|
|
3
3
|
* rationale. (pi-tui is a pi-bundled core package, host-provided at runtime.)
|
|
4
|
+
*
|
|
5
|
+
* Re-export named bindings, not `export *`: with the package kept external, a
|
|
6
|
+
* wildcard re-export leaves the namespace undefined at runtime under the bundle.
|
|
4
7
|
*/
|
|
5
|
-
export
|
|
8
|
+
export { Text, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Centralized accessor for `typebox`. See ./typescript.ts for the rationale.
|
|
3
3
|
* (typebox is a pi-bundled core package, so it resolves from the host at
|
|
4
|
-
* runtime
|
|
4
|
+
* runtime -- but it's still routed through here for a uniform dep surface.)
|
|
5
|
+
*
|
|
6
|
+
* Re-export named bindings, not `export *`: with the package kept external, a
|
|
7
|
+
* wildcard re-export leaves the namespace undefined at runtime under the bundle.
|
|
5
8
|
*/
|
|
6
|
-
export
|
|
9
|
+
export { Type } from "typebox";
|
|
@@ -1,3 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralized LAZY accessor for `web-tree-sitter` (wasm — loaded on demand). See
|
|
3
|
+
* ./typescript.ts for the rationale. Types are re-exported; the module itself is
|
|
4
|
+
* fetched via `loadWebTreeSitter()`.
|
|
5
|
+
*
|
|
6
|
+
* Resolved to an absolute `file://` URL via `createRequire` before importing: an
|
|
7
|
+
* absolute-path dynamic import works under pi's bundled host, a bare specifier
|
|
8
|
+
* does not. The package `exports` map exposes only the `.` entry (no custom
|
|
9
|
+
* subpath), so we resolve the bare package name and convert it to a `file://`
|
|
10
|
+
* URL (a raw Windows path is not a valid import specifier); bare import kept as
|
|
11
|
+
* a fallback.
|
|
12
|
+
*/
|
|
13
|
+
import { createRequire } from "node:module";
|
|
14
|
+
import { pathToFileURL } from "node:url";
|
|
15
|
+
const _require = createRequire(import.meta.url);
|
|
1
16
|
export function loadWebTreeSitter() {
|
|
2
|
-
|
|
17
|
+
try {
|
|
18
|
+
const entry = _require.resolve("web-tree-sitter");
|
|
19
|
+
return import(pathToFileURL(entry).href);
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return import("web-tree-sitter");
|
|
23
|
+
}
|
|
3
24
|
}
|
|
@@ -3,12 +3,12 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Log file: ~/.pi-lens/logs/{date}.jsonl
|
|
5
5
|
*/
|
|
6
|
-
import * as os from "node:os";
|
|
7
6
|
import * as path from "node:path";
|
|
8
7
|
import { isTestMode } from "./env-utils.js";
|
|
8
|
+
import { getGlobalPiLensDir } from "./file-utils.js";
|
|
9
9
|
import { createNdjsonLogger } from "./ndjson-logger.js";
|
|
10
10
|
function getLogDir() {
|
|
11
|
-
return path.join(
|
|
11
|
+
return path.join(getGlobalPiLensDir(), "logs");
|
|
12
12
|
}
|
|
13
13
|
function getLogFile() {
|
|
14
14
|
const date = new Date().toISOString().split("T")[0];
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Publishes `pilens:diagnostics` on pi's shared `pi.events` bus (#502).
|
|
3
|
+
*
|
|
4
|
+
* Sibling to `clients/bus-publish.ts` (the #482 `pilens:files:touched`
|
|
5
|
+
* producer) rather than a new export crammed into that file: the two events
|
|
6
|
+
* share the emit plumbing (`wireBusEmitter`) and the `PI_LENS_BUS_PUBLISH`
|
|
7
|
+
* kill switch, but this producer owns its OWN piece of module state (the
|
|
8
|
+
* previously-reported-paths set for clean-transition tracking, the seq
|
|
9
|
+
* counter) that has nothing to do with files-touched.
|
|
10
|
+
*
|
|
11
|
+
* ## CONSUMER CONTRACT — staleness / replace semantics (2026-07-11 design, #502)
|
|
12
|
+
*
|
|
13
|
+
* Diagnostics are STATE; bus events are point-in-time snapshots of a slice of
|
|
14
|
+
* that state. To guarantee a consumer can always reconstruct the latest
|
|
15
|
+
* known picture from the event stream alone, this producer follows LSP
|
|
16
|
+
* `publishDiagnostics` semantics:
|
|
17
|
+
*
|
|
18
|
+
* 1. **Full-replace per file, never a delta.** Every event carries the
|
|
19
|
+
* COMPLETE current diagnostic set for each file it mentions. An event
|
|
20
|
+
* mentioning path P replaces everything a consumer previously held for P
|
|
21
|
+
* — never merge/append across events for the same path.
|
|
22
|
+
* 2. **Empty array = explicitly clean.** When a previously-reported file's
|
|
23
|
+
* diagnostics clear, this producer emits `{path, diagnostics: []}` for it
|
|
24
|
+
* exactly once, on the transition. Silence never means clean (the #240
|
|
25
|
+
* doctrine, applied here on the producer side) — a consumer that stops
|
|
26
|
+
* hearing about a path has learned NOTHING about its current state.
|
|
27
|
+
* 3. **Monotonic `seq` + `ts` per emission.** `seq` increments once per
|
|
28
|
+
* `publishDiagnostics` call (module-level counter, process-lifetime
|
|
29
|
+
* monotonic — never reset except in tests). Out-of-order receipt resolves
|
|
30
|
+
* deterministically: higher `seq` always wins, lower is discarded.
|
|
31
|
+
* 4. **`pilens:files:touched` (#482) is an INVALIDATION HINT, not new data.**
|
|
32
|
+
* Between an edit landing (a files:touched event) and the next
|
|
33
|
+
* diagnostics batch for that path, a consumer's previously-held
|
|
34
|
+
* diagnostics for that path are PROVISIONAL — the file has changed on
|
|
35
|
+
* disk but pi-lens hasn't re-analyzed it yet. Consumers that want to
|
|
36
|
+
* avoid rendering stale annotations across that window should treat a
|
|
37
|
+
* files:touched path as "diagnostics pending" until the next
|
|
38
|
+
* pilens:diagnostics event mentions it (at any seq).
|
|
39
|
+
*
|
|
40
|
+
* Late-joiners are a non-problem in-process: extensions activate at
|
|
41
|
+
* `session_start`, before any turn emits, so v1 is push-only (no
|
|
42
|
+
* request/replay). #478's future `pilens:rpc:diagnostics` pull API reuses
|
|
43
|
+
* this exact `PilensDiagnosticsPayload` shape verbatim — push and pull are
|
|
44
|
+
* two deliveries of the same schema over the same lens-engine seam; #478
|
|
45
|
+
* stays separately gated on #449 registry dogfooding.
|
|
46
|
+
*
|
|
47
|
+
* ## Emission seam
|
|
48
|
+
*
|
|
49
|
+
* `publishDiagnostics` is called once per write batch immediately after
|
|
50
|
+
* `recordDiagnostics` (clients/widget-state.ts) commits the FINAL per-file
|
|
51
|
+
* diagnostic set for that batch — i.e. after format, autofix, and dispatch
|
|
52
|
+
* have all run (see pipeline.ts's phase order). This guarantees the emitted
|
|
53
|
+
* event reflects post-batch latest state, not an intermediate runner result:
|
|
54
|
+
* widget-state's `allDiagnostics` store is exactly what `recordDiagnostics`
|
|
55
|
+
* just wrote, so reading it back at the same call site can't race a later
|
|
56
|
+
* write in the same batch.
|
|
57
|
+
*
|
|
58
|
+
* Versioning policy: frozen-additive, same discipline as #482. New optional
|
|
59
|
+
* fields may be added under `v: 1`; a breaking change to an existing field's
|
|
60
|
+
* meaning must bump `v`.
|
|
61
|
+
*/
|
|
62
|
+
import { normalizeFilePath } from "./path-utils.js";
|
|
63
|
+
import { isBusPublishEnabled } from "./bus-publish.js";
|
|
64
|
+
export const BUS_DIAGNOSTICS_EVENT = "pilens:diagnostics";
|
|
65
|
+
export const BUS_DIAGNOSTICS_VERSION = 1;
|
|
66
|
+
/** Max diagnostics carried per file per event — aligned with the widget's own per-file storage cap (`MAX_STORED_DIAGNOSTICS_PER_FILE`, clients/widget-state.ts). */
|
|
67
|
+
export const MAX_DIAGNOSTICS_PER_FILE_EVENT = 12;
|
|
68
|
+
let busEmit;
|
|
69
|
+
let hasLoggedFailure = false;
|
|
70
|
+
let seqCounter = 0;
|
|
71
|
+
/** Paths this producer has reported with at least one non-empty diagnostics array, so we know when to fire the one-time clean-transition event. */
|
|
72
|
+
const reportedDirtyPaths = new Set();
|
|
73
|
+
/**
|
|
74
|
+
* Wire the emit function from pi's `pi.events` bus. Called once at extension
|
|
75
|
+
* factory time from index.ts, same call as `wireBusEmitter` (#482) — both
|
|
76
|
+
* producers share the identical `pi.events.emit` binding.
|
|
77
|
+
*/
|
|
78
|
+
export function wireDiagnosticsBusEmitter(emitFn) {
|
|
79
|
+
busEmit = emitFn;
|
|
80
|
+
}
|
|
81
|
+
/** Test-only: reset module state between test files. */
|
|
82
|
+
export function _resetDiagnosticsPublishForTests() {
|
|
83
|
+
busEmit = undefined;
|
|
84
|
+
hasLoggedFailure = false;
|
|
85
|
+
seqCounter = 0;
|
|
86
|
+
reportedDirtyPaths.clear();
|
|
87
|
+
}
|
|
88
|
+
function capDiagnostics(diagnostics) {
|
|
89
|
+
if (diagnostics.length <= MAX_DIAGNOSTICS_PER_FILE_EVENT) {
|
|
90
|
+
return { capped: diagnostics, truncated: false };
|
|
91
|
+
}
|
|
92
|
+
// Prioritize errors first (same "blockers first" spirit as the widget cap).
|
|
93
|
+
const errors = diagnostics.filter((d) => d.severity === "error");
|
|
94
|
+
const rest = diagnostics.filter((d) => d.severity !== "error");
|
|
95
|
+
if (errors.length >= MAX_DIAGNOSTICS_PER_FILE_EVENT) {
|
|
96
|
+
return {
|
|
97
|
+
capped: errors.slice(0, MAX_DIAGNOSTICS_PER_FILE_EVENT),
|
|
98
|
+
truncated: true,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
capped: [
|
|
103
|
+
...errors,
|
|
104
|
+
...rest.slice(0, MAX_DIAGNOSTICS_PER_FILE_EVENT - errors.length),
|
|
105
|
+
],
|
|
106
|
+
truncated: true,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Publish one `pilens:diagnostics` event for a write batch's final
|
|
111
|
+
* per-file diagnostic state. Fire-and-forget: never throws, never awaited.
|
|
112
|
+
*
|
|
113
|
+
* Full-replace semantics (see module doc): each `files` entry here is
|
|
114
|
+
* treated as the COMPLETE current set for that path. Additionally, for
|
|
115
|
+
* every path in `reportedDirtyPaths` that is NOT present in this call's
|
|
116
|
+
* `files` list but has gone clean via a prior call in THIS same invocation,
|
|
117
|
+
* callers must pass an explicit `{path, diagnostics: []}` entry — this
|
|
118
|
+
* function does not infer clean transitions for paths it isn't told about.
|
|
119
|
+
* The one caller (the pipeline write-batch seam) always passes the
|
|
120
|
+
* single file it just analyzed, so the common clean-transition path is:
|
|
121
|
+
* dispatch returns zero diagnostics for a file this producer previously
|
|
122
|
+
* reported dirty -> caller passes `{path, diagnostics: []}` -> emitted once,
|
|
123
|
+
* `reportedDirtyPaths` drops the path so a SECOND clean run of the same file
|
|
124
|
+
* does not re-emit (still clean -> silence is fine once the transition
|
|
125
|
+
* itself has been announced).
|
|
126
|
+
*/
|
|
127
|
+
export function publishDiagnostics(args) {
|
|
128
|
+
if (args.origin === "bus")
|
|
129
|
+
return;
|
|
130
|
+
if (args.files.length === 0)
|
|
131
|
+
return;
|
|
132
|
+
if (!isBusPublishEnabled())
|
|
133
|
+
return;
|
|
134
|
+
if (!busEmit)
|
|
135
|
+
return;
|
|
136
|
+
try {
|
|
137
|
+
const fileEntries = args.files.map((f) => {
|
|
138
|
+
const normPath = normalizeFilePath(f.path);
|
|
139
|
+
const { capped, truncated } = capDiagnostics(f.diagnostics);
|
|
140
|
+
if (capped.length > 0) {
|
|
141
|
+
reportedDirtyPaths.add(normPath);
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
reportedDirtyPaths.delete(normPath);
|
|
145
|
+
}
|
|
146
|
+
const entry = {
|
|
147
|
+
path: normPath,
|
|
148
|
+
diagnostics: capped,
|
|
149
|
+
};
|
|
150
|
+
if (truncated)
|
|
151
|
+
entry.truncated = true;
|
|
152
|
+
return entry;
|
|
153
|
+
});
|
|
154
|
+
seqCounter += 1;
|
|
155
|
+
const payload = {
|
|
156
|
+
v: BUS_DIAGNOSTICS_VERSION,
|
|
157
|
+
source: "pi-lens",
|
|
158
|
+
cwd: normalizeFilePath(args.cwd),
|
|
159
|
+
seq: seqCounter,
|
|
160
|
+
ts: Date.now(),
|
|
161
|
+
files: fileEntries,
|
|
162
|
+
};
|
|
163
|
+
busEmit(BUS_DIAGNOSTICS_EVENT, payload);
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
if (!hasLoggedFailure) {
|
|
167
|
+
hasLoggedFailure = true;
|
|
168
|
+
args.dbg?.(`diagnostics-publish: pilens:diagnostics emit failed (further failures suppressed): ${err}`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Whether `path` was last reported with a non-empty diagnostic set (i.e. a
|
|
174
|
+
* clean run for it now would be a transition worth emitting `[]` for).
|
|
175
|
+
* Exposed for the pipeline call site to decide whether to include a
|
|
176
|
+
* currently-clean file in the batch it passes to `publishDiagnostics`.
|
|
177
|
+
*/
|
|
178
|
+
export function wasPreviouslyReportedDirty(path) {
|
|
179
|
+
return reportedDirtyPaths.has(normalizeFilePath(path));
|
|
180
|
+
}
|