pi-sessions 0.6.0 → 0.7.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.
Files changed (43) hide show
  1. package/README.md +22 -11
  2. package/extensions/session-ask/agent.ts +400 -0
  3. package/extensions/session-ask/navigate.ts +880 -0
  4. package/extensions/session-ask.ts +79 -131
  5. package/extensions/session-auto-title/model.ts +3 -11
  6. package/extensions/session-handoff/picker.ts +50 -4
  7. package/extensions/session-handoff/query.ts +74 -56
  8. package/extensions/session-handoff/refs.ts +12 -18
  9. package/extensions/session-handoff.ts +36 -27
  10. package/extensions/session-messaging/broker/process.ts +23 -26
  11. package/extensions/session-messaging/broker/spawn.ts +7 -2
  12. package/extensions/session-messaging/pi/incoming-runtime.ts +2 -18
  13. package/extensions/session-messaging/pi/message-contracts.ts +9 -10
  14. package/extensions/session-messaging/pi/message-view.ts +12 -1
  15. package/extensions/session-messaging/pi/service.ts +117 -62
  16. package/extensions/session-messaging/pi/tools.ts +4 -56
  17. package/extensions/session-search/extract.ts +86 -436
  18. package/extensions/session-search/hooks.ts +17 -25
  19. package/extensions/session-search/normalize.ts +1 -1
  20. package/extensions/session-search/reindex.ts +1 -0
  21. package/extensions/session-search.ts +157 -128
  22. package/extensions/shared/model.ts +18 -0
  23. package/extensions/shared/session-broker/active.ts +26 -0
  24. package/extensions/{session-messaging/pi → shared/session-broker}/client.ts +16 -19
  25. package/extensions/{session-messaging/shared → shared/session-broker}/framing.ts +1 -1
  26. package/extensions/{session-messaging/shared → shared/session-broker}/protocol.ts +5 -12
  27. package/extensions/shared/session-index/access.ts +128 -0
  28. package/extensions/shared/session-index/common.ts +43 -48
  29. package/extensions/shared/session-index/index.ts +1 -0
  30. package/extensions/shared/session-index/lineage.ts +10 -0
  31. package/extensions/shared/session-index/query/ast.ts +40 -0
  32. package/extensions/shared/session-index/query/compiler.ts +146 -0
  33. package/extensions/shared/session-index/query/lexer.ts +140 -0
  34. package/extensions/shared/session-index/query/parser.ts +178 -0
  35. package/extensions/shared/session-index/schema.ts +22 -6
  36. package/extensions/shared/session-index/scoring.ts +80 -0
  37. package/extensions/shared/session-index/search.ts +552 -278
  38. package/extensions/shared/session-index/sqlite.ts +16 -9
  39. package/extensions/shared/session-index/store.ts +12 -21
  40. package/extensions/shared/settings.ts +61 -4
  41. package/extensions/shared/text.ts +50 -0
  42. package/package.json +1 -1
  43. /package/extensions/{session-messaging/shared → shared/session-broker}/socket-path.ts +0 -0
@@ -1,23 +1,15 @@
1
- import type { Message } from "@earendil-works/pi-ai";
2
- import { complete } from "@earendil-works/pi-ai/compat";
3
1
  import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
4
- import { Text } from "@earendil-works/pi-tui";
2
+ import { getKeybindings, type Keybinding, Text } from "@earendil-works/pi-tui";
5
3
  import { Type } from "typebox";
6
- import { type RenderedSessionTree, renderSessionTreeMarkdown } from "./session-search/extract.ts";
4
+ import { runSessionAskAgent } from "./session-ask/agent.ts";
7
5
  import {
8
- getIndexStatus,
9
6
  getSessionById,
10
- INDEX_SCHEMA_VERSION,
11
- openIndexDatabase,
12
7
  type SessionLineageRow,
8
+ withSessionIndex,
13
9
  } from "./shared/session-index/index.ts";
14
10
  import { formatSessionTitleOrShortId, isExactSessionId } from "./shared/session-ui.ts";
15
11
  import { loadSettings } from "./shared/settings.ts";
16
12
 
17
- 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.
18
-
19
- 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.`;
20
-
21
13
  const COLLAPSED_ANSWER_PREVIEW_ROWS = 6;
22
14
 
23
15
  interface SessionAskToolParams {
@@ -25,21 +17,21 @@ interface SessionAskToolParams {
25
17
  question: string;
26
18
  }
27
19
 
20
+ interface SessionAskRelevantFile {
21
+ path: string;
22
+ reason: string;
23
+ }
24
+
28
25
  interface SessionAskToolDetails {
29
- cancelled?: boolean | undefined;
30
- error?: boolean | undefined;
31
26
  answer?: string | undefined;
27
+ debugSessionPath?: string | undefined;
32
28
  question?: string | undefined;
29
+ relevantFiles?: SessionAskRelevantFile[] | undefined;
33
30
  sessionId?: string | undefined;
34
31
  sessionName?: string | undefined;
35
32
  sessionPath?: string | undefined;
36
33
  }
37
34
 
38
- interface TextContentBlock {
39
- type: "text";
40
- text: string;
41
- }
42
-
43
35
  export default function sessionAskExtension(pi: ExtensionAPI): void {
44
36
  const settings = loadSettings();
45
37
 
@@ -61,32 +53,17 @@ export default function sessionAskExtension(pi: ExtensionAPI): void {
61
53
  async execute(_toolCallId, params: SessionAskToolParams, signal, onUpdate, ctx) {
62
54
  const sessionId = params.session.trim();
63
55
  if (!sessionId) {
64
- return errorResult("session_ask requires a session id.", { error: true });
56
+ throw new Error("session_ask requires a session id.");
65
57
  }
66
58
 
67
59
  const question = params.question.trim();
68
60
  if (!question) {
69
- return errorResult("session_ask requires a question.", { error: true, sessionId });
61
+ throw new Error("session_ask requires a question.");
70
62
  }
71
63
 
72
64
  const resolvedTarget = resolveSessionAskTarget(sessionId, settings.index.path);
73
65
  if (!resolvedTarget.resolved) {
74
- return errorResult(resolvedTarget.error ?? "Unable to resolve session id.", {
75
- error: true,
76
- sessionId,
77
- question,
78
- });
79
- }
80
-
81
- if (!ctx.model) {
82
- return errorResult("No active model is available for session_ask.", { error: true });
83
- }
84
-
85
- const auth = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model);
86
- if (!auth.ok || !auth.apiKey) {
87
- return errorResult(`No API key is available for ${ctx.model.provider}/${ctx.model.id}.`, {
88
- error: true,
89
- });
66
+ throw new Error(resolvedTarget.error ?? "Unable to resolve session id.");
90
67
  }
91
68
 
92
69
  const progressDetails: SessionAskToolDetails = {
@@ -100,68 +77,41 @@ export default function sessionAskExtension(pi: ExtensionAPI): void {
100
77
  details: progressDetails,
101
78
  });
102
79
 
103
- let rendered: RenderedSessionTree;
104
- try {
105
- rendered = renderSessionTreeMarkdown(resolvedTarget.resolved.sessionPath);
106
- } catch (error) {
107
- return errorResult(formatSessionAskLoadError(resolvedTarget.resolved.sessionPath, error), {
108
- error: true,
109
- sessionId,
110
- sessionPath: resolvedTarget.resolved.sessionPath,
111
- question,
112
- });
113
- }
114
-
115
- const loadedDetails: SessionAskToolDetails = {
116
- question,
117
- sessionId: rendered.sessionId,
118
- sessionName: rendered.sessionName,
119
- sessionPath: resolvedTarget.resolved.sessionPath,
120
- };
121
80
  onUpdate?.({
122
81
  content: [
123
82
  {
124
83
  type: "text",
125
- text: formatSessionAskHeader(rendered.sessionId, rendered.sessionName, question),
84
+ text: formatSessionAskHeader(
85
+ resolvedTarget.resolved.sessionId,
86
+ resolvedTarget.resolved.sessionName,
87
+ question,
88
+ ),
126
89
  },
127
90
  ],
128
- details: loadedDetails,
91
+ details: progressDetails,
129
92
  });
130
93
 
131
- const userMessage: Message = {
132
- role: "user",
133
- content: [
134
- {
135
- type: "text",
136
- text: [`## Session`, rendered.markdown, "", "## Question", question].join("\n"),
137
- },
138
- ],
139
- timestamp: Date.now(),
140
- };
141
-
142
- const response = await complete(
143
- ctx.model,
144
- { systemPrompt: SESSION_ASK_SYSTEM_PROMPT, messages: [userMessage] },
145
- signal
146
- ? {
147
- apiKey: auth.apiKey,
148
- ...(auth.headers ? { headers: auth.headers } : {}),
149
- signal,
150
- }
151
- : {
152
- apiKey: auth.apiKey,
153
- ...(auth.headers ? { headers: auth.headers } : {}),
154
- },
155
- );
94
+ const agentResult = await runSessionAskAgent({
95
+ ctx,
96
+ target: resolvedTarget.resolved,
97
+ question,
98
+ indexPath: settings.index.path,
99
+ askSettings: settings.ask,
100
+ thinkingLevel: pi.getThinkingLevel(),
101
+ signal,
102
+ });
156
103
 
157
- if (response.stopReason === "aborted") {
158
- return errorResult("Session ask was cancelled.", { cancelled: true });
104
+ if (signal?.aborted) {
105
+ throw new Error("Session ask was cancelled.");
159
106
  }
160
107
 
161
- const answer = collectTextBlocks(response.content).join("\n").trim();
108
+ const answer = agentResult?.answer || "Could not determine an answer from the session.";
109
+ const relevantFiles = agentResult?.relevantFiles ?? [];
162
110
 
163
111
  const details: SessionAskToolDetails = {
164
112
  answer,
113
+ debugSessionPath: agentResult?.debugSessionPath,
114
+ relevantFiles,
165
115
  sessionId: resolvedTarget.resolved.sessionId,
166
116
  sessionName: resolvedTarget.resolved.sessionName,
167
117
  sessionPath: resolvedTarget.resolved.sessionPath,
@@ -172,21 +122,29 @@ export default function sessionAskExtension(pi: ExtensionAPI): void {
172
122
  {
173
123
  type: "text",
174
124
  text: [
175
- formatSessionAskHeader(rendered.sessionId, rendered.sessionName, question),
176
- answer || "No answer generated.",
125
+ formatSessionAskHeader(
126
+ resolvedTarget.resolved.sessionId,
127
+ resolvedTarget.resolved.sessionName,
128
+ question,
129
+ ),
130
+ formatSessionAskAnswer(answer, relevantFiles, agentResult?.debugSessionPath),
177
131
  ].join("\n\n"),
178
132
  },
179
133
  ],
180
134
  details,
181
135
  };
182
136
  },
183
- renderResult(result, { expanded, isPartial }, theme) {
137
+ renderResult(result, { expanded, isPartial }, theme, context) {
184
138
  const details = result.details as SessionAskToolDetails | undefined;
185
139
  const content = result.content[0];
186
140
  if (content?.type !== "text") {
187
141
  return new Text(theme.fg("error", "No session output"), 0, 0);
188
142
  }
189
143
 
144
+ if (context.isError) {
145
+ return new Text(theme.fg("error", content.text), 0, 0);
146
+ }
147
+
190
148
  if (isPartial) {
191
149
  const lines = [theme.bold(theme.fg("warning", "Reading session..."))];
192
150
  if (details?.sessionId || details?.sessionName) {
@@ -199,14 +157,6 @@ export default function sessionAskExtension(pi: ExtensionAPI): void {
199
157
  return new Text(lines.join("\n"), 0, 0);
200
158
  }
201
159
 
202
- if (details?.cancelled) {
203
- return new Text(theme.fg("warning", content.text), 0, 0);
204
- }
205
-
206
- if (details?.error) {
207
- return new Text(theme.fg("error", content.text), 0, 0);
208
- }
209
-
210
160
  const answer = (details?.answer ?? "").trim() || "No answer generated.";
211
161
  const identity = formatSessionTitleOrShortId(details?.sessionName, details?.sessionId);
212
162
  const lines = [`title: ${theme.bold(identity)}`];
@@ -234,30 +184,20 @@ function resolveSessionAskTarget(
234
184
  };
235
185
  }
236
186
 
237
- const status = getIndexStatus(indexPath);
238
- if (!status.exists || status.schemaVersion !== INDEX_SCHEMA_VERSION) {
239
- return {
240
- error: `Session index missing or incompatible at ${indexPath}. Run /session-index and press r to rebuild it.`,
241
- };
242
- }
243
-
244
- const db = openIndexDatabase(status.dbPath, { create: false });
245
187
  try {
246
- const row = getSessionById(db, sessionId);
247
- if (!row) {
248
- return { error: `No indexed session found for id: ${sessionId}` };
249
- }
188
+ return withSessionIndex(indexPath, { mode: "read", required: true }, ({ db }) => {
189
+ const row = getSessionById(db, sessionId);
190
+ if (!row) {
191
+ return { error: `No indexed session found for id: ${sessionId}` };
192
+ }
250
193
 
251
- return { resolved: row };
252
- } finally {
253
- db.close();
194
+ return { resolved: row };
195
+ });
196
+ } catch (error) {
197
+ return { error: error instanceof Error ? error.message : String(error) };
254
198
  }
255
199
  }
256
200
 
257
- function collectTextBlocks(content: Array<{ type: string; text?: string }>): string[] {
258
- return content.filter(isTextContentBlock).map((block) => block.text);
259
- }
260
-
261
201
  function formatSessionAskHeader(sessionId: string, sessionName: string, question: string): string {
262
202
  return [
263
203
  `session: ${sessionId}`,
@@ -266,14 +206,20 @@ function formatSessionAskHeader(sessionId: string, sessionName: string, question
266
206
  ].join("\n");
267
207
  }
268
208
 
269
- function errorResult(
270
- text: string,
271
- details: SessionAskToolDetails,
272
- ): { content: Array<{ type: "text"; text: string }>; details: SessionAskToolDetails } {
273
- return {
274
- content: [{ type: "text", text }],
275
- details,
276
- };
209
+ function formatSessionAskAnswer(
210
+ answer: string,
211
+ relevantFiles: SessionAskRelevantFile[],
212
+ debugSessionPath: string | undefined,
213
+ ): string {
214
+ const lines = [answer || "No answer generated."];
215
+ if (relevantFiles.length > 0) {
216
+ lines.push("", "Relevant files:");
217
+ lines.push(...relevantFiles.map((file) => `- ${file.path}: ${file.reason}`));
218
+ }
219
+ if (debugSessionPath) {
220
+ lines.push("", `debug_session: ${debugSessionPath}`);
221
+ }
222
+ return lines.join("\n");
277
223
  }
278
224
 
279
225
  function formatSessionAskAnswerPreview(answer: string, expanded: boolean, theme: Theme): string[] {
@@ -282,17 +228,19 @@ function formatSessionAskAnswerPreview(answer: string, expanded: boolean, theme:
282
228
  return lines;
283
229
  }
284
230
 
285
- return [...lines.slice(0, COLLAPSED_ANSWER_PREVIEW_ROWS), theme.fg("dim", "...")];
231
+ return [
232
+ ...lines.slice(0, COLLAPSED_ANSWER_PREVIEW_ROWS),
233
+ formatOverflowHint(lines.length - COLLAPSED_ANSWER_PREVIEW_ROWS, lines.length, theme),
234
+ ];
286
235
  }
287
236
 
288
- function formatSessionAskLoadError(sessionPath: string, error: unknown): string {
289
- if (error instanceof Error && "code" in error && error.code === "ENOENT") {
290
- return `Session file not found: ${sessionPath}`;
291
- }
292
-
293
- return `Unable to load session file: ${sessionPath}`;
237
+ function formatOverflowHint(remaining: number, total: number, theme: Theme): string {
238
+ return `${theme.fg("muted", `... (${remaining} more lines, ${total} total,`)} ${theme.fg(
239
+ "dim",
240
+ formatKeyHint("app.tools.expand"),
241
+ )}${theme.fg("muted", " to expand)")}`;
294
242
  }
295
243
 
296
- function isTextContentBlock(content: { type: string; text?: string }): content is TextContentBlock {
297
- return content.type === "text" && typeof content.text === "string";
244
+ function formatKeyHint(keybinding: Keybinding): string {
245
+ return getKeybindings().getKeys(keybinding).join("/");
298
246
  }
@@ -1,5 +1,6 @@
1
1
  import type { Api, Model } from "@earendil-works/pi-ai";
2
2
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { findModelByReference } from "../shared/model.ts";
3
4
  import { ModelReference } from "../shared/settings.ts";
4
5
 
5
6
  const DEFAULT_AUTO_TITLE_FALLBACK_MODELS: readonly ModelReference[] = [
@@ -22,7 +23,7 @@ export function resolveAutoTitleModel(
22
23
  const availableModels = ctx.modelRegistry.getAvailable();
23
24
 
24
25
  if (configuredModel) {
25
- const configuredMatch = findMatchingModel(availableModels, configuredModel);
26
+ const configuredMatch = findModelByReference(availableModels, configuredModel);
26
27
  if (configuredMatch) {
27
28
  return {
28
29
  model: configuredMatch,
@@ -32,7 +33,7 @@ export function resolveAutoTitleModel(
32
33
  }
33
34
 
34
35
  for (const fallbackReference of DEFAULT_AUTO_TITLE_FALLBACK_MODELS) {
35
- const fallbackMatch = findMatchingModel(availableModels, fallbackReference);
36
+ const fallbackMatch = findModelByReference(availableModels, fallbackReference);
36
37
  if (fallbackMatch) {
37
38
  return {
38
39
  model: fallbackMatch,
@@ -50,12 +51,3 @@ export function resolveAutoTitleModel(
50
51
  source: "current",
51
52
  };
52
53
  }
53
-
54
- function findMatchingModel(
55
- availableModels: Model<Api>[],
56
- reference: ModelReference,
57
- ): Model<Api> | undefined {
58
- return availableModels.find(
59
- (model) => model.provider === reference.provider && model.id === reference.modelId,
60
- );
61
- }
@@ -16,6 +16,7 @@ import { listSessionPickerItems, normalizeDisplayText, type SessionPickerItem }
16
16
 
17
17
  const MAX_VISIBLE_BROWSE_ROWS = 10;
18
18
  const MAX_VISIBLE_SEARCH_ROWS = 4;
19
+ const SEARCH_RELOAD_DEBOUNCE_MS = 200;
19
20
 
20
21
  interface PickerRightColumnWidths {
21
22
  marker: number;
@@ -65,6 +66,7 @@ export class SessionReferencePickerComponent implements Focusable {
65
66
  private includeAll = false;
66
67
  private items: SessionPickerItem[] = [];
67
68
  private selectedIndex = 0;
69
+ private searchReloadTimer: ReturnType<typeof setTimeout> | undefined;
68
70
 
69
71
  get focused(): boolean {
70
72
  return this._focused;
@@ -89,17 +91,18 @@ export class SessionReferencePickerComponent implements Focusable {
89
91
 
90
92
  handleInput(data: string): void {
91
93
  if (matchesKey(data, this.options.shortcut)) {
92
- this.done({ kind: "cancel" });
94
+ this.finish({ kind: "cancel" });
93
95
  return;
94
96
  }
95
97
 
96
98
  if (this.keybindings.matches(data, "tui.select.cancel")) {
97
- this.done({ kind: "cancel" });
99
+ this.finish({ kind: "cancel" });
98
100
  return;
99
101
  }
100
102
 
101
103
  if (this.keybindings.matches(data, "tui.input.tab")) {
102
104
  this.includeAll = !this.includeAll;
105
+ this.cancelSearchReload();
103
106
  this.reload();
104
107
  this.tui.requestRender();
105
108
  return;
@@ -130,9 +133,10 @@ export class SessionReferencePickerComponent implements Focusable {
130
133
  }
131
134
 
132
135
  if (this.keybindings.matches(data, "tui.select.confirm")) {
136
+ this.flushSearchReload();
133
137
  const selected = this.items[this.selectedIndex];
134
138
  if (selected?.kind === "session") {
135
- this.done({ kind: "insert-session-token", sessionId: selected.sessionId });
139
+ this.finish({ kind: "insert-session-token", sessionId: selected.sessionId });
136
140
  }
137
141
  return;
138
142
  }
@@ -140,7 +144,7 @@ export class SessionReferencePickerComponent implements Focusable {
140
144
  const before = this.input.getValue();
141
145
  this.input.handleInput(data);
142
146
  if (this.input.getValue() !== before) {
143
- this.reload();
147
+ this.reloadAfterInputChange();
144
148
  this.tui.requestRender();
145
149
  }
146
150
  }
@@ -284,6 +288,48 @@ export class SessionReferencePickerComponent implements Focusable {
284
288
  .trim();
285
289
  }
286
290
 
291
+ private reloadAfterInputChange(): void {
292
+ if (!this.input.getValue().trim()) {
293
+ this.cancelSearchReload();
294
+ this.reload();
295
+ return;
296
+ }
297
+
298
+ this.scheduleSearchReload();
299
+ }
300
+
301
+ private scheduleSearchReload(): void {
302
+ this.cancelSearchReload();
303
+ this.searchReloadTimer = setTimeout(() => {
304
+ this.searchReloadTimer = undefined;
305
+ this.reload();
306
+ this.tui.requestRender();
307
+ }, SEARCH_RELOAD_DEBOUNCE_MS);
308
+ }
309
+
310
+ private flushSearchReload(): void {
311
+ if (!this.searchReloadTimer) {
312
+ return;
313
+ }
314
+
315
+ this.cancelSearchReload();
316
+ this.reload();
317
+ }
318
+
319
+ private cancelSearchReload(): void {
320
+ if (!this.searchReloadTimer) {
321
+ return;
322
+ }
323
+
324
+ clearTimeout(this.searchReloadTimer);
325
+ this.searchReloadTimer = undefined;
326
+ }
327
+
328
+ private finish(result: SessionPickerResult): void {
329
+ this.cancelSearchReload();
330
+ this.done(result);
331
+ }
332
+
287
333
  private reload(): void {
288
334
  this.items = listSessionPickerItems({
289
335
  currentSessionPath: this.options.getCurrentSessionPath(),
@@ -1,15 +1,13 @@
1
1
  import { stripSearchSnippetMarkers } from "../shared/search-snippet.ts";
2
2
  import {
3
- getIndexStatus,
4
- getLineageRelationMap,
5
3
  getSessionByPath,
6
- INDEX_SCHEMA_VERSION,
7
- openIndexDatabase,
8
4
  type SearchSessionResult,
9
5
  type SessionIndexDatabase,
10
6
  type SessionLineageRelation,
11
7
  searchSessions,
8
+ withSessionIndex,
12
9
  } from "../shared/session-index/index.ts";
10
+ import { SearchQuerySyntaxError } from "../shared/session-index/query/ast.ts";
13
11
  import { shortenSessionId } from "../shared/session-ui.ts";
14
12
  import { formatCompactRelativeTime } from "../shared/time.ts";
15
13
 
@@ -19,7 +17,6 @@ const MAX_TREE_DEPTH = 3;
19
17
 
20
18
  interface SessionPickerPresentationContext {
21
19
  currentSessionId?: string | undefined;
22
- relationBySessionId: Map<string, SessionLineageRelation>;
23
20
  }
24
21
 
25
22
  export interface SessionPickerSessionItem {
@@ -32,7 +29,7 @@ export interface SessionPickerSessionItem {
32
29
  modifiedAtText?: string | undefined;
33
30
  prefix: string;
34
31
  snippet?: string | undefined;
35
- relation?: SessionLineageRelation | "self" | undefined;
32
+ relation?: SessionLineageRelation | undefined;
36
33
  }
37
34
 
38
35
  export interface SessionPickerNoticeItem {
@@ -69,67 +66,75 @@ export function listSessionPickerItems(
69
66
  ): SessionPickerQueryResult {
70
67
  const scopeMode = options.includeAll ? "all" : "default";
71
68
  const defaultScopeLabel = options.currentCwd ? "current folder" : undefined;
72
- const status = getIndexStatus(options.indexPath);
73
- if (!status.exists || status.schemaVersion !== INDEX_SCHEMA_VERSION) {
74
- return {
75
- items: [buildIndexErrorItem()],
76
- scopeMode,
77
- defaultScopeLabel,
78
- };
79
- }
80
-
81
- const db = openIndexDatabase(status.dbPath, { create: false });
82
69
  try {
83
- const currentSession = options.currentSessionPath
84
- ? getSessionByPath(db, options.currentSessionPath)
85
- : undefined;
86
- const context = buildPresentationContext(db, currentSession?.sessionId);
87
- const rankedResults = prioritizeSessionResults(
88
- searchSessions(
89
- db,
90
- {
91
- cwd: options.includeAll ? undefined : options.currentCwd,
92
- query: options.mode === "search" ? options.query : undefined,
93
- limit: options.limit,
94
- },
95
- { defaultLimit: undefined },
96
- ),
97
- context,
70
+ return (
71
+ withSessionIndex(options.indexPath, { mode: "read", required: false }, ({ db }) => {
72
+ const currentSession = options.currentSessionPath
73
+ ? getSessionByPath(db, options.currentSessionPath)
74
+ : undefined;
75
+ const context = buildPresentationContext(currentSession?.sessionId);
76
+ const searchResults = searchSessionsSafely(db, options, currentSession?.sessionId);
77
+ const rankedResults =
78
+ options.mode === "browse"
79
+ ? prioritizeSessionResults(searchResults, context)
80
+ : searchResults;
81
+
82
+ if (rankedResults.length === 0) {
83
+ return {
84
+ items: [buildEmptyResultItem(options.mode)],
85
+ scopeMode,
86
+ defaultScopeLabel,
87
+ };
88
+ }
89
+
90
+ const sessionItems =
91
+ options.mode === "browse"
92
+ ? buildBrowseSessionItems(rankedResults, context)
93
+ : rankedResults.map((result) =>
94
+ buildSessionItem(result, context, "", getBestTextSnippet(result)),
95
+ );
96
+
97
+ return {
98
+ items: sessionItems,
99
+ scopeMode,
100
+ defaultScopeLabel,
101
+ };
102
+ }) ?? {
103
+ items: [buildIndexErrorItem()],
104
+ scopeMode,
105
+ defaultScopeLabel,
106
+ }
98
107
  );
99
-
100
- if (rankedResults.length === 0) {
108
+ } catch (error) {
109
+ if (error instanceof SearchQuerySyntaxError) {
101
110
  return {
102
- items: [buildEmptyResultItem(options.mode)],
111
+ items: [buildSearchSyntaxErrorItem(error)],
103
112
  scopeMode,
104
113
  defaultScopeLabel,
105
114
  };
106
115
  }
107
116
 
108
- const sessionItems =
109
- options.mode === "browse"
110
- ? buildBrowseSessionItems(rankedResults, context)
111
- : rankedResults.map((result) => buildSessionItem(result, context, "", result.snippet));
112
-
113
- return {
114
- items: sessionItems,
115
- scopeMode,
116
- defaultScopeLabel,
117
- };
118
- } finally {
119
- db.close();
117
+ throw error;
120
118
  }
121
119
  }
122
120
 
123
- function buildPresentationContext(
121
+ function searchSessionsSafely(
124
122
  db: SessionIndexDatabase,
123
+ options: ListSessionPickerItemsOptions,
124
+ currentSessionId?: string | undefined,
125
+ ): SearchSessionResult[] {
126
+ return searchSessions(db, {
127
+ cwd: options.includeAll ? undefined : options.currentCwd,
128
+ query: options.mode === "search" ? options.query : undefined,
129
+ limit: options.limit,
130
+ relativeToSessionId: currentSessionId,
131
+ });
132
+ }
133
+
134
+ function buildPresentationContext(
125
135
  currentSessionId?: string | undefined,
126
136
  ): SessionPickerPresentationContext {
127
- return {
128
- currentSessionId,
129
- relationBySessionId: currentSessionId
130
- ? getLineageRelationMap(db, currentSessionId)
131
- : new Map<string, SessionLineageRelation>(),
132
- };
137
+ return { currentSessionId };
133
138
  }
134
139
 
135
140
  function prioritizeSessionResults(
@@ -251,13 +256,18 @@ function buildSessionItem(
251
256
  };
252
257
  }
253
258
 
259
+ function getBestTextSnippet(result: SearchSessionResult): string | undefined {
260
+ const textEvidence = result.evidence.find((evidence) => evidence.kind === "text");
261
+ return textEvidence?.kind === "text" ? textEvidence.snippet : undefined;
262
+ }
263
+
254
264
  function getSessionTitle(result: SearchSessionResult): string {
255
265
  return (
256
266
  normalizeDisplayText(result.sessionName) ??
257
267
  normalizeDisplayText(result.handoffNextTask) ??
258
268
  normalizeDisplayText(result.handoffGoal) ??
259
269
  normalizeDisplayText(result.firstUserPrompt) ??
260
- normalizeDisplayText(stripSearchSnippetMarkers(result.snippet)) ??
270
+ normalizeDisplayText(stripSearchSnippetMarkers(getBestTextSnippet(result))) ??
261
271
  shortenSessionId(result.sessionId)
262
272
  );
263
273
  }
@@ -274,12 +284,12 @@ export function normalizeDisplayText(value?: string): string | undefined {
274
284
  function getSessionRelation(
275
285
  result: SearchSessionResult,
276
286
  context: SessionPickerPresentationContext,
277
- ): SessionLineageRelation | "self" | undefined {
287
+ ): SessionLineageRelation | undefined {
278
288
  if (context.currentSessionId && result.sessionId === context.currentSessionId) {
279
289
  return "self";
280
290
  }
281
291
 
282
- return context.relationBySessionId.get(result.sessionId);
292
+ return result.relation;
283
293
  }
284
294
 
285
295
  function getSessionMarker(
@@ -308,6 +318,14 @@ function buildIndexErrorItem(): SessionPickerNoticeItem {
308
318
  };
309
319
  }
310
320
 
321
+ function buildSearchSyntaxErrorItem(error: SearchQuerySyntaxError): SessionPickerNoticeItem {
322
+ return {
323
+ kind: "error",
324
+ title: "Invalid search query",
325
+ description: error.message,
326
+ };
327
+ }
328
+
311
329
  function buildEmptyResultItem(mode: "browse" | "search"): SessionPickerNoticeItem {
312
330
  return {
313
331
  kind: "empty",