@bacnh85/pi-serena 0.1.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 (4) hide show
  1. package/README.md +60 -0
  2. package/index.ts +552 -0
  3. package/package.json +38 -0
  4. package/worker.ts +347 -0
package/README.md ADDED
@@ -0,0 +1,60 @@
1
+ # pi-serena
2
+
3
+ Pi extension that registers Pi-native `serena_*` tools backed by a persistent TypeScript/Node worker. This avoids configuring Pi as an MCP client while still using Serena's semantic code APIs.
4
+
5
+ Note: Serena itself is a Python package. The TypeScript worker owns lifecycle, request/response handling, and Pi integration, and uses an embedded Python bridge subprocess only to call Serena internals because Serena does not provide a JavaScript SDK and its non-MCP project HTTP server is read-only.
6
+
7
+ ## Install
8
+
9
+ From this repository checkout, install only this extension package:
10
+
11
+ ```bash
12
+ pi install ./extensions/pi-serena
13
+ ```
14
+
15
+ For local development from a checkout:
16
+
17
+ ```bash
18
+ pi -e ./extensions/pi-serena
19
+ ```
20
+
21
+ There is intentionally no repository-level Pi package. Install each extension from its own subdirectory, matching `extensions/pi-rtk` and future extensions.
22
+
23
+ After install or update, restart Pi or run `/reload` in an existing Pi session.
24
+
25
+ ## Tools
26
+
27
+ - `serena_status`
28
+ - `serena_list_tools`
29
+ - `serena_get_symbols_overview`
30
+ - `serena_find_symbol`
31
+ - `serena_find_referencing_symbols`
32
+ - `serena_replace_symbol_body`
33
+ - `serena_insert_before_symbol`
34
+ - `serena_insert_after_symbol`
35
+ - `serena_rename_symbol`
36
+ - `serena_safe_delete_symbol`
37
+ - `serena_search_for_pattern`
38
+ - `serena_replace_content`
39
+ - `serena_restart_language_server`
40
+ - `serena_get_current_config`
41
+ - `serena_check_onboarding_performed`
42
+ - `serena_onboarding`
43
+ - `serena_list_memories`
44
+ - `serena_read_memory`
45
+ - `serena_write_memory`
46
+ - `serena_delete_memory`
47
+
48
+ All tool outputs are truncated to 50KB / 2000 lines to match Pi-friendly output limits. Most tools accept optional `timeout_ms`.
49
+
50
+ ## Commands
51
+
52
+ - `/serena-status [project]`
53
+ - `/serena-dashboard [project]`
54
+ - `/serena-restart`
55
+
56
+ The persistent Pi worker keeps one Serena bridge process per Pi process/session. It keeps the dashboard server available by default but does not open a browser tab automatically; use `/serena-dashboard` when you want to open it. Set `SERENA_BRIDGE_WEB_DASHBOARD=0` to disable the dashboard server, or `SERENA_BRIDGE_OPEN_DASHBOARD=1` to restore automatic browser launch. These variables are read from the process environment, current working directory `.env.local`/`.env`, or Pi global config `.env.local`/`.env` under `$PI_CODING_AGENT_DIR`, `~/.pi/agent`, or compatibility path `~/.pi/agents`.
57
+
58
+ ## Worker protocol
59
+
60
+ `worker.ts` implements the persistent worker client in TypeScript. The extension starts one worker per Pi process, lazily on first use, and shuts it down on `session_shutdown`.
package/index.ts ADDED
@@ -0,0 +1,552 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+ import { SerenaWorkerClient, type SerenaWorkerResponse } from "./worker";
4
+
5
+ const DEFAULT_CONTEXT = "ide";
6
+
7
+ const PROJECT_PARAM = Type.Optional(Type.String({ description: "Project path or registered Serena project name. Defaults to Pi's current working directory." }));
8
+ const CONTEXT_PARAM = Type.Optional(Type.String({ description: "Serena context name. Defaults to ide." }));
9
+ const MAX_CHARS_PARAM = Type.Optional(Type.Number({ description: "Maximum Serena response characters. Defaults to Serena config." }));
10
+ const TIMEOUT_MS_PARAM = Type.Optional(Type.Number({ description: "Per-call timeout in milliseconds. Defaults to 120000." }));
11
+ const OUTPUT_MAX_BYTES = 50 * 1024;
12
+ const OUTPUT_MAX_LINES = 2_000;
13
+
14
+ const controlSchema = {
15
+ project: PROJECT_PARAM,
16
+ context: CONTEXT_PARAM,
17
+ timeout_ms: TIMEOUT_MS_PARAM,
18
+ };
19
+
20
+ const statusSchema = Type.Object({
21
+ project: PROJECT_PARAM,
22
+ context: CONTEXT_PARAM,
23
+ includeAgent: Type.Optional(Type.Boolean({ description: "Initialize/read a SerenaAgent and include active tools/backend details." })),
24
+ timeout_ms: TIMEOUT_MS_PARAM,
25
+ });
26
+
27
+ const listToolsSchema = Type.Object({
28
+ ...controlSchema,
29
+ includeAgent: Type.Optional(Type.Boolean({ description: "Initialize/read a SerenaAgent before listing active tools." })),
30
+ });
31
+
32
+ const overviewSchema = Type.Object({
33
+ ...controlSchema,
34
+ relative_path: Type.String({ description: "File path relative to the Serena project root." }),
35
+ depth: Type.Optional(Type.Number({ description: "Symbol overview depth." })),
36
+ max_answer_chars: MAX_CHARS_PARAM,
37
+ });
38
+
39
+ const findSymbolSchema = Type.Object({
40
+ ...controlSchema,
41
+ name_path_pattern: Type.String({ description: "Symbol name path pattern, e.g. MyClass/my_method." }),
42
+ depth: Type.Optional(Type.Number()),
43
+ relative_path: Type.Optional(Type.String({ description: "Optional file or directory restriction relative to project root." })),
44
+ include_body: Type.Optional(Type.Boolean()),
45
+ include_info: Type.Optional(Type.Boolean()),
46
+ substring_matching: Type.Optional(Type.Boolean()),
47
+ max_matches: Type.Optional(Type.Number()),
48
+ max_answer_chars: MAX_CHARS_PARAM,
49
+ });
50
+
51
+ const referencingSchema = Type.Object({
52
+ ...controlSchema,
53
+ name_path: Type.String({ description: "Exact symbol name path from find_symbol." }),
54
+ relative_path: Type.String({ description: "File containing the symbol, relative to project root." }),
55
+ include_body: Type.Optional(Type.Boolean()),
56
+ max_answer_chars: MAX_CHARS_PARAM,
57
+ });
58
+
59
+ const replaceBodySchema = Type.Object({
60
+ ...controlSchema,
61
+ name_path: Type.String({ description: "Exact symbol name path to replace." }),
62
+ relative_path: Type.String({ description: "File containing the symbol, relative to project root." }),
63
+ body: Type.String({ description: "Complete new symbol definition/body, including signature line." }),
64
+ });
65
+
66
+ const insertSchema = Type.Object({
67
+ ...controlSchema,
68
+ name_path: Type.String({ description: "Exact reference symbol name path." }),
69
+ relative_path: Type.String({ description: "File containing the reference symbol, relative to project root." }),
70
+ body: Type.String({ description: "Content to insert." }),
71
+ });
72
+
73
+ const renameSchema = Type.Object({
74
+ ...controlSchema,
75
+ name_path: Type.String({ description: "Exact symbol name path to rename." }),
76
+ relative_path: Type.String({ description: "File containing the symbol, relative to project root." }),
77
+ new_name: Type.String({ description: "New symbol name." }),
78
+ });
79
+
80
+ const safeDeleteSchema = Type.Object({
81
+ ...controlSchema,
82
+ name_path_pattern: Type.String({ description: "Symbol name path pattern to delete if unreferenced." }),
83
+ relative_path: Type.String({ description: "File containing the symbol, relative to project root." }),
84
+ });
85
+
86
+ const emptyToolSchema = Type.Object({ ...controlSchema });
87
+
88
+ const memoryNameSchema = Type.Object({
89
+ ...controlSchema,
90
+ memory_name: Type.String({ description: "Serena memory file name." }),
91
+ });
92
+
93
+ const writeMemorySchema = Type.Object({
94
+ ...controlSchema,
95
+ memory_name: Type.String({ description: "Serena memory file name." }),
96
+ content: Type.String({ description: "Memory content to write." }),
97
+ });
98
+
99
+ const searchPatternSchema = Type.Object({
100
+ ...controlSchema,
101
+ pattern: Type.String({ description: "Pattern to search for." }),
102
+ relative_path: Type.Optional(Type.String({ description: "Optional file or directory restriction relative to the Serena project root." })),
103
+ paths_include_glob: Type.Optional(Type.String({ description: "Optional glob for included paths." })),
104
+ paths_exclude_glob: Type.Optional(Type.String({ description: "Optional glob for excluded paths." })),
105
+ max_answer_chars: MAX_CHARS_PARAM,
106
+ });
107
+
108
+ const replaceContentSchema = Type.Object({
109
+ ...controlSchema,
110
+ relative_path: Type.String({ description: "File path relative to the Serena project root." }),
111
+ old_string: Type.Optional(Type.String({ description: "Exact text or regex to replace, depending on Serena version." })),
112
+ new_string: Type.Optional(Type.String({ description: "Replacement text, depending on Serena version." })),
113
+ content: Type.Optional(Type.String({ description: "Replacement content for Serena versions that use content-based replacement." })),
114
+ regex: Type.Optional(Type.String({ description: "Regex pattern for Serena versions that use regex replacement." })),
115
+ allow_multiple_occurrences: Type.Optional(Type.Boolean({ description: "Allow replacing multiple matches when supported." })),
116
+ });
117
+
118
+ function normalizeProject(project: unknown): string {
119
+ return typeof project === "string" && project.trim() ? project : process.cwd();
120
+ }
121
+
122
+ function normalizeContext(context: unknown): string {
123
+ return typeof context === "string" && context.trim() ? context : DEFAULT_CONTEXT;
124
+ }
125
+
126
+ function normalizeTimeoutMs(timeoutMs: unknown): number | undefined {
127
+ if (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs) || timeoutMs <= 0) return undefined;
128
+ return timeoutMs;
129
+ }
130
+
131
+ function stripControlParams(params: Record<string, unknown>): { project: string; context: string; timeoutMs: number | undefined; params: Record<string, unknown> } {
132
+ const { project, context, timeout_ms, ...toolParams } = params;
133
+ return { project: normalizeProject(project), context: normalizeContext(context), timeoutMs: normalizeTimeoutMs(timeout_ms), params: toolParams };
134
+ }
135
+
136
+ function truncateText(text: string): string {
137
+ const lines = text.split("\n");
138
+ let output = lines.slice(0, OUTPUT_MAX_LINES).join("\n");
139
+ let truncatedByLines = lines.length > OUTPUT_MAX_LINES;
140
+ let truncatedByBytes = false;
141
+
142
+ while (Buffer.byteLength(output, "utf8") > OUTPUT_MAX_BYTES) {
143
+ truncatedByBytes = true;
144
+ output = output.slice(0, Math.max(0, output.length - 1024));
145
+ }
146
+
147
+ if (truncatedByLines || truncatedByBytes) {
148
+ output += `\n\n[Serena output truncated to ${OUTPUT_MAX_LINES} lines / ${OUTPUT_MAX_BYTES} bytes.]`;
149
+ }
150
+ return output;
151
+ }
152
+
153
+ function resultText(response: SerenaWorkerResponse): string {
154
+ const text = !response.ok ? `Error: ${response.error ?? "Unknown Serena error"}` : typeof response.result === "string" ? response.result : JSON.stringify(response, null, 2);
155
+ return truncateText(text);
156
+ }
157
+
158
+ const CODE_FILE_EXTENSIONS = new Set([
159
+ ".c",
160
+ ".cc",
161
+ ".cpp",
162
+ ".cs",
163
+ ".css",
164
+ ".go",
165
+ ".java",
166
+ ".js",
167
+ ".jsx",
168
+ ".kt",
169
+ ".lua",
170
+ ".mjs",
171
+ ".php",
172
+ ".py",
173
+ ".rb",
174
+ ".rs",
175
+ ".scala",
176
+ ".sh",
177
+ ".swift",
178
+ ".ts",
179
+ ".tsx",
180
+ ".vue",
181
+ ]);
182
+
183
+ const NON_SEMANTIC_FILE_EXTENSIONS = new Set([".json", ".jsonl", ".lock", ".md", ".txt", ".yaml", ".yml"]);
184
+
185
+ function pathLooksLikeCode(value: unknown): boolean {
186
+ if (typeof value !== "string" || value.trim() === "") return false;
187
+ const cleanPath = value.split(/[?#]/, 1)[0].toLowerCase();
188
+ const dotIndex = cleanPath.lastIndexOf(".");
189
+ if (dotIndex < 0) return false;
190
+ return CODE_FILE_EXTENSIONS.has(cleanPath.slice(dotIndex));
191
+ }
192
+
193
+ function pathLooksNonSemantic(value: unknown): boolean {
194
+ if (typeof value !== "string" || value.trim() === "") return false;
195
+ const cleanPath = value.split(/[?#]/, 1)[0].toLowerCase();
196
+ const dotIndex = cleanPath.lastIndexOf(".");
197
+ if (dotIndex < 0) return false;
198
+ return NON_SEMANTIC_FILE_EXTENSIONS.has(cleanPath.slice(dotIndex));
199
+ }
200
+
201
+ function commandLooksLikeSemanticCodeSearch(command: string): boolean {
202
+ if (!/\b(rg|grep|fd|find)\b/.test(command)) return false;
203
+ if (/\b(SKILL\.md|README\.md|AGENTS\.md|package\.json|skill-registry\.json|skill-history\.jsonl)\b/i.test(command)) return false;
204
+ if (/\b(symbol|class|method|function|def|interface|references?|implementation|declaration|rename|refactor)\b/i.test(command)) return true;
205
+ 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);
206
+ }
207
+
208
+ export default function serenaToolsExtension(pi: ExtensionAPI) {
209
+ let worker: SerenaWorkerClient | undefined;
210
+ let semanticMissCount = 0;
211
+
212
+ const getWorker = (ctx?: { ui?: { setStatus?: (key: string, value: string | undefined) => void } }) => {
213
+ if (!worker) worker = new SerenaWorkerClient((status) => ctx?.ui?.setStatus?.("serena", status ? "serena ✓" : undefined));
214
+ return worker;
215
+ };
216
+
217
+ const callSerena = async (ctx: any, tool: string, rawParams: Record<string, unknown>) => {
218
+ const { project, context, timeoutMs, params } = stripControlParams(rawParams);
219
+ const response = await getWorker(ctx).request({ action: "call", project, context, tool, params }, timeoutMs);
220
+ return {
221
+ content: [{ type: "text" as const, text: resultText(response) }],
222
+ details: response,
223
+ };
224
+ };
225
+
226
+ pi.registerTool({
227
+ name: "serena_status",
228
+ label: "Serena Status",
229
+ description: "Show Serena worker and project status for semantic code tools.",
230
+ promptSnippet: "Check Serena semantic worker availability and active project/tool state",
231
+ promptGuidelines: ["Use serena_status before semantic code retrieval/refactoring if Serena availability is uncertain."],
232
+ parameters: statusSchema,
233
+ async execute(_id, params, _signal, _onUpdate, ctx) {
234
+ const project = normalizeProject(params.project);
235
+ const context = normalizeContext(params.context);
236
+ const response = await getWorker(ctx).request({ action: "status", project, context, includeAgent: Boolean(params.includeAgent) }, normalizeTimeoutMs(params.timeout_ms));
237
+ return { content: [{ type: "text", text: truncateText(JSON.stringify(response, null, 2)) }], details: response };
238
+ },
239
+ });
240
+
241
+ pi.registerTool({
242
+ name: "serena_list_tools",
243
+ label: "Serena List Tools",
244
+ description: "List active Serena tools for the current project/context.",
245
+ promptSnippet: "List available Serena semantic, memory, and workflow tools",
246
+ promptGuidelines: ["Use serena_list_tools when you need to know which Serena tools are active for this project/context."],
247
+ parameters: listToolsSchema,
248
+ async execute(_id, params, _signal, _onUpdate, ctx) {
249
+ const project = normalizeProject(params.project);
250
+ const context = normalizeContext(params.context);
251
+ const response = await getWorker(ctx).request({ action: "status", project, context, includeAgent: true }, normalizeTimeoutMs(params.timeout_ms));
252
+ const tools = Array.isArray(response.activeTools) ? response.activeTools : [];
253
+ const text = tools.length > 0 ? tools.map((tool) => `- ${tool}`).join("\n") : JSON.stringify(response, null, 2);
254
+ return { content: [{ type: "text", text: truncateText(text) }], details: response };
255
+ },
256
+ });
257
+
258
+ pi.registerTool({
259
+ name: "serena_get_symbols_overview",
260
+ label: "Serena Symbols Overview",
261
+ description: "Get top-level symbols for a code file using Serena's language-server backend.",
262
+ promptSnippet: "Get a semantic outline of symbols in a source file",
263
+ promptGuidelines: ["Use serena_get_symbols_overview before reading an entire code file when symbol structure is enough."],
264
+ parameters: overviewSchema,
265
+ async execute(_id, params, _signal, _onUpdate, ctx) {
266
+ return callSerena(ctx, "get_symbols_overview", params);
267
+ },
268
+ });
269
+
270
+ pi.registerTool({
271
+ name: "serena_find_symbol",
272
+ label: "Serena Find Symbol",
273
+ description: "Find symbols by name path pattern using Serena semantic code retrieval.",
274
+ promptSnippet: "Find functions, classes, methods, and variables by semantic symbol name path",
275
+ promptGuidelines: ["Use serena_find_symbol for code navigation before grep when the target is a function, class, method, or variable."],
276
+ parameters: findSymbolSchema,
277
+ async execute(_id, params, _signal, _onUpdate, ctx) {
278
+ return callSerena(ctx, "find_symbol", params);
279
+ },
280
+ });
281
+
282
+ pi.registerTool({
283
+ name: "serena_find_referencing_symbols",
284
+ label: "Serena Find References",
285
+ description: "Find symbols that reference a given symbol using Serena semantic retrieval.",
286
+ promptSnippet: "Find semantic references/callers/usages of a known symbol",
287
+ promptGuidelines: ["Use serena_find_referencing_symbols before changing public behavior or renaming a symbol."],
288
+ parameters: referencingSchema,
289
+ async execute(_id, params, _signal, _onUpdate, ctx) {
290
+ return callSerena(ctx, "find_referencing_symbols", params);
291
+ },
292
+ });
293
+
294
+ pi.registerTool({
295
+ name: "serena_replace_symbol_body",
296
+ label: "Serena Replace Symbol Body",
297
+ description: "Replace a complete function/class/method definition using Serena symbol-aware editing.",
298
+ promptSnippet: "Replace a complete known symbol body by semantic boundary",
299
+ promptGuidelines: ["Use serena_replace_symbol_body only after serena_find_symbol identifies the exact symbol to replace."],
300
+ parameters: replaceBodySchema,
301
+ async execute(_id, params, _signal, _onUpdate, ctx) {
302
+ return callSerena(ctx, "replace_symbol_body", params);
303
+ },
304
+ });
305
+
306
+ pi.registerTool({
307
+ name: "serena_insert_before_symbol",
308
+ label: "Serena Insert Before Symbol",
309
+ description: "Insert content before a known symbol definition using Serena symbol-aware editing.",
310
+ promptSnippet: "Insert code before a known symbol definition",
311
+ promptGuidelines: ["Use serena_insert_before_symbol for symbol-adjacent code insertion after locating the target symbol."],
312
+ parameters: insertSchema,
313
+ async execute(_id, params, _signal, _onUpdate, ctx) {
314
+ return callSerena(ctx, "insert_before_symbol", params);
315
+ },
316
+ });
317
+
318
+ pi.registerTool({
319
+ name: "serena_insert_after_symbol",
320
+ label: "Serena Insert After Symbol",
321
+ description: "Insert content after a known symbol definition using Serena symbol-aware editing.",
322
+ promptSnippet: "Insert code after a known symbol definition",
323
+ promptGuidelines: ["Use serena_insert_after_symbol for adding sibling/helper symbols after a located symbol."],
324
+ parameters: insertSchema,
325
+ async execute(_id, params, _signal, _onUpdate, ctx) {
326
+ return callSerena(ctx, "insert_after_symbol", params);
327
+ },
328
+ });
329
+
330
+ pi.registerTool({
331
+ name: "serena_rename_symbol",
332
+ label: "Serena Rename Symbol",
333
+ description: "Rename a symbol across the codebase using Serena language-server refactoring.",
334
+ promptSnippet: "Semantic rename of a known symbol across references",
335
+ promptGuidelines: ["Use serena_rename_symbol for cross-file code renames after finding the exact symbol and references."],
336
+ parameters: renameSchema,
337
+ async execute(_id, params, _signal, _onUpdate, ctx) {
338
+ return callSerena(ctx, "rename_symbol", params);
339
+ },
340
+ });
341
+
342
+ pi.registerTool({
343
+ name: "serena_safe_delete_symbol",
344
+ label: "Serena Safe Delete Symbol",
345
+ description: "Delete a symbol only if Serena finds no remaining references.",
346
+ promptSnippet: "Safely delete an unreferenced symbol",
347
+ promptGuidelines: ["Use serena_safe_delete_symbol instead of text deletion when remaining references matter."],
348
+ parameters: safeDeleteSchema,
349
+ async execute(_id, params, _signal, _onUpdate, ctx) {
350
+ return callSerena(ctx, "safe_delete_symbol", params);
351
+ },
352
+ });
353
+
354
+ pi.registerTool({
355
+ name: "serena_search_for_pattern",
356
+ label: "Serena Search Pattern",
357
+ description: "Search project files through Serena with project-aware path filtering.",
358
+ promptSnippet: "Search for a text or regex pattern using Serena",
359
+ promptGuidelines: ["Use serena_search_for_pattern for project-scoped code searches when symbol lookup is not enough."],
360
+ parameters: searchPatternSchema,
361
+ async execute(_id, params, _signal, _onUpdate, ctx) {
362
+ return callSerena(ctx, "search_for_pattern", params);
363
+ },
364
+ });
365
+
366
+ pi.registerTool({
367
+ name: "serena_replace_content",
368
+ label: "Serena Replace Content",
369
+ description: "Replace file content through Serena when symbol-aware editing is not the right boundary.",
370
+ promptSnippet: "Perform a Serena content replacement in a project file",
371
+ promptGuidelines: ["Prefer symbol-aware Serena edit tools for whole symbols; use serena_replace_content for non-symbol scoped replacements supported by Serena."],
372
+ parameters: replaceContentSchema,
373
+ async execute(_id, params, _signal, _onUpdate, ctx) {
374
+ return callSerena(ctx, "replace_content", params);
375
+ },
376
+ });
377
+
378
+ pi.registerTool({
379
+ name: "serena_restart_language_server",
380
+ label: "Serena Restart Language Server",
381
+ description: "Restart Serena's language server for the active project when diagnostics/symbol data are stale.",
382
+ promptSnippet: "Restart the Serena language server",
383
+ promptGuidelines: ["Use serena_restart_language_server when Serena symbol retrieval appears stale or language-server diagnostics are stuck."],
384
+ parameters: emptyToolSchema,
385
+ async execute(_id, params, _signal, _onUpdate, ctx) {
386
+ return callSerena(ctx, "restart_language_server", params);
387
+ },
388
+ });
389
+
390
+ pi.registerTool({
391
+ name: "serena_get_current_config",
392
+ label: "Serena Current Config",
393
+ description: "Show Serena's current project/configuration details.",
394
+ promptSnippet: "Inspect Serena project, context, modes, tools, and backend configuration",
395
+ promptGuidelines: ["Use serena_get_current_config when project activation, contexts, modes, or tool availability are unclear."],
396
+ parameters: emptyToolSchema,
397
+ async execute(_id, params, _signal, _onUpdate, ctx) {
398
+ return callSerena(ctx, "get_current_config", params);
399
+ },
400
+ });
401
+
402
+ pi.registerTool({
403
+ name: "serena_check_onboarding_performed",
404
+ label: "Serena Check Onboarding",
405
+ description: "Check whether Serena onboarding memories exist for this project.",
406
+ promptSnippet: "Check whether project onboarding was already performed",
407
+ promptGuidelines: ["Use serena_check_onboarding_performed before relying on Serena project memories."],
408
+ parameters: emptyToolSchema,
409
+ async execute(_id, params, _signal, _onUpdate, ctx) {
410
+ return callSerena(ctx, "check_onboarding_performed", params);
411
+ },
412
+ });
413
+
414
+ pi.registerTool({
415
+ name: "serena_onboarding",
416
+ label: "Serena Onboarding",
417
+ description: "Run Serena's onboarding prompt for collecting project memories.",
418
+ promptSnippet: "Start Serena project onboarding",
419
+ promptGuidelines: ["Use serena_onboarding only when project onboarding has not been performed or the user asks to refresh it."],
420
+ parameters: emptyToolSchema,
421
+ async execute(_id, params, _signal, _onUpdate, ctx) {
422
+ return callSerena(ctx, "onboarding", params);
423
+ },
424
+ });
425
+
426
+ pi.registerTool({
427
+ name: "serena_list_memories",
428
+ label: "Serena List Memories",
429
+ description: "List Serena project memories available for the active project.",
430
+ promptSnippet: "List available Serena memories",
431
+ promptGuidelines: ["Use serena_list_memories before reading Serena memories relevant to a task."],
432
+ parameters: emptyToolSchema,
433
+ async execute(_id, params, _signal, _onUpdate, ctx) {
434
+ return callSerena(ctx, "list_memories", params);
435
+ },
436
+ });
437
+
438
+ pi.registerTool({
439
+ name: "serena_read_memory",
440
+ label: "Serena Read Memory",
441
+ description: "Read a named Serena project memory.",
442
+ promptSnippet: "Read a Serena project memory by name",
443
+ promptGuidelines: ["Use serena_read_memory only for memories that are relevant to the current task."],
444
+ parameters: memoryNameSchema,
445
+ async execute(_id, params, _signal, _onUpdate, ctx) {
446
+ return callSerena(ctx, "read_memory", params);
447
+ },
448
+ });
449
+
450
+ pi.registerTool({
451
+ name: "serena_write_memory",
452
+ label: "Serena Write Memory",
453
+ description: "Write a named Serena project memory.",
454
+ promptSnippet: "Write durable Serena project memory content",
455
+ promptGuidelines: ["Use serena_write_memory after onboarding or when durable verified project knowledge should be available to Serena."],
456
+ parameters: writeMemorySchema,
457
+ async execute(_id, params, _signal, _onUpdate, ctx) {
458
+ return callSerena(ctx, "write_memory", params);
459
+ },
460
+ });
461
+
462
+ pi.registerTool({
463
+ name: "serena_delete_memory",
464
+ label: "Serena Delete Memory",
465
+ description: "Delete a named Serena project memory when the user explicitly asks.",
466
+ promptSnippet: "Delete a Serena project memory by name",
467
+ promptGuidelines: ["Use serena_delete_memory only when the user explicitly asks to remove a Serena memory."],
468
+ parameters: memoryNameSchema,
469
+ async execute(_id, params, _signal, _onUpdate, ctx) {
470
+ return callSerena(ctx, "delete_memory", params);
471
+ },
472
+ });
473
+
474
+ pi.registerCommand("serena-status", {
475
+ description: "Show Serena worker status",
476
+ handler: async (args, ctx) => {
477
+ const project = args?.trim() || process.cwd();
478
+ const response = await getWorker(ctx).request({ action: "status", project, context: DEFAULT_CONTEXT, includeAgent: true });
479
+ ctx.ui.notify(JSON.stringify(response, null, 2), response.ok ? "info" : "error");
480
+ },
481
+ });
482
+
483
+ pi.registerCommand("serena-dashboard", {
484
+ description: "Open the Serena dashboard for the current project",
485
+ handler: async (args, ctx) => {
486
+ const project = args?.trim() || process.cwd();
487
+ const response = await getWorker(ctx).request({ action: "dashboard", project, context: DEFAULT_CONTEXT, open: true });
488
+ if (response.ok) {
489
+ const opened = response.opened ? "opened" : "available";
490
+ ctx.ui.notify(`Serena dashboard ${opened}: ${response.dashboardUrl ?? "dashboard URL unavailable"}`, "info");
491
+ } else {
492
+ ctx.ui.notify(resultText(response), "error");
493
+ }
494
+ },
495
+ });
496
+
497
+ pi.registerCommand("serena-restart", {
498
+ description: "Restart the persistent Serena worker",
499
+ handler: async (_args, ctx) => {
500
+ getWorker(ctx).restart();
501
+ ctx.ui.notify("Restarted Serena worker", "info");
502
+ },
503
+ });
504
+
505
+ pi.on("before_agent_start", async (event) => {
506
+ const prompt = event.prompt.toLowerCase();
507
+ if (/\b(find references|rename|refactor|symbol|declaration|implementation|diagnostic|large repo|semantic|class|method|function)\b/.test(prompt)) {
508
+ return {
509
+ systemPrompt:
510
+ event.systemPrompt +
511
+ "\n\nSerena semantic tools are available as Pi-native tools. For symbol lookup, references, semantic code navigation, whole-symbol edits, safe deletion, or cross-file renames, prefer serena_* tools before repeated grep/read/edit operations. Use normal Pi tools for small exact text edits and non-code files.",
512
+ };
513
+ }
514
+ });
515
+
516
+ pi.on("tool_call", async (event) => {
517
+ if (event.toolName.startsWith("serena_")) {
518
+ semanticMissCount = 0;
519
+ return;
520
+ }
521
+
522
+ let looksLikeSemanticMiss = false;
523
+ if (event.toolName === "read") {
524
+ const input = event.input as Record<string, unknown>;
525
+ looksLikeSemanticMiss = pathLooksLikeCode(input.path) && !pathLooksNonSemantic(input.path);
526
+ } else if (event.toolName === "bash") {
527
+ const command = (event.input as Record<string, unknown>)?.command;
528
+ looksLikeSemanticMiss = typeof command === "string" && commandLooksLikeSemanticCodeSearch(command);
529
+ }
530
+
531
+ if (!looksLikeSemanticMiss) return;
532
+ semanticMissCount += 1;
533
+ if (semanticMissCount >= 4) {
534
+ semanticMissCount = 0;
535
+ pi.sendMessage(
536
+ {
537
+ customType: "serena-reminder",
538
+ content:
539
+ "Reminder: Serena semantic tools are available for code-symbol work. If you are searching code for symbols, references, declarations, implementations, or refactor targets, switch to serena_find_symbol / serena_get_symbols_overview / serena_find_referencing_symbols instead of more grep/read calls. Normal read/search tools are still appropriate for Markdown, JSON/YAML, docs, and exact text edits.",
540
+ display: true,
541
+ },
542
+ { deliverAs: "steer", triggerTurn: true },
543
+ );
544
+ }
545
+ });
546
+
547
+ pi.on("session_shutdown", async (_event, ctx) => {
548
+ await worker?.stop();
549
+ worker = undefined;
550
+ ctx.ui.setStatus("serena", undefined);
551
+ });
552
+ }
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@bacnh85/pi-serena",
3
+ "version": "0.1.0",
4
+ "description": "Pi extension that provides Serena semantic code tools through a persistent worker.",
5
+ "license": "MIT",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "homepage": "https://github.com/bacnh85/skills#readme",
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/bacnh85/skills.git",
13
+ "directory": "extensions/pi-serena"
14
+ },
15
+ "bugs": {
16
+ "url": "https://github.com/bacnh85/skills/issues"
17
+ },
18
+ "keywords": [
19
+ "pi-package",
20
+ "pi-extension",
21
+ "serena",
22
+ "semantic-code"
23
+ ],
24
+ "files": [
25
+ "README.md",
26
+ "index.ts",
27
+ "worker.ts"
28
+ ],
29
+ "pi": {
30
+ "extensions": [
31
+ "."
32
+ ]
33
+ },
34
+ "peerDependencies": {
35
+ "@earendil-works/pi-coding-agent": "*",
36
+ "typebox": "*"
37
+ }
38
+ }
package/worker.ts ADDED
@@ -0,0 +1,347 @@
1
+ import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ export type SerenaWorkerResponse = {
7
+ id: string | null;
8
+ ok: boolean;
9
+ result?: string;
10
+ error?: string;
11
+ [key: string]: unknown;
12
+ };
13
+
14
+ type Pending = {
15
+ resolve: (value: SerenaWorkerResponse) => void;
16
+ reject: (error: Error) => void;
17
+ timer: NodeJS.Timeout;
18
+ };
19
+
20
+ function piConfigDirs(): string[] {
21
+ return process.env.PI_CODING_AGENT_DIR ? [process.env.PI_CODING_AGENT_DIR] : [path.join(os.homedir(), ".pi", "agent"), path.join(os.homedir(), ".pi", "agents")];
22
+ }
23
+
24
+ function loadEnv(cwd = process.cwd()): void {
25
+ for (const file of [path.resolve(cwd, ".env.local"), path.resolve(cwd, ".env"), ...piConfigDirs().flatMap((dir) => [path.join(dir, ".env.local"), path.join(dir, ".env")])]) {
26
+ let text: string;
27
+ try { text = fs.readFileSync(file, "utf8"); } catch (error: any) { if (error?.code === "ENOENT") continue; throw error; }
28
+ for (const rawLine of text.split(/\r?\n/)) {
29
+ const line = rawLine.trim();
30
+ if (!line || line.startsWith("#")) continue;
31
+ const match = line.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
32
+ if (!match || process.env[match[1]] !== undefined) continue;
33
+ process.env[match[1]] = match[2].trim().replace(/^[\'\"]|[\'\"]$/g, "");
34
+ }
35
+ }
36
+ }
37
+
38
+ const PYTHON_BRIDGE = String.raw`
39
+ from __future__ import annotations
40
+
41
+ import contextlib
42
+ import json
43
+ import os
44
+ import sys
45
+ import traceback
46
+ from typing import Any
47
+
48
+ os.environ.setdefault("SERENA_USAGE_REPORTING", "false")
49
+
50
+ try:
51
+ from serena.agent import SerenaAgent
52
+ from serena.config.context_mode import SerenaAgentContext
53
+ from serena.config.serena_config import SerenaConfig
54
+ except Exception as exc:
55
+ print(json.dumps({"id": None, "ok": False, "error": f"Failed to import Serena: {type(exc).__name__}: {exc}"}), flush=True)
56
+ raise
57
+
58
+ try:
59
+ from serena import serena_version
60
+ except Exception:
61
+ serena_version = lambda: "unknown" # type: ignore
62
+
63
+ agents: dict[str, SerenaAgent] = {}
64
+
65
+ def env_flag(name: str, default: bool) -> bool:
66
+ raw = os.environ.get(name)
67
+ if raw is None:
68
+ return default
69
+ return raw.strip().lower() in {"1", "true", "yes", "on"}
70
+
71
+
72
+ def load_bridge_config() -> SerenaConfig:
73
+ config = SerenaConfig.from_config_file()
74
+ # Pi keeps one worker per process/session. Keep dashboard available by default,
75
+ # but do not open browser tabs unless explicitly requested.
76
+ config.web_dashboard = env_flag("SERENA_BRIDGE_WEB_DASHBOARD", True)
77
+ config.web_dashboard_open_on_launch = env_flag("SERENA_BRIDGE_OPEN_DASHBOARD", False)
78
+ return config
79
+
80
+
81
+ def classify_error(message: str) -> str:
82
+ if "Tool named" in message and "not found" in message:
83
+ return "missing_tool"
84
+ if "not active" in message:
85
+ return "inactive_tool"
86
+ if "No active project" in message or "Project" in message and "not found" in message:
87
+ return "project_error"
88
+ if "Cannot extract symbols" in message or "Active languages" in message or "language server" in message or "LSP" in message:
89
+ return "language_server_error"
90
+ if "Timeout" in message or "timed out" in message:
91
+ return "timeout"
92
+ return "serena_error"
93
+
94
+ def respond(payload: dict[str, Any]) -> None:
95
+ print(json.dumps(payload, ensure_ascii=False), flush=True)
96
+
97
+
98
+ def agent_key(project: str, context: str) -> str:
99
+ return f"{os.path.abspath(project)}\0{context}"
100
+
101
+
102
+ def get_agent(project: str, context: str) -> SerenaAgent:
103
+ key = agent_key(project, context)
104
+ agent = agents.get(key)
105
+ if agent is None:
106
+ with contextlib.redirect_stdout(sys.stderr):
107
+ agent = SerenaAgent(project=project, context=SerenaAgentContext.load(context), serena_config=load_bridge_config())
108
+ agents[key] = agent
109
+ return agent
110
+
111
+
112
+ def close_agents() -> None:
113
+ for key, agent in list(agents.items()):
114
+ try:
115
+ agent.on_shutdown()
116
+ except Exception as exc:
117
+ print(f"Error shutting down Serena agent {key}: {exc}", file=sys.stderr, flush=True)
118
+ agents.clear()
119
+
120
+
121
+ def handle(req: dict[str, Any]) -> dict[str, Any]:
122
+ req_id = req.get("id")
123
+ action = req.get("action")
124
+
125
+ if action == "shutdown":
126
+ close_agents()
127
+ return {"id": req_id, "ok": True, "shutdown": True}
128
+
129
+ if action == "status":
130
+ project = str(req.get("project") or os.getcwd())
131
+ context = str(req.get("context") or "ide")
132
+ data: dict[str, Any] = {
133
+ "id": req_id,
134
+ "ok": True,
135
+ "version": serena_version(),
136
+ "pid": os.getpid(),
137
+ "cachedAgents": len(agents),
138
+ "project": os.path.abspath(project),
139
+ "context": context,
140
+ }
141
+ if req.get("includeAgent"):
142
+ agent = get_agent(project, context)
143
+ data["activeTools"] = agent.get_active_tool_names()
144
+ active_project = agent.get_active_project()
145
+ data["activeProject"] = str(active_project.project_root) if active_project else None
146
+ data["languageBackend"] = agent.get_language_backend().value
147
+ data["dashboardUrl"] = agent.get_dashboard_url()
148
+ return data
149
+
150
+ if action == "dashboard":
151
+ project = str(req.get("project") or os.getcwd())
152
+ context = str(req.get("context") or "ide")
153
+ agent = get_agent(project, context)
154
+ opened = bool(req.get("open")) and agent.open_dashboard()
155
+ return {"id": req_id, "ok": True, "opened": opened, "dashboardUrl": agent.get_dashboard_url()}
156
+
157
+ if action == "call":
158
+ project = str(req.get("project") or os.getcwd())
159
+ context = str(req.get("context") or "ide")
160
+ tool_name = req.get("tool")
161
+ params = req.get("params") or {}
162
+ if not isinstance(tool_name, str) or not tool_name:
163
+ raise ValueError("call request requires non-empty string field 'tool'")
164
+ if not isinstance(params, dict):
165
+ raise ValueError("call request field 'params' must be an object")
166
+ agent = get_agent(project, context)
167
+ with contextlib.redirect_stdout(sys.stderr):
168
+ result = agent.get_tool_by_name(tool_name).apply_ex(**params)
169
+ # Serena's apply_ex catches many tool failures and returns an "Error: ..." string.
170
+ if isinstance(result, str) and result.startswith("Error"):
171
+ return {"id": req_id, "ok": False, "tool": tool_name, "errorType": classify_error(result), "error": result, "result": result}
172
+ return {"id": req_id, "ok": True, "tool": tool_name, "result": result}
173
+
174
+ raise ValueError(f"Unknown action: {action}")
175
+
176
+
177
+ def main() -> int:
178
+ try:
179
+ for line in sys.stdin:
180
+ line = line.strip()
181
+ if not line:
182
+ continue
183
+ req_id = None
184
+ try:
185
+ req = json.loads(line)
186
+ if not isinstance(req, dict):
187
+ raise ValueError("request must be a JSON object")
188
+ req_id = req.get("id")
189
+ response = handle(req)
190
+ respond(response)
191
+ if req.get("action") == "shutdown":
192
+ return 0
193
+ except Exception as exc:
194
+ message = f"{type(exc).__name__}: {exc}"
195
+ print(traceback.format_exc(), file=sys.stderr, flush=True)
196
+ respond({"id": req_id, "ok": False, "errorType": classify_error(message), "error": message})
197
+ finally:
198
+ close_agents()
199
+ return 0
200
+
201
+ if __name__ == "__main__":
202
+ raise SystemExit(main())
203
+ `;
204
+
205
+ function runSync(command: string, args: string[]): string | undefined {
206
+ try {
207
+ const result = spawnSync(command, args, { encoding: "utf8" });
208
+ if (result.status === 0) return String(result.stdout).trim().replace(/\u001b\[[0-9;]*m/g, "");
209
+ } catch {
210
+ // ignore discovery failures
211
+ }
212
+ return undefined;
213
+ }
214
+
215
+ function serenaPythonCandidates(): string[] {
216
+ const candidates: string[] = [];
217
+ const configured = process.env.SERENA_PYTHON;
218
+ if (configured) candidates.push(configured);
219
+
220
+ const addToolDirCandidates = (toolDir: string | undefined) => {
221
+ if (!toolDir) return;
222
+ candidates.push(
223
+ path.join(toolDir, "serena-agent", "Scripts", "python.exe"),
224
+ path.join(toolDir, "serena-agent", "bin", "python"),
225
+ );
226
+ };
227
+
228
+ addToolDirCandidates(runSync("uv", ["tool", "dir"]));
229
+
230
+ const uvFromBash = runSync("bash", ["-lc", "command -v uv"]);
231
+ if (uvFromBash) addToolDirCandidates(runSync(uvFromBash, ["tool", "dir"]));
232
+
233
+ addToolDirCandidates(path.join(os.homedir(), ".local", "share", "uv", "tools"));
234
+ addToolDirCandidates(path.join(os.homedir(), "AppData", "Roaming", "uv", "tools"));
235
+
236
+ return [...new Set(candidates)];
237
+ }
238
+
239
+ function findSerenaPython(): string | undefined {
240
+ return serenaPythonCandidates().find((candidate) => fs.existsSync(candidate));
241
+ }
242
+
243
+ export class SerenaWorkerClient {
244
+ private process?: ChildProcessWithoutNullStreams;
245
+ private buffer = "";
246
+ private nextId = 1;
247
+ private pending = new Map<string, Pending>();
248
+ private generation = 0;
249
+
250
+ constructor(private readonly onStatus?: (text: string | undefined) => void) {}
251
+
252
+ async request(payload: Record<string, unknown>, timeoutMs = 120_000): Promise<SerenaWorkerResponse> {
253
+ this.ensureStarted();
254
+ const id = String(this.nextId++);
255
+ const request = { id, ...payload };
256
+ return new Promise((resolve, reject) => {
257
+ const timer = setTimeout(() => {
258
+ this.pending.delete(id);
259
+ reject(new Error(`Serena worker request timed out: ${payload.action ?? "unknown"}`));
260
+ }, timeoutMs);
261
+ this.pending.set(id, { resolve, reject, timer });
262
+ this.process!.stdin.write(`${JSON.stringify(request)}\n`);
263
+ });
264
+ }
265
+
266
+ async stop(): Promise<void> {
267
+ const proc = this.process;
268
+ if (!proc) return;
269
+ try {
270
+ await this.request({ action: "shutdown" }, 10_000).catch(() => undefined);
271
+ } finally {
272
+ proc.kill();
273
+ this.process = undefined;
274
+ this.onStatus?.(undefined);
275
+ }
276
+ }
277
+
278
+ restart(): void {
279
+ if (this.process) this.process.kill();
280
+ this.failAll(new Error("Serena worker restarted"));
281
+ this.process = undefined;
282
+ this.ensureStarted();
283
+ }
284
+
285
+ private ensureStarted(): void {
286
+ if (this.process && !this.process.killed) return;
287
+ loadEnv();
288
+ const python = findSerenaPython();
289
+ if (!python) {
290
+ const checked = serenaPythonCandidates().map((candidate) => `- ${candidate}`).join("\n") || "- none";
291
+ throw new Error(
292
+ `Could not find Serena Python. Install with: uv tool install -p 3.13 serena-agent && serena init, or set SERENA_PYTHON to the serena-agent Python executable. Checked:\n${checked}`
293
+ );
294
+ }
295
+
296
+ this.generation += 1;
297
+ const proc = spawn(python, ["-u", "-c", PYTHON_BRIDGE], {
298
+ env: { ...process.env, SERENA_USAGE_REPORTING: process.env.SERENA_USAGE_REPORTING ?? "false" },
299
+ stdio: "pipe",
300
+ });
301
+ this.process = proc;
302
+ this.buffer = "";
303
+ this.onStatus?.(`Serena worker pid ${proc.pid} gen ${this.generation}`);
304
+
305
+ proc.stdout.on("data", (chunk) => this.onStdout(String(chunk)));
306
+ proc.stderr.on("data", (chunk) => process.stderr.write(`[serena-worker] ${String(chunk)}`));
307
+ proc.on("exit", (code, signal) => {
308
+ if (this.process === proc) {
309
+ this.process = undefined;
310
+ this.onStatus?.(undefined);
311
+ }
312
+ this.failAll(new Error(`Serena worker exited code=${code} signal=${signal}`));
313
+ });
314
+ }
315
+
316
+ private onStdout(chunk: string): void {
317
+ this.buffer += chunk;
318
+ let index: number;
319
+ while ((index = this.buffer.indexOf("\n")) >= 0) {
320
+ const line = this.buffer.slice(0, index).trim();
321
+ this.buffer = this.buffer.slice(index + 1);
322
+ if (!line) continue;
323
+ let response: SerenaWorkerResponse;
324
+ try {
325
+ response = JSON.parse(line) as SerenaWorkerResponse;
326
+ } catch {
327
+ process.stderr.write(`[serena-worker] non-json stdout: ${line}\n`);
328
+ continue;
329
+ }
330
+ const id = response.id;
331
+ if (!id) continue;
332
+ const pending = this.pending.get(id);
333
+ if (!pending) continue;
334
+ this.pending.delete(id);
335
+ clearTimeout(pending.timer);
336
+ pending.resolve(response);
337
+ }
338
+ }
339
+
340
+ private failAll(error: Error): void {
341
+ for (const [id, pending] of this.pending) {
342
+ clearTimeout(pending.timer);
343
+ pending.reject(error);
344
+ this.pending.delete(id);
345
+ }
346
+ }
347
+ }