@bacnh85/pi-serena 0.4.2 → 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 +59 -69
- package/lib/detect.ts +19 -2
- package/lib/normalize.ts +69 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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([
|
|
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
|
}
|
package/lib/normalize.ts
ADDED
|
@@ -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
|
+
}
|