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
@@ -8,13 +8,10 @@ import {
8
8
  import {
9
9
  clearSessionChunksBySourceKind,
10
10
  clearSessionIndexedData,
11
- getMetadata,
12
11
  getSessionById,
13
12
  getSessionRowByPath,
14
- INDEX_SCHEMA_VERSION,
15
13
  insertSessionFileTouch,
16
14
  insertTextChunk,
17
- openIndexDatabase,
18
15
  refreshSessionLineageRelationsFor,
19
16
  type SessionIndexDatabase,
20
17
  type SessionLineageRow,
@@ -22,6 +19,7 @@ import {
22
19
  type SessionRow,
23
20
  setMetadata,
24
21
  upsertSession,
22
+ withSessionIndex,
25
23
  } from "../shared/session-index/index.ts";
26
24
  import {
27
25
  createSessionNameChunk,
@@ -198,24 +196,19 @@ function syncSessionFile(
198
196
  state?: SessionHookState,
199
197
  sessionOrigin?: SessionOrigin,
200
198
  ): boolean {
201
- if (!sessionFile || !existsSync(sessionFile) || !existsSync(indexPath)) {
199
+ if (!sessionFile || !existsSync(sessionFile)) {
202
200
  return false;
203
201
  }
204
202
 
205
- const db = openIndexDatabase(indexPath, { create: false });
206
- try {
207
- if (readIndexSchemaVersion(db) !== INDEX_SCHEMA_VERSION) {
208
- return false;
209
- }
210
-
211
- const synced = syncSessionFileWithDb(db, sessionFile, eventType, sessionOrigin);
212
- if (synced && state) {
213
- state.lastFlushedSessionFile = sessionFile;
214
- }
215
- return synced;
216
- } finally {
217
- db.close();
218
- }
203
+ return (
204
+ withSessionIndex(indexPath, { mode: "write", required: false }, ({ db }) => {
205
+ const synced = syncSessionFileWithDb(db, sessionFile, eventType, sessionOrigin);
206
+ if (synced && state) {
207
+ state.lastFlushedSessionFile = sessionFile;
208
+ }
209
+ return synced;
210
+ }) ?? false
211
+ );
219
212
  }
220
213
 
221
214
  interface TailSyncBaseline extends SessionRow {
@@ -346,10 +339,14 @@ function applyTailSync(
346
339
 
347
340
  if (scan.sessionName !== undefined && scan.sessionName !== baseline.sessionName) {
348
341
  clearSessionChunksBySourceKind(db, baseline.sessionId, "session_name");
349
- if (scan.sessionName) {
342
+ if (scan.sessionName && scan.sessionNameEntryId) {
350
343
  insertTextChunk(db, {
351
344
  sessionId: baseline.sessionId,
352
- ...createSessionNameChunk(scan.sessionName, baseline.startedAt),
345
+ ...createSessionNameChunk(
346
+ scan.sessionName,
347
+ scan.sessionNameTs ?? baseline.startedAt,
348
+ scan.sessionNameEntryId,
349
+ ),
353
350
  });
354
351
  }
355
352
  }
@@ -439,11 +436,6 @@ function writeHookSyncMetadata(db: SessionIndexDatabase, eventType: string): voi
439
436
  setMetadata(db, "hook_last_event", eventType);
440
437
  }
441
438
 
442
- function readIndexSchemaVersion(db: SessionIndexDatabase): number | undefined {
443
- const raw = getMetadata(db, "schema_version");
444
- return raw === undefined ? undefined : Number(raw);
445
- }
446
-
447
439
  function mergeSessionLineage(
448
440
  extracted: ExtractedSessionRecord,
449
441
  existing: SessionLineageRow | undefined,
@@ -2,7 +2,7 @@ import { existsSync } from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
 
5
- export type FileTouchOp = "changed" | "read" | "touched";
5
+ export type FileTouchOp = "changed" | "read";
6
6
  export type FileTouchSource = "tool_call" | "branch_summary_details" | "compaction_details";
7
7
  export type PathScope = "absolute" | "relative" | "basename";
8
8
 
@@ -66,6 +66,7 @@ function dropIndexTables(db: SessionIndexDatabase): void {
66
66
  // implicit DELETE that parent-side constraints would reject.
67
67
  db.exec(`
68
68
  DROP TABLE IF EXISTS session_lineage_relations;
69
+ DROP TABLE IF EXISTS session_repo_roots;
69
70
  DROP TABLE IF EXISTS session_text_chunks;
70
71
  DROP TABLE IF EXISTS session_file_touches;
71
72
  DROP TABLE IF EXISTS session_text_chunks_fts;
@@ -1,19 +1,23 @@
1
1
  import path from "node:path";
2
2
  import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
3
- import { Text } from "@earendil-works/pi-tui";
3
+ import { getKeybindings, type Keybinding, Text } from "@earendil-works/pi-tui";
4
4
  import { Type } from "typebox";
5
5
  import {
6
6
  stripSearchSnippetMarkers,
7
7
  transformSearchSnippetMatches,
8
8
  } from "./shared/search-snippet.ts";
9
9
  import {
10
- getIndexStatus,
11
- INDEX_SCHEMA_VERSION,
12
- openIndexDatabase,
10
+ type ActiveSessionBrokerConnection,
11
+ getActiveSessionBrokerConnection,
12
+ } from "./shared/session-broker/active.ts";
13
+ import {
13
14
  type SearchSessionResult,
14
15
  type SearchSessionsParams,
16
+ type SearchSort,
15
17
  type SessionIndexStatus,
18
+ type SessionSearchEvidence,
16
19
  searchSessions,
20
+ withSessionIndex,
17
21
  } from "./shared/session-index/index.ts";
18
22
  import { formatSessionTitleOrShortId } from "./shared/session-ui.ts";
19
23
  import { loadSettings } from "./shared/settings.ts";
@@ -22,6 +26,7 @@ interface SessionSearchToolParams {
22
26
  query?: string;
23
27
  files?: {
24
28
  touched?: string[];
29
+ changed?: string[];
25
30
  };
26
31
  repo?: string;
27
32
  cwd?: string;
@@ -29,11 +34,12 @@ interface SessionSearchToolParams {
29
34
  after?: string;
30
35
  before?: string;
31
36
  };
37
+ sort?: SearchSort;
32
38
  limit?: number;
39
+ live?: boolean;
33
40
  }
34
41
 
35
42
  interface SessionSearchToolDetails {
36
- error: boolean;
37
43
  params?: SessionSearchToolParams | undefined;
38
44
  results: SearchSessionResult[];
39
45
  status?: SessionIndexStatus | undefined;
@@ -42,23 +48,32 @@ interface SessionSearchToolDetails {
42
48
  const DEFAULT_SESSION_SEARCH_LIMIT = 6;
43
49
  const COLLAPSED_RESULT_PREVIEW_ROWS = 6;
44
50
 
45
- export default function sessionSearchExtension(pi: ExtensionAPI): void {
51
+ export interface SessionSearchDeps {
52
+ getBrokerConnection: () => ActiveSessionBrokerConnection | undefined;
53
+ }
54
+
55
+ export default function sessionSearchExtension(
56
+ pi: ExtensionAPI,
57
+ deps: SessionSearchDeps = {
58
+ getBrokerConnection: getActiveSessionBrokerConnection,
59
+ },
60
+ ): void {
46
61
  const settings = loadSettings();
47
62
 
48
63
  pi.registerTool({
49
64
  name: "session_search",
50
65
  label: "Session Search",
51
- description: "Search prior Pi sessions",
52
- promptSnippet: "Use when you need to locate an earlier session to do a detailed follow-up",
66
+ description: "Search Pi sessions",
67
+ promptSnippet: "Use when you need to locate a session to do a detailed follow-up",
53
68
  promptGuidelines: [
54
- "query is plain text only. Do not use boolean operators like OR/AND, parentheses, regex, or other search syntax",
55
- "If you want alternatives, run multiple session_search calls with different queries",
69
+ "Omit query to list matching sessions chronologically",
56
70
  "Once you have the right session id, switch to session_ask for questions about that session",
57
71
  ],
58
72
  parameters: Type.Object({
59
73
  query: Type.Optional(
60
74
  Type.String({
61
- description: "Free-text terms to match against",
75
+ description:
76
+ "Use plain adjacent terms for normal search. Supports quoted phrases, AND/OR/NOT, parentheses, and -term negation when matching needs to be stricter.",
62
77
  }),
63
78
  ),
64
79
  files: Type.Optional(
@@ -66,7 +81,14 @@ export default function sessionSearchExtension(pi: ExtensionAPI): void {
66
81
  touched: Type.Optional(
67
82
  Type.Array(
68
83
  Type.String({
69
- description: "File path touched in the session",
84
+ description: "File path read or changed in the session",
85
+ }),
86
+ ),
87
+ ),
88
+ changed: Type.Optional(
89
+ Type.Array(
90
+ Type.String({
91
+ description: "File path changed in the session",
70
92
  }),
71
93
  ),
72
94
  ),
@@ -79,23 +101,36 @@ export default function sessionSearchExtension(pi: ExtensionAPI): void {
79
101
  ),
80
102
  cwd: Type.Optional(
81
103
  Type.String({
82
- description: "Directory the session was started in",
104
+ description: "Directory of the session",
83
105
  }),
84
106
  ),
85
107
  time: Type.Optional(
86
108
  Type.Object({
87
109
  after: Type.Optional(
88
110
  Type.String({
89
- description: "Inclusive lower bound for session modified time, in ISO format",
111
+ description: "Inclusive lower bound for the session activity interval, in ISO format",
90
112
  }),
91
113
  ),
92
114
  before: Type.Optional(
93
115
  Type.String({
94
- description: "Inclusive upper bound for session modified time, in ISO format",
116
+ description: "Inclusive upper bound for the session activity interval, in ISO format",
95
117
  }),
96
118
  ),
97
119
  }),
98
120
  ),
121
+ sort: Type.Optional(
122
+ Type.Union(
123
+ [Type.Literal("relevance"), Type.Literal("modified_desc"), Type.Literal("modified_asc")],
124
+ {
125
+ description: "Display order for returned matches",
126
+ },
127
+ ),
128
+ ),
129
+ live: Type.Optional(
130
+ Type.Boolean({
131
+ description: "When true, only return currently active sessions",
132
+ }),
133
+ ),
99
134
  limit: Type.Optional(
100
135
  Type.Number({
101
136
  description: "Number of matches to return",
@@ -105,19 +140,10 @@ export default function sessionSearchExtension(pi: ExtensionAPI): void {
105
140
  async execute(_toolCallId, params: SessionSearchToolParams, _signal, onUpdate, ctx) {
106
141
  const validationError = validateSearchParams(params);
107
142
  if (validationError) {
108
- const details: SessionSearchToolDetails = {
109
- error: true,
110
- params,
111
- results: [],
112
- };
113
- return {
114
- content: [{ type: "text", text: validationError }],
115
- details,
116
- };
143
+ throw new Error(validationError);
117
144
  }
118
145
 
119
146
  const progressDetails: SessionSearchToolDetails = {
120
- error: false,
121
147
  params,
122
148
  results: [],
123
149
  };
@@ -127,77 +153,53 @@ export default function sessionSearchExtension(pi: ExtensionAPI): void {
127
153
  });
128
154
 
129
155
  const indexPath = settings.index.path;
130
- const status = getIndexStatus(indexPath);
131
- if (!status.exists || status.schemaVersion !== INDEX_SCHEMA_VERSION) {
156
+ const liveSessionIds = params.live
157
+ ? await getLiveSessionIds(deps.getBrokerConnection)
158
+ : undefined;
159
+
160
+ return withSessionIndex(indexPath, { mode: "read", required: true }, ({ db, status }) => {
161
+ const results = searchSessions(db, buildSearchParams(params, ctx, liveSessionIds));
132
162
  const details: SessionSearchToolDetails = {
133
- error: true,
134
163
  params,
135
164
  status,
136
- results: [],
165
+ results,
137
166
  };
138
167
  return {
139
168
  content: [
140
169
  {
141
- type: "text",
142
- text: `Session index missing or incompatible at ${indexPath}. Run /session-index and press r to rebuild it.`,
170
+ type: "text" as const,
171
+ text: formatSearchResultsForModel(details),
143
172
  },
144
173
  ],
145
174
  details,
146
175
  };
147
- }
148
-
149
- const db = openIndexDatabase(status.dbPath, { create: false });
150
- try {
151
- const results = searchSessions(db, buildSearchParams(params, ctx));
152
-
153
- if (results.length === 0) {
154
- const details: SessionSearchToolDetails = {
155
- error: false,
156
- params,
157
- status,
158
- results: [],
159
- };
160
- return {
161
- content: [{ type: "text", text: "No matching sessions found." }],
162
- details,
163
- };
164
- }
165
-
166
- const details: SessionSearchToolDetails = {
167
- error: false,
168
- params,
169
- status,
170
- results,
171
- };
172
- return {
173
- content: [{ type: "text", text: formatSearchResults(results) }],
174
- details,
175
- };
176
- } finally {
177
- db.close();
178
- }
176
+ });
179
177
  },
180
- renderResult(result, { expanded, isPartial }, theme) {
178
+ renderResult(result, { expanded, isPartial }, theme, context) {
181
179
  const details = result.details as SessionSearchToolDetails | undefined;
182
180
  const content = result.content[0];
183
181
  if (content?.type !== "text") {
184
182
  return new Text(theme.fg("error", "No search output"), 0, 0);
185
183
  }
186
184
 
185
+ if (context.isError) {
186
+ return new Text(theme.fg("error", content.text), 0, 0);
187
+ }
188
+
187
189
  if (isPartial) {
188
190
  const lines = [theme.bold(theme.fg("warning", "Searching sessions..."))];
189
191
  lines.push(...formatSessionSearchContextLines(details?.params, theme));
190
192
  return new Text(lines.join("\n"), 0, 0);
191
193
  }
192
194
 
193
- if (!details || details.error) {
195
+ if (!details) {
194
196
  return new Text(theme.fg("error", content.text), 0, 0);
195
197
  }
196
198
 
197
199
  const lines = formatSessionSearchContextLines(details.params, theme);
198
200
  if (details.results.length === 0) {
199
201
  if (lines.length > 0) lines.push("");
200
- lines.push(theme.fg("warning", content.text));
202
+ lines.push(theme.fg("warning", "No matching sessions found."));
201
203
  return new Text(lines.join("\n"), 0, 0);
202
204
  }
203
205
 
@@ -213,21 +215,42 @@ export default function sessionSearchExtension(pi: ExtensionAPI): void {
213
215
  function buildSearchParams(
214
216
  params: SessionSearchToolParams,
215
217
  ctx: ExtensionContext,
218
+ liveSessionIds?: string[] | undefined,
216
219
  ): SearchSessionsParams {
217
220
  const currentSessionId = ctx.sessionManager.getSessionId();
218
221
 
219
222
  return {
220
223
  query: params.query,
221
224
  touched: params.files?.touched,
225
+ changed: params.files?.changed,
222
226
  repo: params.repo,
223
227
  cwd: params.cwd,
224
228
  after: params.time?.after,
225
229
  before: params.time?.before,
230
+ sort: params.sort,
226
231
  limit: params.limit ?? DEFAULT_SESSION_SEARCH_LIMIT,
227
- excludeSessionIds: currentSessionId ? [currentSessionId] : undefined,
232
+ includeSessionIds: liveSessionIds,
233
+ relativeToSessionId: currentSessionId,
228
234
  };
229
235
  }
230
236
 
237
+ async function getLiveSessionIds(
238
+ getBrokerConnection: SessionSearchDeps["getBrokerConnection"],
239
+ ): Promise<string[]> {
240
+ const connection = getBrokerConnection();
241
+ if (!connection) {
242
+ throw new Error(
243
+ "Session messaging is not active; live session search requires session messaging.",
244
+ );
245
+ }
246
+
247
+ try {
248
+ return await connection.listSessionIds();
249
+ } catch (error) {
250
+ throw new Error(`Session messaging is unavailable: ${formatError(error)}`);
251
+ }
252
+ }
253
+
231
254
  function formatSessionSearchContextLines(
232
255
  params: SessionSearchToolParams | undefined,
233
256
  theme: Theme,
@@ -242,7 +265,12 @@ function formatSessionSearchContextLines(
242
265
  const filters: string[] = [];
243
266
  if (params.repo?.trim()) filters.push(`repo: ${params.repo.trim()}`);
244
267
  if (params.cwd?.trim()) filters.push(`cwd: ${params.cwd.trim()}`);
245
- if (params.files?.touched?.length) filters.push(`files: ${params.files.touched.join(", ")}`);
268
+ if (params.files?.touched?.length)
269
+ filters.push(`files.touched: ${params.files.touched.join(", ")}`);
270
+ if (params.files?.changed?.length)
271
+ filters.push(`files.changed: ${params.files.changed.join(", ")}`);
272
+ if (params.live) filters.push("live: true");
273
+ if (params.sort) filters.push(`sort: ${params.sort}`);
246
274
  if (params.time?.after?.trim()) filters.push(`after: ${params.time.after.trim()}`);
247
275
  if (params.time?.before?.trim()) filters.push(`before: ${params.time.before.trim()}`);
248
276
  if (params.limit !== undefined) filters.push(`limit: ${params.limit}`);
@@ -267,13 +295,16 @@ function formatSessionSearchPanelResults(
267
295
  const visibleResults = expanded ? results : results.slice(0, COLLAPSED_RESULT_PREVIEW_ROWS);
268
296
  const lines = visibleResults.flatMap((result, index) => {
269
297
  const location = formatSearchResultLocation(result.cwd);
270
- const heading = `${index + 1}. ${theme.bold(formatSearchResultLabel(result))}${location ? ` ${theme.fg("dim", `(${location})`)}` : ""}`;
271
- const snippet = params?.query ? formatSearchSnippet(result) : undefined;
272
- return snippet ? [heading, theme.fg("dim", ` - ${snippet}`)] : [heading];
298
+ const relation = result.relation ? ` ${theme.fg("dim", `[${result.relation}]`)}` : "";
299
+ const heading = `${index + 1}. ${theme.bold(formatSearchResultLabel(result))}${relation}${location ? ` ${theme.fg("dim", `(${location})`)}` : ""}`;
300
+ const snippets = params?.query ? formatSearchSnippets(result).slice(0, 3) : [];
301
+ return snippets.length > 0
302
+ ? [heading, ...snippets.map((snippet) => theme.fg("dim", ` - ${snippet}`))]
303
+ : [heading];
273
304
  });
274
305
 
275
306
  if (!expanded && results.length > visibleResults.length) {
276
- lines.push(theme.fg("dim", `... ${results.length - visibleResults.length} more`));
307
+ lines.push(formatOverflowHint(results.length - visibleResults.length, results.length, theme));
277
308
  }
278
309
 
279
310
  return lines;
@@ -289,76 +320,70 @@ function formatSearchResultLocation(cwd: string | undefined): string | undefined
289
320
  return base || cwd;
290
321
  }
291
322
 
292
- function formatSearchSnippet(result: SearchSessionResult): string | undefined {
293
- const plainSnippet = stripSearchSnippetMarkers(result.snippet)?.replace(/\s+/g, " ").trim();
294
- if (!plainSnippet) return undefined;
295
- if (plainSnippet === result.sessionName || plainSnippet === result.cwd) return undefined;
296
- return transformSearchSnippetMatches(result.snippet, (match) => `[${match}]`)
297
- ?.replace(/\s+/g, " ")
298
- .trim();
323
+ function formatSearchSnippets(result: SearchSessionResult): string[] {
324
+ return result.evidence
325
+ .filter((evidence): evidence is SearchTextEvidence => evidence.kind === "text")
326
+ .map((evidence) => formatSearchSnippetText(evidence.snippet))
327
+ .filter((snippet): snippet is string => Boolean(snippet));
299
328
  }
300
329
 
301
- interface SearchResultTextStyles {
302
- cwd: (text: string) => string;
303
- primary: (text: string) => string;
304
- secondary: (text: string) => string;
330
+ interface SearchTextEvidence {
331
+ kind: "text";
332
+ sourceKind: string;
333
+ snippet: string;
334
+ score: number;
335
+ entryId: string;
305
336
  }
306
337
 
307
- function formatSearchResults(
308
- results: SearchSessionResult[],
309
- styles: SearchResultTextStyles = defaultSearchResultTextStyles,
310
- ): string {
311
- const groups = new Map<string, SearchSessionResult[]>();
312
-
313
- for (const result of results) {
314
- const bucket = groups.get(result.cwd);
315
- if (bucket) {
316
- bucket.push(result);
317
- continue;
318
- }
319
-
320
- groups.set(result.cwd, [result]);
321
- }
322
-
323
- return [...groups.entries()]
324
- .flatMap(([cwd, groupResults], groupIndex) => {
325
- const lines = [styles.cwd(`cwd: ${cwd}`)];
326
- for (const result of groupResults) {
327
- lines.push(...formatSearchResult(result, styles));
328
- }
329
- if (groupIndex < groups.size - 1) {
330
- lines.push("");
331
- }
332
- return lines;
333
- })
334
- .join("\n")
338
+ function formatSearchSnippetText(snippet: string): string | undefined {
339
+ const plainSnippet = stripSearchSnippetMarkers(snippet)?.replace(/\s+/g, " ").trim();
340
+ if (!plainSnippet) return undefined;
341
+ return transformSearchSnippetMatches(snippet, (match) => `[${match}]`)
342
+ ?.replace(/\s+/g, " ")
335
343
  .trim();
336
344
  }
337
345
 
338
- function formatSearchResult(result: SearchSessionResult, styles: SearchResultTextStyles): string[] {
339
- const lines = [styles.primary(`${result.sessionName || "[unnamed]"}: ${result.sessionId}`)];
346
+ function formatSearchResultsForModel(details: SessionSearchToolDetails): string {
347
+ return JSON.stringify(
348
+ {
349
+ params: details.params,
350
+ status: details.status,
351
+ results: details.results.map(formatSearchResultForModel),
352
+ },
353
+ null,
354
+ 2,
355
+ );
356
+ }
340
357
 
341
- if (result.matchedFiles.length > 0) {
342
- lines.push(styles.secondary(`matched_files: ${result.matchedFiles.join(", ")}`));
343
- }
358
+ function formatSearchResultForModel(result: SearchSessionResult): SearchSessionResult {
359
+ return {
360
+ ...result,
361
+ snippet: stripSearchSnippetMarkers(result.snippet) ?? result.snippet,
362
+ evidence: result.evidence.map(formatEvidenceForModel),
363
+ };
364
+ }
344
365
 
345
- if (result.score > 0 || result.hitCount > 0) {
346
- lines.push(styles.secondary(`score: ${result.score.toFixed(2)} / hits: ${result.hitCount}`));
366
+ function formatEvidenceForModel(evidence: SessionSearchEvidence): SessionSearchEvidence {
367
+ if (evidence.kind !== "text") {
368
+ return evidence;
347
369
  }
348
370
 
349
- const plainSnippet = stripSearchSnippetMarkers(result.snippet)?.replace(/\s+/g, " ").trim();
350
- if (plainSnippet && plainSnippet !== result.sessionName && plainSnippet !== result.cwd) {
351
- lines.push(styles.secondary(`snippet: ${plainSnippet}`));
352
- }
371
+ return {
372
+ ...evidence,
373
+ snippet: stripSearchSnippetMarkers(evidence.snippet) ?? evidence.snippet,
374
+ };
375
+ }
353
376
 
354
- return lines;
377
+ function formatOverflowHint(remaining: number, total: number, theme: Theme): string {
378
+ return `${theme.fg("muted", `... (${remaining} more lines, ${total} total,`)} ${theme.fg(
379
+ "dim",
380
+ formatKeyHint("app.tools.expand"),
381
+ )}${theme.fg("muted", " to expand)")}`;
355
382
  }
356
383
 
357
- const defaultSearchResultTextStyles: SearchResultTextStyles = {
358
- cwd: (text) => text,
359
- primary: (text) => text,
360
- secondary: (text) => text,
361
- };
384
+ function formatKeyHint(keybinding: Keybinding): string {
385
+ return getKeybindings().getKeys(keybinding).join("/");
386
+ }
362
387
 
363
388
  function validateSearchParams(params: SessionSearchToolParams): string | undefined {
364
389
  if (params.time?.after && !isValidIsoDateLike(params.time.after)) {
@@ -388,3 +413,7 @@ function isValidIsoDateLike(value: string): boolean {
388
413
  const date = new Date(value);
389
414
  return !Number.isNaN(date.getTime());
390
415
  }
416
+
417
+ function formatError(error: unknown): string {
418
+ return error instanceof Error && error.message.trim() ? error.message : String(error);
419
+ }
@@ -0,0 +1,18 @@
1
+ import type { Api, Model } from "@earendil-works/pi-ai";
2
+ import type { ModelReference } from "./settings.ts";
3
+
4
+ export function findModelByReference(
5
+ availableModels: Model<Api>[],
6
+ reference: ModelReference,
7
+ ): Model<Api> | undefined {
8
+ return availableModels.find(
9
+ (model) => model.provider === reference.provider && model.id === reference.modelId,
10
+ );
11
+ }
12
+
13
+ export function resolveConfiguredModel(
14
+ availableModels: Model<Api>[],
15
+ configuredModel: ModelReference | undefined,
16
+ ): Model<Api> | undefined {
17
+ return configuredModel ? findModelByReference(availableModels, configuredModel) : undefined;
18
+ }
@@ -0,0 +1,26 @@
1
+ export interface ActiveSessionBrokerConnection {
2
+ listSessionIds(): Promise<string[]>;
3
+ }
4
+
5
+ // Pi loads extension entrypoints through separate jiti module graphs, so module-local
6
+ // singletons are not shared between session-messaging and session-search. The process
7
+ // global is the only way to share a single connection
8
+ const ACTIVE_CONNECTION_KEY = Symbol.for("pi-sessions.active-session-broker-connection");
9
+
10
+ type ActiveConnectionGlobal = typeof globalThis & {
11
+ [ACTIVE_CONNECTION_KEY]?: ActiveSessionBrokerConnection | undefined;
12
+ };
13
+
14
+ function getActiveConnectionGlobal(): ActiveConnectionGlobal {
15
+ return globalThis as ActiveConnectionGlobal;
16
+ }
17
+
18
+ export function setActiveSessionBrokerConnection(
19
+ connection: ActiveSessionBrokerConnection | undefined,
20
+ ): void {
21
+ getActiveConnectionGlobal()[ACTIVE_CONNECTION_KEY] = connection;
22
+ }
23
+
24
+ export function getActiveSessionBrokerConnection(): ActiveSessionBrokerConnection | undefined {
25
+ return getActiveConnectionGlobal()[ACTIVE_CONNECTION_KEY];
26
+ }