@d3ara1n/pi-session-namer 0.1.3 → 0.2.1
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 +1 -1
- package/package.json +1 -1
- package/src/config.ts +24 -36
- package/src/index.ts +154 -126
- package/src/namer.ts +59 -62
package/README.md
CHANGED
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -12,52 +12,40 @@ import type { SessionNamerConfig } from "./types.ts";
|
|
|
12
12
|
import { DEFAULT_CONFIG } from "./types.ts";
|
|
13
13
|
|
|
14
14
|
function getAgentDir(): string {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}
|
|
21
|
+
try {
|
|
22
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
23
|
+
return JSON.parse(content);
|
|
24
|
+
} catch {
|
|
25
|
+
return {};
|
|
26
|
+
}
|
|
28
27
|
}
|
|
29
28
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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;
|
|
29
|
+
/** Read the `sessionNamer` block from a settings file. */
|
|
30
|
+
function readNamer(filePath: string): Record<string, any> | undefined {
|
|
31
|
+
const raw = readSettingsFile(filePath)?.sessionNamer;
|
|
32
|
+
return raw && typeof raw === "object" ? raw : undefined;
|
|
42
33
|
}
|
|
43
34
|
|
|
44
35
|
/**
|
|
45
|
-
* Load session-namer config
|
|
36
|
+
* Load session-namer config. Project overrides global wholesale; per-field
|
|
37
|
+
* `?? DEFAULT` fills any gap. (No field-level merge — project replaces global.)
|
|
46
38
|
* @param cwd - Project working directory
|
|
47
39
|
*/
|
|
48
40
|
export function loadNamerConfig(cwd?: string): SessionNamerConfig {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const settings = merge(globalSettings, projectSettings);
|
|
41
|
+
const globalRaw = readNamer(path.join(getAgentDir(), "settings.json"));
|
|
42
|
+
const projectRaw = cwd ? readNamer(path.join(cwd, ".pi", "settings.json")) : undefined;
|
|
43
|
+
const raw = projectRaw ?? globalRaw;
|
|
44
|
+
if (!raw) return DEFAULT_CONFIG;
|
|
54
45
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
sideAgentRole: raw.sideAgentRole ?? DEFAULT_CONFIG.sideAgentRole,
|
|
61
|
-
maxLength: raw.maxLength ?? DEFAULT_CONFIG.maxLength,
|
|
62
|
-
};
|
|
46
|
+
return {
|
|
47
|
+
enabled: raw.enabled ?? DEFAULT_CONFIG.enabled,
|
|
48
|
+
sideAgentRole: raw.sideAgentRole ?? DEFAULT_CONFIG.sideAgentRole,
|
|
49
|
+
maxLength: raw.maxLength ?? DEFAULT_CONFIG.maxLength,
|
|
50
|
+
};
|
|
63
51
|
}
|
package/src/index.ts
CHANGED
|
@@ -8,136 +8,164 @@
|
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
18
|
+
let config: SessionNamerConfig = DEFAULT_CONFIG;
|
|
19
|
+
let hasNamed = false;
|
|
20
|
+
|
|
21
|
+
// ── session_start: load config, reset flag ──────────────────────
|
|
22
|
+
pi.on("session_start", async (_event, _ctx) => {
|
|
23
|
+
if (!_ctx.hasUI) return;
|
|
24
|
+
config = loadNamerConfig(_ctx.cwd);
|
|
25
|
+
hasNamed = false;
|
|
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
|
+
if (!ctx.hasUI) return;
|
|
37
|
+
|
|
38
|
+
if (!config.enabled || hasNamed) return;
|
|
39
|
+
|
|
40
|
+
// Skip empty prompts (e.g. image-only messages)
|
|
41
|
+
if (!event.prompt?.trim()) return;
|
|
42
|
+
|
|
43
|
+
// Mark as handled (no retry regardless of subsequent success/failure)
|
|
44
|
+
hasNamed = true;
|
|
45
|
+
|
|
46
|
+
// Name asynchronously so we don't block the main agent startup
|
|
47
|
+
(async () => {
|
|
48
|
+
let rolesApi: ModelRolesAPI;
|
|
49
|
+
try {
|
|
50
|
+
rolesApi = getModelRolesAPI();
|
|
51
|
+
} catch {
|
|
52
|
+
console.warn("[pi-session-namer] pi-model-roles not initialized — skipping");
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!rolesApi.resolveRole(config.sideAgentRole).model) {
|
|
57
|
+
console.warn(
|
|
58
|
+
`[pi-session-namer] Side agent role "${config.sideAgentRole}" not available — skipping`,
|
|
59
|
+
);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const name = await generateSessionName(
|
|
64
|
+
rolesApi,
|
|
65
|
+
config.sideAgentRole,
|
|
66
|
+
config,
|
|
67
|
+
event.prompt,
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
pi.setSessionName(name);
|
|
71
|
+
})().catch((err) => console.warn("[pi-session-namer] naming failed:", err));
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// ── /namer — show status ────────────────────────────────────────
|
|
75
|
+
pi.registerCommand("namer", {
|
|
76
|
+
description: "Show session namer status and config",
|
|
77
|
+
handler: async (args, ctx) => {
|
|
78
|
+
const value = (args ?? "").trim().toLowerCase();
|
|
79
|
+
|
|
80
|
+
// Handle on/off toggles
|
|
81
|
+
if (value === "on") {
|
|
82
|
+
config.enabled = true;
|
|
83
|
+
ctx.ui.notify("Session Namer: enabled", "info");
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (value === "off") {
|
|
87
|
+
config.enabled = false;
|
|
88
|
+
ctx.ui.notify("Session Namer: disabled", "info");
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const currentName = pi.getSessionName();
|
|
93
|
+
const lines = [
|
|
94
|
+
`Session Namer: ${config.enabled ? "enabled" : "disabled"}`,
|
|
95
|
+
`Side agent role: ${config.sideAgentRole}`,
|
|
96
|
+
`Max length: ${config.maxLength}`,
|
|
97
|
+
`Current name: ${currentName ?? "(none)"}`,
|
|
98
|
+
`Has auto-named: ${hasNamed}`,
|
|
99
|
+
];
|
|
100
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// ── /namer:rename — force regenerate ────────────────────────────
|
|
105
|
+
pi.registerCommand("namer:rename", {
|
|
106
|
+
description: "Regenerate session name from the last user prompt",
|
|
107
|
+
handler: async (_args, ctx) => {
|
|
108
|
+
const lastUserPrompt = getLastUserPrompt(ctx.sessionManager.getEntries());
|
|
109
|
+
if (!lastUserPrompt?.trim()) {
|
|
110
|
+
ctx.ui.notify("No user prompt available to generate a name from.", "warning");
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
let rolesApi: ModelRolesAPI;
|
|
115
|
+
try {
|
|
116
|
+
rolesApi = getModelRolesAPI();
|
|
117
|
+
} catch {
|
|
118
|
+
ctx.ui.notify("pi-model-roles not initialized. Cannot rename.", "error");
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (!rolesApi.resolveRole(config.sideAgentRole).model) {
|
|
123
|
+
ctx.ui.notify(
|
|
124
|
+
`Side agent role "${config.sideAgentRole}" not available. Cannot rename.`,
|
|
125
|
+
"error",
|
|
126
|
+
);
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const name = await generateSessionName(
|
|
131
|
+
rolesApi,
|
|
132
|
+
config.sideAgentRole,
|
|
133
|
+
config,
|
|
134
|
+
lastUserPrompt,
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
pi.setSessionName(name);
|
|
138
|
+
ctx.ui.notify(`Session renamed: ${name}`, "info");
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Extract the most recent user message text from session entries.
|
|
145
|
+
*
|
|
146
|
+
* Read live from the session manager rather than a cached variable, so it
|
|
147
|
+
* survives extension reloads (which reset closure state).
|
|
148
|
+
*/
|
|
149
|
+
function getLastUserPrompt(entries: unknown[]): string | undefined {
|
|
150
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
151
|
+
const entry = entries[i] as any;
|
|
152
|
+
if (entry?.type !== "message") continue;
|
|
153
|
+
const msg = entry.message;
|
|
154
|
+
if (msg?.role !== "user") continue;
|
|
155
|
+
const text = extractEntryText(msg.content);
|
|
156
|
+
if (text.trim()) return text;
|
|
157
|
+
}
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Pull text out of a content field that may be a string or a ContentBlock[]. */
|
|
162
|
+
function extractEntryText(content: unknown): string {
|
|
163
|
+
if (typeof content === "string") return content;
|
|
164
|
+
if (Array.isArray(content)) {
|
|
165
|
+
return content
|
|
166
|
+
.filter((b: any) => b?.type === "text" && typeof b.text === "string")
|
|
167
|
+
.map((b: any) => b.text)
|
|
168
|
+
.join("");
|
|
169
|
+
}
|
|
170
|
+
return "";
|
|
143
171
|
}
|
package/src/namer.ts
CHANGED
|
@@ -1,96 +1,93 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Side agent invocation for session naming.
|
|
3
3
|
*
|
|
4
|
-
* Calls the side agent model
|
|
4
|
+
* Calls the side agent via model-roles' complete() (auth resolved internally)
|
|
5
5
|
* and returns a cleaned session name string.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import type { ModelRolesAPI } from "@d3ara1n/pi-model-roles";
|
|
9
9
|
import type { SessionNamerConfig } from "./types.ts";
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* Build the system prompt for the naming side agent.
|
|
13
13
|
*/
|
|
14
14
|
export function buildNamerSystemPrompt(maxLength: number): string {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
userPrompt: string,
|
|
32
|
+
rolesApi: ModelRolesAPI,
|
|
33
|
+
roleName: string,
|
|
34
|
+
config: SessionNamerConfig,
|
|
35
|
+
userPrompt: string,
|
|
37
36
|
): Promise<string> {
|
|
38
|
-
|
|
37
|
+
const systemPrompt = buildNamerSystemPrompt(config.maxLength);
|
|
39
38
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
? userPrompt.slice(0, 2000) + "..."
|
|
43
|
-
: userPrompt;
|
|
39
|
+
// Truncate very long prompts to avoid wasting tokens
|
|
40
|
+
const truncatedPrompt = userPrompt.length > 2000 ? userPrompt.slice(0, 2000) + "..." : userPrompt;
|
|
44
41
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
42
|
+
try {
|
|
43
|
+
const result = await rolesApi.complete(
|
|
44
|
+
roleName,
|
|
45
|
+
{
|
|
46
|
+
systemPrompt,
|
|
47
|
+
messages: [{ role: "user", content: truncatedPrompt, timestamp: Date.now() }],
|
|
48
|
+
},
|
|
49
|
+
// No maxTokens: cost is controlled by the role's thinking level.
|
|
50
|
+
// The namer side-agent role (utility, thinking:off) skips reasoning,
|
|
51
|
+
// so a short title needs only ~dozens of tokens.
|
|
52
|
+
);
|
|
48
53
|
|
|
49
|
-
|
|
50
|
-
|
|
54
|
+
const raw =
|
|
55
|
+
result.content
|
|
56
|
+
?.filter((block: any) => block.type === "text")
|
|
57
|
+
?.map((block: any) => block.text)
|
|
58
|
+
?.join("")
|
|
59
|
+
?.trim() ?? "";
|
|
51
60
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const raw = result.content
|
|
59
|
-
?.filter((block: any) => block.type === "text")
|
|
60
|
-
?.map((block: any) => block.text)
|
|
61
|
-
?.join("")
|
|
62
|
-
?.trim() ?? "";
|
|
63
|
-
|
|
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
|
-
}
|
|
61
|
+
return cleanSessionName(raw, config.maxLength);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
console.warn("[pi-session-namer] Side agent call failed:", err);
|
|
64
|
+
// Fallback: truncate user prompt as name
|
|
65
|
+
return userPrompt.slice(0, config.maxLength).replace(/\n/g, " ").trim();
|
|
66
|
+
}
|
|
70
67
|
}
|
|
71
68
|
|
|
72
69
|
/**
|
|
73
70
|
* Clean and truncate the generated name.
|
|
74
71
|
*/
|
|
75
72
|
function cleanSessionName(raw: string, maxLength: number): string {
|
|
76
|
-
|
|
73
|
+
let name = raw.trim();
|
|
77
74
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
75
|
+
// Strip surrounding quotes if present
|
|
76
|
+
if (
|
|
77
|
+
(name.startsWith('"') && name.endsWith('"')) ||
|
|
78
|
+
(name.startsWith("'") && name.endsWith("'")) ||
|
|
79
|
+
(name.startsWith("「") && name.endsWith("」"))
|
|
80
|
+
) {
|
|
81
|
+
name = name.slice(1, -1);
|
|
82
|
+
}
|
|
86
83
|
|
|
87
|
-
|
|
88
|
-
|
|
84
|
+
// Remove newlines
|
|
85
|
+
name = name.replace(/\n/g, " ").trim();
|
|
89
86
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
87
|
+
// Truncate
|
|
88
|
+
if (name.length > maxLength) {
|
|
89
|
+
name = name.slice(0, maxLength - 3) + "...";
|
|
90
|
+
}
|
|
94
91
|
|
|
95
|
-
|
|
92
|
+
return name || "New session";
|
|
96
93
|
}
|