@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 +1 -1
- package/package.json +5 -4
- package/session-recall.ts +215 -50
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`) —
|
|
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.
|
|
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-
|
|
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-
|
|
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 {
|
|
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
|
|
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
|
-
//
|
|
118
|
-
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
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 =
|
|
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
|
-
|
|
563
|
-
|
|
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
|
|
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);
|