@ogulcancelik/pi-session-recall 1.0.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 +63 -0
- package/package.json +38 -0
- package/session-recall.ts +810 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Can Celik
|
|
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,63 @@
|
|
|
1
|
+
# pi-session-recall
|
|
2
|
+
|
|
3
|
+
Recall past [pi](https://github.com/badlogic/pi-mono) sessions. Search through conversation history and query specific sessions with an LLM.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pi install npm:@ogulcancelik/pi-session-recall
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or add manually to `~/.pi/agent/settings.json`:
|
|
12
|
+
|
|
13
|
+
```json
|
|
14
|
+
{
|
|
15
|
+
"packages": ["npm:@ogulcancelik/pi-session-recall"]
|
|
16
|
+
}
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## What it does
|
|
20
|
+
|
|
21
|
+
Two tools that let the agent recall past sessions:
|
|
22
|
+
|
|
23
|
+
### `session_search`
|
|
24
|
+
|
|
25
|
+
Keyword search across all past sessions using ripgrep. Returns matching sessions with context snippets.
|
|
26
|
+
|
|
27
|
+
The agent uses this automatically when you reference something from a previous conversation — e.g. *"remember when we tried X?"*
|
|
28
|
+
|
|
29
|
+
### `session_query`
|
|
30
|
+
|
|
31
|
+
Deep-dives into a specific session file. Loads the conversation, sends it to an LLM, and answers your question about it.
|
|
32
|
+
|
|
33
|
+
For large sessions that exceed the model's context window, it uses smart windowing: keeps the first/last messages plus keyword-relevant sections, marking gaps with `[... N messages omitted ...]`.
|
|
34
|
+
|
|
35
|
+
### `/session-recall`
|
|
36
|
+
|
|
37
|
+
Command to configure which model is used for `session_query`. Opens a picker with all your available models.
|
|
38
|
+
|
|
39
|
+
By default, queries use your **current session model**. If you want to save tokens, pick a cheaper model (e.g. Haiku, GPT-4o mini).
|
|
40
|
+
|
|
41
|
+
## Configuration
|
|
42
|
+
|
|
43
|
+
Config is stored at `~/.pi/agent/session-recall.json`:
|
|
44
|
+
|
|
45
|
+
```json
|
|
46
|
+
{
|
|
47
|
+
"queryModel": {
|
|
48
|
+
"provider": "anthropic",
|
|
49
|
+
"id": "claude-haiku-4-5"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
If no model is configured (or the configured model isn't available), it falls back to whatever model is active in your current session.
|
|
55
|
+
|
|
56
|
+
## Requirements
|
|
57
|
+
|
|
58
|
+
- [pi](https://github.com/badlogic/pi-mono) v0.40+
|
|
59
|
+
- [ripgrep](https://github.com/BurntSushi/ripgrep) (`rg`) — used for fast session search
|
|
60
|
+
|
|
61
|
+
## License
|
|
62
|
+
|
|
63
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ogulcancelik/pi-session-recall",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Search and query past pi sessions. Recall previous conversations on demand.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"pi-package",
|
|
8
|
+
"pi-extension",
|
|
9
|
+
"session-recall",
|
|
10
|
+
"session-search",
|
|
11
|
+
"conversation-history",
|
|
12
|
+
"agent-memory",
|
|
13
|
+
"coding-agent"
|
|
14
|
+
],
|
|
15
|
+
"author": "Can Celik",
|
|
16
|
+
"license": "MIT",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/ogulcancelik/pi-session-recall.git"
|
|
20
|
+
},
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/ogulcancelik/pi-session-recall/issues"
|
|
23
|
+
},
|
|
24
|
+
"homepage": "https://github.com/ogulcancelik/pi-session-recall#readme",
|
|
25
|
+
"engines": {
|
|
26
|
+
"node": ">=18.0.0"
|
|
27
|
+
},
|
|
28
|
+
"pi": {
|
|
29
|
+
"extensions": [
|
|
30
|
+
"./session-recall.ts"
|
|
31
|
+
]
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"session-recall.ts",
|
|
35
|
+
"README.md",
|
|
36
|
+
"LICENSE"
|
|
37
|
+
]
|
|
38
|
+
}
|
|
@@ -0,0 +1,810 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session Recall Extension - Search and query past pi sessions
|
|
3
|
+
*
|
|
4
|
+
* Two tools + one command:
|
|
5
|
+
*
|
|
6
|
+
* Tools:
|
|
7
|
+
* 1. session_search - Find relevant past sessions by keywords.
|
|
8
|
+
* Use when the user mentions something from a previous conversation
|
|
9
|
+
* and you need to find which session it was in.
|
|
10
|
+
*
|
|
11
|
+
* 2. session_query - Deep dive into a specific session.
|
|
12
|
+
* Use after session_search to get detailed information from a
|
|
13
|
+
* specific session, or when you already know the session path
|
|
14
|
+
* (e.g., from a handoff).
|
|
15
|
+
*
|
|
16
|
+
* Command:
|
|
17
|
+
* /session-recall - Configure the model used for session queries.
|
|
18
|
+
* Shows all available models and lets you pick one. Persists the
|
|
19
|
+
* choice to ~/.pi/agent/session-recall.json (or equivalent).
|
|
20
|
+
*
|
|
21
|
+
* Typical flow: user says "remember when we tried X?" →
|
|
22
|
+
* session_search("X") → find the right session →
|
|
23
|
+
* session_query(path, "what approach did we take for X?")
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { complete, type Model, type Api, type Message } from "@mariozechner/pi-ai";
|
|
27
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
|
|
28
|
+
import {
|
|
29
|
+
getAgentDir,
|
|
30
|
+
SessionManager,
|
|
31
|
+
convertToLlm,
|
|
32
|
+
getMarkdownTheme,
|
|
33
|
+
serializeConversation,
|
|
34
|
+
type SessionEntry,
|
|
35
|
+
} from "@mariozechner/pi-coding-agent";
|
|
36
|
+
import {
|
|
37
|
+
Container,
|
|
38
|
+
Input,
|
|
39
|
+
Markdown,
|
|
40
|
+
SelectList,
|
|
41
|
+
type SelectItem,
|
|
42
|
+
type SelectListTheme,
|
|
43
|
+
Spacer,
|
|
44
|
+
Text,
|
|
45
|
+
} from "@mariozechner/pi-tui";
|
|
46
|
+
import { Type } from "@sinclair/typebox";
|
|
47
|
+
import { execSync } from "node:child_process";
|
|
48
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
49
|
+
import { join, dirname } from "node:path";
|
|
50
|
+
import { homedir } from "node:os";
|
|
51
|
+
|
|
52
|
+
// ── Config ───────────────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
interface SessionRecallConfig {
|
|
55
|
+
queryModel?: {
|
|
56
|
+
provider: string;
|
|
57
|
+
id: string;
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function getConfigPath(): string {
|
|
62
|
+
return join(getAgentDir(), "session-recall.json");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function loadConfig(): SessionRecallConfig {
|
|
66
|
+
const configPath = getConfigPath();
|
|
67
|
+
if (!existsSync(configPath)) return {};
|
|
68
|
+
try {
|
|
69
|
+
return JSON.parse(readFileSync(configPath, "utf-8"));
|
|
70
|
+
} catch {
|
|
71
|
+
return {};
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function saveConfig(config: SessionRecallConfig): void {
|
|
76
|
+
const configPath = getConfigPath();
|
|
77
|
+
const dir = dirname(configPath);
|
|
78
|
+
if (!existsSync(dir)) {
|
|
79
|
+
mkdirSync(dir, { recursive: true });
|
|
80
|
+
}
|
|
81
|
+
writeFileSync(configPath, JSON.stringify(config, null, 2), "utf-8");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Resolve the query model from config → fallback to current session model.
|
|
86
|
+
*/
|
|
87
|
+
function resolveQueryModel(ctx: ExtensionCommandContext): Model<Api> | undefined {
|
|
88
|
+
const config = loadConfig();
|
|
89
|
+
if (config.queryModel) {
|
|
90
|
+
const found = ctx.modelRegistry.find(config.queryModel.provider, config.queryModel.id);
|
|
91
|
+
if (found) return found;
|
|
92
|
+
}
|
|
93
|
+
// Fallback: current session model
|
|
94
|
+
return ctx.model;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
/** Get the sessions root directory */
|
|
100
|
+
function getSessionsDir(): string {
|
|
101
|
+
return join(getAgentDir(), "sessions");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Extract a readable project name from the encoded session directory path.
|
|
106
|
+
* Session dirs are encoded as: --home-user-projects-myapp--
|
|
107
|
+
* We strip the home directory prefix and convert dashes back to slashes.
|
|
108
|
+
*/
|
|
109
|
+
function projectFromPath(sessionPath: string): string {
|
|
110
|
+
// Match the encoded directory name between sessions/ and the next /
|
|
111
|
+
const match = sessionPath.match(/sessions\/(--.*?--)\//);
|
|
112
|
+
if (!match || !match[1]) return "~";
|
|
113
|
+
|
|
114
|
+
// Decode: strip leading/trailing --, replace internal - with /
|
|
115
|
+
let encoded = match[1].slice(2, -2); // remove -- from both ends
|
|
116
|
+
|
|
117
|
+
// Strip home directory prefix (encoded as home-username)
|
|
118
|
+
const homeDir = homedir();
|
|
119
|
+
const homeParts = homeDir.split("/").filter(Boolean); // e.g. ["home", "can"]
|
|
120
|
+
const homePrefix = homeParts.join("-"); // "home-can"
|
|
121
|
+
if (encoded.startsWith(homePrefix + "-")) {
|
|
122
|
+
encoded = encoded.slice(homePrefix.length + 1);
|
|
123
|
+
} else if (encoded === homePrefix) {
|
|
124
|
+
return "~";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return encoded.replace(/-/g, "/") || "~";
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Extract date from session filename */
|
|
131
|
+
function dateFromPath(sessionPath: string): string {
|
|
132
|
+
const match = sessionPath.match(/(\d{4}-\d{2}-\d{2})T/);
|
|
133
|
+
return match?.[1] ?? "unknown";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Parse a JSONL line and extract the text content if it's a message */
|
|
137
|
+
function extractMessageText(jsonLine: string): { role: string; text: string } | null {
|
|
138
|
+
try {
|
|
139
|
+
const entry = JSON.parse(jsonLine);
|
|
140
|
+
if (entry.type !== "message") return null;
|
|
141
|
+
const msg = entry.message;
|
|
142
|
+
const role = msg.role as string;
|
|
143
|
+
const texts = (msg.content ?? [])
|
|
144
|
+
.filter((c: any) => c.type === "text")
|
|
145
|
+
.map((c: any) => c.text)
|
|
146
|
+
.join(" ");
|
|
147
|
+
if (!texts) return null;
|
|
148
|
+
return { role, text: texts };
|
|
149
|
+
} catch {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Get a snippet of text around a keyword match */
|
|
155
|
+
function snippetAround(text: string, keyword: string, radius = 100): string {
|
|
156
|
+
const lower = text.toLowerCase();
|
|
157
|
+
const idx = lower.indexOf(keyword.toLowerCase());
|
|
158
|
+
if (idx === -1) return text.slice(0, radius * 2);
|
|
159
|
+
const start = Math.max(0, idx - radius);
|
|
160
|
+
const end = Math.min(text.length, idx + keyword.length + radius);
|
|
161
|
+
const prefix = start > 0 ? "..." : "";
|
|
162
|
+
const suffix = end < text.length ? "..." : "";
|
|
163
|
+
return prefix + text.slice(start, end) + suffix;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Rough token estimate: ~4 chars per token */
|
|
167
|
+
function estimateTokens(text: string): number {
|
|
168
|
+
return Math.ceil(text.length / 4);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Extract keywords from a question for matching.
|
|
173
|
+
* Strips common stop words and short words.
|
|
174
|
+
*/
|
|
175
|
+
function extractKeywords(question: string): string[] {
|
|
176
|
+
const stopWords = new Set([
|
|
177
|
+
"a", "an", "the", "is", "was", "were", "are", "be", "been", "being",
|
|
178
|
+
"have", "has", "had", "do", "does", "did", "will", "would", "could",
|
|
179
|
+
"should", "may", "might", "can", "shall", "to", "of", "in", "for",
|
|
180
|
+
"on", "with", "at", "by", "from", "as", "into", "about", "like",
|
|
181
|
+
"through", "after", "over", "between", "out", "against", "during",
|
|
182
|
+
"without", "before", "under", "around", "among", "and", "but", "or",
|
|
183
|
+
"nor", "not", "so", "yet", "both", "either", "neither", "each",
|
|
184
|
+
"every", "all", "any", "few", "more", "most", "other", "some", "such",
|
|
185
|
+
"no", "only", "own", "same", "than", "too", "very", "just", "because",
|
|
186
|
+
"if", "when", "where", "how", "what", "which", "who", "whom", "this",
|
|
187
|
+
"that", "these", "those", "it", "its", "they", "them", "their", "we",
|
|
188
|
+
"us", "our", "you", "your", "he", "him", "his", "she", "her", "i", "me", "my",
|
|
189
|
+
]);
|
|
190
|
+
return question
|
|
191
|
+
.toLowerCase()
|
|
192
|
+
.replace(/[^\w\s-]/g, " ")
|
|
193
|
+
.split(/\s+/)
|
|
194
|
+
.filter((w) => w.length > 2 && !stopWords.has(w));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
interface SerializedMessage {
|
|
198
|
+
index: number;
|
|
199
|
+
role: string;
|
|
200
|
+
text: string;
|
|
201
|
+
charCount: number;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Build a context-windowed conversation text for large sessions.
|
|
206
|
+
*
|
|
207
|
+
* Strategy:
|
|
208
|
+
* - Always include first few messages (setup/context) and last few (final state)
|
|
209
|
+
* - Find messages matching question keywords
|
|
210
|
+
* - Expand windows around matches until we hit the token budget
|
|
211
|
+
* - Mark gaps with "[... N messages omitted ...]"
|
|
212
|
+
*/
|
|
213
|
+
function buildWindowedContext(
|
|
214
|
+
serializedMessages: SerializedMessage[],
|
|
215
|
+
question: string,
|
|
216
|
+
tokenBudget: number,
|
|
217
|
+
): string {
|
|
218
|
+
const BOOKEND_COUNT = 3;
|
|
219
|
+
const total = serializedMessages.length;
|
|
220
|
+
|
|
221
|
+
if (total <= BOOKEND_COUNT * 2 + 2) {
|
|
222
|
+
return serializedMessages.map((m) => `[${m.role}]\n${m.text}`).join("\n\n");
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const keywords = extractKeywords(question);
|
|
226
|
+
const scores = serializedMessages.map((msg) => {
|
|
227
|
+
const lower = msg.text.toLowerCase();
|
|
228
|
+
let score = 0;
|
|
229
|
+
for (const kw of keywords) {
|
|
230
|
+
if (lower.includes(kw)) score++;
|
|
231
|
+
}
|
|
232
|
+
return score;
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
const included = new Set<number>();
|
|
236
|
+
for (let i = 0; i < Math.min(BOOKEND_COUNT, total); i++) included.add(i);
|
|
237
|
+
for (let i = Math.max(0, total - BOOKEND_COUNT); i < total; i++) included.add(i);
|
|
238
|
+
|
|
239
|
+
const matchIndices = scores
|
|
240
|
+
.map((score, i) => ({ score, i }))
|
|
241
|
+
.filter((x) => x.score > 0)
|
|
242
|
+
.sort((a, b) => b.score - a.score)
|
|
243
|
+
.map((x) => x.i);
|
|
244
|
+
|
|
245
|
+
for (const i of matchIndices) included.add(i);
|
|
246
|
+
|
|
247
|
+
const charBudget = tokenBudget * 4;
|
|
248
|
+
const currentChars = () =>
|
|
249
|
+
[...included].reduce((sum, i) => sum + serializedMessages[i].charCount + 20, 0);
|
|
250
|
+
|
|
251
|
+
let radius = 1;
|
|
252
|
+
while (currentChars() < charBudget * 0.8 && radius < total) {
|
|
253
|
+
const before = included.size;
|
|
254
|
+
for (const i of matchIndices) {
|
|
255
|
+
for (let d = -radius; d <= radius; d++) {
|
|
256
|
+
const idx = i + d;
|
|
257
|
+
if (idx >= 0 && idx < total) included.add(idx);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
if (included.size === before) break;
|
|
261
|
+
radius++;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (currentChars() < charBudget * 0.8) {
|
|
265
|
+
for (let i = BOOKEND_COUNT; i < total && currentChars() < charBudget * 0.8; i++) {
|
|
266
|
+
included.add(i);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const sortedIndices = [...included].sort((a, b) => a - b);
|
|
271
|
+
const parts: string[] = [];
|
|
272
|
+
let lastIdx = -1;
|
|
273
|
+
|
|
274
|
+
for (const i of sortedIndices) {
|
|
275
|
+
if (lastIdx >= 0 && i > lastIdx + 1) {
|
|
276
|
+
const gap = i - lastIdx - 1;
|
|
277
|
+
parts.push(`[... ${gap} message${gap > 1 ? "s" : ""} omitted ...]`);
|
|
278
|
+
}
|
|
279
|
+
const msg = serializedMessages[i];
|
|
280
|
+
parts.push(`[${msg.role}]\n${msg.text}`);
|
|
281
|
+
lastIdx = i;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (lastIdx < total - 1 && !included.has(total - 1)) {
|
|
285
|
+
const gap = total - 1 - lastIdx;
|
|
286
|
+
parts.push(`[... ${gap} message${gap > 1 ? "s" : ""} omitted ...]`);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return parts.join("\n\n");
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Shell-escape a string for use in execSync */
|
|
293
|
+
function escapeShellArg(arg: string): string {
|
|
294
|
+
return "'" + arg.replace(/'/g, "'\\''") + "'";
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ── Constants ────────────────────────────────────────────────────────────────
|
|
298
|
+
|
|
299
|
+
const QUERY_SYSTEM_PROMPT = `You are a session context assistant. Given the conversation history from a pi coding session and a question, provide a concise answer based on the session contents.
|
|
300
|
+
|
|
301
|
+
Focus on:
|
|
302
|
+
- Specific facts, decisions, and outcomes
|
|
303
|
+
- File paths and code changes mentioned
|
|
304
|
+
- Key context the user is asking about
|
|
305
|
+
|
|
306
|
+
Be concise and direct. If the information isn't in the session, say so.`;
|
|
307
|
+
|
|
308
|
+
const MAX_SEARCH_RESULTS = 10;
|
|
309
|
+
const MAX_SNIPPETS_PER_SESSION = 3;
|
|
310
|
+
|
|
311
|
+
// ── Extension ────────────────────────────────────────────────────────────────
|
|
312
|
+
|
|
313
|
+
export default function sessionRecallExtension(pi: ExtensionAPI) {
|
|
314
|
+
// ── /session-recall command ────────────────────────────────────────────
|
|
315
|
+
|
|
316
|
+
pi.registerCommand("session-recall", {
|
|
317
|
+
description: "Configure the model used for session queries",
|
|
318
|
+
handler: async (_args, ctx) => {
|
|
319
|
+
if (!ctx.hasUI) {
|
|
320
|
+
ctx.ui.notify("Interactive mode required for /session-recall", "warning");
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const config = loadConfig();
|
|
325
|
+
const available = ctx.modelRegistry.getAvailable();
|
|
326
|
+
|
|
327
|
+
if (available.length === 0) {
|
|
328
|
+
ctx.ui.notify("No models available. Configure API keys first.", "warning");
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const currentKey = config.queryModel
|
|
333
|
+
? `${config.queryModel.provider}/${config.queryModel.id}`
|
|
334
|
+
: null;
|
|
335
|
+
|
|
336
|
+
// Build select items: session model fallback + all available models
|
|
337
|
+
const SESSION_MODEL_VALUE = "__session_model__";
|
|
338
|
+
const items: SelectItem[] = [
|
|
339
|
+
{
|
|
340
|
+
value: SESSION_MODEL_VALUE,
|
|
341
|
+
label: "(use current session model)",
|
|
342
|
+
description: currentKey === null ? "active" : undefined,
|
|
343
|
+
},
|
|
344
|
+
...available.map((m) => {
|
|
345
|
+
const key = `${m.provider}/${m.id}`;
|
|
346
|
+
return {
|
|
347
|
+
value: key,
|
|
348
|
+
label: key,
|
|
349
|
+
description: key === currentKey ? "active" : undefined,
|
|
350
|
+
};
|
|
351
|
+
}),
|
|
352
|
+
];
|
|
353
|
+
|
|
354
|
+
const choice = await ctx.ui.custom<string | undefined>(
|
|
355
|
+
(tui, theme, _kb, done) => {
|
|
356
|
+
const selectTheme: SelectListTheme = {
|
|
357
|
+
selectedPrefix: (t) => theme.fg("accent", t),
|
|
358
|
+
selectedText: (t) => theme.fg("accent", t),
|
|
359
|
+
description: (t) => theme.fg("muted", t),
|
|
360
|
+
scrollInfo: (t) => theme.fg("muted", t),
|
|
361
|
+
noMatch: (t) => theme.fg("muted", t),
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
const container = new Container();
|
|
365
|
+
const title = new Text(
|
|
366
|
+
theme.bold("Query model for session_query") +
|
|
367
|
+
theme.fg("muted", " (type to filter)"),
|
|
368
|
+
0,
|
|
369
|
+
0,
|
|
370
|
+
);
|
|
371
|
+
const input = new Input();
|
|
372
|
+
const listSpacer = new Spacer(1);
|
|
373
|
+
const hint = new Text(
|
|
374
|
+
theme.fg("muted", " ↑/↓ navigate · Enter select · Esc cancel"),
|
|
375
|
+
0,
|
|
376
|
+
0,
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
// Fuzzy filter: every term must appear somewhere in the label
|
|
380
|
+
const fuzzyMatch = (label: string, query: string): boolean => {
|
|
381
|
+
const lower = label.toLowerCase();
|
|
382
|
+
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
383
|
+
return terms.every((term) => lower.includes(term));
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
let list: SelectList;
|
|
387
|
+
|
|
388
|
+
const buildList = (filtered: SelectItem[]): SelectList => {
|
|
389
|
+
const sl = new SelectList(filtered, 10, selectTheme);
|
|
390
|
+
sl.onSelect = (item) => done(item.value);
|
|
391
|
+
sl.onCancel = () => done(undefined);
|
|
392
|
+
return sl;
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
// Initial list with all items, pre-select active
|
|
396
|
+
list = buildList(items);
|
|
397
|
+
const activeIdx = items.findIndex(
|
|
398
|
+
(i) => i.value === (currentKey ?? SESSION_MODEL_VALUE),
|
|
399
|
+
);
|
|
400
|
+
if (activeIdx >= 0) list.setSelectedIndex(activeIdx);
|
|
401
|
+
|
|
402
|
+
container.addChild(title);
|
|
403
|
+
container.addChild(new Spacer(1));
|
|
404
|
+
container.addChild(input);
|
|
405
|
+
container.addChild(listSpacer);
|
|
406
|
+
container.addChild(list);
|
|
407
|
+
container.addChild(new Spacer(1));
|
|
408
|
+
container.addChild(hint);
|
|
409
|
+
|
|
410
|
+
input.onSubmit = () => {
|
|
411
|
+
const selected = list.getSelectedItem();
|
|
412
|
+
if (selected) done(selected.value);
|
|
413
|
+
};
|
|
414
|
+
input.onEscape = () => done(undefined);
|
|
415
|
+
|
|
416
|
+
const rebuildList = () => {
|
|
417
|
+
const query = input.getValue();
|
|
418
|
+
const filtered = query
|
|
419
|
+
? items.filter((item) => fuzzyMatch(item.label, query))
|
|
420
|
+
: items;
|
|
421
|
+
|
|
422
|
+
container.removeChild(list);
|
|
423
|
+
list = buildList(filtered);
|
|
424
|
+
// Re-insert list at correct position (after listSpacer)
|
|
425
|
+
const spacerIdx = container.children.indexOf(listSpacer);
|
|
426
|
+
container.children.splice(spacerIdx + 1, 0, list);
|
|
427
|
+
tui.requestRender();
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
container.handleInput = (data: string) => {
|
|
431
|
+
// Navigation keys go to the select list
|
|
432
|
+
const isNav =
|
|
433
|
+
data === "\x1b[A" || // up
|
|
434
|
+
data === "\x1b[B" || // down
|
|
435
|
+
data === "\r" || // enter
|
|
436
|
+
data === "\n";
|
|
437
|
+
|
|
438
|
+
if (isNav) {
|
|
439
|
+
list.handleInput(data);
|
|
440
|
+
} else if (data === "\x1b" || data === "\x03") {
|
|
441
|
+
done(undefined);
|
|
442
|
+
} else {
|
|
443
|
+
input.handleInput(data);
|
|
444
|
+
rebuildList();
|
|
445
|
+
}
|
|
446
|
+
};
|
|
447
|
+
|
|
448
|
+
return container;
|
|
449
|
+
},
|
|
450
|
+
);
|
|
451
|
+
|
|
452
|
+
if (choice === undefined) return; // cancelled
|
|
453
|
+
|
|
454
|
+
if (choice === SESSION_MODEL_VALUE) {
|
|
455
|
+
delete config.queryModel;
|
|
456
|
+
saveConfig(config);
|
|
457
|
+
ctx.ui.notify("Session query will use the current session model", "info");
|
|
458
|
+
} else {
|
|
459
|
+
const slashIdx = choice.indexOf("/");
|
|
460
|
+
if (slashIdx === -1) return;
|
|
461
|
+
|
|
462
|
+
const provider = choice.slice(0, slashIdx);
|
|
463
|
+
const id = choice.slice(slashIdx + 1);
|
|
464
|
+
config.queryModel = { provider, id };
|
|
465
|
+
saveConfig(config);
|
|
466
|
+
ctx.ui.notify(`Session query model set to ${choice}`, "info");
|
|
467
|
+
}
|
|
468
|
+
},
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
// ── session_search tool ────────────────────────────────────────────────
|
|
472
|
+
|
|
473
|
+
pi.registerTool({
|
|
474
|
+
name: "session_search",
|
|
475
|
+
label: (params) => `Session Search: ${params.query}`,
|
|
476
|
+
description:
|
|
477
|
+
"Search past conversation sessions by keywords. Use when the user references something from a previous session and you need to find it. " +
|
|
478
|
+
"Returns matching sessions with conversation snippets. Follow up with session_query to get detailed information from a specific session.",
|
|
479
|
+
renderResult: (result, options, theme) => {
|
|
480
|
+
const container = new Container();
|
|
481
|
+
const text = result.content?.[0]?.type === "text" ? result.content[0].text : "";
|
|
482
|
+
const details = result.details as { matchCount?: number; query?: string; error?: boolean } | undefined;
|
|
483
|
+
|
|
484
|
+
if (details?.error || !details?.matchCount) {
|
|
485
|
+
container.addChild(new Text(theme.fg("toolOutput", text), 0, 0));
|
|
486
|
+
return container;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const summary = `${details.matchCount} session${details.matchCount > 1 ? "s" : ""} matching "${details.query}"`;
|
|
490
|
+
container.addChild(new Text(theme.fg("toolOutput", summary), 0, 0));
|
|
491
|
+
|
|
492
|
+
if (options.expanded) {
|
|
493
|
+
container.addChild(new Spacer(1));
|
|
494
|
+
const body = text.replace(/^Found \d+ sessions? matching "[^"]*":\n\n/, "");
|
|
495
|
+
container.addChild(
|
|
496
|
+
new Markdown(body, 0, 0, getMarkdownTheme(), {
|
|
497
|
+
color: (text: string) => theme.fg("toolOutput", text),
|
|
498
|
+
}),
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
return container;
|
|
503
|
+
},
|
|
504
|
+
parameters: Type.Object({
|
|
505
|
+
query: Type.String({
|
|
506
|
+
description:
|
|
507
|
+
"Search keywords (e.g., 'redis caching', 'blender vat bake'). Multiple words are searched as a phrase. Use simple, specific terms.",
|
|
508
|
+
}),
|
|
509
|
+
}),
|
|
510
|
+
|
|
511
|
+
async execute(_toolCallId, params, signal, _onUpdate, _ctx) {
|
|
512
|
+
const { query } = params;
|
|
513
|
+
const sessionsDir = getSessionsDir();
|
|
514
|
+
|
|
515
|
+
const errorResult = (text: string) => ({
|
|
516
|
+
content: [{ type: "text" as const, text }],
|
|
517
|
+
details: { error: true },
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
if (!existsSync(sessionsDir)) {
|
|
521
|
+
return errorResult("No sessions directory found.");
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
let rgOutput: string;
|
|
525
|
+
try {
|
|
526
|
+
rgOutput = execSync(`rg -ic ${escapeShellArg(query)} ${escapeShellArg(sessionsDir)}`, {
|
|
527
|
+
encoding: "utf-8",
|
|
528
|
+
timeout: 10000,
|
|
529
|
+
});
|
|
530
|
+
} catch (err: any) {
|
|
531
|
+
if (err.status === 1) {
|
|
532
|
+
return {
|
|
533
|
+
content: [{ type: "text" as const, text: `No sessions found matching "${query}".` }],
|
|
534
|
+
details: { matchCount: 0 },
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
return errorResult(`Search failed: ${err.message}`);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const fileMatches = rgOutput
|
|
541
|
+
.trim()
|
|
542
|
+
.split("\n")
|
|
543
|
+
.filter(Boolean)
|
|
544
|
+
.map((line) => {
|
|
545
|
+
const lastColon = line.lastIndexOf(":");
|
|
546
|
+
return {
|
|
547
|
+
path: line.slice(0, lastColon),
|
|
548
|
+
count: parseInt(line.slice(lastColon + 1), 10),
|
|
549
|
+
};
|
|
550
|
+
})
|
|
551
|
+
.sort((a, b) => b.count - a.count || b.path.localeCompare(a.path))
|
|
552
|
+
.slice(0, MAX_SEARCH_RESULTS);
|
|
553
|
+
|
|
554
|
+
const results: string[] = [];
|
|
555
|
+
|
|
556
|
+
for (const { path: filePath, count } of fileMatches) {
|
|
557
|
+
if (signal?.aborted) break;
|
|
558
|
+
|
|
559
|
+
const project = projectFromPath(filePath);
|
|
560
|
+
const date = dateFromPath(filePath);
|
|
561
|
+
|
|
562
|
+
let matchLines: string;
|
|
563
|
+
try {
|
|
564
|
+
matchLines = execSync(
|
|
565
|
+
`rg -i -m ${MAX_SNIPPETS_PER_SESSION} ${escapeShellArg(query)} ${escapeShellArg(filePath)}`,
|
|
566
|
+
{ encoding: "utf-8", timeout: 5000 },
|
|
567
|
+
);
|
|
568
|
+
} catch {
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const snippets: string[] = [];
|
|
573
|
+
for (const line of matchLines.trim().split("\n")) {
|
|
574
|
+
if (!line) continue;
|
|
575
|
+
const msg = extractMessageText(line);
|
|
576
|
+
if (!msg) continue;
|
|
577
|
+
const snippet = snippetAround(msg.text, query);
|
|
578
|
+
snippets.push(` [${msg.role}] ${snippet}`);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
if (snippets.length === 0) continue;
|
|
582
|
+
|
|
583
|
+
results.push(
|
|
584
|
+
`**${date}** · \`${project}\` · ${count} match${count > 1 ? "es" : ""}\n` +
|
|
585
|
+
`Session: \`${filePath}\`\n` +
|
|
586
|
+
snippets.join("\n"),
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
if (results.length === 0) {
|
|
591
|
+
return {
|
|
592
|
+
content: [{ type: "text" as const, text: `No readable matches found for "${query}".` }],
|
|
593
|
+
details: { matchCount: 0 },
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const output =
|
|
598
|
+
`Found ${results.length} session${results.length > 1 ? "s" : ""} matching "${query}":\n\n` +
|
|
599
|
+
results.join("\n\n---\n\n");
|
|
600
|
+
|
|
601
|
+
return {
|
|
602
|
+
content: [{ type: "text" as const, text: output }],
|
|
603
|
+
details: { matchCount: results.length, query },
|
|
604
|
+
};
|
|
605
|
+
},
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
// ── session_query tool ─────────────────────────────────────────────────
|
|
609
|
+
|
|
610
|
+
pi.registerTool({
|
|
611
|
+
name: "session_query",
|
|
612
|
+
label: (params) => `Session Query: ${params.question}`,
|
|
613
|
+
description:
|
|
614
|
+
"Query a specific session file to get detailed information. Use after session_search to dig into a particular session, " +
|
|
615
|
+
"or when you already have a session path (e.g., from a handoff). Sends the full conversation to an LLM for analysis.",
|
|
616
|
+
renderResult: (result, options, theme) => {
|
|
617
|
+
const container = new Container();
|
|
618
|
+
const text = result.content?.[0]?.type === "text" ? result.content[0].text : "";
|
|
619
|
+
const match = text.match(/\*\*Query:\*\* (.+?)\n\n---\n\n([\s\S]+)/);
|
|
620
|
+
|
|
621
|
+
if (!match) {
|
|
622
|
+
container.addChild(new Text(theme.fg("toolOutput", text), 0, 0));
|
|
623
|
+
return container;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const [, query, answer] = match;
|
|
627
|
+
|
|
628
|
+
if (options.expanded) {
|
|
629
|
+
container.addChild(new Text(theme.bold("Query: ") + theme.fg("accent", query), 0, 0));
|
|
630
|
+
container.addChild(new Spacer(1));
|
|
631
|
+
container.addChild(
|
|
632
|
+
new Markdown(answer.trim(), 0, 0, getMarkdownTheme(), {
|
|
633
|
+
color: (text: string) => theme.fg("toolOutput", text),
|
|
634
|
+
}),
|
|
635
|
+
);
|
|
636
|
+
} else {
|
|
637
|
+
const firstLine = answer
|
|
638
|
+
.trim()
|
|
639
|
+
.split("\n")
|
|
640
|
+
.find((l) => l.trim().length > 0 && !l.startsWith("#") && !l.startsWith("---"))
|
|
641
|
+
?.trim() ?? query;
|
|
642
|
+
const summary = firstLine.length > 120 ? firstLine.slice(0, 120) + "..." : firstLine;
|
|
643
|
+
container.addChild(new Text(theme.fg("toolOutput", summary), 0, 0));
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
return container;
|
|
647
|
+
},
|
|
648
|
+
parameters: Type.Object({
|
|
649
|
+
sessionPath: Type.String({
|
|
650
|
+
description:
|
|
651
|
+
"Full path to the session file (e.g., /home/user/.pi/agent/sessions/.../session.jsonl)",
|
|
652
|
+
}),
|
|
653
|
+
question: Type.String({
|
|
654
|
+
description:
|
|
655
|
+
"What you want to know about that session (e.g., 'What files were modified?' or 'What approach was chosen?')",
|
|
656
|
+
}),
|
|
657
|
+
}),
|
|
658
|
+
|
|
659
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
660
|
+
const { sessionPath, question } = params;
|
|
661
|
+
|
|
662
|
+
const errorResult = (text: string) => ({
|
|
663
|
+
content: [{ type: "text" as const, text }],
|
|
664
|
+
details: { error: true },
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
if (!sessionPath.endsWith(".jsonl")) {
|
|
668
|
+
return errorResult(
|
|
669
|
+
`Error: Invalid session path. Expected a .jsonl file, got: ${sessionPath}`,
|
|
670
|
+
);
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
if (!existsSync(sessionPath)) {
|
|
674
|
+
return errorResult(`Error: Session file not found: ${sessionPath}`);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
onUpdate?.({
|
|
678
|
+
content: [{ type: "text", text: `Query: ${question}` }],
|
|
679
|
+
details: { status: "loading", question },
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
// Load the session
|
|
683
|
+
let sessionManager: SessionManager;
|
|
684
|
+
try {
|
|
685
|
+
sessionManager = SessionManager.open(sessionPath);
|
|
686
|
+
} catch (err) {
|
|
687
|
+
return errorResult(`Error loading session: ${err}`);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const branch = sessionManager.getBranch();
|
|
691
|
+
const messages = branch
|
|
692
|
+
.filter(
|
|
693
|
+
(entry): entry is SessionEntry & { type: "message" } =>
|
|
694
|
+
entry.type === "message",
|
|
695
|
+
)
|
|
696
|
+
.map((entry) => entry.message);
|
|
697
|
+
|
|
698
|
+
if (messages.length === 0) {
|
|
699
|
+
return {
|
|
700
|
+
content: [
|
|
701
|
+
{ type: "text" as const, text: "Session is empty - no messages found." },
|
|
702
|
+
],
|
|
703
|
+
details: { empty: true },
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// Resolve query model: user config → current session model
|
|
708
|
+
const queryModel = resolveQueryModel(ctx);
|
|
709
|
+
if (!queryModel) {
|
|
710
|
+
return errorResult(
|
|
711
|
+
"Error: No model available to analyze the session. " +
|
|
712
|
+
"Configure one with /session-recall or set up API keys.",
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// Serialize and check if we need windowing
|
|
717
|
+
const llmMessages = convertToLlm(messages);
|
|
718
|
+
const fullText = serializeConversation(llmMessages);
|
|
719
|
+
const fullTokens = estimateTokens(fullText);
|
|
720
|
+
|
|
721
|
+
// Reserve 20% of context for system prompt + question + response
|
|
722
|
+
const tokenBudget = Math.floor(queryModel.contextWindow * 0.8);
|
|
723
|
+
let conversationText: string;
|
|
724
|
+
let wasWindowed = false;
|
|
725
|
+
|
|
726
|
+
if (fullTokens <= tokenBudget) {
|
|
727
|
+
conversationText = fullText;
|
|
728
|
+
} else {
|
|
729
|
+
const serialized: SerializedMessage[] = [];
|
|
730
|
+
for (let i = 0; i < llmMessages.length; i++) {
|
|
731
|
+
const msg = llmMessages[i];
|
|
732
|
+
const text = serializeConversation([msg]);
|
|
733
|
+
serialized.push({
|
|
734
|
+
index: i,
|
|
735
|
+
role: msg.role,
|
|
736
|
+
text,
|
|
737
|
+
charCount: text.length,
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
conversationText = buildWindowedContext(serialized, question, tokenBudget);
|
|
741
|
+
wasWindowed = true;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
try {
|
|
745
|
+
const apiKey = await ctx.modelRegistry.getApiKey(queryModel);
|
|
746
|
+
|
|
747
|
+
const contextNote = wasWindowed
|
|
748
|
+
? "\n\n**Note:** This is a large session. The conversation has been windowed to focus on sections most relevant to your question. Some messages were omitted (marked with [...])."
|
|
749
|
+
: "";
|
|
750
|
+
|
|
751
|
+
const userMessage: Message = {
|
|
752
|
+
role: "user",
|
|
753
|
+
content: [
|
|
754
|
+
{
|
|
755
|
+
type: "text",
|
|
756
|
+
text: `## Session Conversation${contextNote}\n\n${conversationText}\n\n## Question\n\n${question}`,
|
|
757
|
+
},
|
|
758
|
+
],
|
|
759
|
+
timestamp: Date.now(),
|
|
760
|
+
};
|
|
761
|
+
|
|
762
|
+
const response = await complete(
|
|
763
|
+
queryModel,
|
|
764
|
+
{ systemPrompt: QUERY_SYSTEM_PROMPT, messages: [userMessage] },
|
|
765
|
+
{ apiKey, signal },
|
|
766
|
+
);
|
|
767
|
+
|
|
768
|
+
if (response.stopReason === "aborted") {
|
|
769
|
+
return {
|
|
770
|
+
content: [
|
|
771
|
+
{ type: "text" as const, text: "Query was cancelled." },
|
|
772
|
+
],
|
|
773
|
+
details: { cancelled: true },
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
if (response.stopReason === "error" || response.content.length === 0) {
|
|
778
|
+
return errorResult(
|
|
779
|
+
`Error: LLM returned empty response (${response.stopReason}). Session may be too large even after windowing.`,
|
|
780
|
+
);
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
const answer = response.content
|
|
784
|
+
.filter(
|
|
785
|
+
(c): c is { type: "text"; text: string } => c.type === "text",
|
|
786
|
+
)
|
|
787
|
+
.map((c) => c.text)
|
|
788
|
+
.join("\n");
|
|
789
|
+
|
|
790
|
+
const windowNote = wasWindowed ? ` · windowed` : "";
|
|
791
|
+
return {
|
|
792
|
+
content: [
|
|
793
|
+
{
|
|
794
|
+
type: "text" as const,
|
|
795
|
+
text: `**Query:** ${question}\n\n---\n\n${answer}\n\n*Answered by ${queryModel.id} (${queryModel.provider})${windowNote}*`,
|
|
796
|
+
},
|
|
797
|
+
],
|
|
798
|
+
details: {
|
|
799
|
+
sessionPath,
|
|
800
|
+
question,
|
|
801
|
+
messageCount: messages.length,
|
|
802
|
+
wasWindowed,
|
|
803
|
+
},
|
|
804
|
+
};
|
|
805
|
+
} catch (err) {
|
|
806
|
+
return errorResult(`Error querying session: ${err}`);
|
|
807
|
+
}
|
|
808
|
+
},
|
|
809
|
+
});
|
|
810
|
+
}
|