@d3ara1n/pi-session-namer 0.1.2 → 0.2.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/README.md CHANGED
@@ -47,11 +47,9 @@ Project-level `.pi/settings.json` overrides global settings.
47
47
 
48
48
  ## Dependencies
49
49
 
50
- - `@earendil-works/pi-ai` — `complete()` for LLM calls
51
- - `@earendil-works/pi-coding-agent` — Extension API
52
- - `@d3ara1n/pi-model-roles` — Model role resolution
50
+ - [`@d3ara1n/pi-model-roles`](../pi-model-roles)model role resolution
53
51
 
54
- ## Install
52
+ ## Installation
55
53
 
56
54
  Add to `~/.pi/agent/settings.json`:
57
55
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@d3ara1n/pi-session-namer",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "type": "module",
5
5
  "description": "Auto-name pi sessions using a cheap side agent — generates concise titles from the first user message",
6
6
  "main": "src/index.ts",
package/src/config.ts CHANGED
@@ -12,34 +12,33 @@ import type { SessionNamerConfig } from "./types.ts";
12
12
  import { DEFAULT_CONFIG } from "./types.ts";
13
13
 
14
14
  function getAgentDir(): string {
15
- const envDir = process.env.PI_AGENT_DIR;
16
- if (envDir) return envDir;
17
- return path.join(os.homedir(), ".pi", "agent");
15
+ const envDir = process.env.PI_AGENT_DIR;
16
+ if (envDir) return envDir;
17
+ return path.join(os.homedir(), ".pi", "agent");
18
18
  }
19
19
 
20
20
  function readSettingsFile(filePath: string): any {
21
- try {
22
- if (!fs.existsSync(filePath)) return {};
23
- const content = fs.readFileSync(filePath, "utf-8");
24
- const stripped = content.replace(/\/\/.*$/gm, "").replace(/\/\*[\s\S]*?\*\//g, "");
25
- return JSON.parse(stripped);
26
- } catch {
27
- return {};
28
- }
21
+ try {
22
+ if (!fs.existsSync(filePath)) return {};
23
+ const content = fs.readFileSync(filePath, "utf-8");
24
+ return JSON.parse(content);
25
+ } catch {
26
+ return {};
27
+ }
29
28
  }
30
29
 
31
30
  function merge(target: any, source: any): any {
32
- if (!source || typeof source !== "object") return target;
33
- if (!target || typeof target !== "object") return source;
34
- const result = { ...target };
35
- for (const key of Object.keys(source)) {
36
- if (source[key] && typeof source[key] === "object" && !Array.isArray(source[key])) {
37
- result[key] = merge(result[key], source[key]);
38
- } else {
39
- result[key] = source[key];
40
- }
41
- }
42
- return result;
31
+ if (!source || typeof source !== "object") return target;
32
+ if (!target || typeof target !== "object") return source;
33
+ const result = { ...target };
34
+ for (const key of Object.keys(source)) {
35
+ if (source[key] && typeof source[key] === "object" && !Array.isArray(source[key])) {
36
+ result[key] = merge(result[key], source[key]);
37
+ } else {
38
+ result[key] = source[key];
39
+ }
40
+ }
41
+ return result;
43
42
  }
44
43
 
45
44
  /**
@@ -47,18 +46,16 @@ function merge(target: any, source: any): any {
47
46
  * @param cwd - Project working directory
48
47
  */
49
48
  export function loadNamerConfig(cwd?: string): SessionNamerConfig {
50
- const globalSettings = readSettingsFile(path.join(getAgentDir(), "settings.json"));
51
- const projectSettings = cwd
52
- ? readSettingsFile(path.join(cwd, ".pi", "settings.json"))
53
- : {};
54
- const settings = merge(globalSettings, projectSettings);
49
+ const globalSettings = readSettingsFile(path.join(getAgentDir(), "settings.json"));
50
+ const projectSettings = cwd ? readSettingsFile(path.join(cwd, ".pi", "settings.json")) : {};
51
+ const settings = merge(globalSettings, projectSettings);
55
52
 
56
- const raw = settings?.sessionNamer;
57
- if (!raw) return DEFAULT_CONFIG;
53
+ const raw = settings?.sessionNamer;
54
+ if (!raw) return DEFAULT_CONFIG;
58
55
 
59
- return {
60
- enabled: raw.enabled ?? DEFAULT_CONFIG.enabled,
61
- sideAgentRole: raw.sideAgentRole ?? DEFAULT_CONFIG.sideAgentRole,
62
- maxLength: raw.maxLength ?? DEFAULT_CONFIG.maxLength,
63
- };
56
+ return {
57
+ enabled: raw.enabled ?? DEFAULT_CONFIG.enabled,
58
+ sideAgentRole: raw.sideAgentRole ?? DEFAULT_CONFIG.sideAgentRole,
59
+ maxLength: raw.maxLength ?? DEFAULT_CONFIG.maxLength,
60
+ };
64
61
  }
package/src/index.ts CHANGED
@@ -8,136 +8,141 @@
8
8
 
9
9
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
10
10
  import { getModelRolesAPI } from "@d3ara1n/pi-model-roles";
11
+ import type { ModelRolesAPI } from "@d3ara1n/pi-model-roles";
11
12
  import { DEFAULT_CONFIG } from "./types.ts";
12
13
  import type { SessionNamerConfig } from "./types.ts";
13
14
  import { loadNamerConfig } from "./config.ts";
14
15
  import { generateSessionName } from "./namer.ts";
15
16
 
16
17
  export default function sessionNamerExtension(pi: ExtensionAPI) {
17
- let config: SessionNamerConfig = DEFAULT_CONFIG;
18
- let hasNamed = false;
19
- let lastPrompt = "";
20
-
21
- // ── session_start: load config, reset flag ──────────────────────
22
- pi.on("session_start", async (_event, _ctx) => {
23
- config = loadNamerConfig(_ctx.cwd);
24
- hasNamed = false;
25
- lastPrompt = "";
26
-
27
- // If the session already has a name (resume/fork/user-set), don't auto-name
28
- const existingName = pi.getSessionName();
29
- if (existingName) {
30
- hasNamed = true;
31
- }
32
- });
33
-
34
- // ── before_agent_start: auto-name on first prompt ───────────────
35
- pi.on("before_agent_start", async (event, ctx) => {
36
- // Always cache the latest prompt for /namer:rename, even if auto-naming is done
37
- lastPrompt = event.prompt;
38
-
39
- if (!config.enabled || hasNamed) return;
40
-
41
- // Skip empty prompts (e.g. image-only messages)
42
- if (!event.prompt?.trim()) return;
43
-
44
- // Mark as handled (no retry regardless of subsequent success/failure)
45
- hasNamed = true;
46
-
47
- // Name asynchronously so we don't block the main agent startup
48
- (async () => {
49
- let rolesApi;
50
- try {
51
- rolesApi = getModelRolesAPI();
52
- } catch {
53
- console.warn("[pi-session-namer] pi-model-roles not initialized — skipping");
54
- return;
55
- }
56
-
57
- const resolved = await rolesApi.resolveRoleAsync(config.sideAgentRole);
58
- if (!resolved.model) {
59
- console.warn(`[pi-session-namer] Side agent role "${config.sideAgentRole}" not available — skipping`);
60
- return;
61
- }
62
-
63
- const name = await generateSessionName(
64
- resolved.model,
65
- resolved.apiKey,
66
- resolved.headers,
67
- config,
68
- event.prompt,
69
- );
70
-
71
- pi.setSessionName(name);
72
- })().catch((err) => console.warn("[pi-session-namer] naming failed:", err));
73
- });
74
-
75
- // ── /namer — show status ────────────────────────────────────────
76
- pi.registerCommand("namer", {
77
- description: "Show session namer status and config",
78
- handler: async (args, ctx) => {
79
- const value = (args ?? "").trim().toLowerCase();
80
-
81
- // Handle on/off toggles
82
- if (value === "on") {
83
- config.enabled = true;
84
- ctx.ui.notify("Session Namer: enabled", "info");
85
- return;
86
- }
87
- if (value === "off") {
88
- config.enabled = false;
89
- ctx.ui.notify("Session Namer: disabled", "info");
90
- return;
91
- }
92
-
93
- const currentName = pi.getSessionName();
94
- const lines = [
95
- `Session Namer: ${config.enabled ? "enabled" : "disabled"}`,
96
- `Side agent role: ${config.sideAgentRole}`,
97
- `Max length: ${config.maxLength}`,
98
- `Current name: ${currentName ?? "(none)"}`,
99
- `Has auto-named: ${hasNamed}`,
100
- ];
101
- ctx.ui.notify(lines.join("\n"), "info");
102
- },
103
- });
104
-
105
- // ── /namer:rename — force regenerate ────────────────────────────
106
- pi.registerCommand("namer:rename", {
107
- description: "Regenerate session name from the last user prompt",
108
- handler: async (_args, ctx) => {
109
- if (!lastPrompt?.trim()) {
110
- ctx.ui.notify("No user prompt available to generate a name from.", "warning");
111
- return;
112
- }
113
-
114
- let rolesApi;
115
- try {
116
- rolesApi = getModelRolesAPI();
117
- } catch {
118
- ctx.ui.notify("pi-model-roles not initialized. Cannot rename.", "error");
119
- return;
120
- }
121
-
122
- const resolved = await rolesApi.resolveRoleAsync(config.sideAgentRole);
123
- if (!resolved.model) {
124
- ctx.ui.notify(
125
- `Side agent role "${config.sideAgentRole}" not available. Cannot rename.`,
126
- "error",
127
- );
128
- return;
129
- }
130
-
131
- const name = await generateSessionName(
132
- resolved.model,
133
- resolved.apiKey,
134
- resolved.headers,
135
- config,
136
- lastPrompt,
137
- );
138
-
139
- pi.setSessionName(name);
140
- ctx.ui.notify(`Session renamed: ${name}`, "info");
141
- },
142
- });
18
+ let config: SessionNamerConfig = DEFAULT_CONFIG;
19
+ let hasNamed = false;
20
+ let lastPrompt = "";
21
+
22
+ // ── session_start: load config, reset flag ──────────────────────
23
+ pi.on("session_start", async (_event, _ctx) => {
24
+ if (!_ctx.hasUI) return;
25
+ config = loadNamerConfig(_ctx.cwd);
26
+ hasNamed = false;
27
+ lastPrompt = "";
28
+
29
+ // If the session already has a name (resume/fork/user-set), don't auto-name
30
+ const existingName = pi.getSessionName();
31
+ if (existingName) {
32
+ hasNamed = true;
33
+ }
34
+ });
35
+
36
+ // ── before_agent_start: auto-name on first prompt ───────────────
37
+ pi.on("before_agent_start", async (event, ctx) => {
38
+ if (!ctx.hasUI) return;
39
+ // Always cache the latest prompt for /namer:rename, even if auto-naming is done
40
+ lastPrompt = event.prompt;
41
+
42
+ if (!config.enabled || hasNamed) return;
43
+
44
+ // Skip empty prompts (e.g. image-only messages)
45
+ if (!event.prompt?.trim()) return;
46
+
47
+ // Mark as handled (no retry regardless of subsequent success/failure)
48
+ hasNamed = true;
49
+
50
+ // Name asynchronously so we don't block the main agent startup
51
+ (async () => {
52
+ let rolesApi: ModelRolesAPI;
53
+ try {
54
+ rolesApi = getModelRolesAPI();
55
+ } catch {
56
+ console.warn("[pi-session-namer] pi-model-roles not initialized — skipping");
57
+ return;
58
+ }
59
+
60
+ const resolved = await rolesApi.resolveRoleAsync(config.sideAgentRole);
61
+ if (!resolved.model) {
62
+ console.warn(
63
+ `[pi-session-namer] Side agent role "${config.sideAgentRole}" not available — skipping`,
64
+ );
65
+ return;
66
+ }
67
+
68
+ const name = await generateSessionName(
69
+ resolved.model,
70
+ resolved.apiKey,
71
+ resolved.headers,
72
+ config,
73
+ event.prompt,
74
+ );
75
+
76
+ pi.setSessionName(name);
77
+ })().catch((err) => console.warn("[pi-session-namer] naming failed:", err));
78
+ });
79
+
80
+ // ── /namer show status ────────────────────────────────────────
81
+ pi.registerCommand("namer", {
82
+ description: "Show session namer status and config",
83
+ handler: async (args, ctx) => {
84
+ const value = (args ?? "").trim().toLowerCase();
85
+
86
+ // Handle on/off toggles
87
+ if (value === "on") {
88
+ config.enabled = true;
89
+ ctx.ui.notify("Session Namer: enabled", "info");
90
+ return;
91
+ }
92
+ if (value === "off") {
93
+ config.enabled = false;
94
+ ctx.ui.notify("Session Namer: disabled", "info");
95
+ return;
96
+ }
97
+
98
+ const currentName = pi.getSessionName();
99
+ const lines = [
100
+ `Session Namer: ${config.enabled ? "enabled" : "disabled"}`,
101
+ `Side agent role: ${config.sideAgentRole}`,
102
+ `Max length: ${config.maxLength}`,
103
+ `Current name: ${currentName ?? "(none)"}`,
104
+ `Has auto-named: ${hasNamed}`,
105
+ ];
106
+ ctx.ui.notify(lines.join("\n"), "info");
107
+ },
108
+ });
109
+
110
+ // ── /namer:rename — force regenerate ────────────────────────────
111
+ pi.registerCommand("namer:rename", {
112
+ description: "Regenerate session name from the last user prompt",
113
+ handler: async (_args, ctx) => {
114
+ if (!lastPrompt?.trim()) {
115
+ ctx.ui.notify("No user prompt available to generate a name from.", "warning");
116
+ return;
117
+ }
118
+
119
+ let rolesApi: ModelRolesAPI;
120
+ try {
121
+ rolesApi = getModelRolesAPI();
122
+ } catch {
123
+ ctx.ui.notify("pi-model-roles not initialized. Cannot rename.", "error");
124
+ return;
125
+ }
126
+
127
+ const resolved = await rolesApi.resolveRoleAsync(config.sideAgentRole);
128
+ if (!resolved.model) {
129
+ ctx.ui.notify(
130
+ `Side agent role "${config.sideAgentRole}" not available. Cannot rename.`,
131
+ "error",
132
+ );
133
+ return;
134
+ }
135
+
136
+ const name = await generateSessionName(
137
+ resolved.model,
138
+ resolved.apiKey,
139
+ resolved.headers,
140
+ config,
141
+ lastPrompt,
142
+ );
143
+
144
+ pi.setSessionName(name);
145
+ ctx.ui.notify(`Session renamed: ${name}`, "info");
146
+ },
147
+ });
143
148
  }
package/src/namer.ts CHANGED
@@ -12,85 +12,88 @@ import type { SessionNamerConfig } from "./types.ts";
12
12
  * Build the system prompt for the naming side agent.
13
13
  */
14
14
  export function buildNamerSystemPrompt(maxLength: number): string {
15
- return [
16
- `You are a session naming assistant. Generate a concise title for a coding session based on the user's first message.`,
17
- ``,
18
- `Rules:`,
19
- `- Output in the SAME language as the user's message`,
20
- `- Maximum ${maxLength} characters`,
21
- `- Output ONLY the title, no quotes, no prefix, no explanation`,
22
- `- Summarize intent, do not copy the original message verbatim`,
23
- `- If the message mentions specific files, modules, or functions, keep those names`,
24
- `- Be specific: "Fix auth token refresh bug" is better than "Fix a bug"`,
25
- ].join("\n");
15
+ return [
16
+ `You are a session naming assistant. Generate a concise title for a coding session based on the user's first message.`,
17
+ ``,
18
+ `Rules:`,
19
+ `- Output in the SAME language as the user's message`,
20
+ `- Maximum ${maxLength} characters`,
21
+ `- Output ONLY the title, no quotes, no prefix, no explanation`,
22
+ `- Summarize intent, do not copy the original message verbatim`,
23
+ `- If the message mentions specific files, modules, or functions, keep those names`,
24
+ `- Be specific: "Fix auth token refresh bug" is better than "Fix a bug"`,
25
+ ].join("\n");
26
26
  }
27
27
 
28
28
  /**
29
29
  * Call the side agent to generate a session name.
30
30
  */
31
31
  export async function generateSessionName(
32
- sideModel: any,
33
- apiKey: string | undefined,
34
- headers: Record<string, string> | undefined,
35
- config: SessionNamerConfig,
36
- userPrompt: string,
32
+ sideModel: any,
33
+ apiKey: string | undefined,
34
+ headers: Record<string, string> | undefined,
35
+ config: SessionNamerConfig,
36
+ userPrompt: string,
37
37
  ): Promise<string> {
38
- const systemPrompt = buildNamerSystemPrompt(config.maxLength);
38
+ const systemPrompt = buildNamerSystemPrompt(config.maxLength);
39
39
 
40
- // Truncate very long prompts to avoid wasting tokens
41
- const truncatedPrompt = userPrompt.length > 2000
42
- ? userPrompt.slice(0, 2000) + "..."
43
- : userPrompt;
40
+ // Truncate very long prompts to avoid wasting tokens
41
+ const truncatedPrompt = userPrompt.length > 2000 ? userPrompt.slice(0, 2000) + "..." : userPrompt;
44
42
 
45
- const options: Record<string, any> = {
46
- maxTokens: 64,
47
- };
43
+ const options: Record<string, any> = {
44
+ maxTokens: 64,
45
+ };
48
46
 
49
- if (apiKey) options.apiKey = apiKey;
50
- if (headers) options.headers = headers;
47
+ if (apiKey) options.apiKey = apiKey;
48
+ if (headers) options.headers = headers;
51
49
 
52
- try {
53
- const result = await complete(sideModel, {
54
- systemPrompt,
55
- messages: [{ role: "user", content: truncatedPrompt, timestamp: Date.now() }],
56
- }, options);
50
+ try {
51
+ const result = await complete(
52
+ sideModel,
53
+ {
54
+ systemPrompt,
55
+ messages: [{ role: "user", content: truncatedPrompt, timestamp: Date.now() }],
56
+ },
57
+ options,
58
+ );
57
59
 
58
- const raw = result.content
59
- ?.filter((block: any) => block.type === "text")
60
- ?.map((block: any) => block.text)
61
- ?.join("")
62
- ?.trim() ?? "";
60
+ const raw =
61
+ result.content
62
+ ?.filter((block: any) => block.type === "text")
63
+ ?.map((block: any) => block.text)
64
+ ?.join("")
65
+ ?.trim() ?? "";
63
66
 
64
- return cleanSessionName(raw, config.maxLength);
65
- } catch (err) {
66
- console.warn("[pi-session-namer] Side agent call failed:", err);
67
- // Fallback: truncate user prompt as name
68
- return userPrompt.slice(0, config.maxLength).replace(/\n/g, " ").trim();
69
- }
67
+ return cleanSessionName(raw, config.maxLength);
68
+ } catch (err) {
69
+ console.warn("[pi-session-namer] Side agent call failed:", err);
70
+ // Fallback: truncate user prompt as name
71
+ return userPrompt.slice(0, config.maxLength).replace(/\n/g, " ").trim();
72
+ }
70
73
  }
71
74
 
72
75
  /**
73
76
  * Clean and truncate the generated name.
74
77
  */
75
78
  function cleanSessionName(raw: string, maxLength: number): string {
76
- let name = raw.trim();
79
+ let name = raw.trim();
77
80
 
78
- // Strip surrounding quotes if present
79
- if (
80
- (name.startsWith('"') && name.endsWith('"')) ||
81
- (name.startsWith("'") && name.endsWith("'")) ||
82
- (name.startsWith("「") && name.endsWith("」"))
83
- ) {
84
- name = name.slice(1, -1);
85
- }
81
+ // Strip surrounding quotes if present
82
+ if (
83
+ (name.startsWith('"') && name.endsWith('"')) ||
84
+ (name.startsWith("'") && name.endsWith("'")) ||
85
+ (name.startsWith("「") && name.endsWith("」"))
86
+ ) {
87
+ name = name.slice(1, -1);
88
+ }
86
89
 
87
- // Remove newlines
88
- name = name.replace(/\n/g, " ").trim();
90
+ // Remove newlines
91
+ name = name.replace(/\n/g, " ").trim();
89
92
 
90
- // Truncate
91
- if (name.length > maxLength) {
92
- name = name.slice(0, maxLength - 3) + "...";
93
- }
93
+ // Truncate
94
+ if (name.length > maxLength) {
95
+ name = name.slice(0, maxLength - 3) + "...";
96
+ }
94
97
 
95
- return name || "New session";
98
+ return name || "New session";
96
99
  }