decorated-pi 0.3.0 → 0.4.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.
@@ -1,18 +1,51 @@
1
1
  /**
2
- * LSP Child Process Environment — restricted env for LSP server processes
2
+ * LSP Child Process Environment — whitelist-only for maximum security.
3
3
  *
4
- * Based on @spences10/pi-lsp by Scott Spence
5
- * https://github.com/spences10/my-pi/tree/main/packages/pi-lsp (MIT License)
4
+ * LSP servers communicate over stdio, not HTTP. They only need a minimal
5
+ * set of environment variables to locate binaries, read user config, and
6
+ * respect locale settings.
7
+ *
8
+ * Proxy variables are explicitly excluded — LSP over stdio has no use for
9
+ * them, and passing them through could cause hangs if the server tries
10
+ * to make outbound HTTP requests.
6
11
  */
7
- import { create_child_process_env as create_shared_child_process_env } from "@spences10/pi-child-env";
8
12
 
9
- export function create_child_process_env(
10
- explicit_env: Record<string, string> = {},
11
- source_env: NodeJS.ProcessEnv = process.env
13
+ const WHITELIST = new Set([
14
+ // Core needed to find binaries and user config
15
+ "PATH", "HOME", "USER", "SHELL",
16
+ // Terminal compatibility
17
+ "TERM", "COLORTERM",
18
+ // Locale
19
+ "LANG",
20
+ // Pi-specific
21
+ "PI_CODING_AGENT_DIR",
22
+ // Node.js
23
+ "NODE_PATH", "NODE_OPTIONS",
24
+ ]);
25
+
26
+ /**
27
+ * Build a safe environment for spawning an LSP server child process.
28
+ *
29
+ * Strategy: whitelist. Only variables explicitly listed are passed through.
30
+ * Proxy, API keys, tokens, and all other env vars are silently stripped.
31
+ */
32
+ export function createChildProcessEnv(
33
+ extras: Record<string, string> = {},
34
+ source: NodeJS.ProcessEnv = process.env,
12
35
  ): NodeJS.ProcessEnv {
13
- return create_shared_child_process_env({
14
- profile: "lsp",
15
- explicit_env,
16
- source_env,
17
- });
36
+ const env: Record<string, string> = {};
37
+
38
+ for (const [key, value] of Object.entries(source)) {
39
+ if (typeof value !== "string") continue;
40
+ if (WHITELIST.has(key)) env[key] = value;
41
+ // Also pass through LC_* variables (locale category vars)
42
+ if (key.startsWith("LC_")) env[key] = value;
43
+ }
44
+
45
+ // Explicit overrides always win
46
+ for (const [key, value] of Object.entries(extras)) {
47
+ if (typeof value === "string") env[key] = value;
48
+ }
49
+
50
+ return env;
18
51
  }
@@ -1,13 +1,24 @@
1
1
  /**
2
- * LSP Output Formatting — diagnostics, hover, locations, symbols
2
+ * LSP Output Formatting — test-compatible standalone module.
3
3
  *
4
- * Based on @spences10/pi-lsp by Scott Spence
5
- * https://github.com/spences10/my-pi/tree/main/packages/pi-lsp (MIT License)
4
+ * Core formatting logic duplicated here so tests can import directly.
5
+ * Runtime tools.ts has its own inline copies.
6
6
  */
7
7
  import { fileURLToPath } from "node:url";
8
- import type { LspDiagnostic, LspHover, LspLocation, LspDocumentSymbol } from "./client.js";
8
+ import type { LspDiagnostic, LspHover, LspLocation, LspDocumentSymbol } from "./types.js";
9
9
  import { LspClientStartError } from "./client.js";
10
- import { get_server_config, list_supported_languages } from "./servers.js";
10
+
11
+ export type { LspDiagnostic, LspHover, LspLocation, LspDocumentSymbol } from "./types.js";
12
+ export { LspClientStartError } from "./client.js";
13
+
14
+ // ─── Error formatting ────────────────────────────────────────────────────
15
+
16
+ export class LspToolError extends Error {
17
+ constructor(public readonly details: LspToolErrorDetail) {
18
+ super(details.message);
19
+ this.name = "LspToolError";
20
+ }
21
+ }
11
22
 
12
23
  export interface LspToolErrorDetail {
13
24
  kind: string;
@@ -20,126 +31,31 @@ export interface LspToolErrorDetail {
20
31
  code?: string;
21
32
  }
22
33
 
23
- export class LspToolError extends Error {
24
- details: LspToolErrorDetail;
25
- constructor(details: LspToolErrorDetail) {
26
- super(details.message);
27
- this.name = "LspToolError";
28
- this.details = details;
29
- }
30
- }
31
-
32
- const SYMBOL_KIND_LABELS: Record<number, string> = {
33
- 2: "module",
34
- 3: "namespace",
35
- 5: "class",
36
- 6: "method",
37
- 7: "property",
38
- 8: "field",
39
- 9: "constructor",
40
- 11: "interface",
41
- 12: "function",
42
- 13: "variable",
43
- 14: "constant",
44
- 23: "struct",
45
- 24: "event",
46
- };
47
-
48
- export const SYMBOL_KIND_NAMES = Object.values(SYMBOL_KIND_LABELS);
49
-
50
- export function format_status_lines(
51
- cwd: string,
52
- clients_by_server: Map<string, any>,
53
- failed_servers: Map<string, any>
54
- ): string[] {
55
- const lines: string[] = [];
56
- const active_languages = new Set<string>();
57
-
58
- const running_states = Array.from(clients_by_server.values()).sort(
59
- (a: any, b: any) =>
60
- a.language.localeCompare(b.language) ||
61
- a.workspace_root.localeCompare(b.workspace_root)
62
- );
63
- for (const running of running_states) {
64
- active_languages.add(running.language);
65
- lines.push(
66
- `${running.language}: running (ready=${running.client.is_ready()}) — ${running.command} [workspace ${running.workspace_root}]`
67
- );
68
- }
69
-
70
- const failures = Array.from(failed_servers.values()).sort(
71
- (a: any, b: any) =>
72
- (a.language ?? "").localeCompare(b.language ?? "") ||
73
- (a.workspace_root ?? "").localeCompare(b.workspace_root ?? "")
74
- );
75
- for (const failure of failures) {
76
- if (failure.language) active_languages.add(failure.language);
77
- const workspace = failure.workspace_root
78
- ? ` [workspace ${failure.workspace_root}]`
79
- : "";
80
- const language = failure.language ?? "unknown";
81
- lines.push(`${language}: failed — ${failure.message}${workspace}`);
82
- }
83
-
84
- for (const language of list_supported_languages()) {
85
- if (active_languages.has(language)) continue;
86
- const config = get_server_config(language, cwd);
87
- if (config) {
88
- lines.push(`${language}: idle — ${config.command}`);
89
- }
90
- }
91
-
92
- return lines.length > 0 ? lines : ["No language servers configured for this project."];
93
- }
94
-
95
34
  export function to_lsp_tool_error(
96
- file: string,
97
- language: string,
98
- workspace_root: string | undefined,
99
- command: string,
100
- install_hint: string | undefined,
101
- error: unknown
35
+ file: string, language: string, workspaceRoot: string | undefined,
36
+ command: string, installHint: string | undefined, error: unknown,
102
37
  ): LspToolErrorDetail {
103
- if (error instanceof LspToolError) {
104
- return error.details;
105
- }
38
+ if (error instanceof LspToolError) return error.details;
106
39
  if (error instanceof LspClientStartError) {
107
- const missing_binary = error.code === "ENOENT";
108
40
  return {
109
- kind: "server_start_failed",
110
- file,
111
- language,
112
- workspace_root,
113
- command,
114
- install_hint,
115
- code: error.code,
116
- message: missing_binary
117
- ? `command "${command}" not found`
118
- : error.message,
41
+ kind: "server_start_failed", file, language, workspace_root: workspaceRoot,
42
+ command, install_hint: installHint, code: error.code,
43
+ message: error.code === "ENOENT" ? `command "${command}" not found` : error.message,
119
44
  };
120
45
  }
121
46
  const err = error as Record<string, unknown> | undefined;
122
47
  return {
123
- kind: "tool_execution_failed",
124
- file,
125
- language,
126
- workspace_root,
127
- command,
128
- install_hint,
48
+ kind: "tool_execution_failed", file, language, workspace_root: workspaceRoot,
49
+ command, install_hint: installHint,
129
50
  message: error instanceof Error ? error.message : String(error),
130
- code:
131
- err && typeof err.code === "string" ? err.code : undefined,
51
+ code: err?.code as string | undefined,
132
52
  };
133
53
  }
134
54
 
135
55
  export function format_tool_error(details: LspToolErrorDetail): string {
136
- if (details.kind === "unsupported_language") {
137
- return details.message;
138
- }
56
+ if (details.kind === "unsupported_language") return details.message;
139
57
  const lines = [
140
- details.language
141
- ? `${details.language} LSP unavailable for ${details.file}`
142
- : `LSP request failed for ${details.file}`,
58
+ details.language ? `${details.language} LSP unavailable for ${details.file}` : `LSP request failed for ${details.file}`,
143
59
  `Reason: ${details.message}`,
144
60
  ];
145
61
  if (details.command) lines.push(`Command: ${details.command}`);
@@ -148,90 +64,84 @@ export function format_tool_error(details: LspToolErrorDetail): string {
148
64
  return lines.join("\n");
149
65
  }
150
66
 
151
- function severity_label(severity: number): string {
152
- switch (severity) {
153
- case 1: return "error";
154
- case 2: return "warning";
155
- case 3: return "info";
156
- case 4: return "hint";
157
- default: return "info";
158
- }
159
- }
67
+ // ─── Severity ─────────────────────────────────────────────────────────────
160
68
 
161
69
  export type SeverityFilter = "error" | "warning" | "info" | "hint";
70
+ const SEVERITY_MAP: Record<SeverityFilter, number> = { error: 1, warning: 2, info: 3, hint: 4 };
162
71
 
163
- const SEVERITY_MAP: Record<SeverityFilter, number> = {
164
- error: 1,
165
- warning: 2,
166
- info: 3,
167
- hint: 4,
168
- };
72
+ function severityLabel(s: number): string {
73
+ return s === 1 ? "error" : s === 2 ? "warning" : s === 3 ? "info" : "hint";
74
+ }
169
75
 
170
- export function filter_diagnostics(
171
- diagnostics: LspDiagnostic[],
172
- severities?: SeverityFilter[]
173
- ): LspDiagnostic[] {
174
- if (!severities || severities.length === 0) return diagnostics;
175
- // 取最小的 severity 值(error=1 < warning=2 < info=3 < hint=4)
176
- const minSeverity = Math.min(...severities.map((s) => SEVERITY_MAP[s]));
177
- // 显示 severity <= minSeverity(更严重 + 自身)
178
- return diagnostics.filter((d) => (d.severity ?? 1) <= minSeverity);
76
+ export function filter_diagnostics(diagnostics: LspDiagnostic[], severities?: SeverityFilter[]): LspDiagnostic[] {
77
+ if (!severities?.length) return diagnostics;
78
+ const min = Math.min(...severities.map((s) => SEVERITY_MAP[s]));
79
+ return diagnostics.filter((d) => (d.severity ?? 1) <= min);
179
80
  }
180
81
 
181
- export function format_diagnostics(
182
- file: string,
183
- diagnostics: LspDiagnostic[],
184
- severities?: SeverityFilter[]
185
- ): string {
82
+ export function format_diagnostics(file: string, diagnostics: LspDiagnostic[], severities?: SeverityFilter[]): string {
186
83
  const filtered = filter_diagnostics(diagnostics, severities);
187
84
  if (filtered.length === 0) return `${file}: no diagnostics`;
188
85
  const lines = [`${file}: ${filtered.length} diagnostic(s)`];
189
86
  for (const d of filtered) {
190
- const position = `${d.range.start.line + 1}:${d.range.start.character + 1}`;
87
+ const pos = `${d.range.start.line + 1}:${d.range.start.character + 1}`;
191
88
  const source = d.source ? ` [${d.source}]` : "";
192
89
  const code = d.code != null ? ` (${d.code})` : "";
193
- lines.push(
194
- ` ${position} ${severity_label(d.severity ?? 1)}${source}${code}: ${d.message}`
195
- );
90
+ lines.push(` ${pos} ${severityLabel(d.severity ?? 1)}${source}${code}: ${d.message}`);
196
91
  }
197
92
  return lines.join("\n");
198
93
  }
199
94
 
95
+ // ─── Hover ────────────────────────────────────────────────────────────────
96
+
200
97
  export function format_hover(hover: LspHover | null): string {
201
98
  if (!hover) return "No hover info.";
202
- const contents = hover.contents;
203
- const extract = (item: unknown): string =>
204
- typeof item === "string" ? item : ((item as { value?: string })?.value ?? "");
205
- if (Array.isArray(contents)) {
206
- return contents.map(extract).join("\n\n").trim() || "No hover info.";
207
- }
208
- return extract(contents).trim() || "No hover info.";
99
+ const extract = (item: unknown): string => typeof item === "string" ? item : ((item as any)?.value ?? "");
100
+ if (Array.isArray(hover.contents)) return hover.contents.map(extract).join("\n\n").trim() || "No hover info.";
101
+ return extract(hover.contents).trim() || "No hover info.";
209
102
  }
210
103
 
211
- export function format_locations(
212
- locations: LspLocation[],
213
- empty_message: string
214
- ): string {
215
- if (locations.length === 0) return empty_message;
216
- return locations
217
- .map((loc) => {
218
- const path = file_url_to_path_or_value(loc.uri);
219
- return `${path}:${loc.range.start.line + 1}:${loc.range.start.character + 1}`;
220
- })
221
- .join("\n");
104
+ // ─── Locations ────────────────────────────────────────────────────────────
105
+
106
+ export function format_locations(locations: LspLocation[], emptyMessage: string): string {
107
+ if (locations.length === 0) return emptyMessage;
108
+ return locations.map((loc) => `${fileUrlToPath(loc.uri)}:${loc.range.start.line + 1}:${loc.range.start.character + 1}`).join("\n");
109
+ }
110
+
111
+ function fileUrlToPath(uri: string): string {
112
+ try { return uri.startsWith("file:") ? fileURLToPath(uri) : uri; } catch { return uri; }
113
+ }
114
+
115
+ // ─── Symbols ──────────────────────────────────────────────────────────────
116
+
117
+ const SYMBOL_KIND_LABELS: Record<number, string> = {
118
+ 2: "module", 3: "namespace", 5: "class", 6: "method", 7: "property",
119
+ 8: "field", 9: "constructor", 11: "interface", 12: "function",
120
+ 13: "variable", 14: "constant", 23: "struct", 24: "event",
121
+ };
122
+
123
+ export function symbol_kind_label(kind: number): string {
124
+ return SYMBOL_KIND_LABELS[kind] ?? "symbol";
222
125
  }
223
126
 
224
- export function format_document_symbols(
225
- file: string,
226
- symbols: LspDocumentSymbol[]
227
- ): string {
127
+ export function format_document_symbols(file: string, symbols: LspDocumentSymbol[]): string {
228
128
  if (symbols.length === 0) return `${file}: no symbols`;
229
129
  const lines = [`${file}: ${symbols.length} top-level symbol(s)`];
230
- append_symbol_lines(lines, symbols, 1);
130
+ appendSymbols(lines, symbols, 1);
231
131
  return lines.join("\n");
232
132
  }
233
133
 
234
- export interface SymbolMatchOptions {
134
+ function appendSymbols(lines: string[], symbols: LspDocumentSymbol[], depth: number) {
135
+ for (const s of symbols) {
136
+ const indent = " ".repeat(depth);
137
+ const detail = s.detail ? ` — ${s.detail}` : "";
138
+ const range = `${s.range.start.line + 1}:${s.range.start.character + 1}`;
139
+ lines.push(`${indent}${symbol_kind_label(s.kind)} ${s.name}${detail} @ ${range}`);
140
+ if (s.children?.length) appendSymbols(lines, s.children, depth + 1);
141
+ }
142
+ }
143
+
144
+ interface SymbolMatchOptions {
235
145
  max_results: number;
236
146
  top_level_only: boolean;
237
147
  exact_match: boolean;
@@ -239,57 +149,41 @@ export interface SymbolMatchOptions {
239
149
  language: string;
240
150
  }
241
151
 
242
- export interface SymbolMatch {
243
- symbol: LspDocumentSymbol;
244
- depth: number;
245
- }
152
+ export interface SymbolMatch { symbol: LspDocumentSymbol; depth: number }
246
153
 
247
154
  export function find_symbol_matches(
248
155
  symbols: LspDocumentSymbol[],
249
156
  query: string,
250
- options: SymbolMatchOptions
157
+ options: SymbolMatchOptions,
251
158
  ): SymbolMatch[] {
252
159
  const normalized = query.trim().toLowerCase();
253
160
  if (!normalized) return [];
254
-
255
161
  const matches: SymbolMatch[] = [];
256
-
257
- const expand_exact_name_values = (name: string): string[] => {
162
+ const expandName = (name: string): string[] => {
258
163
  const trimmed = name.trim().toLowerCase();
259
164
  if (!trimmed) return [];
260
165
  const expanded = new Set([trimmed]);
261
166
  if (options.language === "cpp" && trimmed.includes("::")) {
262
- const parts = trimmed
263
- .split("::")
264
- .map((part) => part.trim())
265
- .filter(Boolean);
167
+ const parts = trimmed.split("::").map(p => p.trim()).filter(Boolean);
266
168
  if (parts.length > 0) expanded.add(parts[parts.length - 1]!);
267
169
  }
268
- return Array.from(expanded);
170
+ return [...expanded];
269
171
  };
270
172
 
271
- const matches_query = (symbol: LspDocumentSymbol): boolean => {
272
- const raw_name = symbol.name.trim().toLowerCase();
273
- const raw_detail = (symbol.detail ?? "").trim().toLowerCase();
173
+ const matchesQuery = (s: LspDocumentSymbol): boolean => {
174
+ const name = s.name.trim().toLowerCase();
175
+ const detail = (s.detail ?? "").trim().toLowerCase();
274
176
  if (options.exact_match) {
275
- const exact_values = [
276
- ...expand_exact_name_values(symbol.name),
277
- ...(raw_detail ? [raw_detail] : []),
278
- ];
279
- return exact_values.some((value) => value === normalized);
177
+ const exactValues = [...expandName(s.name), ...(detail ? [detail] : [])];
178
+ return exactValues.some(v => v === normalized);
280
179
  }
281
- const fuzzy_values = [raw_name, raw_detail].filter(Boolean);
282
- return fuzzy_values.some((value) => value.includes(normalized));
180
+ return name.includes(normalized) || detail.includes(normalized);
283
181
  };
284
-
285
- const matches_kind = (symbol: LspDocumentSymbol): boolean => {
286
- if (options.kinds.size === 0) return true;
287
- return options.kinds.has(symbol_kind_label(symbol.kind));
288
- };
289
-
290
- const visit = (entries: LspDocumentSymbol[], depth: number): void => {
182
+ const matchesKind = (s: LspDocumentSymbol): boolean =>
183
+ options.kinds.size === 0 || options.kinds.has(symbol_kind_label(s.kind));
184
+ const visit = (entries: LspDocumentSymbol[], depth: number) => {
291
185
  for (const symbol of entries) {
292
- if (matches_kind(symbol) && matches_query(symbol)) {
186
+ if (matchesKind(symbol) && matchesQuery(symbol)) {
293
187
  matches.push({ symbol, depth });
294
188
  if (matches.length >= options.max_results) return;
295
189
  }
@@ -299,57 +193,28 @@ export function find_symbol_matches(
299
193
  }
300
194
  }
301
195
  };
302
-
303
196
  visit(symbols, 1);
304
197
  return matches;
305
198
  }
306
199
 
307
- export function format_symbol_matches(
308
- file: string,
309
- query: string,
310
- matches: SymbolMatch[]
311
- ): string {
312
- if (matches.length === 0) {
313
- return `${file}: no symbols matching "${query}"`;
314
- }
200
+ export function format_symbol_matches(file: string, query: string, matches: SymbolMatch[]): string {
201
+ if (matches.length === 0) return `${file}: no symbols matching "${query}"`;
315
202
  const lines = [`${file}: ${matches.length} symbol match(es) for "${query}"`];
316
203
  for (const { symbol, depth } of matches) {
317
204
  const indent = " ".repeat(depth);
318
205
  const detail = symbol.detail ? ` — ${symbol.detail}` : "";
319
206
  const range = `${symbol.range.start.line + 1}:${symbol.range.start.character + 1}`;
320
- lines.push(
321
- `${indent}${symbol_kind_label(symbol.kind)} ${symbol.name}${detail} @ ${range}`
322
- );
207
+ lines.push(`${indent}${symbol_kind_label(symbol.kind)} ${symbol.name}${detail} @ ${range}`);
323
208
  }
324
209
  return lines.join("\n");
325
210
  }
326
211
 
327
- function append_symbol_lines(
328
- lines: string[],
329
- symbols: LspDocumentSymbol[],
330
- depth: number
331
- ): void {
332
- for (const symbol of symbols) {
333
- const indent = " ".repeat(depth);
334
- const detail = symbol.detail ? ` — ${symbol.detail}` : "";
335
- const range = `${symbol.range.start.line + 1}:${symbol.range.start.character + 1}`;
336
- lines.push(
337
- `${indent}${symbol_kind_label(symbol.kind)} ${symbol.name}${detail} @ ${range}`
338
- );
339
- if (symbol.children?.length) {
340
- append_symbol_lines(lines, symbol.children, depth + 1);
341
- }
342
- }
343
- }
212
+ // ─── Collapse (for tools test) ────────────────────────────────────────────
344
213
 
345
- export function symbol_kind_label(kind: number): string {
346
- return SYMBOL_KIND_LABELS[kind] ?? "symbol";
214
+ export function collapse_lsp_text(text: string, maxLines = 20) {
215
+ const lines = text.split("\n").reverse().reduce((acc, l) => l === "" && acc.length === 0 ? acc : [l, ...acc], [] as string[]);
216
+ const totalLines = lines.length;
217
+ return { totalLines, displayLines: lines.slice(0, maxLines), remainingLines: Math.max(0, totalLines - maxLines) };
347
218
  }
348
219
 
349
- function file_url_to_path_or_value(uri: string): string {
350
- try {
351
- return uri.startsWith("file:") ? fileURLToPath(uri) : uri;
352
- } catch {
353
- return uri;
354
- }
355
- }
220
+ export const __lspFormatTest = { collapse_lsp_text: collapse_lsp_text };
@@ -1,20 +1,17 @@
1
1
  /**
2
- * LSP Extension Entry Point
2
+ * LSP Extension language server integration for Pi.
3
3
  *
4
- * Based on @spences10/pi-lsp by Scott Spence
5
- * https://github.com/spences10/my-pi/tree/main/packages/pi-lsp (MIT License)
4
+ * Provides: lsp_diagnostics, lsp_document_symbols.
6
5
  */
7
- import { LspServerManager } from "./server-manager.js";
8
- import { register_lsp_tools } from "./tools.js";
9
- import { setup_lsp_prompt } from "./prompt.js";
6
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
7
+ import { LspServerManager } from "./manager.js";
8
+ import { registerLspTools } from "./tools.js";
10
9
 
11
- export function setupLsp(pi: any) {
10
+ export function setupLsp(pi: ExtensionAPI) {
12
11
  const manager = new LspServerManager();
13
-
14
- setup_lsp_prompt(pi);
15
- register_lsp_tools(pi, manager);
12
+ registerLspTools(pi, manager);
16
13
 
17
14
  pi.on("session_shutdown", async () => {
18
- await manager.clear_language_state();
15
+ await manager.clearLanguageState();
19
16
  });
20
17
  }