@ogulcancelik/pi-session-recall 1.0.5 → 1.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -13
- package/package.json +1 -1
- package/session-recall.ts +87 -196
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# pi-session-recall
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Give [pi](https://github.com/earendil-works/pi) access to past conversations. Ask a normal question about previous work, and the agent can find the relevant sessions and inspect them for an answer.
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
@@ -16,31 +16,53 @@ Or add manually to `~/.pi/agent/settings.json`:
|
|
|
16
16
|
}
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
-
##
|
|
19
|
+
## How it works
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
The extension adds two tools to pi: `session_search` and `session_query`. You do not need to call them yourself. Ask the agent a natural-language question such as:
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
> What did we decide about authentication and security?
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
The agent can then:
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
1. Search for a few distinctive terms or exact phrases across your saved session files.
|
|
28
|
+
2. Review the matching snippets and select the most relevant sessions.
|
|
29
|
+
3. Ask `session_query` a focused question about each selected session.
|
|
30
|
+
4. Combine the answers and respond with the relevant decisions, changes, or context.
|
|
28
31
|
|
|
29
|
-
|
|
32
|
+
For example, the tool flow may look like this:
|
|
33
|
+
|
|
34
|
+
```text
|
|
35
|
+
You: What did we decide about authentication and security?
|
|
36
|
+
Agent: session_search("authentication")
|
|
37
|
+
Agent: session_search("security")
|
|
38
|
+
Agent: session_query(<matching-session>, "What decisions did we make about authentication and security?")
|
|
39
|
+
Agent: <answers your question>
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Other useful prompts include:
|
|
30
43
|
|
|
31
|
-
|
|
44
|
+
- `How did we fix the Cannot find module '@sinclair/typebox' error?`
|
|
45
|
+
- `Which files did we change when we added passkey authentication?`
|
|
46
|
+
- `Find the session where we discussed Blender VAT baking.`
|
|
47
|
+
- `What approach did we reject for token refresh, and why?`
|
|
32
48
|
|
|
33
|
-
|
|
49
|
+
There is no background indexing or vector database. Recall happens on demand: the main agent searches session JSONL files, then an LLM reads the relevant conversation and answers a focused question.
|
|
34
50
|
|
|
35
|
-
###
|
|
51
|
+
### `session_search`
|
|
52
|
+
|
|
53
|
+
Searches all past sessions using case-insensitive, literal fixed-string matching—essentially `rg -i -F`. It is not regex or semantic search. One call should contain one distinctive token or exact phrase, such as a filename, package name, error string, function name, issue ID, or remembered wording.
|
|
54
|
+
|
|
55
|
+
Spaces are treated as part of the exact phrase. To search for separate concepts such as authentication and security, the agent makes separate calls rather than searching for `authentication security` as one phrase.
|
|
56
|
+
|
|
57
|
+
### `session_query`
|
|
36
58
|
|
|
37
|
-
|
|
59
|
+
Loads one selected session and sends its conversation to an LLM with a focused question. It includes user and assistant messages plus tool calls, while omitting assistant thinking and tool results to reduce cost.
|
|
38
60
|
|
|
39
|
-
|
|
61
|
+
For a session larger than the model's context window, it keeps the beginning, end, and sections relevant to the question. Omitted sections are marked with `[... N messages omitted ...]`.
|
|
40
62
|
|
|
41
63
|
## Configuration
|
|
42
64
|
|
|
43
|
-
|
|
65
|
+
To use a dedicated model for `session_query`, create or edit `~/.pi/agent/session-recall.json`:
|
|
44
66
|
|
|
45
67
|
```json
|
|
46
68
|
{
|
package/package.json
CHANGED
package/session-recall.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Session Recall Extension - Search and query past pi sessions
|
|
3
3
|
*
|
|
4
|
-
* Two tools
|
|
4
|
+
* Two tools:
|
|
5
5
|
*
|
|
6
|
-
* Tools:
|
|
7
6
|
* 1. session_search - Find relevant past sessions by keywords.
|
|
8
7
|
* Use when the user mentions something from a previous conversation
|
|
9
8
|
* and you need to find which session it was in.
|
|
@@ -13,18 +12,19 @@
|
|
|
13
12
|
* specific session, or when you already know the session path
|
|
14
13
|
* (e.g., from a handoff).
|
|
15
14
|
*
|
|
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
15
|
* Typical flow: user says "remember when we tried X?" →
|
|
22
16
|
* session_search("X") → find the right session →
|
|
23
17
|
* session_query(path, "what approach did we take for X?")
|
|
24
18
|
*/
|
|
25
19
|
|
|
26
|
-
import {
|
|
27
|
-
|
|
20
|
+
import {
|
|
21
|
+
cleanupSessionResources,
|
|
22
|
+
type Model,
|
|
23
|
+
type Api,
|
|
24
|
+
type Message,
|
|
25
|
+
} from "@earendil-works/pi-ai";
|
|
26
|
+
import { complete } from "@earendil-works/pi-ai/compat";
|
|
27
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
28
28
|
import {
|
|
29
29
|
getAgentDir,
|
|
30
30
|
SessionManager,
|
|
@@ -33,20 +33,12 @@ import {
|
|
|
33
33
|
serializeConversation,
|
|
34
34
|
type SessionEntry,
|
|
35
35
|
} from "@earendil-works/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 "@earendil-works/pi-tui";
|
|
36
|
+
import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
|
|
46
37
|
import { Type } from "@sinclair/typebox";
|
|
47
38
|
import { execFileSync } from "node:child_process";
|
|
48
|
-
import {
|
|
49
|
-
import {
|
|
39
|
+
import { randomBytes } from "node:crypto";
|
|
40
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
41
|
+
import { join } from "node:path";
|
|
50
42
|
import { homedir } from "node:os";
|
|
51
43
|
|
|
52
44
|
// ── Config ───────────────────────────────────────────────────────────────────
|
|
@@ -72,19 +64,10 @@ function loadConfig(): SessionRecallConfig {
|
|
|
72
64
|
}
|
|
73
65
|
}
|
|
74
66
|
|
|
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
67
|
/**
|
|
85
68
|
* Resolve the query model from config → fallback to current session model.
|
|
86
69
|
*/
|
|
87
|
-
function resolveQueryModel(ctx:
|
|
70
|
+
function resolveQueryModel(ctx: ExtensionContext): Model<Api> | undefined {
|
|
88
71
|
const config = loadConfig();
|
|
89
72
|
if (config.queryModel) {
|
|
90
73
|
const found = ctx.modelRegistry.find(config.queryModel.provider, config.queryModel.id);
|
|
@@ -174,6 +157,63 @@ function estimateTokens(text: string): number {
|
|
|
174
157
|
return Math.ceil(text.length / 4);
|
|
175
158
|
}
|
|
176
159
|
|
|
160
|
+
/** Create a UUIDv7 for Codex request routing, which is unreliable with UUIDv4. */
|
|
161
|
+
function createUuidV7(): string {
|
|
162
|
+
const bytes = randomBytes(16);
|
|
163
|
+
const timestamp = BigInt(Date.now());
|
|
164
|
+
|
|
165
|
+
bytes[0] = Number((timestamp >> 40n) & 0xffn);
|
|
166
|
+
bytes[1] = Number((timestamp >> 32n) & 0xffn);
|
|
167
|
+
bytes[2] = Number((timestamp >> 24n) & 0xffn);
|
|
168
|
+
bytes[3] = Number((timestamp >> 16n) & 0xffn);
|
|
169
|
+
bytes[4] = Number((timestamp >> 8n) & 0xffn);
|
|
170
|
+
bytes[5] = Number(timestamp & 0xffn);
|
|
171
|
+
bytes[6] = 0x70 | (bytes[6] & 0x0f);
|
|
172
|
+
bytes[8] = 0x80 | (bytes[8] & 0x3f);
|
|
173
|
+
|
|
174
|
+
const hex = bytes.toString("hex");
|
|
175
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function completeRecallRequest(
|
|
179
|
+
model: Model<Api>,
|
|
180
|
+
context: Parameters<typeof complete>[1],
|
|
181
|
+
options: NonNullable<Parameters<typeof complete>[2]>,
|
|
182
|
+
): ReturnType<typeof complete> {
|
|
183
|
+
if (model.provider !== "openai-codex") return complete(model, context, options);
|
|
184
|
+
|
|
185
|
+
const sessionId = createUuidV7();
|
|
186
|
+
try {
|
|
187
|
+
return await complete(model, context, {
|
|
188
|
+
...options,
|
|
189
|
+
sessionId,
|
|
190
|
+
transport: "websocket",
|
|
191
|
+
});
|
|
192
|
+
} finally {
|
|
193
|
+
cleanupSessionResources(sessionId);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Keep the conversational record and actions while dropping the two largest,
|
|
199
|
+
* least useful sources of recall context: assistant thinking and tool output.
|
|
200
|
+
*/
|
|
201
|
+
function prepareRecallMessages(messages: Message[]): Message[] {
|
|
202
|
+
const prepared: Message[] = [];
|
|
203
|
+
|
|
204
|
+
for (const message of messages) {
|
|
205
|
+
if (message.role === "toolResult") continue;
|
|
206
|
+
if (message.role === "assistant") {
|
|
207
|
+
const content = message.content.filter((block) => block.type !== "thinking");
|
|
208
|
+
if (content.length > 0) prepared.push({ ...message, content });
|
|
209
|
+
} else {
|
|
210
|
+
prepared.push(message);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return prepared;
|
|
215
|
+
}
|
|
216
|
+
|
|
177
217
|
/**
|
|
178
218
|
* Extract keywords from a question for matching.
|
|
179
219
|
* Strips common stop words and short words.
|
|
@@ -501,168 +541,11 @@ const MAX_SNIPPETS_PER_SESSION = 3;
|
|
|
501
541
|
// ── Extension ────────────────────────────────────────────────────────────────
|
|
502
542
|
|
|
503
543
|
export default function sessionRecallExtension(pi: ExtensionAPI) {
|
|
504
|
-
// ── /session-recall command ────────────────────────────────────────────
|
|
505
|
-
|
|
506
|
-
pi.registerCommand("session-recall", {
|
|
507
|
-
description: "Configure the model used for session queries",
|
|
508
|
-
handler: async (_args, ctx) => {
|
|
509
|
-
if (!ctx.hasUI) {
|
|
510
|
-
ctx.ui.notify("Interactive mode required for /session-recall", "warning");
|
|
511
|
-
return;
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
const config = loadConfig();
|
|
515
|
-
const available = ctx.modelRegistry.getAvailable();
|
|
516
|
-
|
|
517
|
-
if (available.length === 0) {
|
|
518
|
-
ctx.ui.notify("No models available. Configure API keys first.", "warning");
|
|
519
|
-
return;
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
const currentKey = config.queryModel
|
|
523
|
-
? `${config.queryModel.provider}/${config.queryModel.id}`
|
|
524
|
-
: null;
|
|
525
|
-
|
|
526
|
-
// Build select items: session model fallback + all available models
|
|
527
|
-
const SESSION_MODEL_VALUE = "__session_model__";
|
|
528
|
-
const items: SelectItem[] = [
|
|
529
|
-
{
|
|
530
|
-
value: SESSION_MODEL_VALUE,
|
|
531
|
-
label: "(use current session model)",
|
|
532
|
-
description: currentKey === null ? "active" : undefined,
|
|
533
|
-
},
|
|
534
|
-
...available.map((m) => {
|
|
535
|
-
const key = `${m.provider}/${m.id}`;
|
|
536
|
-
return {
|
|
537
|
-
value: key,
|
|
538
|
-
label: key,
|
|
539
|
-
description: key === currentKey ? "active" : undefined,
|
|
540
|
-
};
|
|
541
|
-
}),
|
|
542
|
-
];
|
|
543
|
-
|
|
544
|
-
const choice = await ctx.ui.custom<string | undefined>(
|
|
545
|
-
(tui, theme, _kb, done) => {
|
|
546
|
-
const selectTheme: SelectListTheme = {
|
|
547
|
-
selectedPrefix: (t) => theme.fg("accent", t),
|
|
548
|
-
selectedText: (t) => theme.fg("accent", t),
|
|
549
|
-
description: (t) => theme.fg("muted", t),
|
|
550
|
-
scrollInfo: (t) => theme.fg("muted", t),
|
|
551
|
-
noMatch: (t) => theme.fg("muted", t),
|
|
552
|
-
};
|
|
553
|
-
|
|
554
|
-
const container = new Container();
|
|
555
|
-
const title = new Text(
|
|
556
|
-
theme.bold("Query model for session_query") +
|
|
557
|
-
theme.fg("muted", " (type to filter)"),
|
|
558
|
-
0,
|
|
559
|
-
0,
|
|
560
|
-
);
|
|
561
|
-
const input = new Input();
|
|
562
|
-
const listSpacer = new Spacer(1);
|
|
563
|
-
const hint = new Text(
|
|
564
|
-
theme.fg("muted", " ↑/↓ navigate · Enter select · Esc cancel"),
|
|
565
|
-
0,
|
|
566
|
-
0,
|
|
567
|
-
);
|
|
568
|
-
|
|
569
|
-
// Fuzzy filter: every term must appear somewhere in the label
|
|
570
|
-
const fuzzyMatch = (label: string, query: string): boolean => {
|
|
571
|
-
const lower = label.toLowerCase();
|
|
572
|
-
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
573
|
-
return terms.every((term) => lower.includes(term));
|
|
574
|
-
};
|
|
575
|
-
|
|
576
|
-
let list: SelectList;
|
|
577
|
-
|
|
578
|
-
const buildList = (filtered: SelectItem[]): SelectList => {
|
|
579
|
-
const sl = new SelectList(filtered, 10, selectTheme);
|
|
580
|
-
sl.onSelect = (item) => done(item.value);
|
|
581
|
-
sl.onCancel = () => done(undefined);
|
|
582
|
-
return sl;
|
|
583
|
-
};
|
|
584
|
-
|
|
585
|
-
// Initial list with all items, pre-select active
|
|
586
|
-
list = buildList(items);
|
|
587
|
-
const activeIdx = items.findIndex(
|
|
588
|
-
(i) => i.value === (currentKey ?? SESSION_MODEL_VALUE),
|
|
589
|
-
);
|
|
590
|
-
if (activeIdx >= 0) list.setSelectedIndex(activeIdx);
|
|
591
|
-
|
|
592
|
-
container.addChild(title);
|
|
593
|
-
container.addChild(new Spacer(1));
|
|
594
|
-
container.addChild(input);
|
|
595
|
-
container.addChild(listSpacer);
|
|
596
|
-
container.addChild(list);
|
|
597
|
-
container.addChild(new Spacer(1));
|
|
598
|
-
container.addChild(hint);
|
|
599
|
-
|
|
600
|
-
input.onSubmit = () => {
|
|
601
|
-
const selected = list.getSelectedItem();
|
|
602
|
-
if (selected) done(selected.value);
|
|
603
|
-
};
|
|
604
|
-
input.onEscape = () => done(undefined);
|
|
605
|
-
|
|
606
|
-
const rebuildList = () => {
|
|
607
|
-
const query = input.getValue();
|
|
608
|
-
const filtered = query
|
|
609
|
-
? items.filter((item) => fuzzyMatch(item.label, query))
|
|
610
|
-
: items;
|
|
611
|
-
|
|
612
|
-
container.removeChild(list);
|
|
613
|
-
list = buildList(filtered);
|
|
614
|
-
// Re-insert list at correct position (after listSpacer)
|
|
615
|
-
const spacerIdx = container.children.indexOf(listSpacer);
|
|
616
|
-
container.children.splice(spacerIdx + 1, 0, list);
|
|
617
|
-
tui.requestRender();
|
|
618
|
-
};
|
|
619
|
-
|
|
620
|
-
container.handleInput = (data: string) => {
|
|
621
|
-
// Navigation keys go to the select list
|
|
622
|
-
const isNav =
|
|
623
|
-
data === "\x1b[A" || // up
|
|
624
|
-
data === "\x1b[B" || // down
|
|
625
|
-
data === "\r" || // enter
|
|
626
|
-
data === "\n";
|
|
627
|
-
|
|
628
|
-
if (isNav) {
|
|
629
|
-
list.handleInput(data);
|
|
630
|
-
} else if (data === "\x1b" || data === "\x03") {
|
|
631
|
-
done(undefined);
|
|
632
|
-
} else {
|
|
633
|
-
input.handleInput(data);
|
|
634
|
-
rebuildList();
|
|
635
|
-
}
|
|
636
|
-
};
|
|
637
|
-
|
|
638
|
-
return container;
|
|
639
|
-
},
|
|
640
|
-
);
|
|
641
|
-
|
|
642
|
-
if (choice === undefined) return; // cancelled
|
|
643
|
-
|
|
644
|
-
if (choice === SESSION_MODEL_VALUE) {
|
|
645
|
-
delete config.queryModel;
|
|
646
|
-
saveConfig(config);
|
|
647
|
-
ctx.ui.notify("Session query will use the current session model", "info");
|
|
648
|
-
} else {
|
|
649
|
-
const slashIdx = choice.indexOf("/");
|
|
650
|
-
if (slashIdx === -1) return;
|
|
651
|
-
|
|
652
|
-
const provider = choice.slice(0, slashIdx);
|
|
653
|
-
const id = choice.slice(slashIdx + 1);
|
|
654
|
-
config.queryModel = { provider, id };
|
|
655
|
-
saveConfig(config);
|
|
656
|
-
ctx.ui.notify(`Session query model set to ${choice}`, "info");
|
|
657
|
-
}
|
|
658
|
-
},
|
|
659
|
-
});
|
|
660
|
-
|
|
661
544
|
// ── session_search tool ────────────────────────────────────────────────
|
|
662
545
|
|
|
663
546
|
pi.registerTool({
|
|
664
547
|
name: "session_search",
|
|
665
|
-
label:
|
|
548
|
+
label: "Session Search",
|
|
666
549
|
description:
|
|
667
550
|
"Find past sessions by literal text search. This is essentially `rg -i -F` over session JSONL files, not semantic search. " +
|
|
668
551
|
"Use one exact token or phrase. Spaces are exact spaces in an exact phrase, so only use spaces for wording you expect appeared in the session, such as an error message. " +
|
|
@@ -792,10 +675,10 @@ export default function sessionRecallExtension(pi: ExtensionAPI) {
|
|
|
792
675
|
|
|
793
676
|
pi.registerTool({
|
|
794
677
|
name: "session_query",
|
|
795
|
-
label:
|
|
678
|
+
label: "Session Query",
|
|
796
679
|
description:
|
|
797
680
|
"Query a specific session file to get detailed information. Use after session_search to dig into a particular session, " +
|
|
798
|
-
"or when you already have a session path (e.g., from a handoff). Sends the
|
|
681
|
+
"or when you already have a session path (e.g., from a handoff). Sends the conversation and tool calls, without assistant thinking or tool output, to an LLM for analysis.",
|
|
799
682
|
renderResult: (result, options, theme) => {
|
|
800
683
|
const container = new Container();
|
|
801
684
|
const text = result.content?.[0]?.type === "text" ? result.content[0].text : "";
|
|
@@ -892,12 +775,12 @@ export default function sessionRecallExtension(pi: ExtensionAPI) {
|
|
|
892
775
|
if (!queryModel) {
|
|
893
776
|
return errorResult(
|
|
894
777
|
"Error: No model available to analyze the session. " +
|
|
895
|
-
"Configure
|
|
778
|
+
"Configure queryModel in session-recall.json or set up API keys.",
|
|
896
779
|
);
|
|
897
780
|
}
|
|
898
781
|
|
|
899
782
|
// Serialize and check if we need windowing
|
|
900
|
-
const llmMessages = convertToLlm(messages);
|
|
783
|
+
const llmMessages = prepareRecallMessages(convertToLlm(messages));
|
|
901
784
|
const fullText = serializeConversation(llmMessages);
|
|
902
785
|
const fullTokens = estimateTokens(fullText);
|
|
903
786
|
|
|
@@ -945,7 +828,7 @@ export default function sessionRecallExtension(pi: ExtensionAPI) {
|
|
|
945
828
|
timestamp: Date.now(),
|
|
946
829
|
};
|
|
947
830
|
|
|
948
|
-
const response = await
|
|
831
|
+
const response = await completeRecallRequest(
|
|
949
832
|
queryModel,
|
|
950
833
|
{ systemPrompt: QUERY_SYSTEM_PROMPT, messages: [userMessage] },
|
|
951
834
|
{ apiKey: auth.apiKey, headers: auth.headers, signal },
|
|
@@ -960,9 +843,17 @@ export default function sessionRecallExtension(pi: ExtensionAPI) {
|
|
|
960
843
|
};
|
|
961
844
|
}
|
|
962
845
|
|
|
963
|
-
if (response.stopReason === "error"
|
|
846
|
+
if (response.stopReason === "error") {
|
|
847
|
+
const providerError =
|
|
848
|
+
response.errorMessage?.trim() || "The provider returned an error without details.";
|
|
849
|
+
return errorResult(
|
|
850
|
+
`Error querying ${queryModel.provider}/${queryModel.id}: ${providerError}`,
|
|
851
|
+
);
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
if (response.content.length === 0) {
|
|
964
855
|
return errorResult(
|
|
965
|
-
`Error
|
|
856
|
+
`Error querying ${queryModel.provider}/${queryModel.id}: LLM returned an empty response.`,
|
|
966
857
|
);
|
|
967
858
|
}
|
|
968
859
|
|