@mrclrchtr/supi-lsp 0.1.0 → 1.0.0

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.
Files changed (71) hide show
  1. package/README.md +112 -0
  2. package/node_modules/@mrclrchtr/supi-core/README.md +90 -0
  3. package/node_modules/@mrclrchtr/supi-core/package.json +30 -0
  4. package/node_modules/@mrclrchtr/supi-core/src/config-settings.ts +76 -0
  5. package/node_modules/@mrclrchtr/supi-core/src/config.ts +186 -0
  6. package/node_modules/@mrclrchtr/supi-core/src/context-messages.ts +119 -0
  7. package/node_modules/@mrclrchtr/supi-core/src/context-provider-registry.ts +36 -0
  8. package/node_modules/@mrclrchtr/supi-core/src/context-tag.ts +31 -0
  9. package/node_modules/@mrclrchtr/supi-core/src/debug-registry.ts +255 -0
  10. package/node_modules/@mrclrchtr/supi-core/src/index.ts +83 -0
  11. package/node_modules/@mrclrchtr/supi-core/src/project-roots.ts +170 -0
  12. package/node_modules/@mrclrchtr/supi-core/src/registry-utils.ts +54 -0
  13. package/node_modules/@mrclrchtr/supi-core/src/session-utils.ts +29 -0
  14. package/node_modules/@mrclrchtr/supi-core/src/settings-command.ts +15 -0
  15. package/node_modules/@mrclrchtr/supi-core/src/settings-registry.ts +41 -0
  16. package/node_modules/@mrclrchtr/supi-core/src/settings-ui.ts +226 -0
  17. package/node_modules/@mrclrchtr/supi-core/src/terminal.ts +60 -0
  18. package/package.json +18 -9
  19. package/{capabilities.ts → src/capabilities.ts} +8 -0
  20. package/src/client/client-refresh.ts +229 -0
  21. package/{client.ts → src/client/client.ts} +178 -30
  22. package/{transport.ts → src/client/transport.ts} +10 -6
  23. package/src/config.ts +143 -0
  24. package/src/defaults.json +82 -0
  25. package/src/diagnostics/diagnostic-augmentation.ts +82 -0
  26. package/src/diagnostics/diagnostic-display.ts +68 -0
  27. package/{diagnostic-summary.ts → src/diagnostics/diagnostic-summary.ts} +11 -7
  28. package/{diagnostics.ts → src/diagnostics/diagnostics.ts} +9 -4
  29. package/src/diagnostics/stale-diagnostics.ts +47 -0
  30. package/src/diagnostics/suppression-diagnostics.ts +58 -0
  31. package/src/format.ts +359 -0
  32. package/src/guidance.ts +163 -0
  33. package/src/index.ts +17 -0
  34. package/src/lsp-state.ts +82 -0
  35. package/src/lsp.ts +470 -0
  36. package/src/manager/manager-client-state.ts +34 -0
  37. package/src/manager/manager-diagnostics.ts +139 -0
  38. package/src/manager/manager-helpers.ts +39 -0
  39. package/src/manager/manager-project-info.ts +46 -0
  40. package/src/manager/manager-types.ts +39 -0
  41. package/src/manager/manager-workspace-recovery.ts +83 -0
  42. package/src/manager/manager-workspace-symbol.ts +18 -0
  43. package/src/manager/manager.ts +550 -0
  44. package/src/overrides.ts +173 -0
  45. package/src/pattern-matcher.ts +197 -0
  46. package/src/renderer.ts +120 -0
  47. package/src/scanner.ts +153 -0
  48. package/src/search-fallback.ts +98 -0
  49. package/src/service-registry.ts +153 -0
  50. package/src/settings-registration.ts +292 -0
  51. package/{summary.ts → src/summary.ts} +44 -9
  52. package/src/tool-actions.ts +430 -0
  53. package/src/tree-persist.ts +48 -0
  54. package/src/tsconfig-scope.ts +156 -0
  55. package/{types.ts → src/types.ts} +123 -0
  56. package/src/ui.ts +358 -0
  57. package/{utils.ts → src/utils.ts} +8 -25
  58. package/src/workspace-sentinels.ts +114 -0
  59. package/bash-guard.ts +0 -58
  60. package/config.ts +0 -99
  61. package/defaults.json +0 -40
  62. package/format.ts +0 -190
  63. package/guidance.ts +0 -140
  64. package/lsp.ts +0 -375
  65. package/manager.ts +0 -396
  66. package/overrides.ts +0 -95
  67. package/recent-paths.ts +0 -126
  68. package/runtime-state.ts +0 -113
  69. package/tool-actions.ts +0 -211
  70. package/tsconfig.json +0 -5
  71. package/ui.ts +0 -303
package/manager.ts DELETED
@@ -1,396 +0,0 @@
1
- // LSP Manager — server pool with lazy spawning and diagnostic collection.
2
-
3
- import * as fs from "node:fs";
4
- import * as path from "node:path";
5
- import { LspClient } from "./client.ts";
6
- import { getServerForFile } from "./config.ts";
7
- import {
8
- accumulateOutstandingDiagnostics,
9
- collectDiagnosticSummaryCounts,
10
- createOutstandingDiagnosticSummary,
11
- relativeFilePathFromUri,
12
- } from "./diagnostic-summary.ts";
13
- import {
14
- displayRelativeFilePath,
15
- formatCoverageSummaryText,
16
- formatOutstandingDiagnosticsSummaryText,
17
- isPathRelevant,
18
- normalizeRelevantPaths,
19
- shouldIgnoreLspPath,
20
- } from "./summary.ts";
21
- import type { Diagnostic, LspConfig } from "./types.ts";
22
- import { commandExists, findProjectRoot } from "./utils.ts";
23
-
24
- // ── Types ─────────────────────────────────────────────────────────────
25
-
26
- export interface ServerStatus {
27
- name: string;
28
- status: "running" | "error" | "unavailable";
29
- root: string;
30
- openFiles: string[];
31
- }
32
-
33
- export interface DiagnosticSummary {
34
- file: string;
35
- errors: number;
36
- warnings: number;
37
- }
38
-
39
- export interface CoverageSummaryEntry {
40
- name: string;
41
- fileTypes: string[];
42
- active: boolean;
43
- openFiles: number;
44
- }
45
-
46
- export interface ActiveCoverageSummaryEntry {
47
- name: string;
48
- openFiles: string[];
49
- }
50
-
51
- export interface OutstandingDiagnosticSummaryEntry {
52
- file: string;
53
- total: number;
54
- errors: number;
55
- warnings: number;
56
- information: number;
57
- hints: number;
58
- }
59
-
60
- export interface ManagerStatus {
61
- servers: ServerStatus[];
62
- }
63
-
64
- // ── LspManager ────────────────────────────────────────────────────────
65
-
66
- export class LspManager {
67
- /** Active clients keyed by "serverName:root" */
68
- private clients = new Map<string, LspClient>();
69
- /** Servers we've already tried and failed to start */
70
- private unavailable = new Set<string>();
71
- /** Memoized per-command availability of LSP server binaries on PATH */
72
- private commandAvailability = new Map<string, boolean>();
73
-
74
- constructor(private readonly config: LspConfig) {}
75
-
76
- // ── Public API ────────────────────────────────────────────────────
77
-
78
- /**
79
- * Synchronously check whether a file path maps to a configured LSP server
80
- * whose binary is actually installed and has not already failed to start.
81
- * Used by tool-event activation so we don't advertise LSP readiness for
82
- * files whose server cannot run in this environment.
83
- */
84
- isSupportedSourceFile(filePath: string): boolean {
85
- // Dependency directories are intentionally excluded from recent-path
86
- // tracking and diagnostic summaries (shouldIgnoreLspPath). Keep runtime
87
- // guidance activation consistent: reading or editing a file under
88
- // node_modules / .pnpm must not arm LSP guidance for dependency sources.
89
- if (shouldIgnoreLspPath(filePath)) return false;
90
- const match = getServerForFile(this.config, filePath);
91
- if (!match) return false;
92
- const [serverName, serverConfig] = match;
93
- // Mirror getClientForFile's root resolution so the unavailable check stays
94
- // root-specific. A failed startup in one workspace must not suppress
95
- // activation for unrelated roots served by the same language server.
96
- const fileDir = path.dirname(path.resolve(filePath));
97
- const root = findProjectRoot(fileDir, serverConfig.rootMarkers, process.cwd());
98
- if (this.unavailable.has(`${serverName}:${root}`)) return false;
99
- return this.isServerCommandAvailable(serverConfig.command);
100
- }
101
-
102
- private isServerCommandAvailable(command: string): boolean {
103
- // Only memoize positive lookups. A negative result may become stale if the
104
- // user installs the binary mid-session (e.g. `mise install`), and
105
- // getClientForFile calls commandExists directly — caching false here would
106
- // leave runtime guidance permanently dormant while client spawning can
107
- // still succeed.
108
- if (this.commandAvailability.get(command) === true) return true;
109
- const available = commandExists(command);
110
- if (available) this.commandAvailability.set(command, true);
111
- return available;
112
- }
113
-
114
- /**
115
- * Get or create an LSP client for the given file.
116
- * Returns null if no server is configured or available.
117
- */
118
- async getClientForFile(filePath: string): Promise<LspClient | null> {
119
- const match = getServerForFile(this.config, filePath);
120
- if (!match) return null;
121
-
122
- const [serverName, serverConfig] = match;
123
-
124
- // Find project root
125
- const fileDir = path.dirname(path.resolve(filePath));
126
- const root = findProjectRoot(fileDir, serverConfig.rootMarkers, process.cwd());
127
- const key = `${serverName}:${root}`;
128
-
129
- // Check if unavailable
130
- if (this.unavailable.has(key)) return null;
131
-
132
- // Return existing client
133
- const existing = this.clients.get(key);
134
- if (existing && existing.status === "running") return existing;
135
-
136
- // If existing client errored, remove it
137
- if (existing && existing.status === "error") {
138
- this.clients.delete(key);
139
- this.unavailable.add(key);
140
- return null;
141
- }
142
-
143
- // Validate command exists
144
- if (!commandExists(serverConfig.command)) {
145
- this.unavailable.add(key);
146
- return null;
147
- }
148
-
149
- // Spawn new client
150
- const client = new LspClient(serverName, serverConfig, root);
151
- this.clients.set(key, client);
152
-
153
- try {
154
- await client.start();
155
- return client;
156
- } catch (_err) {
157
- this.unavailable.add(key);
158
- this.clients.delete(key);
159
- return null;
160
- }
161
- }
162
-
163
- /**
164
- * Sync a file with its LSP server and wait for diagnostics.
165
- * Returns diagnostics filtered to the given severity threshold.
166
- */
167
- async syncFileAndGetDiagnostics(
168
- filePath: string,
169
- maxSeverity: number = 1,
170
- ): Promise<Diagnostic[]> {
171
- const client = await this.getClientForFile(filePath);
172
- if (!client) return [];
173
-
174
- const resolvedPath = path.resolve(filePath);
175
- let content: string;
176
- try {
177
- content = fs.readFileSync(resolvedPath, "utf-8");
178
- } catch {
179
- this.closeFile(resolvedPath);
180
- return [];
181
- }
182
-
183
- const diagnostics = await client.syncAndWaitForDiagnostics(resolvedPath, content);
184
- return diagnostics.filter((d) => d.severity !== undefined && d.severity <= maxSeverity);
185
- }
186
-
187
- /** Close a file across any active LSP clients and clear its cached diagnostics. */
188
- closeFile(filePath: string): void {
189
- const resolvedPath = path.resolve(filePath);
190
- for (const client of this.clients.values()) {
191
- client.didClose(resolvedPath);
192
- }
193
- }
194
-
195
- /** Remove any missing files from open-document and diagnostic state. */
196
- pruneMissingFiles(): string[] {
197
- const removed: string[] = [];
198
- for (const client of this.clients.values()) {
199
- const prune = (client as unknown as { pruneMissingFiles?: () => string[] }).pruneMissingFiles;
200
- if (typeof prune === "function") {
201
- removed.push(...prune.call(client));
202
- }
203
- }
204
- return removed;
205
- }
206
-
207
- /** Shut down all running LSP servers. */
208
- async shutdownAll(): Promise<void> {
209
- const shutdowns = Array.from(this.clients.values()).map((c) => c.shutdown().catch(() => {}));
210
- await Promise.all(shutdowns);
211
- this.clients.clear();
212
- this.unavailable.clear();
213
- }
214
-
215
- /** Get status of all servers. */
216
- getStatus(): ManagerStatus {
217
- this.pruneMissingFiles();
218
- const servers: ServerStatus[] = [];
219
- for (const [_key, client] of this.clients) {
220
- servers.push({
221
- name: client.name,
222
- status: client.status === "running" ? "running" : "error",
223
- root: client.root,
224
- openFiles: client.openFiles,
225
- });
226
- }
227
- return { servers };
228
- }
229
-
230
- /** Get configured and active LSP coverage for the current project. */
231
- getCoverageSummary(): CoverageSummaryEntry[] {
232
- this.pruneMissingFiles();
233
- const activeServers = new Map<string, { active: boolean; openFiles: number }>();
234
-
235
- for (const server of this.getStatus().servers) {
236
- const current = activeServers.get(server.name) ?? { active: false, openFiles: 0 };
237
- current.active = current.active || server.status === "running";
238
- current.openFiles += server.openFiles.length;
239
- activeServers.set(server.name, current);
240
- }
241
-
242
- return Object.entries(this.config.servers)
243
- .map(([name, server]) => {
244
- const activity = activeServers.get(name);
245
- return {
246
- name,
247
- fileTypes: server.fileTypes,
248
- active: activity?.active ?? false,
249
- openFiles: activity?.openFiles ?? 0,
250
- } satisfies CoverageSummaryEntry;
251
- })
252
- .sort(
253
- (a, b) =>
254
- Number(b.active) - Number(a.active) ||
255
- b.openFiles - a.openFiles ||
256
- a.name.localeCompare(b.name),
257
- );
258
- }
259
-
260
- /** Get active LSP coverage summarized by running servers with open files. */
261
- getActiveCoverageSummary(): ActiveCoverageSummaryEntry[] {
262
- this.pruneMissingFiles();
263
- const activeServers = new Map<string, Set<string>>();
264
-
265
- for (const server of this.getStatus().servers) {
266
- if (server.status !== "running" || server.openFiles.length === 0) continue;
267
-
268
- const openFiles = activeServers.get(server.name) ?? new Set<string>();
269
- for (const file of server.openFiles) {
270
- const relativeFile = displayRelativeFilePath(file);
271
- if (shouldIgnoreLspPath(relativeFile)) continue;
272
- openFiles.add(relativeFile);
273
- }
274
- activeServers.set(server.name, openFiles);
275
- }
276
-
277
- return Array.from(activeServers.entries())
278
- .map(([name, openFiles]) => ({
279
- name,
280
- openFiles: Array.from(openFiles).sort(),
281
- }))
282
- .sort((a, b) => b.openFiles.length - a.openFiles.length || a.name.localeCompare(b.name));
283
- }
284
-
285
- /** Get active coverage as compact text suitable for pre-turn context. */
286
- getCoverageSummaryText(maxServers: number = 2, maxFiles: number = 2): string | null {
287
- return formatCoverageSummaryText(this.getActiveCoverageSummary(), maxServers, maxFiles);
288
- }
289
-
290
- /** Get active coverage filtered to files or directories relevant to the current turn. */
291
- getRelevantCoverageSummaryText(
292
- relevantPaths: string[],
293
- maxServers: number = 2,
294
- maxFiles: number = 2,
295
- ): string | null {
296
- const normalizedPaths = normalizeRelevantPaths(relevantPaths);
297
- if (normalizedPaths.length === 0) return null;
298
-
299
- const relevantEntries = this.getActiveCoverageSummary()
300
- .map((entry) => ({
301
- ...entry,
302
- openFiles: entry.openFiles.filter((file) => isPathRelevant(file, normalizedPaths)),
303
- }))
304
- .filter((entry) => entry.openFiles.length > 0);
305
-
306
- return formatCoverageSummaryText(relevantEntries, maxServers, maxFiles);
307
- }
308
-
309
- /** Get a diagnostic summary across all servers and files. */
310
- getDiagnosticSummary(): DiagnosticSummary[] {
311
- this.pruneMissingFiles();
312
- const fileDiags = new Map<string, { errors: number; warnings: number }>();
313
-
314
- for (const client of this.clients.values()) {
315
- for (const entry of client.getAllDiagnostics()) {
316
- collectDiagnosticSummaryCounts(fileDiags, entry);
317
- }
318
- }
319
-
320
- return Array.from(fileDiags.entries()).map(([file, counts]) => ({ file, ...counts }));
321
- }
322
-
323
- /** Get outstanding diagnostics at or above the configured inline threshold. */
324
- getOutstandingDiagnosticSummary(maxSeverity: number = 1): OutstandingDiagnosticSummaryEntry[] {
325
- this.pruneMissingFiles();
326
- const fileDiags = new Map<string, OutstandingDiagnosticSummaryEntry>();
327
-
328
- for (const client of this.clients.values()) {
329
- for (const entry of client.getAllDiagnostics()) {
330
- const file = relativeFilePathFromUri(entry.uri);
331
- if (shouldIgnoreLspPath(file)) continue;
332
- const current = fileDiags.get(file) ?? createOutstandingDiagnosticSummary(file);
333
- const next = accumulateOutstandingDiagnostics(current, entry.diagnostics, maxSeverity);
334
-
335
- if (next.total > 0) {
336
- fileDiags.set(file, next);
337
- }
338
- }
339
- }
340
-
341
- return Array.from(fileDiags.values()).sort(
342
- (a, b) =>
343
- b.errors - a.errors ||
344
- b.warnings - a.warnings ||
345
- b.information - a.information ||
346
- b.hints - a.hints ||
347
- a.file.localeCompare(b.file),
348
- );
349
- }
350
-
351
- /** Get outstanding diagnostics as compact text suitable for pre-turn context. */
352
- getOutstandingDiagnosticsSummaryText(
353
- maxSeverity: number = 1,
354
- maxFiles: number = 3,
355
- ): string | null {
356
- return formatOutstandingDiagnosticsSummaryText(
357
- this.getOutstandingDiagnosticSummary(maxSeverity),
358
- maxFiles,
359
- );
360
- }
361
-
362
- /** Get outstanding diagnostics filtered to files or directories relevant to the current turn. */
363
- getRelevantOutstandingDiagnosticsSummaryText(
364
- relevantPaths: string[],
365
- maxSeverity: number = 1,
366
- maxFiles: number = 3,
367
- ): string | null {
368
- const normalizedPaths = normalizeRelevantPaths(relevantPaths);
369
- if (normalizedPaths.length === 0) return null;
370
-
371
- const relevantEntries = this.getOutstandingDiagnosticSummary(maxSeverity).filter((entry) =>
372
- isPathRelevant(entry.file, normalizedPaths),
373
- );
374
-
375
- return formatOutstandingDiagnosticsSummaryText(relevantEntries, maxFiles);
376
- }
377
-
378
- /**
379
- * Ensure a file is open in its LSP server.
380
- * Used when the agent needs to read a file for the first time.
381
- */
382
- async ensureFileOpen(filePath: string): Promise<LspClient | null> {
383
- const client = await this.getClientForFile(filePath);
384
- if (!client) return null;
385
-
386
- const resolvedPath = path.resolve(filePath);
387
- try {
388
- const content = fs.readFileSync(resolvedPath, "utf-8");
389
- client.didOpen(resolvedPath, content);
390
- return client;
391
- } catch {
392
- this.closeFile(resolvedPath);
393
- return null;
394
- }
395
- }
396
- }
package/overrides.ts DELETED
@@ -1,95 +0,0 @@
1
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
- import { createEditTool, createReadTool, createWriteTool } from "@mariozechner/pi-coding-agent";
3
- import { formatDiagnostics } from "./diagnostics.ts";
4
- import type { LspManager } from "./manager.ts";
5
- import { trackRecentPath } from "./recent-paths.ts";
6
-
7
- interface LspOverrideState {
8
- inlineSeverity: number;
9
- getManager(): LspManager | null;
10
- getRecentPaths(): string[];
11
- setRecentPaths(paths: string[]): void;
12
- onRecentPathsChange?(): void;
13
- }
14
-
15
- export function registerLspAwareToolOverrides(pi: ExtensionAPI, state: LspOverrideState): void {
16
- const cwd = process.cwd();
17
- const originalRead = createReadTool(cwd);
18
- const originalWrite = createWriteTool(cwd);
19
- const originalEdit = createEditTool(cwd);
20
-
21
- pi.registerTool({
22
- ...originalRead,
23
- // biome-ignore lint/complexity/useMaxParams: pi ToolDefinition.execute signature
24
- async execute(toolCallId, params, signal, onUpdate, _ctx) {
25
- const result = await originalRead.execute(toolCallId, params, signal, onUpdate);
26
- recordRecentPath(state, params.path);
27
- await ensureFileOpen(state.getManager(), params.path);
28
- return result;
29
- },
30
- });
31
-
32
- pi.registerTool({
33
- ...originalWrite,
34
- // biome-ignore lint/complexity/useMaxParams: pi ToolDefinition.execute signature
35
- async execute(toolCallId, params, signal, onUpdate, _ctx) {
36
- const result = await originalWrite.execute(toolCallId, params, signal, onUpdate);
37
-
38
- recordRecentPath(state, params.path);
39
- return appendInlineDiagnostics(state.getManager(), params.path, state.inlineSeverity, result);
40
- },
41
- });
42
-
43
- pi.registerTool({
44
- ...originalEdit,
45
- // biome-ignore lint/complexity/useMaxParams: pi ToolDefinition.execute signature
46
- async execute(toolCallId, params, signal, onUpdate, _ctx) {
47
- const result = await originalEdit.execute(toolCallId, params, signal, onUpdate);
48
-
49
- recordRecentPath(state, params.path);
50
- return appendInlineDiagnostics(state.getManager(), params.path, state.inlineSeverity, result);
51
- },
52
- });
53
- }
54
-
55
- async function appendInlineDiagnostics<T extends { content: unknown[]; details: unknown }>(
56
- manager: LspManager | null,
57
- filePath: string,
58
- inlineSeverity: number,
59
- result: T,
60
- ): Promise<T> {
61
- if (!manager) return result;
62
-
63
- try {
64
- const diags = await manager.syncFileAndGetDiagnostics(filePath, inlineSeverity);
65
- if (diags.length === 0) return result;
66
-
67
- const diagText = formatDiagnostics(filePath, diags);
68
- const diagnosticContent = {
69
- type: "text" as const,
70
- text: `\n\n⚠️ LSP Diagnostics:\n${diagText}`,
71
- } as T["content"][number];
72
-
73
- return {
74
- ...result,
75
- content: [...result.content, diagnosticContent],
76
- } as T;
77
- } catch {
78
- return result;
79
- }
80
- }
81
-
82
- async function ensureFileOpen(manager: LspManager | null, filePath: string): Promise<void> {
83
- if (!manager) return;
84
-
85
- try {
86
- await manager.ensureFileOpen(filePath);
87
- } catch {
88
- // Never block the agent on LSP errors
89
- }
90
- }
91
-
92
- function recordRecentPath(state: LspOverrideState, filePath: string): void {
93
- state.setRecentPaths(trackRecentPath(state.getRecentPaths(), filePath));
94
- state.onRecentPathsChange?.();
95
- }
package/recent-paths.ts DELETED
@@ -1,126 +0,0 @@
1
- import * as path from "node:path";
2
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
3
- import { shouldIgnoreLspPath } from "./summary.ts";
4
-
5
- const LSP_STATE_ENTRY_TYPE = "lsp-state";
6
-
7
- export function updateRecentPathsFromToolEvent(
8
- toolName: string,
9
- input: Record<string, unknown>,
10
- recentPaths: string[],
11
- ): string[] {
12
- const filePath = getFilePathFromToolEvent(toolName, input);
13
- return filePath ? trackRecentPath(recentPaths, filePath) : recentPaths;
14
- }
15
-
16
- export function getFilePathFromToolEvent(
17
- toolName: string,
18
- input: Record<string, unknown>,
19
- ): string | null {
20
- const raw = getRawFilePathFromToolEvent(toolName, input);
21
- return raw === null ? null : normalizeTrackedPath(raw);
22
- }
23
-
24
- /**
25
- * Like getFilePathFromToolEvent but returns the unfiltered string path from
26
- * the tool input. Used by runtime-guidance gating, which must accept absolute
27
- * paths to files outside cwd (sibling worktrees, monorepo packages) since
28
- * read/edit/lsp all support them and they get real LSP coverage.
29
- */
30
- export function getRawFilePathFromToolEvent(
31
- toolName: string,
32
- input: Record<string, unknown>,
33
- ): string | null {
34
- if (
35
- (toolName === "read" || toolName === "write" || toolName === "edit") &&
36
- typeof input.path === "string"
37
- ) {
38
- return input.path;
39
- }
40
-
41
- if (toolName === "lsp" && typeof input.file === "string") {
42
- return input.file;
43
- }
44
-
45
- return null;
46
- }
47
-
48
- export function trackRecentPath(
49
- recentPaths: string[],
50
- filePath: string,
51
- maxEntries: number = 6,
52
- ): string[] {
53
- const normalized = normalizeTrackedPath(filePath);
54
- if (!normalized) return recentPaths;
55
-
56
- const next = [normalized, ...recentPaths.filter((entry) => entry !== normalized)];
57
- return next.slice(0, maxEntries);
58
- }
59
-
60
- /**
61
- * Convert any tool-supplied path into a stable form for tracking. In-tree
62
- * paths collapse to their project-relative form; out-of-tree paths preserve
63
- * the absolute form so sibling worktrees / monorepo packages stay in the
64
- * relevance set that powers `getSemanticBashBlockReason`. This mirrors
65
- * `displayRelativeFilePath`, so recent-paths, tracked runtime paths, and
66
- * diagnostic keys all share one representation. Files in dependency
67
- * directories (node_modules, .pnpm) are dropped so they don't crowd out real
68
- * source files.
69
- */
70
- export function normalizeTrackedPath(filePath: string): string | null {
71
- const resolved = path.resolve(filePath);
72
- if (shouldIgnoreLspPath(resolved)) return null;
73
- const relative = path.relative(process.cwd(), resolved);
74
- if (relative === "") return path.basename(resolved);
75
- if (relative.startsWith(`..${path.sep}`) || relative === "..") {
76
- return resolved;
77
- }
78
- return relative.replaceAll(path.sep, "/");
79
- }
80
-
81
- /**
82
- * Recover the recent-paths list from previously persisted session entries.
83
- * Each turn that saw new paths appended a fresh `lsp-state` entry, so the
84
- * latest one (`.pop()`) is the authoritative snapshot — earlier entries are
85
- * stale superseded states, not history we want to merge.
86
- */
87
- export function restoreRecentPaths(
88
- entries: Array<{ type?: string; customType?: string; data?: unknown }>,
89
- ): string[] {
90
- const entry = entries
91
- .filter(
92
- (candidate) => candidate.type === "custom" && candidate.customType === LSP_STATE_ENTRY_TYPE,
93
- )
94
- .pop() as { data?: { recentPaths?: unknown } } | undefined;
95
-
96
- return sanitizeRecentPaths(entry?.data?.recentPaths);
97
- }
98
-
99
- export function persistRecentPaths(
100
- pi: ExtensionAPI,
101
- recentPaths: string[],
102
- persistedRecentPaths: string[],
103
- ): string[] {
104
- const sanitized = sanitizeRecentPaths(recentPaths);
105
- if (samePaths(sanitized, persistedRecentPaths)) return persistedRecentPaths;
106
-
107
- pi.appendEntry(LSP_STATE_ENTRY_TYPE, { recentPaths: sanitized });
108
- return sanitized;
109
- }
110
-
111
- function sanitizeRecentPaths(paths: unknown, maxEntries: number = 6): string[] {
112
- if (!Array.isArray(paths)) return [];
113
-
114
- return Array.from(
115
- new Set(
116
- paths
117
- .filter((value): value is string => typeof value === "string" && value.trim().length > 0)
118
- .map(normalizeTrackedPath)
119
- .filter((value): value is string => value !== null),
120
- ),
121
- ).slice(0, maxEntries);
122
- }
123
-
124
- function samePaths(a: string[], b: string[]): boolean {
125
- return a.length === b.length && a.every((entry, index) => entry === b[index]);
126
- }