@0xkahi/pi-qol 0.0.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 ADDED
@@ -0,0 +1,135 @@
1
+ # pi-qol
2
+
3
+ > Quality-of-life extensions for [pi](https://github.com/earendil-works/pi).
4
+
5
+ A small collection of opt-in extensions that smooth out everyday pi usage. Today
6
+ it ships **`auto_session_name`** — automatic, opencode-style session titling — with
7
+ more QoL features planned.
8
+
9
+ ## Features
10
+
11
+ ### `auto_session_name`
12
+
13
+ Automatically generates a short, human-readable title for a brand-new session from
14
+ its first user message, using a cheap model call that runs in the background and
15
+ never blocks your turn.
16
+
17
+ - **Non-blocking** — titles are generated asynchronously while the agent responds.
18
+ - **Fires once, early** — only on the very first real user turn of a session.
19
+ - **Smart guards** — skips sessions that already have a name, forked/child
20
+ sessions, and empty prompts.
21
+ - **Model-flexible** — use a configured cheap model, or fall back to the active
22
+ session model automatically.
23
+ - **Safe output** — strips `<think>` blocks, unwraps quotes, and truncates titles
24
+ to 100 characters.
25
+
26
+ ## Installation
27
+
28
+ Install the package from npm:
29
+
30
+ ```bash
31
+ pi install npm:@0xkahi/pi-qol
32
+ ```
33
+
34
+ Then register it in your pi configuration so pi loads `./src/index.ts` as an
35
+ extension (see the `pi.extensions` field in `package.json`).
36
+
37
+ ## Configuration
38
+
39
+ `pi-qol` reads a JSON config file named `config.json` from two locations and merges
40
+ them (project overrides global):
41
+
42
+ | Scope | Path |
43
+ | ------- | ---------------------------------------------------------- |
44
+ | Global | `<agent-dir>/extensions/pi-qol/config.json` |
45
+ | Project | `<cwd>/.pi/extensions/pi-qol/config.json` (trusted projects only) |
46
+
47
+ > Project config is only loaded when the project is trusted.
48
+
49
+ ### Example
50
+
51
+ ```json
52
+ {
53
+ "$schema": "https://raw.githubusercontent.com/0xKahi/pi-qol/main/assets/config.schema.json",
54
+ "auto_session_name": {
55
+ "enabled": true,
56
+ "model": {
57
+ "provider": "anthropic",
58
+ "modelId": "claude-3-5-haiku-latest",
59
+ "reasoning": "minimal"
60
+ }
61
+ }
62
+ }
63
+ ```
64
+
65
+ ### Options
66
+
67
+ #### `auto_session_name`
68
+
69
+ | Key | Type | Default | Description |
70
+ | --------- | --------- | ------- | ------------------------------------------------------------------ |
71
+ | `enabled` | `boolean` | `false` | Turn the auto session naming feature on. |
72
+ | `model` | `object` | — | Optional model used to generate titles. Falls back to the session model if omitted. |
73
+
74
+
75
+ #### `model`
76
+
77
+ | Key | Type | Description |
78
+ | ----------- | -------- | --------------------------------------------------------------------- |
79
+ | `provider` | `string` | Model provider id (e.g. `anthropic`, `openai`). |
80
+ | `modelId` | `string` | Model identifier as registered in pi's model registry. |
81
+ | `reasoning` | `enum` | One of `off`, `minimal`, `low`, `medium`, `high`, `xhigh`. |
82
+
83
+
84
+ When `model` is set but cannot be found or authenticated, `pi-qol` automatically
85
+ falls back to the active session model and surfaces a warning notification.
86
+
87
+ ## How it works
88
+
89
+ On `before_agent_start`, the `auto_session_name` extension checks a series of
90
+ guards before doing any work. It only proceeds when **all** of the following hold:
91
+
92
+ 1. The feature is enabled in config.
93
+ 2. The session does not already have a name.
94
+ 3. The session is not a fork/child session.
95
+ 4. It is the user's first turn.
96
+ 5. The user prompt is non-empty.
97
+
98
+ If those pass, it resolves a model, generates a title via a dedicated
99
+ title-generation prompt, cleans the result, and persists it with
100
+ `pi.setSessionName()`. The work runs in a cancellable background task that is
101
+ aborted on session shutdown.
102
+
103
+ ## Development
104
+
105
+ This project uses [Bun](https://bun.sh) and [Biome](https://biomejs.dev).
106
+
107
+ ```bash
108
+ bun install # install dependencies
109
+ bun run check # biome fix + type-check
110
+ bun test # run tests
111
+ bun run buildSchema # regenerate assets/config.schema.json from the zod schemas
112
+ ```
113
+
114
+ ### Project structure
115
+
116
+ ```
117
+ src/
118
+ index.ts # extension entry — lifecycle wiring only
119
+ config-loader.ts # loads + merges global/project config
120
+ constants.ts # EXTENSION_ID
121
+ schemas/ # zod config schemas (full + partial)
122
+ utils/ # path + model-resolution helpers
123
+ extensions/
124
+ auto-session-name/ # the auto_session_name feature
125
+ scripts/ # JSON schema generation
126
+ assets/config.schema.json # generated config schema
127
+ ```
128
+
129
+ ## License
130
+
131
+ See repository for license details.
132
+
133
+ ## Author
134
+
135
+ [0xKahi](https://github.com/0xKahi)
@@ -0,0 +1,60 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://raw.githubusercontent.com/0xKahi/pi-qol/main/assets/config.schema.json",
4
+ "title": "Pi Btw Extension Configuration",
5
+ "description": "Configuration schema for pi-qol extension",
6
+ "type": "object",
7
+ "properties": {
8
+ "$schema": {
9
+ "type": "string"
10
+ },
11
+ "auto_session_name": {
12
+ "default": {
13
+ "enabled": false
14
+ },
15
+ "type": "object",
16
+ "properties": {
17
+ "enabled": {
18
+ "default": false,
19
+ "type": "boolean"
20
+ },
21
+ "model": {
22
+ "type": "object",
23
+ "properties": {
24
+ "provider": {
25
+ "type": "string"
26
+ },
27
+ "modelId": {
28
+ "type": "string"
29
+ },
30
+ "reasoning": {
31
+ "type": "string",
32
+ "enum": [
33
+ "off",
34
+ "minimal",
35
+ "low",
36
+ "medium",
37
+ "high",
38
+ "xhigh"
39
+ ]
40
+ }
41
+ },
42
+ "required": [
43
+ "provider",
44
+ "modelId",
45
+ "reasoning"
46
+ ],
47
+ "additionalProperties": false
48
+ }
49
+ },
50
+ "required": [
51
+ "enabled"
52
+ ],
53
+ "additionalProperties": false
54
+ }
55
+ },
56
+ "required": [
57
+ "auto_session_name"
58
+ ],
59
+ "additionalProperties": false
60
+ }
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@0xkahi/pi-qol",
3
+ "version": "0.0.1",
4
+ "description": "quality of life pi extensions",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi-package",
8
+ "pi-extension"
9
+ ],
10
+ "author": "0xKahi",
11
+ "publishConfig": {
12
+ "access": "public"
13
+ },
14
+ "files": [
15
+ "src",
16
+ "assets/config.schema.json",
17
+ "README.md"
18
+ ],
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/0xKahi/pi-qol.git"
22
+ },
23
+ "pi": {
24
+ "extensions": [
25
+ "./src/index.ts"
26
+ ]
27
+ },
28
+ "scripts": {
29
+ "biome": "biome check ./src",
30
+ "lint": "bun biome",
31
+ "lint:fix": "bun biome --write",
32
+ "type-check": "tsc --noEmit",
33
+ "test": "bun test",
34
+ "check": "bun run lint:fix && bun run type-check",
35
+ "release": "changeset publish",
36
+ "version": "changeset version",
37
+ "changeset": "changeset",
38
+ "buildSchema": "bun run scripts/build-schema.ts"
39
+ },
40
+ "peerDependencies": {
41
+ "@earendil-works/pi-ai": "*",
42
+ "@earendil-works/pi-coding-agent": "*",
43
+ "@earendil-works/pi-tui": "*"
44
+ },
45
+ "devDependencies": {
46
+ "@earendil-works/pi-ai": "^0.79.4",
47
+ "@earendil-works/pi-coding-agent": "^0.79.4",
48
+ "@earendil-works/pi-tui": "^0.79.4",
49
+ "@biomejs/biome": "^2.4.15",
50
+ "@changesets/cli": "^2.31.0",
51
+ "@types/node": "^24.0.0",
52
+ "@types/bun": "latest",
53
+ "bun-types": "latest",
54
+ "typescript": "^5"
55
+ },
56
+ "dependencies": {
57
+ "zod": "^4.4.3"
58
+ }
59
+ }
@@ -0,0 +1,80 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
3
+ import { z } from 'zod';
4
+ import { type Config, ConfigSchema, type PartialConfig, PartialConfigSchema } from './schemas/config.schema';
5
+ import { PathUtil } from './utils/path.util';
6
+
7
+ export class ConfigLoader {
8
+ private config: Config = this.defaultConfig;
9
+
10
+ get defaultConfig(): Config {
11
+ return ConfigSchema.parse({});
12
+ }
13
+
14
+ getConfig(): Config {
15
+ return this.config;
16
+ }
17
+
18
+ isEnabled(key: keyof Config): boolean {
19
+ const section = this.config[key];
20
+ return typeof section === 'object' && section !== null && 'enabled' in section ? Boolean((section as { enabled?: boolean }).enabled) : false;
21
+ }
22
+
23
+ getAutoSessionName(): Config['auto_session_name'] {
24
+ return this.config.auto_session_name;
25
+ }
26
+
27
+ initializeConfig(ctx: ExtensionContext): { success: boolean; error?: string } {
28
+ let merged: Config = this.defaultConfig;
29
+
30
+ const globalRes = PathUtil.findExtensionConfig({ type: 'global' });
31
+ if (globalRes.exists) {
32
+ const loaded = this.loadConfig(globalRes.path);
33
+ if (!loaded.success) return { success: false, error: `at path => ${globalRes.path}\n${loaded.error}` };
34
+ merged = this.mergeConfig(merged, loaded.data);
35
+ }
36
+
37
+ if (ctx.isProjectTrusted()) {
38
+ const projectRes = PathUtil.findExtensionConfig({ type: 'project', cwd: ctx.cwd });
39
+ if (projectRes.exists) {
40
+ const loaded = this.loadConfig(projectRes.path);
41
+ if (!loaded.success) return { success: false, error: `at path => ${projectRes.path}\n${loaded.error}` };
42
+ merged = this.mergeConfig(merged, loaded.data);
43
+ }
44
+ }
45
+
46
+ const parsed = ConfigSchema.safeParse(merged);
47
+ if (!parsed.success) return { success: false, error: z.prettifyError(parsed.error) };
48
+
49
+ this.config = parsed.data;
50
+ return { success: true };
51
+ }
52
+
53
+ private mergeConfig(base: Config, partial: PartialConfig): Config {
54
+ const next = { ...base } as Record<string, unknown>;
55
+
56
+ for (const [key, value] of Object.entries(partial)) {
57
+ if (key === '$schema' || value === undefined) continue;
58
+
59
+ const baseValue = next[key];
60
+ next[key] = this.isPlainObject(baseValue) && this.isPlainObject(value) ? { ...baseValue, ...value } : value;
61
+ }
62
+
63
+ return ConfigSchema.parse(next);
64
+ }
65
+
66
+ private isPlainObject(value: unknown): value is Record<string, unknown> {
67
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
68
+ }
69
+
70
+ private loadConfig(path: string): { success: true; data: PartialConfig } | { success: false; error: string } {
71
+ try {
72
+ const raw = JSON.parse(readFileSync(path, 'utf8'));
73
+ const parsed = PartialConfigSchema.safeParse(raw);
74
+ if (!parsed.success) return { success: false, error: z.prettifyError(parsed.error) };
75
+ return { success: true, data: parsed.data };
76
+ } catch (err) {
77
+ return { success: false, error: err instanceof Error ? err.message : String(err) };
78
+ }
79
+ }
80
+ }
@@ -0,0 +1 @@
1
+ export const EXTENSION_ID = 'pi-qol';
@@ -0,0 +1,3 @@
1
+ export const MAX_TITLE_LENGTH = 100;
2
+ export const TITLE_MAX_TOKENS = 1024;
3
+ export const MAX_RETRIES = 2;
@@ -0,0 +1,22 @@
1
+ import type { ExtensionAPI, SessionEntry, SessionHeader } from '@earendil-works/pi-coding-agent';
2
+
3
+ type SessionManagerForGuards = {
4
+ getBranch(): SessionEntry[];
5
+ getHeader(): SessionHeader | null;
6
+ };
7
+
8
+ export class AutoSessionNameGuard {
9
+ static isSessionNameSet(pi: Pick<ExtensionAPI, 'getSessionName'>): boolean {
10
+ return pi.getSessionName() !== undefined;
11
+ }
12
+
13
+ static isChildSession(sessionOpts: { startReason?: string; manager: Pick<SessionManagerForGuards, 'getHeader'> }): boolean {
14
+ return sessionOpts.startReason === 'fork' || Boolean(sessionOpts.manager.getHeader()?.parentSession);
15
+ }
16
+
17
+ static isUsersFirstTurn(sessionManager: Pick<SessionManagerForGuards, 'getBranch'>): boolean {
18
+ // Pi emits before_agent_start before appending the current user message, so a fresh opening turn has zero prior user messages.
19
+ const userMsgs = sessionManager.getBranch().filter(entry => entry.type === 'message' && entry.message.role === 'user');
20
+ return userMsgs.length === 0;
21
+ }
22
+ }
@@ -0,0 +1,74 @@
1
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
2
+ import type { ConfigLoader } from '../../config-loader';
3
+ import { ModelResolver } from '../../utils/model-resolver.util';
4
+ import { AutoSessionNameGuard } from './guards';
5
+ import { AutoSessionNameTitleGenerator } from './title-generator';
6
+
7
+ export function registerAutoSessionName(pi: ExtensionAPI, deps: { config: ConfigLoader }) {
8
+ let lastSessionStartReason: string | undefined;
9
+ let titleController: AbortController | undefined;
10
+
11
+ pi.on('session_start', (event, _ctx) => {
12
+ lastSessionStartReason = event.reason;
13
+ });
14
+
15
+ pi.on('session_shutdown', () => {
16
+ titleController?.abort();
17
+ titleController = undefined;
18
+ });
19
+
20
+ pi.on('before_agent_start', (event, ctx) => {
21
+ if (!deps.config.isEnabled('auto_session_name')) return;
22
+ if (AutoSessionNameGuard.isSessionNameSet(pi)) return;
23
+ if (AutoSessionNameGuard.isChildSession({ startReason: lastSessionStartReason, manager: ctx.sessionManager })) return;
24
+ if (!AutoSessionNameGuard.isUsersFirstTurn(ctx.sessionManager)) return;
25
+
26
+ const userText = event.prompt?.trim();
27
+ if (!userText) return;
28
+
29
+ const cfg = deps.config.getAutoSessionName();
30
+ const configModel = cfg.enabled ? cfg.model : undefined;
31
+
32
+ titleController?.abort();
33
+ titleController = new AbortController();
34
+ const signal = titleController.signal;
35
+
36
+ void (async () => {
37
+ const resolved = await new ModelResolver(ctx).resolveModel(configModel);
38
+ if (resolved.error) {
39
+ ctx.ui.notify(resolved.error, 'warning');
40
+ }
41
+ if (!resolved.result) return;
42
+
43
+ const result = await AutoSessionNameTitleGenerator.generateAndApplyTitle({
44
+ pi,
45
+ userText,
46
+ resolvedModel: {
47
+ ...resolved.result,
48
+ reasoning: resolved.result.reasoning ?? 'minimal',
49
+ },
50
+ signal,
51
+ });
52
+
53
+ if (result.error) {
54
+ ctx.ui.notify(`(pi-qol) auto_session_name: ${result.error}`, 'warning');
55
+ return;
56
+ }
57
+
58
+ if (!result.text) {
59
+ ctx.ui.notify('(pi-qol) auto_session_name: no title was generated', 'warning');
60
+ return;
61
+ }
62
+
63
+ const appliedTitle = pi.getSessionName();
64
+ if (appliedTitle !== result.text) {
65
+ ctx.ui.notify(`(pi-qol) auto_session_name generated but not applied: ${result.text}`, 'warning');
66
+ return;
67
+ }
68
+
69
+ ctx.ui.notify(`(pi-qol) auto_session_name: ${result.text}  `, 'info');
70
+ })().catch(err => {
71
+ ctx.ui.notify(`(pi-qol) auto_session_name: ${JSON.stringify(err)}`, 'error');
72
+ });
73
+ });
74
+ }
@@ -0,0 +1,64 @@
1
+ import type { Context } from '@earendil-works/pi-ai';
2
+
3
+ const LEADING_PROMPT = 'Generate a title for this conversation:\n';
4
+ const TITLE_SYSTEM_PROMPT = `You are a title generator. You output ONLY a thread title. Nothing else.
5
+
6
+ <task>
7
+ Generate a brief title that would help the user find this conversation later.
8
+
9
+ Follow all rules in <rules>
10
+ Use the <examples> so you know what a good title looks like.
11
+ Your output must be:
12
+ - A single line
13
+ - ≤70 characters
14
+ - No explanations
15
+ </task>
16
+
17
+ <rules>
18
+ - you MUST use the same language as the user message you are summarizing
19
+ - Title must be grammatically correct and read naturally - no word salad
20
+ - Never include tool names in the title (e.g. "read tool", "bash tool", "edit tool")
21
+ - Focus on the main topic or question the user needs to retrieve
22
+ - Vary your phrasing - avoid repetitive patterns like always starting with "Analyzing"
23
+ - When a file is mentioned, focus on WHAT the user wants to do WITH the file, not just that they shared it
24
+ - Keep exact: technical terms, numbers, filenames, HTTP codes
25
+ - Remove: the, this, my, a, an
26
+ - Never assume tech stack
27
+ - Never use tools
28
+ - NEVER respond to questions, just generate a title for the conversation
29
+ - The title should NEVER include "summarizing" or "generating" when generating a title
30
+ - DO NOT SAY YOU CANNOT GENERATE A TITLE OR COMPLAIN ABOUT THE INPUT
31
+ - Always output something meaningful, even if the input is minimal.
32
+ - If the user message is short or conversational (e.g. "hello", "lol", "what's up", "hey"):
33
+ → create a title that reflects the user's tone or intent (such as Greeting, Quick check-in, Light chat, Intro message, etc.)
34
+ </rules>
35
+
36
+ <examples>
37
+ "debug 500 errors in production" → Debugging production 500 errors
38
+ "refactor user service" → Refactoring user service
39
+ "why is app.js failing" → app.js failure investigation
40
+ "implement rate limiting" → Rate limiting implementation
41
+ "how do I connect postgres to my API" → Postgres API connection
42
+ "best practices for React hooks" → React hooks best practices
43
+ "@src/auth.ts can you add refresh token support" → Auth refresh token support
44
+ "@utils/parser.ts this is broken" → Parser bug fix
45
+ "look at @config.json" → Config review
46
+ "@App.tsx add dark mode toggle" → Dark mode toggle in App
47
+ </examples>`;
48
+
49
+ function buildTitleUserMessage(userText: string): string {
50
+ return `${LEADING_PROMPT}${userText}`;
51
+ }
52
+
53
+ export function buildTitleContext(userText: string): Context {
54
+ return {
55
+ systemPrompt: TITLE_SYSTEM_PROMPT,
56
+ messages: [
57
+ {
58
+ role: 'user',
59
+ content: buildTitleUserMessage(userText),
60
+ timestamp: Date.now(),
61
+ },
62
+ ],
63
+ };
64
+ }
@@ -0,0 +1,113 @@
1
+ import type { Api, AssistantMessage, Context, Model, SimpleStreamOptions, TextContent } from '@earendil-works/pi-ai';
2
+ import { completeSimple } from '@earendil-works/pi-ai';
3
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
4
+ import type { ResolvedModel } from '../../utils/model-resolver.util';
5
+ import { MAX_RETRIES, MAX_TITLE_LENGTH, TITLE_MAX_TOKENS } from './constants';
6
+ import { buildTitleContext } from './prompt';
7
+
8
+ type CompleteFn = (model: Model<Api>, context: Context, options?: SimpleStreamOptions) => Promise<AssistantMessage>;
9
+
10
+ export type GenerateTitleResult = {
11
+ text?: string;
12
+ error?: string;
13
+ };
14
+
15
+ export class AutoSessionNameTitleGenerator {
16
+ static async generateAndApplyTitle(deps: {
17
+ pi: Pick<ExtensionAPI, 'setSessionName'>;
18
+ userText: string;
19
+ resolvedModel: ResolvedModel;
20
+ signal?: AbortSignal;
21
+ completeFn?: CompleteFn;
22
+ }): Promise<GenerateTitleResult> {
23
+ try {
24
+ const completeTitle = deps.completeFn ?? completeSimple;
25
+ const msg = await completeTitle(
26
+ deps.resolvedModel.model,
27
+ buildTitleContext(deps.userText),
28
+ AutoSessionNameTitleGenerator.buildTitleOptions(deps.resolvedModel, deps.signal),
29
+ );
30
+
31
+ const rawText = AutoSessionNameTitleGenerator.extractText(msg);
32
+ const title = AutoSessionNameTitleGenerator.cleanTitle(rawText);
33
+ if (!title) {
34
+ return {
35
+ error: AutoSessionNameTitleGenerator.buildEmptyTitleError(msg, rawText),
36
+ text: rawText || undefined,
37
+ };
38
+ }
39
+
40
+ deps.pi.setSessionName(title);
41
+ return { text: title };
42
+ } catch (err) {
43
+ const error = AutoSessionNameTitleGenerator.formatError(err);
44
+ return { error };
45
+ }
46
+ }
47
+
48
+ static cleanTitle(raw: string): string {
49
+ const withoutThinking = raw.replace(/<think>[\s\S]*?<\/think>/gi, '').replace(/<\/?think>/gi, '');
50
+ const title = withoutThinking
51
+ .split(/\r?\n/)
52
+ .map(line => line.trim())
53
+ .find(Boolean);
54
+
55
+ if (!title) return '';
56
+
57
+ const unwrapped = AutoSessionNameTitleGenerator.unwrapSurroundingQuotes(title);
58
+ if (!unwrapped) return '';
59
+ if (unwrapped.length <= MAX_TITLE_LENGTH) return unwrapped;
60
+
61
+ return `${unwrapped.slice(0, MAX_TITLE_LENGTH - 1).trimEnd()}…`;
62
+ }
63
+
64
+ private static extractText(msg: AssistantMessage): string {
65
+ return msg.content
66
+ .filter((content): content is TextContent => content.type === 'text')
67
+ .map(content => content.text)
68
+ .join('')
69
+ .trim();
70
+ }
71
+
72
+ private static unwrapSurroundingQuotes(value: string): string {
73
+ return value.replace(/^("+|'+|`+)([\s\S]*)\1$/, '$2').trim();
74
+ }
75
+
76
+ private static buildTitleOptions(resolvedModel: ResolvedModel, signal?: AbortSignal): SimpleStreamOptions {
77
+ const base: SimpleStreamOptions = {
78
+ apiKey: resolvedModel.apiKey,
79
+ headers: resolvedModel.headers,
80
+ signal,
81
+ maxRetries: MAX_RETRIES,
82
+ maxTokens: TITLE_MAX_TOKENS,
83
+ };
84
+
85
+ if (!resolvedModel.model.reasoning || !resolvedModel.reasoning || resolvedModel.reasoning === 'off') return base;
86
+
87
+ return {
88
+ ...base,
89
+ reasoning: resolvedModel.reasoning,
90
+ };
91
+ }
92
+
93
+ private static buildEmptyTitleError(msg: AssistantMessage, rawText: string): string {
94
+ const contentTypes = msg.content.map(content => content.type).join(', ') || 'none';
95
+ const details = [`model returned no title`, `stopReason=${msg.stopReason}`, `contentTypes=${contentTypes}`];
96
+
97
+ if (msg.errorMessage) details.push(`errorMessage=${msg.errorMessage}`);
98
+ if (rawText) details.push(`rawText=${rawText}`);
99
+
100
+ return details.join('; ');
101
+ }
102
+
103
+ private static formatError(err: unknown): string {
104
+ if (err instanceof Error) return err.message;
105
+ if (typeof err === 'string') return err;
106
+
107
+ try {
108
+ return JSON.stringify(err);
109
+ } catch {
110
+ return String(err);
111
+ }
112
+ }
113
+ }
package/src/index.ts ADDED
@@ -0,0 +1,14 @@
1
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
2
+ import { ConfigLoader } from './config-loader';
3
+ import { registerAutoSessionName } from './extensions/auto-session-name';
4
+
5
+ export default function (pi: ExtensionAPI) {
6
+ const config = new ConfigLoader();
7
+
8
+ pi.on('session_start', (_event, ctx) => {
9
+ const { error } = config.initializeConfig(ctx);
10
+ if (error) ctx.ui.notify(error, 'error');
11
+ });
12
+
13
+ registerAutoSessionName(pi, { config });
14
+ }
@@ -0,0 +1,11 @@
1
+ import z from 'zod';
2
+ import { ModelConfigschema } from './shared-config.schema';
3
+
4
+ export const AutoSessionNameConfigSchema = z.object({
5
+ enabled: z.boolean().default(false),
6
+ model: ModelConfigschema.optional(),
7
+ });
8
+ export type AutoSessionNameConfig = z.infer<typeof AutoSessionNameConfigSchema>;
9
+
10
+ export const PartialAutoSessionNameConfigSchema = AutoSessionNameConfigSchema.partial();
11
+ export type PartialAutoSessionNameConfig = z.infer<typeof PartialAutoSessionNameConfigSchema>;
@@ -0,0 +1,16 @@
1
+ import z from 'zod';
2
+ import { AutoSessionNameConfigSchema, PartialAutoSessionNameConfigSchema } from './auto-session-name.config.schema';
3
+
4
+ export const ConfigSchema = z.object({
5
+ $schema: z.string().optional(),
6
+ auto_session_name: AutoSessionNameConfigSchema.default({
7
+ enabled: false,
8
+ }),
9
+ });
10
+ export type Config = z.infer<typeof ConfigSchema>;
11
+
12
+ export const PartialConfigSchema = z.object({
13
+ $schema: z.string().optional(),
14
+ auto_session_name: PartialAutoSessionNameConfigSchema.optional(),
15
+ });
16
+ export type PartialConfig = z.infer<typeof PartialConfigSchema>;
@@ -0,0 +1,11 @@
1
+ import z from 'zod';
2
+
3
+ export const ReasoningLevelSchema = z.enum(['off', 'minimal', 'low', 'medium', 'high', 'xhigh']);
4
+ export type ReasoningLevel = z.infer<typeof ReasoningLevelSchema>;
5
+
6
+ export const ModelConfigschema = z.object({
7
+ provider: z.string(),
8
+ modelId: z.string(),
9
+ reasoning: ReasoningLevelSchema,
10
+ });
11
+ export type ModelConfig = z.infer<typeof ModelConfigschema>;
@@ -0,0 +1,67 @@
1
+ import type { Api, Model } from '@earendil-works/pi-ai';
2
+ import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
3
+ import type { ModelConfig } from '../schemas/shared-config.schema';
4
+
5
+ export type ResolvedModel = {
6
+ model: Model<Api>;
7
+ apiKey?: string;
8
+ headers?: Record<string, string>;
9
+ reasoning?: ModelConfig['reasoning'];
10
+ };
11
+
12
+ export type ResolveModelResult = {
13
+ error?: string;
14
+ result?: ResolvedModel;
15
+ };
16
+
17
+ export class ModelResolver {
18
+ constructor(private readonly ctx: ExtensionContext) {}
19
+
20
+ sessionModel(): Model<Api> | undefined {
21
+ return this.ctx.model;
22
+ }
23
+
24
+ async resolveModel(configModel?: ModelConfig): Promise<ResolveModelResult> {
25
+ const errors: string[] = [];
26
+ const candidates: Array<{ model: Model<Api>; reasoning?: ModelConfig['reasoning'] }> = [];
27
+
28
+ if (configModel) {
29
+ const found = this.ctx.modelRegistry.find(configModel.provider, configModel.modelId);
30
+ if (found) {
31
+ candidates.push({ model: found, reasoning: configModel.reasoning });
32
+ } else {
33
+ errors.push(`Configured model not found: ${configModel.provider}/${configModel.modelId}`);
34
+ }
35
+ }
36
+
37
+ const sessionModel = this.sessionModel();
38
+ if (sessionModel && !candidates.some(candidate => candidate.model.provider === sessionModel.provider && candidate.model.id === sessionModel.id)) {
39
+ candidates.push({ model: sessionModel });
40
+ }
41
+
42
+ for (const candidate of candidates) {
43
+ const { model } = candidate;
44
+ const auth = await this.ctx.modelRegistry.getApiKeyAndHeaders(model);
45
+ if (auth.ok) {
46
+ return {
47
+ error: errors.length > 0 ? errors.join('\n') : undefined,
48
+ result: {
49
+ model,
50
+ apiKey: auth.apiKey,
51
+ headers: auth.headers,
52
+ reasoning: candidate.reasoning,
53
+ },
54
+ };
55
+ }
56
+
57
+ const isConfiguredModel = configModel && model.provider === configModel.provider && model.id === configModel.modelId;
58
+ errors.push(`${isConfiguredModel ? 'Configured' : 'Session'} model auth failed for ${model.provider}/${model.id}: ${auth.error}`);
59
+ }
60
+
61
+ if (!sessionModel) errors.push('No active session model is available as a fallback.');
62
+
63
+ return {
64
+ error: errors.length > 0 ? errors.join('\n') : 'No model could be resolved.',
65
+ };
66
+ }
67
+ }
@@ -0,0 +1,37 @@
1
+ import { existsSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { getAgentDir } from '@earendil-works/pi-coding-agent';
4
+ import { EXTENSION_ID } from '../constants';
5
+
6
+ export type FileSearchResult = {
7
+ exists: boolean;
8
+ path: string;
9
+ };
10
+
11
+ type FindConfigInput = { type: 'global' } | { type: 'project'; cwd: string };
12
+
13
+ export class PathUtil {
14
+ static findFile(filePath: string): FileSearchResult {
15
+ if (existsSync(filePath)) {
16
+ return { exists: true, path: filePath };
17
+ }
18
+ return { exists: false, path: filePath };
19
+ }
20
+
21
+ static findExtensionConfig(input: { type: 'global' }): FileSearchResult;
22
+ static findExtensionConfig(input: { type: 'project'; cwd: string }): FileSearchResult;
23
+ static findExtensionConfig(input: FindConfigInput): FileSearchResult {
24
+ switch (input.type) {
25
+ case 'global': {
26
+ return PathUtil.findFile(PathUtil.getExtensionConfig([getAgentDir()]));
27
+ }
28
+ case 'project': {
29
+ return PathUtil.findFile(PathUtil.getExtensionConfig([input.cwd, '.pi']));
30
+ }
31
+ }
32
+ }
33
+
34
+ private static getExtensionConfig(paths: string[]): string {
35
+ return path.join(...paths, 'extensions', EXTENSION_ID, 'config.json');
36
+ }
37
+ }