@bacnh85/pi-serena 0.4.1 → 0.5.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.
package/index.ts CHANGED
@@ -4,6 +4,7 @@ import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
4
4
  import path from "node:path";
5
5
  import { SerenaWorkerClient, type SerenaWorkerResponse } from "./worker";
6
6
  import { SEMANTIC_MISS_THRESHOLD, pathLooksLikeCode, pathLooksNonSemantic, commandLooksLikeSemanticCodeSearch } from "./lib/detect";
7
+ import { normalizeProject, normalizeContext, normalizeTimeoutMs, stripControlParams, normalizeSearchPatternParams, normalizeFindReferencesParams, normalizeReplaceContentParams, validateReplaceContentParams } from "./lib/normalize";
7
8
 
8
9
  const DEFAULT_CONTEXT = "ide";
9
10
 
@@ -145,77 +146,12 @@ const replaceContentSchema = Type.Object({
145
146
  needle: Type.Optional(Type.String({ description: "Exact text or regex pattern to replace." })),
146
147
  repl: Type.Optional(Type.String({ description: "Replacement text." })),
147
148
  mode: Type.Optional(Type.Union([Type.Literal("literal"), Type.Literal("regex")], { description: "Interpret needle as exact text or as a Python regex." })),
148
- old_string: Type.Optional(Type.String({ description: "Deprecated alias for literal needle." })),
149
- new_string: Type.Optional(Type.String({ description: "Deprecated alias for repl with old_string." })),
150
- content: Type.Optional(Type.String({ description: "Deprecated alias for repl with regex." })),
151
- regex: Type.Optional(Type.String({ description: "Deprecated alias for regex needle." })),
152
149
  allow_multiple_occurrences: Type.Optional(Type.Boolean({ description: "Allow replacing multiple matches when supported." })),
153
150
  });
154
151
 
155
- function normalizeProject(project: unknown): string {
156
- return typeof project === "string" && project.trim() ? project : process.cwd();
157
- }
158
-
159
- function normalizeContext(context: unknown): string {
160
- return typeof context === "string" && context.trim() ? context : DEFAULT_CONTEXT;
161
- }
162
-
163
- function normalizeTimeoutMs(timeoutMs: unknown): number | undefined {
164
- if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs) || timeoutMs <= 0) return undefined;
165
- return timeoutMs;
166
- }
167
152
 
168
- function stripControlParams(params: Record<string, unknown>): { project: string; context: string; timeoutMs: number | undefined; params: Record<string, unknown> } {
169
- const { project, context, timeout_ms, ...toolParams } = params;
170
- return { project: normalizeProject(project), context: normalizeContext(context), timeoutMs: normalizeTimeoutMs(timeout_ms), params: toolParams };
171
- }
172
153
 
173
- export function normalizeSearchPatternParams(params: Record<string, unknown>): Record<string, unknown> {
174
- const normalized = { ...params };
175
- if (normalized.substring_pattern === undefined && normalized.pattern !== undefined) {
176
- normalized.substring_pattern = normalized.pattern;
177
- }
178
- delete normalized.pattern;
179
- // The Python SearchForPatternTool.apply() does not accept a multiline parameter.
180
- // Strip it here to avoid TypeError when the parameter description says "when supported by Serena".
181
- delete normalized.multiline;
182
- return normalized;
183
- }
184
-
185
- export function normalizeFindReferencesParams(params: Record<string, unknown>): Record<string, unknown> {
186
- const normalized = { ...params };
187
- delete normalized.include_body;
188
- return normalized;
189
- }
190
-
191
- export function normalizeReplaceContentParams(params: Record<string, unknown>): Record<string, unknown> {
192
- const normalized = { ...params };
193
-
194
- if (normalized.needle === undefined) {
195
- normalized.needle = normalized.old_string ?? normalized.regex;
196
- }
197
- if (normalized.repl === undefined) {
198
- normalized.repl = normalized.new_string ?? normalized.content;
199
- }
200
- if (normalized.mode === undefined) {
201
- if (normalized.regex !== undefined) normalized.mode = "regex";
202
- else if (normalized.old_string !== undefined) normalized.mode = "literal";
203
- }
204
154
 
205
- delete normalized.old_string;
206
- delete normalized.new_string;
207
- delete normalized.content;
208
- delete normalized.regex;
209
-
210
- return normalized;
211
- }
212
-
213
- function validateReplaceContentParams(params: Record<string, unknown>): string | undefined {
214
- if (typeof params.needle !== "string") return "serena_replace_content requires string parameter 'needle' (or deprecated alias 'old_string'/'regex').";
215
- if (typeof params.repl !== "string") return "serena_replace_content requires string parameter 'repl' (or deprecated alias 'new_string'/'content').";
216
- if (params.mode !== "literal" && params.mode !== "regex") return "serena_replace_content requires mode to be 'literal' or 'regex'.";
217
- return undefined;
218
- }
219
155
 
220
156
  function truncateText(text: string): string {
221
157
  const lines = text.split("\n");
@@ -234,9 +170,27 @@ function truncateText(text: string): string {
234
170
  return output;
235
171
  }
236
172
 
173
+ function errorSuggestion(errorType: string | undefined, tool: string | undefined): string {
174
+ switch (errorType) {
175
+ case "language_server_error":
176
+ return " The language server may need a restart. Try serena_restart_language_server first.";
177
+ case "missing_tool":
178
+ return ` The tool '${tool ?? "unknown"}' is not available. Try serena_list_tools to see available tools for this project.`;
179
+ case "inactive_tool":
180
+ return ` The tool '${tool ?? "unknown"}' is not active in the current context. Try serena_list_tools to see active tools.`;
181
+ case "timeout":
182
+ return " The request timed out. Retry with a longer timeout_ms parameter.";
183
+ case "project_error":
184
+ return " There is a project configuration issue. Try serena_get_current_config to inspect the active project.";
185
+ default:
186
+ return "";
187
+ }
188
+ }
189
+
237
190
  function resultText(response: SerenaWorkerResponse): string {
238
191
  if (!response.ok) {
239
- return `Error: ${response.error ?? "Unknown Serena error"}`;
192
+ const suggestion = errorSuggestion(response.errorType as string | undefined, response.tool as string | undefined);
193
+ return `Error: ${response.error ?? "Unknown Serena error"}${suggestion}`;
240
194
  }
241
195
  // For search results, show a friendly empty-state instead of raw "{}".
242
196
  if (response.tool === "search_for_pattern") {
@@ -258,8 +212,17 @@ export default function serenaToolsExtension(pi: ExtensionAPI) {
258
212
 
259
213
  const callWorkerAction = async (ctx: any, action: string, rawParams: Record<string, unknown>, extraPayload: Record<string, unknown> = {}, lockPath?: string) => {
260
214
  const { project, context, timeoutMs, params } = stripControlParams(rawParams);
261
- const run = async () => {
215
+ const run = async (): Promise<{ content: { type: "text"; text: string }[]; details: SerenaWorkerResponse }> => {
262
216
  const response = await getWorker(ctx).request({ action, project, context, params, ...extraPayload }, timeoutMs);
217
+ // Auto-retry once on transient errors (timeout, language_server_error)
218
+ const errorType = response.errorType as string | undefined;
219
+ if (!response.ok && (errorType === "timeout" || errorType === "language_server_error")) {
220
+ const retryResponse = await getWorker(ctx).request({ action, project, context, params, ...extraPayload }, timeoutMs);
221
+ return {
222
+ content: [{ type: "text" as const, text: resultText(retryResponse) }],
223
+ details: retryResponse,
224
+ };
225
+ }
263
226
  return {
264
227
  content: [{ type: "text" as const, text: resultText(response) }],
265
228
  details: response,
@@ -417,6 +380,12 @@ export default function serenaToolsExtension(pi: ExtensionAPI) {
417
380
  promptGuidelines: ["Use serena_search_for_pattern for project-scoped code searches when symbol lookup is not enough."],
418
381
  parameters: searchPatternSchema,
419
382
  async execute(_id, params, _signal, _onUpdate, ctx) {
383
+ if (params.multiline === true) {
384
+ return {
385
+ content: [{ type: "text" as const, text: "Error: serena_search_for_pattern does not support multiline mode. Use serena_search_for_pattern without multiline or use serena_find_symbol for symbol-aware searches instead." }],
386
+ details: { ok: false, error: "multiline not supported" },
387
+ };
388
+ }
420
389
  return callSerena(ctx, "search_for_pattern", normalizeSearchPatternParams(params));
421
390
  },
422
391
  });
@@ -429,10 +398,15 @@ export default function serenaToolsExtension(pi: ExtensionAPI) {
429
398
  promptGuidelines: ["Prefer symbol-aware Serena edit tools for whole symbols; use serena_replace_content for non-symbol scoped replacements supported by Serena."],
430
399
  parameters: replaceContentSchema,
431
400
  async execute(_id, params, _signal, _onUpdate, ctx) {
401
+ const hasDeprecated = params.old_string !== undefined || params.new_string !== undefined || params.content !== undefined || params.regex !== undefined;
432
402
  const normalized = normalizeReplaceContentParams(params);
433
403
  const error = validateReplaceContentParams(normalized);
434
404
  if (error) return { content: [{ type: "text" as const, text: `Error: ${error}` }], details: { ok: false, error } };
435
- return callSerena(ctx, "replace_content", normalized, lockPathForRelativeFile(params));
405
+ const result = await callSerena(ctx, "replace_content", normalized, lockPathForRelativeFile(params));
406
+ if (hasDeprecated) {
407
+ result.content[0].text = "[Deprecation warning] Use 'needle', 'repl', and 'mode' instead of deprecated aliases (old_string, new_string, content, regex).\n\n" + result.content[0].text;
408
+ }
409
+ return result;
436
410
  },
437
411
  });
438
412
 
@@ -623,8 +597,10 @@ export default function serenaToolsExtension(pi: ExtensionAPI) {
623
597
  },
624
598
  });
625
599
 
626
- // Unconditional Serena guidance on every session
600
+ // Serena guidance only inject when serena tools are actually active
627
601
  pi.on("before_agent_start", async (event) => {
602
+ const serenaActive = event.systemPromptOptions?.selectedTools?.includes("serena_find_symbol");
603
+ if (!serenaActive) return;
628
604
  return {
629
605
  systemPrompt:
630
606
  event.systemPrompt +
@@ -633,6 +609,10 @@ export default function serenaToolsExtension(pi: ExtensionAPI) {
633
609
  });
634
610
 
635
611
  pi.on("tool_call", async (event) => {
612
+ // Skip semantic miss detection if serena tools are not active (e.g., plan mode)
613
+ const activeTools = pi.getActiveTools();
614
+ if (!activeTools.includes("serena_find_symbol")) return;
615
+
636
616
  if (event.toolName.startsWith("serena_")) {
637
617
  semanticMissCount = 0;
638
618
  return;
@@ -663,6 +643,16 @@ export default function serenaToolsExtension(pi: ExtensionAPI) {
663
643
  }
664
644
  });
665
645
 
646
+ // Eager startup: pre-spawn the worker on session start when SERENA_EAGER_STARTUP=1
647
+ pi.on("session_start", async (_event, ctx) => {
648
+ if (process.env.SERENA_EAGER_STARTUP === "1") {
649
+ // Spawn the worker via a lightweight status request to warm the Python bridge
650
+ getWorker(ctx).request({ action: "status", project: process.cwd(), context: DEFAULT_CONTEXT }, 30_000).catch(() => {
651
+ // Eager startup is best-effort; failures are handled when the tool is first used
652
+ });
653
+ }
654
+ });
655
+
666
656
  pi.on("session_shutdown", async (_event, ctx) => {
667
657
  await worker?.stop();
668
658
  worker = undefined;
package/lib/detect.ts CHANGED
@@ -26,9 +26,21 @@ export const CODE_FILE_EXTENSIONS = new Set([
26
26
  ".ts",
27
27
  ".tsx",
28
28
  ".vue",
29
+ // Additional code file extensions commonly encountered
30
+ ".spec.ts",
31
+ ".test.ts",
32
+ ".server.ts",
33
+ ".server.tsx",
34
+ ".d.ts",
35
+ ".config.ts",
36
+ ".cjs",
29
37
  ]);
30
38
 
31
- const NON_SEMANTIC_FILE_EXTENSIONS = new Set([".json", ".jsonl", ".lock", ".md", ".txt", ".yaml", ".yml"]);
39
+ const NON_SEMANTIC_FILE_EXTENSIONS = new Set([
40
+ ".json", ".jsonl", ".lock", ".md", ".txt", ".yaml", ".yml",
41
+ ".csv", ".log", ".env", ".ini", ".cfg", ".toml",
42
+ ".editorconfig", ".gitignore",
43
+ ]);
32
44
 
33
45
  export const SEMANTIC_MISS_THRESHOLD = 2;
34
46
 
@@ -48,9 +60,14 @@ export function pathLooksNonSemantic(value: unknown): boolean {
48
60
  return NON_SEMANTIC_FILE_EXTENSIONS.has(cleanPath.slice(dotIndex));
49
61
  }
50
62
 
63
+ const TODO_LIKE_PATTERNS = /\b(TODO|FIXME|HACK|NOTE|XXX|BUG|WORKAROUND|OPTIMIZE|REVIEW|TEMP|WARNING|WORKAROUND)\b/i;
64
+
51
65
  export function commandLooksLikeSemanticCodeSearch(command: string): boolean {
52
66
  if (!/\b(rg|grep|fd|find)\b/.test(command)) return false;
67
+ // Non-semantic targets: docs, configs, and project files
53
68
  if (/\b(SKILL\.md|README\.md|AGENTS\.md|package\.json|skill-registry\.json|skill-history\.jsonl)\b/i.test(command)) return false;
69
+ // Searching for TODO/FIXME/etc. is a non-semantic text search
70
+ if (TODO_LIKE_PATTERNS.test(command)) return false;
54
71
  if (/\b(symbol|class|method|function|def|interface|references?|implementation|declaration|rename|refactor)\b/i.test(command)) return true;
55
- return /\.(c|cc|cpp|cs|go|java|js|jsx|kt|lua|mjs|php|py|rb|rs|scala|sh|swift|ts|tsx|vue)\b/i.test(command);
72
+ return /\.(c|cc|cpp|cs|go|java|js|jsx|kt|lua|mjs|php|py|rb|rs|scala|sh|swift|ts|tsx|vue|spec\.ts|test\.ts|server\.ts|server\.tsx|d\.ts|config\.ts|cjs)\b/i.test(command);
56
73
  }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Parameter normalization logic for pi-serena.
3
+ * Extracted from index.ts so it can be tested without importing pi-coding-agent.
4
+ */
5
+
6
+ export function normalizeProject(project: unknown): string {
7
+ return typeof project === "string" && project.trim() ? project : process.cwd();
8
+ }
9
+
10
+ export function normalizeContext(context: unknown): string {
11
+ return typeof context === "string" && context.trim() ? context : "ide";
12
+ }
13
+
14
+ export function normalizeTimeoutMs(timeoutMs: unknown): number | undefined {
15
+ if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs) || timeoutMs <= 0) return undefined;
16
+ return timeoutMs;
17
+ }
18
+
19
+ export function stripControlParams(params: Record<string, unknown>): { project: string; context: string; timeoutMs: number | undefined; params: Record<string, unknown> } {
20
+ const { project, context, timeout_ms, ...toolParams } = params;
21
+ return { project: normalizeProject(project), context: normalizeContext(context), timeoutMs: normalizeTimeoutMs(timeout_ms), params: toolParams };
22
+ }
23
+
24
+ export function normalizeSearchPatternParams(params: Record<string, unknown>): Record<string, unknown> {
25
+ const normalized = { ...params };
26
+ if (normalized.substring_pattern === undefined && normalized.pattern !== undefined) {
27
+ normalized.substring_pattern = normalized.pattern;
28
+ }
29
+ delete normalized.pattern;
30
+ // multiline is not supported by the Python backend; validation above catches true values.
31
+ // Strip it here as a safety net to avoid TypeError.
32
+ delete normalized.multiline;
33
+ return normalized;
34
+ }
35
+
36
+ export function normalizeFindReferencesParams(params: Record<string, unknown>): Record<string, unknown> {
37
+ const normalized = { ...params };
38
+ delete normalized.include_body;
39
+ return normalized;
40
+ }
41
+
42
+ export function normalizeReplaceContentParams(params: Record<string, unknown>): Record<string, unknown> {
43
+ const normalized = { ...params };
44
+
45
+ if (normalized.needle === undefined) {
46
+ normalized.needle = normalized.old_string ?? normalized.regex;
47
+ }
48
+ if (normalized.repl === undefined) {
49
+ normalized.repl = normalized.new_string ?? normalized.content;
50
+ }
51
+ if (normalized.mode === undefined) {
52
+ if (normalized.regex !== undefined) normalized.mode = "regex";
53
+ else if (normalized.old_string !== undefined) normalized.mode = "literal";
54
+ }
55
+
56
+ delete normalized.old_string;
57
+ delete normalized.new_string;
58
+ delete normalized.content;
59
+ delete normalized.regex;
60
+
61
+ return normalized;
62
+ }
63
+
64
+ export function validateReplaceContentParams(params: Record<string, unknown>): string | undefined {
65
+ if (typeof params.needle !== "string") return "serena_replace_content requires string parameter 'needle' (or deprecated alias 'old_string'/'regex').";
66
+ if (typeof params.repl !== "string") return "serena_replace_content requires string parameter 'repl' (or deprecated alias 'new_string'/'content').";
67
+ if (params.mode !== "literal" && params.mode !== "regex") return "serena_replace_content requires mode to be 'literal' or 'regex'.";
68
+ return undefined;
69
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bacnh85/pi-serena",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "description": "Pi extension that provides Serena semantic code tools through a persistent worker.",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
package/worker.ts CHANGED
@@ -281,10 +281,12 @@ def _handle_diagnostics(req_id, req: dict[str, Any]) -> dict[str, Any]:
281
281
 
282
282
  try:
283
283
  uri = pathlib.Path(str(PurePath(lang_server.repository_root_path, relative_path))).as_uri()
284
- result = lang_server.send.text_document_diagnostic({
284
+ result = lang_server.server.send.text_document_diagnostic({
285
285
  "textDocument": {"uri": uri},
286
286
  })
287
287
  return {"id": req_id, "ok": True, "tool": "get_diagnostics_for_file", "result": _json.dumps(result, indent=2, default=str)}
288
+ except AttributeError:
289
+ return {"id": req_id, "ok": True, "tool": "get_diagnostics_for_file", "result": _json.dumps({"note": "Language server does not support textDocument/diagnostic"})}
288
290
  except Exception as exc:
289
291
  return {"id": req_id, "ok": True, "tool": "get_diagnostics_for_file", "result": _json.dumps({"note": "Diagnostics request completed with no issues or language server does not support textDocument/diagnostic", "detail": str(exc)})}
290
292