@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 +135 -0
- package/assets/config.schema.json +60 -0
- package/package.json +59 -0
- package/src/config-loader.ts +80 -0
- package/src/constants.ts +1 -0
- package/src/extensions/auto-session-name/constants.ts +3 -0
- package/src/extensions/auto-session-name/guards.ts +22 -0
- package/src/extensions/auto-session-name/index.ts +74 -0
- package/src/extensions/auto-session-name/prompt.ts +64 -0
- package/src/extensions/auto-session-name/title-generator.ts +113 -0
- package/src/index.ts +14 -0
- package/src/schemas/auto-session-name.config.schema.ts +11 -0
- package/src/schemas/config.schema.ts +16 -0
- package/src/schemas/shared-config.schema.ts +11 -0
- package/src/utils/model-resolver.util.ts +67 -0
- package/src/utils/path.util.ts +37 -0
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
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const EXTENSION_ID = 'pi-qol';
|
|
@@ -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
|
+
}
|