@pi-archimedes/session-name 2.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/LICENSE +21 -0
  2. package/README.md +45 -0
  3. package/package.json +32 -0
  4. package/src/index.ts +260 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Daniel Cherubini and contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,45 @@
1
+ # @pi-archimedes/session-name
2
+
3
+ Auto session naming for the [Pi coding agent](https://github.com/earendil-works/pi).
4
+
5
+ Automatically generates concise, descriptive session titles using AI after each conversation — so your sessions are easy to find and resume later. Skips sessions already named via `--name` or `/name`, and silently handles errors.
6
+
7
+ ## What you get
8
+
9
+ - **Automatic naming** — fires on `agent_end` to generate a title from the first user/assistant exchange
10
+ - **Smart model resolution** — supports canonical `provider/id`, bare `id`, and thinking-suffix tolerance
11
+ - **Respects manual names** — skips sessions already named via `--name` or `/name`
12
+ - **Ephemeral-aware** — only names sessions with a session file (skips temporary sessions)
13
+ - **Race-safe** — re-checks session name before setting to avoid overwriting manual changes
14
+ - **Silent failures** — any error (auth, network, etc.) is caught and ignored
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ pi install npm:@pi-archimedes/session-name
20
+ ```
21
+
22
+ Or install full meta package:
23
+
24
+ ```bash
25
+ pi install npm:pi-archimedes
26
+ ```
27
+
28
+ ## Usage
29
+
30
+ After a conversation ends (`agent_end`), the extension extracts the first user message and assistant response, sends them to the AI with a title-generation prompt, and sets the session name via `pi.setSessionName()`. The title is limited to 80 characters and stripped of surrounding quotes.
31
+
32
+ ## Settings
33
+
34
+ | Setting | Type | Default | Description |
35
+ |---------|------|---------|-------------|
36
+ | `enabled` | bool | `true` | Enable automatic session naming |
37
+ | `model` | string | _(current model)_ | Override model for title generation (e.g., `openai/gpt-4o-mini`) |
38
+
39
+ Settings are stored in `~/.pi/agent/settings.json` under the `archimedes.sessionName` namespace.
40
+
41
+ ## Integration
42
+
43
+ When installed via `pi-archimedes` (the meta package), the session-name package is automatically registered. Standalone installs work independently — it listens for `agent_end` events and uses the Pi extension API to set session names.
44
+
45
+ ← Back to [pi-archimedes](../../README.md)
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@pi-archimedes/session-name",
3
+ "version": "2.1.0",
4
+ "type": "module",
5
+ "keywords": [
6
+ "pi-package"
7
+ ],
8
+ "description": "Auto-generate session names after the first exchange",
9
+ "files": [
10
+ "src"
11
+ ],
12
+ "main": "./src/index.ts",
13
+ "exports": {
14
+ ".": "./src/index.ts"
15
+ },
16
+ "dependencies": {
17
+ "@pi-archimedes/core": "2.1.0"
18
+ },
19
+ "peerDependencies": {
20
+ "@earendil-works/pi-coding-agent": ">=0.1.0",
21
+ "@earendil-works/pi-ai": ">=0.1.0",
22
+ "@earendil-works/pi-tui": ">=0.1.0"
23
+ },
24
+ "devDependencies": {
25
+ "typescript": "^6.0.0"
26
+ },
27
+ "pi": {
28
+ "extensions": [
29
+ "./src/index.ts"
30
+ ]
31
+ }
32
+ }
package/src/index.ts ADDED
@@ -0,0 +1,260 @@
1
+ import { complete } from "@earendil-works/pi-ai";
2
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { loadConfig } from "@pi-archimedes/core/settings-io";
4
+ import type { SettingItem } from "@earendil-works/pi-tui";
5
+
6
+ // ── Config ──────────────────────────────────────────────────────────────────
7
+
8
+ export interface SessionNameSettings {
9
+ enabled?: boolean | undefined;
10
+ model?: string | undefined;
11
+ }
12
+
13
+ const DEFAULT_SESSION_NAME_CONFIG: SessionNameSettings = {
14
+ enabled: true,
15
+ model: undefined,
16
+ };
17
+
18
+ const NAMESPACE = "archimedes.sessionName";
19
+
20
+ export function loadSessionNameConfig(): SessionNameSettings {
21
+ return loadConfig(NAMESPACE, DEFAULT_SESSION_NAME_CONFIG);
22
+ }
23
+
24
+ // ── Model resolution ────────────────────────────────────────────────────────
25
+
26
+ /**
27
+ * Find a model by reference string.
28
+ *
29
+ * Resolution order:
30
+ * 1. Canonical "provider/id" (case-insensitive)
31
+ * 2. Bare "id" (case-insensitive) — only if unique across providers
32
+ * 3. Thinking-suffix tolerance: strip everything after the last colon and retry
33
+ */
34
+ function findMatch<T extends { provider: string; id: string }>(
35
+ ref: string,
36
+ models: readonly T[],
37
+ ): T | undefined {
38
+ const lower = ref.toLowerCase();
39
+ if (!lower) return undefined;
40
+
41
+ // 1. Canonical provider/id match
42
+ const canonical = models.find(
43
+ (m) => `${m.provider}/${m.id}`.toLowerCase() === lower,
44
+ );
45
+ if (canonical) return canonical;
46
+
47
+ // 2. Bare id match — must be unique
48
+ const idMatches = models.filter((m) => m.id.toLowerCase() === lower);
49
+ if (idMatches.length === 1) return idMatches[0];
50
+
51
+ // 3. Thinking-suffix tolerance: strip after last colon and retry
52
+ if (ref.includes(":")) {
53
+ const prefix = ref.slice(0, ref.lastIndexOf(":"));
54
+ if (prefix) return findMatch(prefix, models);
55
+ }
56
+
57
+ return undefined;
58
+ }
59
+
60
+ /**
61
+ * Resolve a model reference against the available models.
62
+ * Returns the matched model or undefined if no match found.
63
+ */
64
+ export function resolveModel<T extends { provider: string; id: string }>(
65
+ modelRef: string | undefined,
66
+ models: readonly T[],
67
+ ): T | undefined {
68
+ if (!modelRef || !modelRef.trim()) return undefined;
69
+ return findMatch(modelRef.trim(), models);
70
+ }
71
+
72
+ // ── Title generation (runs in background) ───────────────────────────────────
73
+
74
+ /**
75
+ * Generate and set a session title. Runs asynchronously without blocking
76
+ * the agent_end handler so the UI stays responsive.
77
+ */
78
+ async function generateTitle(
79
+ pi: ExtensionAPI,
80
+ ctx: ExtensionContext,
81
+ onSuccess: () => void,
82
+ onFailure: () => void,
83
+ ) {
84
+ try {
85
+ const settings = loadSessionNameConfig();
86
+
87
+ // Guard: feature disabled
88
+ if (!settings.enabled) return;
89
+
90
+ // 1. Build conversation text — first user + assistant exchange only
91
+ const branch = ctx.sessionManager.getBranch();
92
+ const userLines: string[] = [];
93
+ const assistantLines: string[] = [];
94
+ let foundUser = false;
95
+ let foundAssistant = false;
96
+
97
+ for (const entry of branch) {
98
+ if (entry.type !== "message") continue;
99
+ const msg = entry.message;
100
+ if (msg.role !== "user" && msg.role !== "assistant") continue;
101
+
102
+ const content = msg.content;
103
+
104
+ if (msg.role === "user" && !foundUser) {
105
+ const texts = typeof content === "string"
106
+ ? [content]
107
+ : Array.isArray(content)
108
+ ? content
109
+ .filter((b: any) => b?.type === "text" && typeof b.text === "string")
110
+ .map((b: any) => b.text)
111
+ : [];
112
+ if (texts.length > 0) {
113
+ const userText = texts.join("\n").trim().slice(0, 500);
114
+ userLines.push("User: " + userText);
115
+ foundUser = true;
116
+ }
117
+ }
118
+
119
+ if (msg.role === "assistant" && !foundAssistant && foundUser) {
120
+ const texts = typeof content === "string"
121
+ ? [content]
122
+ : Array.isArray(content)
123
+ ? content
124
+ .filter((b: any) => b?.type === "text" && typeof b.text === "string")
125
+ .map((b: any) => b.text)
126
+ : [];
127
+ if (texts.length > 0) {
128
+ const assistantText = texts.join("\n").trim().slice(0, 500);
129
+ assistantLines.push("Assistant: " + assistantText);
130
+ foundAssistant = true;
131
+ }
132
+ }
133
+
134
+ if (foundUser && foundAssistant) break;
135
+ }
136
+
137
+ const conversationText = [...userLines, ...assistantLines].join("\n");
138
+ if (!conversationText.trim()) return;
139
+
140
+ // 2. Build title prompt
141
+ const titlePrompt = [
142
+ "Generate a concise title (3-8 words) for this conversation.",
143
+ "The title should capture what the user is working on.",
144
+ "Return only the title, nothing else.",
145
+ "",
146
+ "<conversation>",
147
+ conversationText,
148
+ "</conversation>",
149
+ ].join("\n");
150
+
151
+ // 3. Resolve model
152
+ const settingsModel = resolveModel(settings.model, ctx.modelRegistry.getAll());
153
+ const model = settingsModel ?? ctx.model;
154
+ if (!model) return;
155
+
156
+ // 4. Check auth
157
+ if (!ctx.modelRegistry.hasConfiguredAuth(model)) return;
158
+
159
+ // 5. Get API key and headers
160
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
161
+ if (!auth.ok) return;
162
+
163
+ // 6. Make API call
164
+ const opts: { reasoning: "minimal"; cacheRetention: "none"; sessionId: string; apiKey?: string; headers?: Record<string, string> } = {
165
+ reasoning: "minimal",
166
+ cacheRetention: "none",
167
+ sessionId: crypto.randomUUID(),
168
+ };
169
+ if (auth.apiKey) opts.apiKey = auth.apiKey;
170
+ if (auth.headers) opts.headers = auth.headers;
171
+
172
+ const response = await complete(model, {
173
+ messages: [
174
+ {
175
+ role: "user" as const,
176
+ content: [{ type: "text" as const, text: titlePrompt }],
177
+ timestamp: Date.now(),
178
+ },
179
+ ],
180
+ }, opts);
181
+
182
+ // 7. Extract and clean title
183
+ const title = response.content
184
+ .filter((c: any): c is { type: "text"; text: string } => c.type === "text")
185
+ .map((c) => c.text)
186
+ .join("\n")
187
+ .replace(/\s+/g, " ")
188
+ .trim()
189
+ .replace(/^(\"|')((?:(?!\1).)*)\1$/, "$2")
190
+ .slice(0, 80);
191
+
192
+ if (!title) {
193
+ onFailure();
194
+ return;
195
+ }
196
+
197
+ // 8. Race guard — re-check before setting
198
+ if (pi.getSessionName()) return;
199
+
200
+ // 9. Set session name
201
+ pi.setSessionName(title);
202
+ onSuccess();
203
+ } catch (e) {
204
+ console.error("[archimedes] session-name failed:", e);
205
+ onFailure();
206
+ }
207
+ }
208
+
209
+ // ── Registration ────────────────────────────────────────────────────────────
210
+
211
+ export function registerSessionName(pi: ExtensionAPI) {
212
+ let hasNamed = false;
213
+ let failCount = 0;
214
+
215
+ pi.on("session_start", () => {
216
+ hasNamed = false;
217
+ failCount = 0;
218
+ });
219
+
220
+ pi.on("agent_end", async (_event, ctx: ExtensionContext) => {
221
+ // Guard: already named this session
222
+ if (hasNamed) return;
223
+
224
+ // Guard: too many transient failures — stop trying
225
+ if (failCount >= 3) return;
226
+
227
+ // Guard: session already named via --name or /name
228
+ if (pi.getSessionName()) return;
229
+
230
+ // Guard: skip ephemeral sessions (no session file)
231
+ if (!ctx.sessionManager.getSessionFile()) return;
232
+
233
+ // Fire-and-forget: spawn title generation in background so handler
234
+ // returns immediately and the UI becomes responsive.
235
+ void generateTitle(pi, ctx, () => { hasNamed = true; }, () => { failCount++; });
236
+ });
237
+ }
238
+
239
+ export default registerSessionName;
240
+
241
+ // ── Settings UI ─────────────────────────────────────────────────────────────
242
+
243
+ /** Build settings UI items for the session-name package. */
244
+ export function getSessionNameSettingsItems(config: SessionNameSettings): SettingItem[] {
245
+ return [
246
+ {
247
+ id: "sessionNameEnabled",
248
+ label: "Auto session naming",
249
+ description: "Auto-generate session titles after the first exchange",
250
+ currentValue: config.enabled ? "On" : "Off",
251
+ values: ["On", "Off"],
252
+ },
253
+ {
254
+ id: "sessionNameModel",
255
+ label: "Model for naming",
256
+ description: "Model used for title generation (leave empty for current model)",
257
+ currentValue: config.model || "(current model)",
258
+ },
259
+ ];
260
+ }