@ryan_nookpi/pi-extension-auto-name 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.
package/README.md ADDED
@@ -0,0 +1,15 @@
1
+ # @ryan_nookpi/pi-extension-auto-name
2
+
3
+ Auto session name extension for pi.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ pi install /Users/creatrip/Documents/pi-extension/packages/auto-name
9
+ pi install npm:@ryan_nookpi/pi-extension-auto-name
10
+ ```
11
+
12
+ ## What it provides
13
+
14
+ - Automatic session name detection
15
+ - `./index.ts` entry
package/index.ts ADDED
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Auto session name — detects purpose from first user message
3
+ * and sets it as the session name via pi.setSessionName().
4
+ *
5
+ * - Auto-detect: uses pi-ai completeSimple() to summarize first message → pi.setSessionName()
6
+ * - Footer display: shows session name in status bar via setStatus()
7
+ * - Manual control: use built-in /name command (no custom command needed)
8
+ * - Skips auto-detection for subagent sessions
9
+ */
10
+
11
+ import * as path from "node:path";
12
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
13
+ import {
14
+ buildNameContext,
15
+ extractNameFromResult,
16
+ extractSessionFilePath,
17
+ formatNameStatus,
18
+ isSubagentSessionPath,
19
+ NAME_SYSTEM_PROMPT,
20
+ } from "./utils/auto-name-utils.ts";
21
+ import { generateShortLabel } from "./utils/short-label.js";
22
+ import { NAME_STATUS_KEY } from "./utils/status-keys.ts";
23
+
24
+ // ── Helpers ──────────────────────────────────────────────────────────────────
25
+
26
+ function isSubagentSession(ctx: ExtensionContext): boolean {
27
+ const sessionFilePath = extractSessionFilePath(ctx.sessionManager);
28
+ return isSubagentSessionPath(sessionFilePath);
29
+ }
30
+
31
+ async function detectNameFromMessage(userMessage: string, ctx: ExtensionContext): Promise<string> {
32
+ return generateShortLabel(ctx, {
33
+ systemPrompt: NAME_SYSTEM_PROMPT,
34
+ prompt: buildNameContext(userMessage),
35
+ extractText: extractNameFromResult,
36
+ });
37
+ }
38
+
39
+ // ── Extension ────────────────────────────────────────────────────────────────
40
+
41
+ export default function autoSessionName(pi: ExtensionAPI) {
42
+ const updateTerminalTitle = (ctx: ExtensionContext) => {
43
+ if (!ctx.hasUI) return;
44
+ const cwdBasename = path.basename(process.cwd());
45
+ const name = pi.getSessionName();
46
+ if (name) {
47
+ ctx.ui.setTitle(`π - ${name} - ${cwdBasename}`);
48
+ } else {
49
+ ctx.ui.setTitle(`π - ${cwdBasename}`);
50
+ }
51
+ };
52
+
53
+ const updateStatus = (ctx: ExtensionContext) => {
54
+ if (!ctx.hasUI) return;
55
+
56
+ const name = pi.getSessionName();
57
+ if (!name) {
58
+ ctx.ui.setStatus(NAME_STATUS_KEY, undefined);
59
+ return;
60
+ }
61
+
62
+ ctx.ui.setStatus(NAME_STATUS_KEY, formatNameStatus(name));
63
+ updateTerminalTitle(ctx);
64
+ };
65
+
66
+ // ── Auto Name (async) ──────────────────────────────────────
67
+
68
+ pi.on("before_agent_start", async (event, ctx) => {
69
+ if (isSubagentSession(ctx)) return;
70
+
71
+ // name이 이미 있으면 스킵
72
+ if (pi.getSessionName()) return;
73
+
74
+ const text = event.prompt.trim();
75
+ if (!text) return;
76
+
77
+ // Fire-and-forget: 비동기로 name 감지 후 설정
78
+ (async () => {
79
+ try {
80
+ const detected = await detectNameFromMessage(text, ctx);
81
+ if (detected && !pi.getSessionName()) {
82
+ pi.setSessionName(detected);
83
+ updateStatus(ctx);
84
+ }
85
+ } catch {
86
+ // 실패 시 무시
87
+ }
88
+ })();
89
+ });
90
+
91
+ // ── Lifecycle ─────────────────────────────────────────────────
92
+
93
+ pi.on("session_start", async (_event, ctx) => {
94
+ updateStatus(ctx);
95
+ });
96
+
97
+ pi.on("session_tree", async (_event, ctx) => {
98
+ updateStatus(ctx);
99
+ });
100
+
101
+ pi.on("session_shutdown", async (_event, ctx) => {
102
+ if (!ctx.hasUI) return;
103
+ ctx.ui.setStatus(NAME_STATUS_KEY, undefined);
104
+ });
105
+ }
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@ryan_nookpi/pi-extension-auto-name",
3
+ "version": "0.1.0",
4
+ "description": "Auto session name extension for pi.",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi-package"
8
+ ],
9
+ "files": [
10
+ "index.ts",
11
+ "README.md",
12
+ "utils"
13
+ ],
14
+ "pi": {
15
+ "extensions": [
16
+ "./index.ts"
17
+ ]
18
+ },
19
+ "peerDependencies": {
20
+ "@mariozechner/pi-ai": "*",
21
+ "@mariozechner/pi-coding-agent": "*"
22
+ },
23
+ "publishConfig": {
24
+ "access": "public"
25
+ }
26
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Pure utility functions for auto-name extension.
3
+ * Extracted for testability — no I/O, no pi SDK dependencies.
4
+ */
5
+
6
+ import * as os from "node:os";
7
+ import * as path from "node:path";
8
+
9
+ // ── Constants ────────────────────────────────────────────────────────────────
10
+
11
+ /** Must match subagent/session.ts:SUBAGENT_SESSION_DIR */
12
+ export const SUBAGENT_SESSION_DIR = path.join(os.homedir(), ".pi", "agent", "sessions", "subagents");
13
+
14
+ export const NAME_SYSTEM_PROMPT =
15
+ "사용자 메시지를 분석해서 세션의 목적을 20자 이내 한 줄로 추출해. 오직 목적 텍스트만 출력하고, 설명이나 다른 텍스트는 절대 출력하지 마.";
16
+
17
+ /** Max chars for the user message sent to the LLM. */
18
+ export const MAX_MESSAGE_LENGTH = 500;
19
+
20
+ /** Max chars for the resulting session name. */
21
+ export const MAX_NAME_LENGTH = 30;
22
+
23
+ /** Max chars shown in the status bar. */
24
+ export const MAX_STATUS_CHARS = 90;
25
+
26
+ /** Only a fully completed response should be used as a session name. */
27
+ export const SUCCESSFUL_STOP_REASON = "stop";
28
+
29
+ // ── Pure Functions ───────────────────────────────────────────────────────────
30
+
31
+ /**
32
+ * Check if a session file path belongs to the subagent sessions directory.
33
+ * Returns true if the path starts with SUBAGENT_SESSION_DIR.
34
+ */
35
+ export function isSubagentSessionPath(sessionFilePath: string | undefined): boolean {
36
+ if (!sessionFilePath) return false;
37
+ return (
38
+ sessionFilePath.startsWith(SUBAGENT_SESSION_DIR + path.sep) ||
39
+ sessionFilePath.startsWith(`${SUBAGENT_SESSION_DIR}/`)
40
+ );
41
+ }
42
+
43
+ /**
44
+ * Safely extract session file path from an ExtensionContext-like object.
45
+ * Returns undefined if extraction fails.
46
+ */
47
+ export function extractSessionFilePath(sessionManager: unknown): string | undefined {
48
+ try {
49
+ if (sessionManager && typeof sessionManager === "object" && "getSessionFile" in sessionManager) {
50
+ const getSessionFile = (sessionManager as Record<string, unknown>).getSessionFile;
51
+ if (typeof getSessionFile === "function") {
52
+ const raw = String(getSessionFile() ?? "");
53
+ const cleaned = raw.replace(/[\r\n\t]+/g, "").trim();
54
+ return cleaned || undefined;
55
+ }
56
+ }
57
+ } catch {
58
+ // Ignore errors
59
+ }
60
+ return undefined;
61
+ }
62
+
63
+ /**
64
+ * Format a session name for status bar display.
65
+ * Normalizes whitespace and clips to MAX_STATUS_CHARS.
66
+ */
67
+ export function formatNameStatus(name: string): string {
68
+ const singleLine = name.replace(/\s+/g, " ").trim();
69
+ return singleLine.length > MAX_STATUS_CHARS ? `${singleLine.slice(0, MAX_STATUS_CHARS - 1)}…` : singleLine;
70
+ }
71
+
72
+ /**
73
+ * Build the user-message text sent to the LLM for name detection.
74
+ * Truncates to MAX_MESSAGE_LENGTH.
75
+ */
76
+ export function buildNameContext(userMessage: string): string {
77
+ return `사용자 메시지: ${userMessage.slice(0, MAX_MESSAGE_LENGTH)}`;
78
+ }
79
+
80
+ /**
81
+ * Check whether a model result completed normally.
82
+ * Only fully completed responses should be used for session naming.
83
+ */
84
+ export function isSuccessfulResult(stopReason: string | undefined): boolean {
85
+ return stopReason === SUCCESSFUL_STOP_REASON;
86
+ }
87
+
88
+ /**
89
+ * Extract the session name text from an LLM AssistantMessage-like result.
90
+ * Filters to text content, joins, trims, and clips to MAX_NAME_LENGTH.
91
+ */
92
+ export function extractNameFromResult(content: ReadonlyArray<{ type: string; text?: string }>): string {
93
+ const text = content
94
+ .filter((c): c is { type: "text"; text: string } => c.type === "text" && typeof c.text === "string")
95
+ .map((c) => c.text)
96
+ .join("")
97
+ .trim();
98
+
99
+ return text.slice(0, MAX_NAME_LENGTH);
100
+ }
@@ -0,0 +1,76 @@
1
+ import { completeSimple } from "@mariozechner/pi-ai";
2
+
3
+ type SummaryModel = Parameters<typeof completeSimple>[0];
4
+ type SummaryResult = Awaited<ReturnType<typeof completeSimple>>;
5
+
6
+ type AuthResult = {
7
+ ok: boolean;
8
+ apiKey?: string;
9
+ headers?: Record<string, string>;
10
+ };
11
+
12
+ export type ShortLabelContext = {
13
+ model?: SummaryModel;
14
+ modelRegistry?: {
15
+ getApiKeyAndHeaders: (model: SummaryModel) => Promise<AuthResult>;
16
+ };
17
+ };
18
+
19
+ export type GenerateShortLabelOptions = {
20
+ systemPrompt: string;
21
+ prompt: string;
22
+ maxTokens?: number;
23
+ timeoutMs?: number;
24
+ extractText?: (content: SummaryResult["content"]) => string;
25
+ };
26
+
27
+ function defaultExtractText(content: SummaryResult["content"]): string {
28
+ return content
29
+ .filter((part): part is { type: "text"; text: string } => part.type === "text" && typeof part.text === "string")
30
+ .map((part) => part.text)
31
+ .join("")
32
+ .trim();
33
+ }
34
+
35
+ export async function generateShortLabel(ctx: ShortLabelContext, options: GenerateShortLabelOptions): Promise<string> {
36
+ const model = ctx.model;
37
+ if (!model || !ctx.modelRegistry) return "";
38
+
39
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
40
+ if (!auth.ok) return "";
41
+
42
+ const controller = new AbortController();
43
+ const timeoutMs = options.timeoutMs ?? 10000;
44
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
45
+
46
+ try {
47
+ const result = await completeSimple(
48
+ model,
49
+ {
50
+ systemPrompt: options.systemPrompt,
51
+ messages: [
52
+ {
53
+ role: "user",
54
+ content: [{ type: "text", text: options.prompt }],
55
+ timestamp: Date.now(),
56
+ },
57
+ ],
58
+ },
59
+ {
60
+ apiKey: auth.apiKey,
61
+ headers: auth.headers,
62
+ signal: controller.signal,
63
+ reasoning: "minimal",
64
+ maxTokens: options.maxTokens ?? 60,
65
+ },
66
+ );
67
+
68
+ if (result.stopReason !== "stop") return "";
69
+ const extractText = options.extractText ?? defaultExtractText;
70
+ return extractText(result.content);
71
+ } catch {
72
+ return "";
73
+ } finally {
74
+ clearTimeout(timer);
75
+ }
76
+ }
@@ -0,0 +1,2 @@
1
+ export const NAME_STATUS_KEY = "name-footer";
2
+ export const ELAPSED_STATUS_KEY = "elapsed-time";