@ryan_nookpi/pi-extension-auto-name 0.1.0 → 0.1.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 CHANGED
@@ -1,15 +1,28 @@
1
1
  # @ryan_nookpi/pi-extension-auto-name
2
2
 
3
- Auto session name extension for pi.
3
+ This extension automatically names a pi session based on the first user message.
4
+
5
+ It makes sessions easier to recognize when you have many tasks open at once.
4
6
 
5
7
  ## Install
6
8
 
7
9
  ```bash
8
- pi install /Users/creatrip/Documents/pi-extension/packages/auto-name
9
10
  pi install npm:@ryan_nookpi/pi-extension-auto-name
10
11
  ```
11
12
 
12
- ## What it provides
13
+ ## Great for
14
+
15
+ - quickly understanding what a session is about
16
+ - avoiding manual naming with `/name`
17
+ - showing the current task clearly in the terminal title and status area
18
+
19
+ ## How it works
20
+
21
+ - It reads the first user message and generates a short session name.
22
+ - The generated name is applied to the session name, status area, and terminal title.
23
+ - If a session already has a name, it does not overwrite it.
24
+ - It skips automatic naming for subagent sessions.
25
+
26
+ ## Example
13
27
 
14
- - Automatic session name detection
15
- - `./index.ts` entry
28
+ If the first request is something like "Prepare a pre-release checklist", pi can automatically turn that into a short session title.
package/index.ts CHANGED
@@ -43,11 +43,8 @@ export default function autoSessionName(pi: ExtensionAPI) {
43
43
  if (!ctx.hasUI) return;
44
44
  const cwdBasename = path.basename(process.cwd());
45
45
  const name = pi.getSessionName();
46
- if (name) {
47
- ctx.ui.setTitle(`π - ${name} - ${cwdBasename}`);
48
- } else {
49
- ctx.ui.setTitle(`π - ${cwdBasename}`);
50
- }
46
+ if (!name) return;
47
+ ctx.ui.setTitle(`π - ${name} - ${cwdBasename}`);
51
48
  };
52
49
 
53
50
  const updateStatus = (ctx: ExtensionContext) => {
@@ -68,13 +65,13 @@ export default function autoSessionName(pi: ExtensionAPI) {
68
65
  pi.on("before_agent_start", async (event, ctx) => {
69
66
  if (isSubagentSession(ctx)) return;
70
67
 
71
- // name 이미 있으면 스킵
68
+ // Skip when a name already exists.
72
69
  if (pi.getSessionName()) return;
73
70
 
74
71
  const text = event.prompt.trim();
75
72
  if (!text) return;
76
73
 
77
- // Fire-and-forget: 비동기로 name 감지 설정
74
+ // Fire-and-forget: detect and set the name asynchronously.
78
75
  (async () => {
79
76
  try {
80
77
  const detected = await detectNameFromMessage(text, ctx);
@@ -83,7 +80,7 @@ export default function autoSessionName(pi: ExtensionAPI) {
83
80
  updateStatus(ctx);
84
81
  }
85
82
  } catch {
86
- // 실패 시 무시
83
+ // Ignore failures.
87
84
  }
88
85
  })();
89
86
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ryan_nookpi/pi-extension-auto-name",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Auto session name extension for pi.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -0,0 +1,66 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ buildNameContext,
4
+ extractNameFromResult,
5
+ extractSessionFilePath,
6
+ formatNameStatus,
7
+ isSubagentSessionPath,
8
+ isSuccessfulResult,
9
+ MAX_MESSAGE_LENGTH,
10
+ MAX_NAME_LENGTH,
11
+ MAX_STATUS_CHARS,
12
+ SUBAGENT_SESSION_DIR,
13
+ } from "./auto-name-utils.ts";
14
+
15
+ describe("auto-name utils", () => {
16
+ it("detects subagent session paths", () => {
17
+ expect(isSubagentSessionPath(`${SUBAGENT_SESSION_DIR}/child/session.json`)).toBe(true);
18
+ expect(isSubagentSessionPath("/tmp/session.json")).toBe(false);
19
+ expect(isSubagentSessionPath(undefined)).toBe(false);
20
+ });
21
+
22
+ it("extracts and sanitizes the session file path", () => {
23
+ const sessionManager = {
24
+ getSessionFile: () => "\n /tmp/example.json \t",
25
+ };
26
+ expect(extractSessionFilePath(sessionManager)).toBe("/tmp/example.json");
27
+ expect(extractSessionFilePath({ getSessionFile: () => undefined })).toBeUndefined();
28
+ expect(extractSessionFilePath({ getSessionFile: "nope" })).toBeUndefined();
29
+ expect(extractSessionFilePath(null)).toBeUndefined();
30
+ expect(
31
+ extractSessionFilePath({
32
+ getSessionFile: () => {
33
+ throw new Error("boom");
34
+ },
35
+ }),
36
+ ).toBeUndefined();
37
+ });
38
+
39
+ it("formats the status line into a single clipped line", () => {
40
+ const noisy = ` alpha\n beta\t${"x".repeat(MAX_STATUS_CHARS)} `;
41
+ const formatted = formatNameStatus(noisy);
42
+ expect(formatted).not.toContain("\n");
43
+ expect(formatted.length).toBeLessThanOrEqual(MAX_STATUS_CHARS);
44
+ });
45
+
46
+ it("builds the name context with truncation", () => {
47
+ const message = "m".repeat(MAX_MESSAGE_LENGTH + 25);
48
+ const context = buildNameContext(message);
49
+ expect(context).toBe(`User message: ${message.slice(0, MAX_MESSAGE_LENGTH)}`);
50
+ });
51
+
52
+ it("extracts text-only content and clips the result length", () => {
53
+ const result = extractNameFromResult([
54
+ { type: "text", text: ` ${"a".repeat(MAX_NAME_LENGTH)} ` },
55
+ { type: "image", text: "ignored" },
56
+ { type: "text", text: "suffix" },
57
+ ]);
58
+ expect(result).toBe(`${"a".repeat(MAX_NAME_LENGTH)}`);
59
+ });
60
+
61
+ it("accepts only fully stopped model results", () => {
62
+ expect(isSuccessfulResult("stop")).toBe(true);
63
+ expect(isSuccessfulResult("length")).toBe(false);
64
+ expect(isSuccessfulResult(undefined)).toBe(false);
65
+ });
66
+ });
@@ -12,7 +12,7 @@ import * as path from "node:path";
12
12
  export const SUBAGENT_SESSION_DIR = path.join(os.homedir(), ".pi", "agent", "sessions", "subagents");
13
13
 
14
14
  export const NAME_SYSTEM_PROMPT =
15
- "사용자 메시지를 분석해서 세션의 목적을 20자 이내 줄로 추출해. 오직 목적 텍스트만 출력하고, 설명이나 다른 텍스트는 절대 출력하지 마.";
15
+ "Analyze the user's message and extract the session purpose as a single line within 20 characters. Output only the purpose text, with no explanation or extra text.";
16
16
 
17
17
  /** Max chars for the user message sent to the LLM. */
18
18
  export const MAX_MESSAGE_LENGTH = 500;
@@ -74,7 +74,7 @@ export function formatNameStatus(name: string): string {
74
74
  * Truncates to MAX_MESSAGE_LENGTH.
75
75
  */
76
76
  export function buildNameContext(userMessage: string): string {
77
- return `사용자 메시지: ${userMessage.slice(0, MAX_MESSAGE_LENGTH)}`;
77
+ return `User message: ${userMessage.slice(0, MAX_MESSAGE_LENGTH)}`;
78
78
  }
79
79
 
80
80
  /**
@@ -0,0 +1,155 @@
1
+ import { completeSimple } from "@mariozechner/pi-ai";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { generateShortLabel, type ShortLabelContext } from "./short-label.ts";
4
+
5
+ vi.mock("@mariozechner/pi-ai", () => ({
6
+ completeSimple: vi.fn(),
7
+ }));
8
+
9
+ const model = { id: "test-model" } as NonNullable<ShortLabelContext["model"]>;
10
+ type CompleteSimpleResult = Awaited<ReturnType<typeof completeSimple>>;
11
+
12
+ describe("generateShortLabel", () => {
13
+ beforeEach(() => {
14
+ vi.mocked(completeSimple).mockReset();
15
+ });
16
+
17
+ afterEach(() => {
18
+ vi.useRealTimers();
19
+ });
20
+
21
+ it("returns an empty string when model information or auth is unavailable", async () => {
22
+ expect(
23
+ await generateShortLabel(
24
+ {},
25
+ {
26
+ systemPrompt: "system",
27
+ prompt: "prompt",
28
+ },
29
+ ),
30
+ ).toBe("");
31
+
32
+ const label = await generateShortLabel(
33
+ {
34
+ model,
35
+ modelRegistry: {
36
+ getApiKeyAndHeaders: async () => ({ ok: false }),
37
+ },
38
+ },
39
+ {
40
+ systemPrompt: "system",
41
+ prompt: "prompt",
42
+ },
43
+ );
44
+
45
+ expect(label).toBe("");
46
+ expect(completeSimple).not.toHaveBeenCalled();
47
+ });
48
+
49
+ it("returns extracted text for successful completions", async () => {
50
+ vi.mocked(completeSimple).mockResolvedValue({
51
+ stopReason: "stop",
52
+ content: [
53
+ { type: "text", text: "first" },
54
+ { type: "text", text: " second" },
55
+ ],
56
+ } as CompleteSimpleResult);
57
+
58
+ const label = await generateShortLabel(
59
+ {
60
+ model,
61
+ modelRegistry: {
62
+ getApiKeyAndHeaders: async () => ({ ok: true, apiKey: "secret" }),
63
+ },
64
+ },
65
+ {
66
+ systemPrompt: "system",
67
+ prompt: "prompt",
68
+ },
69
+ );
70
+
71
+ expect(label).toBe("first second");
72
+ expect(completeSimple).toHaveBeenCalledTimes(1);
73
+ });
74
+
75
+ it("ignores incomplete model responses", async () => {
76
+ vi.mocked(completeSimple).mockResolvedValue({
77
+ stopReason: "length",
78
+ content: [{ type: "text", text: "ignored" }],
79
+ } as CompleteSimpleResult);
80
+
81
+ const label = await generateShortLabel(
82
+ {
83
+ model,
84
+ modelRegistry: {
85
+ getApiKeyAndHeaders: async () => ({ ok: true, apiKey: "secret" }),
86
+ },
87
+ },
88
+ {
89
+ systemPrompt: "system",
90
+ prompt: "prompt",
91
+ },
92
+ );
93
+
94
+ expect(label).toBe("");
95
+ });
96
+
97
+ it("falls back to the default text extractor and handles provider errors", async () => {
98
+ vi.mocked(completeSimple)
99
+ .mockResolvedValueOnce({
100
+ stopReason: "stop",
101
+ content: [
102
+ { type: "image", text: "ignored" },
103
+ { type: "text", text: " label " },
104
+ ],
105
+ } as CompleteSimpleResult)
106
+ .mockRejectedValueOnce(new Error("network"));
107
+
108
+ const ctx = {
109
+ model,
110
+ modelRegistry: {
111
+ getApiKeyAndHeaders: async () => ({ ok: true, apiKey: "secret", headers: { foo: "bar" } }),
112
+ },
113
+ };
114
+
115
+ const label = await generateShortLabel(ctx, {
116
+ systemPrompt: "system",
117
+ prompt: "prompt",
118
+ maxTokens: 10,
119
+ });
120
+ const failed = await generateShortLabel(ctx, {
121
+ systemPrompt: "system",
122
+ prompt: "prompt",
123
+ });
124
+
125
+ expect(label).toBe("label");
126
+ expect(failed).toBe("");
127
+ });
128
+
129
+ it("aborts when the timeout elapses", async () => {
130
+ vi.useFakeTimers();
131
+ vi.mocked(completeSimple).mockImplementation(
132
+ async (_model, _context, options) =>
133
+ new Promise((_, reject) => {
134
+ options?.signal?.addEventListener("abort", () => reject(new Error("aborted")));
135
+ }),
136
+ );
137
+
138
+ const promise = generateShortLabel(
139
+ {
140
+ model,
141
+ modelRegistry: {
142
+ getApiKeyAndHeaders: async () => ({ ok: true, apiKey: "secret" }),
143
+ },
144
+ },
145
+ {
146
+ systemPrompt: "system",
147
+ prompt: "prompt",
148
+ timeoutMs: 5,
149
+ },
150
+ );
151
+
152
+ await vi.advanceTimersByTimeAsync(5);
153
+ await expect(promise).resolves.toBe("");
154
+ });
155
+ });