@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.
- package/LICENSE +21 -0
- package/README.md +45 -0
- package/package.json +32 -0
- 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
|
+
}
|