@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.
Files changed (3) hide show
  1. package/README.md +35 -13
  2. package/package.json +1 -1
  3. package/session-recall.ts +87 -196
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # pi-session-recall
2
2
 
3
- Recall past [pi](https://github.com/earendil-works/pi) sessions. Search through conversation history and query specific sessions with an LLM.
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
- ## What it does
19
+ ## How it works
20
20
 
21
- Two tools that let the agent recall past sessions:
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
- ### `session_search`
23
+ > What did we decide about authentication and security?
24
24
 
25
- Literal text search across all past sessions using ripgrep-style fixed-string matching. It is not semantic search. The agent should search for one distinctive token or exact phrase at a time, such as a filename, package name, error string, function name, issue id, or remembered wording.
25
+ The agent can then:
26
26
 
27
- Spaces mean exact spaces in an exact phrase. For unrelated concepts, the agent should call `session_search` multiple times instead of combining them into one query.
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
- ### `session_query`
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
- Deep-dives into a specific session file. Loads the conversation, sends it to an LLM, and answers your question about it.
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
- 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 ...]`.
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
- ### `/session-recall`
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
- Command to configure which model is used for `session_query`. Opens a picker with all your available models.
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
- By default, queries use your **current session model**. If you want to save tokens, pick a cheaper model (e.g. Haiku, GPT-4o mini).
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
- Config is stored at `~/.pi/agent/session-recall.json`:
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ogulcancelik/pi-session-recall",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
4
4
  "description": "Search and query past pi sessions. Recall previous conversations on demand.",
5
5
  "type": "module",
6
6
  "keywords": [
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 + one command:
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 { complete, type Model, type Api, type Message } from "@earendil-works/pi-ai";
27
- import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
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 { existsSync, readFileSync, readdirSync, writeFileSync, mkdirSync } from "node:fs";
49
- import { join, dirname } from "node:path";
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: ExtensionCommandContext): Model<Api> | undefined {
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: (params) => `Session Search: ${params.query}`,
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: (params) => `Session Query: ${params.question}`,
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 full conversation to an LLM for analysis.",
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 one with /session-recall or set up API keys.",
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 complete(
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" || response.content.length === 0) {
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: LLM returned empty response (${response.stopReason}). Session may be too large even after windowing.`,
856
+ `Error querying ${queryModel.provider}/${queryModel.id}: LLM returned an empty response.`,
966
857
  );
967
858
  }
968
859