pi-sessions 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/LICENSE +21 -0
- package/README.md +173 -0
- package/extensions/session-ask.ts +297 -0
- package/extensions/session-auto-title/command.ts +109 -0
- package/extensions/session-auto-title/context.ts +41 -0
- package/extensions/session-auto-title/controller.ts +298 -0
- package/extensions/session-auto-title/model.ts +61 -0
- package/extensions/session-auto-title/prompt.ts +54 -0
- package/extensions/session-auto-title/retitle.ts +641 -0
- package/extensions/session-auto-title/state.ts +69 -0
- package/extensions/session-auto-title/wizard.ts +583 -0
- package/extensions/session-auto-title.ts +209 -0
- package/extensions/session-handoff/extract.ts +278 -0
- package/extensions/session-handoff/metadata.ts +96 -0
- package/extensions/session-handoff/picker.ts +394 -0
- package/extensions/session-handoff/query.ts +318 -0
- package/extensions/session-handoff/refs.ts +151 -0
- package/extensions/session-handoff/review.ts +268 -0
- package/extensions/session-handoff.ts +203 -0
- package/extensions/session-hooks.ts +55 -0
- package/extensions/session-index.ts +159 -0
- package/extensions/session-search/extract.ts +997 -0
- package/extensions/session-search/hooks.ts +350 -0
- package/extensions/session-search/normalize.ts +170 -0
- package/extensions/session-search/reindex.ts +93 -0
- package/extensions/session-search.ts +390 -0
- package/extensions/shared/search-snippet.ts +40 -0
- package/extensions/shared/session-index/common.ts +222 -0
- package/extensions/shared/session-index/index.ts +5 -0
- package/extensions/shared/session-index/lineage.ts +417 -0
- package/extensions/shared/session-index/schema.ts +178 -0
- package/extensions/shared/session-index/search.ts +688 -0
- package/extensions/shared/session-index/store.ts +173 -0
- package/extensions/shared/session-ui.ts +15 -0
- package/extensions/shared/settings.ts +141 -0
- package/extensions/shared/time.ts +38 -0
- package/extensions/shared/typebox.ts +61 -0
- package/images/handoff.png +0 -0
- package/images/session-title.png +0 -0
- package/images/session_ask.png +0 -0
- package/images/session_picker.png +0 -0
- package/images/session_search.png +0 -0
- package/package.json +64 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 thurstonsand
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# pi-sessions
|
|
2
|
+
|
|
3
|
+
`pi-sessions` turns your old Pi sessions into something you can actually reuse. It gives you search, follow-up Q&A, deliberate handoffs into new child sessions, automatic session titles, and a local index that keeps future sessions searchable.
|
|
4
|
+
|
|
5
|
+
## Screenshots
|
|
6
|
+
|
|
7
|
+
### Session lookup
|
|
8
|
+
|
|
9
|
+

|
|
10
|
+
|
|
11
|
+
### Handoffs
|
|
12
|
+
|
|
13
|
+

|
|
14
|
+
|
|
15
|
+
### Ask about old sessions
|
|
16
|
+
|
|
17
|
+

|
|
18
|
+
|
|
19
|
+

|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
**From npm** (recommended):
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pi install npm:pi-sessions
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
If you want to run directly from a local clone while developing:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
pi -e /absolute/path/to/pi-sessions
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Quick start
|
|
36
|
+
|
|
37
|
+
1. Install the package.
|
|
38
|
+
2. Open Pi and run `/session-index`.
|
|
39
|
+
3. Press `r` to build the index for all your prior sessions.
|
|
40
|
+
4. Try the main flows:
|
|
41
|
+
|
|
42
|
+
```text
|
|
43
|
+
What session did I implement the db layer?
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
```text
|
|
47
|
+
/handoff i want to implement the frontend component now
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Features
|
|
51
|
+
|
|
52
|
+
| Extension | Surface | What it does |
|
|
53
|
+
| ------------------ | ------------------------------------- | ------------------------------------------------------- |
|
|
54
|
+
| Session Search | `session_search` pi tool | Search through old sessions |
|
|
55
|
+
| Session Ask | `session_ask` pi tool | Ask questions about old sessions |
|
|
56
|
+
| Session Handoff | `/handoff`, session picker shortcut | Start a focused new session; alternative to compaction |
|
|
57
|
+
| Session Picker | `Alt+O` | Reference old sessions in your prompt |
|
|
58
|
+
| Session Index | `/session-index` slash command | Shows index status and rebuilds the local session index |
|
|
59
|
+
| Session Auto Title | in background, `/title` slash command | Give sessions titles |
|
|
60
|
+
|
|
61
|
+
## Session Handoff
|
|
62
|
+
|
|
63
|
+
`/handoff <goal>` starts a focused new session. Give pi a goal, and it will generate a prompt for you to review before kicking it off.
|
|
64
|
+
|
|
65
|
+
Flow:
|
|
66
|
+
|
|
67
|
+
- run `/handoff <goal>`
|
|
68
|
+
- review the generated prompt preview
|
|
69
|
+
- optionally edit the prompt to make any adjustments
|
|
70
|
+
- start the new session
|
|
71
|
+
|
|
72
|
+
If you do nothing, the preview autostarts after a short countdown.
|
|
73
|
+
|
|
74
|
+
## Session picker
|
|
75
|
+
|
|
76
|
+
Directly reference prior sessions by looking them up by contents.
|
|
77
|
+
|
|
78
|
+
- shortcut: `Alt+O`
|
|
79
|
+
- press `Tab` to switch between current folder and all sessions
|
|
80
|
+
- type to filter results
|
|
81
|
+
- press `Enter` to insert a session id into your prompt
|
|
82
|
+
|
|
83
|
+
### Handoff setting
|
|
84
|
+
|
|
85
|
+
If you want to override the shortcut, put this in your `~/.pi/agent/settings.json`:
|
|
86
|
+
|
|
87
|
+
```json
|
|
88
|
+
{
|
|
89
|
+
"sessions": {
|
|
90
|
+
"handoff": {
|
|
91
|
+
"pickerShortcut": "alt+p"
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Session Index
|
|
98
|
+
|
|
99
|
+
By default, `pi-sessions` will start indexing all conversations moving forward. If you want to backfill all prior conversations:
|
|
100
|
+
|
|
101
|
+
- run `/session-index`
|
|
102
|
+
- hit `r` to (re)index everything
|
|
103
|
+
|
|
104
|
+
this is idempotent, so if you run into any issues, or disable pi-sessions for a while, feel free to re-index to see if that resolves anything.
|
|
105
|
+
|
|
106
|
+
By default the index lives at:
|
|
107
|
+
|
|
108
|
+
```text
|
|
109
|
+
~/.pi/agent/pi-sessions/index.sqlite
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
but you can change the location in `~/.pi/agent/settings.json`:
|
|
113
|
+
|
|
114
|
+
```json
|
|
115
|
+
{
|
|
116
|
+
"sessions": {
|
|
117
|
+
"index": {
|
|
118
|
+
"dir": "~/.pi/agent/pi-sessions"
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
## Session Auto Title
|
|
125
|
+
|
|
126
|
+
The auto-title extension keeps your session list readable by:
|
|
127
|
+
|
|
128
|
+
- Setting a title based on initial prompt
|
|
129
|
+
- Reevaluating the title every 4 turns to see if it should be updated
|
|
130
|
+
|
|
131
|
+
To manage existing titles, run `/title`, where you can:
|
|
132
|
+
|
|
133
|
+
- Regenerate a title for the current session
|
|
134
|
+
- Generate titles for all sessions in the folder
|
|
135
|
+
- Generate titles for all sessions across pi
|
|
136
|
+
|
|
137
|
+

|
|
138
|
+
|
|
139
|
+
Note that generating titles for all sessions can take some time, and will hit your configured model with the full contents of all sessions.
|
|
140
|
+
|
|
141
|
+
Behavior notes:
|
|
142
|
+
|
|
143
|
+
- automatic retitles run every few turns
|
|
144
|
+
- if you manually rename a session with `/name`, automatic retitling pauses for that session
|
|
145
|
+
- Regenerate the title for the current session to resume automatic retitling
|
|
146
|
+
- if unconfigured, it will attempt to use these models in order, first one that is available:
|
|
147
|
+
- `google/gemini-flash-lite-latest`
|
|
148
|
+
- `anthropic/claude-haiku-4-5`
|
|
149
|
+
- `openai/gpt-5.4-mini`
|
|
150
|
+
- your currently configured model
|
|
151
|
+
|
|
152
|
+
To change auto-titling settings, edit `~/.pi/agent/settings.json`:
|
|
153
|
+
|
|
154
|
+
```json
|
|
155
|
+
{
|
|
156
|
+
"sessions": {
|
|
157
|
+
"autoTitle": {
|
|
158
|
+
"refreshTurns": 4,
|
|
159
|
+
"model": "anthropic/claude-haiku-4-5"
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
## Development
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
npm install
|
|
169
|
+
npm run check
|
|
170
|
+
npm test
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
For an end-to-end manual flow, see [SMOKE.md](./SMOKE.md).
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { complete, type Message } from "@mariozechner/pi-ai";
|
|
2
|
+
import type { ExtensionAPI, Theme } from "@mariozechner/pi-coding-agent";
|
|
3
|
+
import { Text } from "@mariozechner/pi-tui";
|
|
4
|
+
import { Type } from "@sinclair/typebox";
|
|
5
|
+
import { type RenderedSessionTree, renderSessionTreeMarkdown } from "./session-search/extract.js";
|
|
6
|
+
import {
|
|
7
|
+
getIndexStatus,
|
|
8
|
+
getSessionById,
|
|
9
|
+
INDEX_SCHEMA_VERSION,
|
|
10
|
+
openIndexDatabase,
|
|
11
|
+
type SessionLineageRow,
|
|
12
|
+
} from "./shared/session-index/index.js";
|
|
13
|
+
import { formatSessionTitleOrShortId, isExactSessionId } from "./shared/session-ui.js";
|
|
14
|
+
import { loadSettings } from "./shared/settings.js";
|
|
15
|
+
|
|
16
|
+
const SESSION_ASK_SYSTEM_PROMPT = `You are analyzing a Pi coding session transcript. The transcript includes the entire session tree, including abandoned branches and summaries.
|
|
17
|
+
|
|
18
|
+
Answer the user's question using only the session contents. Be specific and concise. Include exact file paths, decisions, and outcomes when present. If the answer is not in the session, say so clearly.`;
|
|
19
|
+
|
|
20
|
+
const COLLAPSED_ANSWER_PREVIEW_ROWS = 6;
|
|
21
|
+
|
|
22
|
+
interface SessionAskToolParams {
|
|
23
|
+
session: string;
|
|
24
|
+
question: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface SessionAskToolDetails {
|
|
28
|
+
cancelled?: boolean | undefined;
|
|
29
|
+
error?: boolean | undefined;
|
|
30
|
+
answer?: string | undefined;
|
|
31
|
+
question?: string | undefined;
|
|
32
|
+
sessionId?: string | undefined;
|
|
33
|
+
sessionName?: string | undefined;
|
|
34
|
+
sessionPath?: string | undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface TextContentBlock {
|
|
38
|
+
type: "text";
|
|
39
|
+
text: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export default function sessionAskExtension(pi: ExtensionAPI): void {
|
|
43
|
+
const settings = loadSettings();
|
|
44
|
+
|
|
45
|
+
pi.registerTool({
|
|
46
|
+
name: "session_ask",
|
|
47
|
+
label: "Session Ask",
|
|
48
|
+
description: "Interrogate a Pi session",
|
|
49
|
+
promptSnippet:
|
|
50
|
+
"Use when you have a session id and want to recall information, decisions, reasoning, etc from it",
|
|
51
|
+
promptGuidelines: ["Prefer focused follow-up questions over broad recap requests"],
|
|
52
|
+
parameters: Type.Object({
|
|
53
|
+
session: Type.String({
|
|
54
|
+
description: "Bare UUID for the session",
|
|
55
|
+
}),
|
|
56
|
+
question: Type.String({
|
|
57
|
+
description: "What to extract, verify, or explain from that session",
|
|
58
|
+
}),
|
|
59
|
+
}),
|
|
60
|
+
async execute(_toolCallId, params: SessionAskToolParams, signal, onUpdate, ctx) {
|
|
61
|
+
const sessionId = params.session.trim();
|
|
62
|
+
if (!sessionId) {
|
|
63
|
+
return errorResult("session_ask requires a session id.", { error: true });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const question = params.question.trim();
|
|
67
|
+
if (!question) {
|
|
68
|
+
return errorResult("session_ask requires a question.", { error: true, sessionId });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const resolvedTarget = resolveSessionAskTarget(sessionId, settings.index.path);
|
|
72
|
+
if (!resolvedTarget.resolved) {
|
|
73
|
+
return errorResult(resolvedTarget.error ?? "Unable to resolve session id.", {
|
|
74
|
+
error: true,
|
|
75
|
+
sessionId,
|
|
76
|
+
question,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (!ctx.model) {
|
|
81
|
+
return errorResult("No active model is available for session_ask.", { error: true });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model);
|
|
85
|
+
if (!auth.ok || !auth.apiKey) {
|
|
86
|
+
return errorResult(`No API key is available for ${ctx.model.provider}/${ctx.model.id}.`, {
|
|
87
|
+
error: true,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const progressDetails: SessionAskToolDetails = {
|
|
92
|
+
question,
|
|
93
|
+
sessionId: resolvedTarget.resolved.sessionId,
|
|
94
|
+
sessionName: resolvedTarget.resolved.sessionName,
|
|
95
|
+
sessionPath: resolvedTarget.resolved.sessionPath,
|
|
96
|
+
};
|
|
97
|
+
onUpdate?.({
|
|
98
|
+
content: [{ type: "text", text: "Reading session..." }],
|
|
99
|
+
details: progressDetails,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
let rendered: RenderedSessionTree;
|
|
103
|
+
try {
|
|
104
|
+
rendered = renderSessionTreeMarkdown(resolvedTarget.resolved.sessionPath);
|
|
105
|
+
} catch (error) {
|
|
106
|
+
return errorResult(formatSessionAskLoadError(resolvedTarget.resolved.sessionPath, error), {
|
|
107
|
+
error: true,
|
|
108
|
+
sessionId,
|
|
109
|
+
sessionPath: resolvedTarget.resolved.sessionPath,
|
|
110
|
+
question,
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const loadedDetails: SessionAskToolDetails = {
|
|
115
|
+
question,
|
|
116
|
+
sessionId: rendered.sessionId,
|
|
117
|
+
sessionName: rendered.sessionName,
|
|
118
|
+
sessionPath: resolvedTarget.resolved.sessionPath,
|
|
119
|
+
};
|
|
120
|
+
onUpdate?.({
|
|
121
|
+
content: [
|
|
122
|
+
{
|
|
123
|
+
type: "text",
|
|
124
|
+
text: formatSessionAskHeader(rendered.sessionId, rendered.sessionName, question),
|
|
125
|
+
},
|
|
126
|
+
],
|
|
127
|
+
details: loadedDetails,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const userMessage: Message = {
|
|
131
|
+
role: "user",
|
|
132
|
+
content: [
|
|
133
|
+
{
|
|
134
|
+
type: "text",
|
|
135
|
+
text: [`## Session`, rendered.markdown, "", "## Question", question].join("\n"),
|
|
136
|
+
},
|
|
137
|
+
],
|
|
138
|
+
timestamp: Date.now(),
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const response = await complete(
|
|
142
|
+
ctx.model,
|
|
143
|
+
{ systemPrompt: SESSION_ASK_SYSTEM_PROMPT, messages: [userMessage] },
|
|
144
|
+
signal
|
|
145
|
+
? {
|
|
146
|
+
apiKey: auth.apiKey,
|
|
147
|
+
...(auth.headers ? { headers: auth.headers } : {}),
|
|
148
|
+
signal,
|
|
149
|
+
}
|
|
150
|
+
: {
|
|
151
|
+
apiKey: auth.apiKey,
|
|
152
|
+
...(auth.headers ? { headers: auth.headers } : {}),
|
|
153
|
+
},
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
if (response.stopReason === "aborted") {
|
|
157
|
+
return errorResult("Session ask was cancelled.", { cancelled: true });
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const answer = collectTextBlocks(response.content).join("\n").trim();
|
|
161
|
+
|
|
162
|
+
const details: SessionAskToolDetails = {
|
|
163
|
+
answer,
|
|
164
|
+
sessionId: resolvedTarget.resolved.sessionId,
|
|
165
|
+
sessionName: resolvedTarget.resolved.sessionName,
|
|
166
|
+
sessionPath: resolvedTarget.resolved.sessionPath,
|
|
167
|
+
question,
|
|
168
|
+
};
|
|
169
|
+
return {
|
|
170
|
+
content: [
|
|
171
|
+
{
|
|
172
|
+
type: "text",
|
|
173
|
+
text: [
|
|
174
|
+
formatSessionAskHeader(rendered.sessionId, rendered.sessionName, question),
|
|
175
|
+
answer || "No answer generated.",
|
|
176
|
+
].join("\n\n"),
|
|
177
|
+
},
|
|
178
|
+
],
|
|
179
|
+
details,
|
|
180
|
+
};
|
|
181
|
+
},
|
|
182
|
+
renderResult(result, { expanded, isPartial }, theme) {
|
|
183
|
+
const details = result.details as SessionAskToolDetails | undefined;
|
|
184
|
+
const content = result.content[0];
|
|
185
|
+
if (content?.type !== "text") {
|
|
186
|
+
return new Text(theme.fg("error", "No session output"), 0, 0);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (isPartial) {
|
|
190
|
+
const lines = [theme.bold(theme.fg("warning", "Reading session..."))];
|
|
191
|
+
if (details?.sessionId || details?.sessionName) {
|
|
192
|
+
const identity = formatSessionTitleOrShortId(details.sessionName, details.sessionId);
|
|
193
|
+
lines.push(`title: ${theme.fg("accent", identity)}`);
|
|
194
|
+
}
|
|
195
|
+
if (details?.question) {
|
|
196
|
+
lines.push(theme.fg("muted", `prompt: ${details.question}`));
|
|
197
|
+
}
|
|
198
|
+
return new Text(lines.join("\n"), 0, 0);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (details?.cancelled) {
|
|
202
|
+
return new Text(theme.fg("warning", content.text), 0, 0);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (details?.error) {
|
|
206
|
+
return new Text(theme.fg("error", content.text), 0, 0);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const answer = (details?.answer ?? "").trim() || "No answer generated.";
|
|
210
|
+
const identity = formatSessionTitleOrShortId(details?.sessionName, details?.sessionId);
|
|
211
|
+
const lines = [`title: ${theme.bold(identity)}`];
|
|
212
|
+
if (details?.question) {
|
|
213
|
+
lines.push(theme.fg("muted", `prompt: ${details.question}`));
|
|
214
|
+
lines.push("");
|
|
215
|
+
}
|
|
216
|
+
lines.push(...formatSessionAskAnswerPreview(answer, expanded, theme));
|
|
217
|
+
return new Text(lines.join("\n"), 0, 0);
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function resolveSessionAskTarget(
|
|
223
|
+
sessionId: string,
|
|
224
|
+
indexPath: string,
|
|
225
|
+
): {
|
|
226
|
+
resolved?: SessionLineageRow | undefined;
|
|
227
|
+
error?: string | undefined;
|
|
228
|
+
} {
|
|
229
|
+
if (!isExactSessionId(sessionId)) {
|
|
230
|
+
return {
|
|
231
|
+
error:
|
|
232
|
+
"session_ask requires an exact session UUID. Use autocomplete or session_search to find it.",
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const status = getIndexStatus(indexPath);
|
|
237
|
+
if (!status.exists || status.schemaVersion !== INDEX_SCHEMA_VERSION) {
|
|
238
|
+
return {
|
|
239
|
+
error: `Session index missing or incompatible at ${indexPath}. Run /session-index and press r to rebuild it.`,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const db = openIndexDatabase(status.dbPath, { create: false });
|
|
244
|
+
try {
|
|
245
|
+
const row = getSessionById(db, sessionId);
|
|
246
|
+
if (!row) {
|
|
247
|
+
return { error: `No indexed session found for id: ${sessionId}` };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return { resolved: row };
|
|
251
|
+
} finally {
|
|
252
|
+
db.close();
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function collectTextBlocks(content: Array<{ type: string; text?: string }>): string[] {
|
|
257
|
+
return content.filter(isTextContentBlock).map((block) => block.text);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function formatSessionAskHeader(sessionId: string, sessionName: string, question: string): string {
|
|
261
|
+
return [
|
|
262
|
+
`session: ${sessionId}`,
|
|
263
|
+
`title: ${sessionName || "[unnamed]"}`,
|
|
264
|
+
`question: ${question}`,
|
|
265
|
+
].join("\n");
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function errorResult(
|
|
269
|
+
text: string,
|
|
270
|
+
details: SessionAskToolDetails,
|
|
271
|
+
): { content: Array<{ type: "text"; text: string }>; details: SessionAskToolDetails } {
|
|
272
|
+
return {
|
|
273
|
+
content: [{ type: "text", text }],
|
|
274
|
+
details,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function formatSessionAskAnswerPreview(answer: string, expanded: boolean, theme: Theme): string[] {
|
|
279
|
+
const lines = answer.split(/\r?\n/);
|
|
280
|
+
if (expanded || lines.length <= COLLAPSED_ANSWER_PREVIEW_ROWS) {
|
|
281
|
+
return lines;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
return [...lines.slice(0, COLLAPSED_ANSWER_PREVIEW_ROWS), theme.fg("dim", "...")];
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function formatSessionAskLoadError(sessionPath: string, error: unknown): string {
|
|
288
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
289
|
+
return `Session file not found: ${sessionPath}`;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return `Unable to load session file: ${sessionPath}`;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function isTextContentBlock(content: { type: string; text?: string }): content is TextContentBlock {
|
|
296
|
+
return content.type === "text" && typeof content.text === "string";
|
|
297
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import type { AutocompleteItem } from "@mariozechner/pi-tui";
|
|
3
|
+
|
|
4
|
+
export const TITLE_USAGE = "Usage: /title [this|folder|pi] [-f]";
|
|
5
|
+
|
|
6
|
+
export type RetitleScope = "this" | "folder" | "global";
|
|
7
|
+
export type RetitleMode = "backfill" | "all";
|
|
8
|
+
|
|
9
|
+
export type RetitleCommandInvocation =
|
|
10
|
+
| { kind: "open-pane" }
|
|
11
|
+
| {
|
|
12
|
+
kind: "run";
|
|
13
|
+
scope: RetitleScope;
|
|
14
|
+
mode?: RetitleMode;
|
|
15
|
+
force?: boolean;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type RetitleCommandParseResult =
|
|
19
|
+
| RetitleCommandInvocation
|
|
20
|
+
| {
|
|
21
|
+
kind: "error";
|
|
22
|
+
message: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type RetitleCommandOutcome = "success" | "cancelled" | "failed";
|
|
26
|
+
|
|
27
|
+
const VALID_TOKENS = new Set(["this", "folder", "pi", "-f"]);
|
|
28
|
+
|
|
29
|
+
const ARGUMENT_COMPLETIONS: AutocompleteItem[] = [
|
|
30
|
+
{
|
|
31
|
+
value: "this",
|
|
32
|
+
label: "this",
|
|
33
|
+
description: "Retitle the current session immediately",
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
value: "folder",
|
|
37
|
+
label: "folder",
|
|
38
|
+
description: "Backfill untitled sessions in this folder; add -f to skip confirmation",
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
value: "pi",
|
|
42
|
+
label: "pi",
|
|
43
|
+
description: "Backfill untitled sessions across all of Pi; add -f to skip confirmation",
|
|
44
|
+
},
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
export function createSessionAutoTitleCommandHandler(
|
|
48
|
+
execute: (
|
|
49
|
+
invocation: RetitleCommandInvocation,
|
|
50
|
+
ctx: ExtensionCommandContext,
|
|
51
|
+
) => Promise<RetitleCommandOutcome>,
|
|
52
|
+
): (args: string, ctx: ExtensionCommandContext) => Promise<void> {
|
|
53
|
+
return async (args: string, ctx: ExtensionCommandContext): Promise<void> => {
|
|
54
|
+
const parsed = parseRetitleCommand(args, ctx.hasUI);
|
|
55
|
+
if (parsed.kind === "error") {
|
|
56
|
+
ctx.ui.notify(parsed.message, "error");
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (parsed.kind === "run") {
|
|
61
|
+
await ctx.waitForIdle();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const outcome = await execute(parsed, ctx);
|
|
65
|
+
if (outcome === "failed") {
|
|
66
|
+
ctx.ui.notify("Session retitle failed.", "error");
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function getRetitleArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null {
|
|
72
|
+
const normalizedPrefix = argumentPrefix.trimStart().toLowerCase();
|
|
73
|
+
const filtered = ARGUMENT_COMPLETIONS.filter((item) =>
|
|
74
|
+
item.value.toLowerCase().startsWith(normalizedPrefix),
|
|
75
|
+
);
|
|
76
|
+
return filtered.length > 0 ? filtered : null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function parseRetitleCommand(args: string, hasUI: boolean): RetitleCommandParseResult {
|
|
80
|
+
const trimmedArgs = args.trim();
|
|
81
|
+
if (!trimmedArgs) {
|
|
82
|
+
return hasUI ? { kind: "open-pane" } : { kind: "run", scope: "this" };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const tokens = trimmedArgs.split(/\s+/);
|
|
86
|
+
const tokenSet = new Set(tokens);
|
|
87
|
+
if (tokenSet.size !== tokens.length || tokens.some((t) => !VALID_TOKENS.has(t))) {
|
|
88
|
+
return { kind: "error", message: TITLE_USAGE };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (tokenSet.has("this")) {
|
|
92
|
+
if (tokenSet.size !== 1) {
|
|
93
|
+
return { kind: "error", message: TITLE_USAGE };
|
|
94
|
+
}
|
|
95
|
+
return { kind: "run", scope: "this" };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const scopeCount = Number(tokenSet.has("folder")) + Number(tokenSet.has("pi"));
|
|
99
|
+
if (scopeCount !== 1) {
|
|
100
|
+
return { kind: "error", message: TITLE_USAGE };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
kind: "run",
|
|
105
|
+
scope: tokenSet.has("pi") ? "global" : "folder",
|
|
106
|
+
mode: "backfill",
|
|
107
|
+
...(tokenSet.has("-f") && { force: true }),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildSessionContext,
|
|
3
|
+
convertToLlm,
|
|
4
|
+
type SessionEntry,
|
|
5
|
+
serializeConversation,
|
|
6
|
+
} from "@mariozechner/pi-coding-agent";
|
|
7
|
+
|
|
8
|
+
export interface AutoTitleContext {
|
|
9
|
+
cwd: string | undefined;
|
|
10
|
+
currentTitle: string | undefined;
|
|
11
|
+
conversationText: string;
|
|
12
|
+
userTurnCount: number;
|
|
13
|
+
assistantTurnCount: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function buildAutoTitleContext(
|
|
17
|
+
entries: SessionEntry[],
|
|
18
|
+
leafId: string | null,
|
|
19
|
+
options?: { cwd?: string; currentTitle?: string | undefined },
|
|
20
|
+
): AutoTitleContext {
|
|
21
|
+
const sessionContext = buildSessionContext(entries, leafId);
|
|
22
|
+
const conversationText = serializeConversation(convertToLlm(sessionContext.messages));
|
|
23
|
+
|
|
24
|
+
let userTurnCount = 0;
|
|
25
|
+
let assistantTurnCount = 0;
|
|
26
|
+
for (const message of sessionContext.messages) {
|
|
27
|
+
if (message.role === "user") {
|
|
28
|
+
userTurnCount += 1;
|
|
29
|
+
} else if (message.role === "assistant") {
|
|
30
|
+
assistantTurnCount += 1;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
cwd: options?.cwd,
|
|
36
|
+
currentTitle: options?.currentTitle,
|
|
37
|
+
conversationText,
|
|
38
|
+
userTurnCount,
|
|
39
|
+
assistantTurnCount,
|
|
40
|
+
};
|
|
41
|
+
}
|