@ogulcancelik/pi-session-recall 1.0.0 → 1.0.2

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 CHANGED
@@ -56,7 +56,7 @@ If no model is configured (or the configured model isn't available), it falls ba
56
56
  ## Requirements
57
57
 
58
58
  - [pi](https://github.com/badlogic/pi-mono) v0.40+
59
- - [ripgrep](https://github.com/BurntSushi/ripgrep) (`rg`) — used for fast session search
59
+ - [ripgrep](https://github.com/BurntSushi/ripgrep) (`rg`) — recommended for fast search, falls back to `grep` or Node-native scan
60
60
 
61
61
  ## License
62
62
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ogulcancelik/pi-session-recall",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Search and query past pi sessions. Recall previous conversations on demand.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -16,12 +16,13 @@
16
16
  "license": "MIT",
17
17
  "repository": {
18
18
  "type": "git",
19
- "url": "git+https://github.com/ogulcancelik/pi-session-recall.git"
19
+ "url": "git+https://github.com/ogulcancelik/pi-extensions.git",
20
+ "directory": "packages/pi-session-recall"
20
21
  },
21
22
  "bugs": {
22
- "url": "https://github.com/ogulcancelik/pi-session-recall/issues"
23
+ "url": "https://github.com/ogulcancelik/pi-extensions/issues"
23
24
  },
24
- "homepage": "https://github.com/ogulcancelik/pi-session-recall#readme",
25
+ "homepage": "https://github.com/ogulcancelik/pi-extensions/tree/main/packages/pi-session-recall#readme",
25
26
  "engines": {
26
27
  "node": ">=18.0.0"
27
28
  },
package/session-recall.ts CHANGED
@@ -44,8 +44,8 @@ import {
44
44
  Text,
45
45
  } from "@mariozechner/pi-tui";
46
46
  import { Type } from "@sinclair/typebox";
47
- import { execSync } from "node:child_process";
48
- import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
47
+ import { execFileSync } from "node:child_process";
48
+ import { existsSync, readFileSync, readdirSync, writeFileSync, mkdirSync } from "node:fs";
49
49
  import { join, dirname } from "node:path";
50
50
  import { homedir } from "node:os";
51
51
 
@@ -105,22 +105,28 @@ function getSessionsDir(): string {
105
105
  * Extract a readable project name from the encoded session directory path.
106
106
  * Session dirs are encoded as: --home-user-projects-myapp--
107
107
  * We strip the home directory prefix and convert dashes back to slashes.
108
+ *
109
+ * Uses the same encoding scheme as session-manager:
110
+ * cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")
111
+ * This handles both Unix (/) and Windows (\, C:\) paths.
108
112
  */
109
113
  function projectFromPath(sessionPath: string): string {
110
- // Match the encoded directory name between sessions/ and the next /
111
- const match = sessionPath.match(/sessions\/(--.*?--)\//);
114
+ // Match the encoded directory name — handle both / and \ separators
115
+ const match = sessionPath.match(/sessions[/\\](--.*?--)[/\\]/);
112
116
  if (!match || !match[1]) return "~";
113
117
 
114
118
  // Decode: strip leading/trailing --, replace internal - with /
115
119
  let encoded = match[1].slice(2, -2); // remove -- from both ends
116
120
 
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) {
121
+ // Encode homedir with the same scheme session-manager uses,
122
+ // so it works on both Unix (/home/can) and Windows (C:\Users\Pc)
123
+ const homeEncoded = homedir()
124
+ .replace(/^[/\\]/, "")
125
+ .replace(/[/\\:]/g, "-");
126
+
127
+ if (encoded.startsWith(homeEncoded + "-")) {
128
+ encoded = encoded.slice(homeEncoded.length + 1);
129
+ } else if (encoded === homeEncoded) {
124
130
  return "~";
125
131
  }
126
132
 
@@ -289,9 +295,193 @@ function buildWindowedContext(
289
295
  return parts.join("\n\n");
290
296
  }
291
297
 
292
- /** Shell-escape a string for use in execSync */
293
- function escapeShellArg(arg: string): string {
294
- return "'" + arg.replace(/'/g, "'\\''") + "'";
298
+ // ── Search Backend ───────────────────────────────────────────────────────────
299
+
300
+ type SearchBackend = "rg" | "grep" | "node";
301
+ let cachedBackend: SearchBackend | undefined;
302
+
303
+ /** Detect best available search backend: rg → grep → node-native */
304
+ function getSearchBackend(): SearchBackend {
305
+ if (cachedBackend !== undefined) return cachedBackend;
306
+
307
+ for (const cmd of ["rg", "grep"] as const) {
308
+ try {
309
+ execFileSync(cmd, ["--version"], { stdio: "ignore", timeout: 5000 });
310
+ cachedBackend = cmd;
311
+ return cmd;
312
+ } catch {
313
+ // not available, try next
314
+ }
315
+ }
316
+
317
+ cachedBackend = "node";
318
+ return "node";
319
+ }
320
+
321
+ interface FileMatchCount {
322
+ path: string;
323
+ count: number;
324
+ }
325
+
326
+ /** Recursively find all .jsonl files under a directory */
327
+ function walkJsonlFiles(dir: string): string[] {
328
+ const results: string[] = [];
329
+ function walk(d: string) {
330
+ let entries;
331
+ try {
332
+ entries = readdirSync(d, { withFileTypes: true });
333
+ } catch {
334
+ return;
335
+ }
336
+ for (const entry of entries) {
337
+ const full = join(d, entry.name);
338
+ if (entry.isDirectory()) walk(full);
339
+ else if (entry.name.endsWith(".jsonl")) results.push(full);
340
+ }
341
+ }
342
+ walk(dir);
343
+ return results;
344
+ }
345
+
346
+ /**
347
+ * Search for a query across all session files, returning files with match counts.
348
+ * Falls back through rg → grep → node-native.
349
+ */
350
+ function searchFiles(query: string, sessionsDir: string, timeout = 10000): FileMatchCount[] {
351
+ const backend = getSearchBackend();
352
+
353
+ if (backend === "rg") {
354
+ try {
355
+ const output = execFileSync("rg", ["-i", "-c", "-F", query, sessionsDir], {
356
+ encoding: "utf-8",
357
+ timeout,
358
+ stdio: ["pipe", "pipe", "pipe"],
359
+ });
360
+ return parseCountOutput(output);
361
+ } catch (err: any) {
362
+ if (err.status === 1) return []; // no matches
363
+ // rg failed unexpectedly — try grep
364
+ }
365
+ }
366
+
367
+ if (backend === "rg" || backend === "grep") {
368
+ try {
369
+ const output = execFileSync(
370
+ "grep",
371
+ ["-r", "-i", "-c", "-F", "--include=*.jsonl", query, sessionsDir],
372
+ {
373
+ encoding: "utf-8",
374
+ timeout,
375
+ stdio: ["pipe", "pipe", "pipe"],
376
+ },
377
+ );
378
+ return parseCountOutput(output);
379
+ } catch (err: any) {
380
+ if (err.status === 1) return []; // no matches
381
+ // grep failed too — fall through to node
382
+ }
383
+ }
384
+
385
+ // Node-native fallback
386
+ return nodeSearchFiles(query, sessionsDir);
387
+ }
388
+
389
+ /** Parse `file:count` output from rg -c / grep -c */
390
+ function parseCountOutput(output: string): FileMatchCount[] {
391
+ return output
392
+ .trim()
393
+ .split("\n")
394
+ .filter(Boolean)
395
+ .map((line) => {
396
+ const lastColon = line.lastIndexOf(":");
397
+ return {
398
+ path: line.slice(0, lastColon),
399
+ count: parseInt(line.slice(lastColon + 1), 10),
400
+ };
401
+ })
402
+ .filter((m) => m.count > 0);
403
+ }
404
+
405
+ /** Node-native: count case-insensitive matches per file */
406
+ function nodeSearchFiles(query: string, sessionsDir: string): FileMatchCount[] {
407
+ const files = walkJsonlFiles(sessionsDir);
408
+ const queryLower = query.toLowerCase();
409
+ const results: FileMatchCount[] = [];
410
+
411
+ for (const filePath of files) {
412
+ try {
413
+ const content = readFileSync(filePath, "utf-8");
414
+ const lower = content.toLowerCase();
415
+ let count = 0;
416
+ let idx = 0;
417
+ while ((idx = lower.indexOf(queryLower, idx)) !== -1) {
418
+ count++;
419
+ idx += queryLower.length;
420
+ }
421
+ if (count > 0) results.push({ path: filePath, count });
422
+ } catch {
423
+ continue;
424
+ }
425
+ }
426
+
427
+ return results;
428
+ }
429
+
430
+ /**
431
+ * Find matching lines in a single file, up to maxMatches.
432
+ * Falls back through rg → grep → node-native.
433
+ */
434
+ function searchLines(query: string, filePath: string, maxMatches: number): string[] {
435
+ const backend = getSearchBackend();
436
+
437
+ if (backend === "rg") {
438
+ try {
439
+ const output = execFileSync(
440
+ "rg",
441
+ ["-i", "-F", "-m", String(maxMatches), query, filePath],
442
+ { encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] },
443
+ );
444
+ return output.trim().split("\n").filter(Boolean);
445
+ } catch (err: any) {
446
+ if (err.status === 1) return []; // no matches
447
+ // rg failed — try grep
448
+ }
449
+ }
450
+
451
+ if (backend === "rg" || backend === "grep") {
452
+ try {
453
+ const output = execFileSync(
454
+ "grep",
455
+ ["-i", "-F", "-m", String(maxMatches), query, filePath],
456
+ { encoding: "utf-8", timeout: 5000, stdio: ["pipe", "pipe", "pipe"] },
457
+ );
458
+ return output.trim().split("\n").filter(Boolean);
459
+ } catch (err: any) {
460
+ if (err.status === 1) return []; // no matches
461
+ // grep failed too — fall through to node
462
+ }
463
+ }
464
+
465
+ // Node-native fallback
466
+ return nodeSearchLines(query, filePath, maxMatches);
467
+ }
468
+
469
+ /** Node-native: find matching lines in a file */
470
+ function nodeSearchLines(query: string, filePath: string, maxMatches: number): string[] {
471
+ try {
472
+ const content = readFileSync(filePath, "utf-8");
473
+ const queryLower = query.toLowerCase();
474
+ const matches: string[] = [];
475
+ for (const line of content.split("\n")) {
476
+ if (matches.length >= maxMatches) break;
477
+ if (line.toLowerCase().includes(queryLower)) {
478
+ matches.push(line);
479
+ }
480
+ }
481
+ return matches;
482
+ } catch {
483
+ return [];
484
+ }
295
485
  }
296
486
 
297
487
  // ── Constants ────────────────────────────────────────────────────────────────
@@ -521,33 +711,16 @@ export default function sessionRecallExtension(pi: ExtensionAPI) {
521
711
  return errorResult("No sessions directory found.");
522
712
  }
523
713
 
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}`);
714
+ const allMatches = searchFiles(query, sessionsDir);
715
+
716
+ if (allMatches.length === 0) {
717
+ return {
718
+ content: [{ type: "text" as const, text: `No sessions found matching "${query}".` }],
719
+ details: { matchCount: 0 },
720
+ };
538
721
  }
539
722
 
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
- })
723
+ const fileMatches = allMatches
551
724
  .sort((a, b) => b.count - a.count || b.path.localeCompare(a.path))
552
725
  .slice(0, MAX_SEARCH_RESULTS);
553
726
 
@@ -559,19 +732,11 @@ export default function sessionRecallExtension(pi: ExtensionAPI) {
559
732
  const project = projectFromPath(filePath);
560
733
  const date = dateFromPath(filePath);
561
734
 
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
- }
735
+ const matchLines = searchLines(query, filePath, MAX_SNIPPETS_PER_SESSION);
736
+ if (matchLines.length === 0) continue;
571
737
 
572
738
  const snippets: string[] = [];
573
- for (const line of matchLines.trim().split("\n")) {
574
- if (!line) continue;
739
+ for (const line of matchLines) {
575
740
  const msg = extractMessageText(line);
576
741
  if (!msg) continue;
577
742
  const snippet = snippetAround(msg.text, query);