pi-atuin 0.1.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 +70 -0
- package/fuzzy-match.ts +103 -0
- package/history-store.ts +215 -0
- package/index.ts +74 -0
- package/package.json +33 -0
- package/screenshot.png +0 -0
- package/search-ui.ts +257 -0
- package/test/fuzzy-match.test.ts +94 -0
- package/test/history-store.test.ts +87 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 slahser
|
|
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
|
|
13
|
+
all 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
|
|
21
|
+
THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
<img src="https://raw.githubusercontent.com/RealAlexandreAI/pi-atuin/main/screenshot.png" alt="pi-atuin preview" width="100%" />
|
|
4
|
+
|
|
5
|
+
# pi-atuin
|
|
6
|
+
|
|
7
|
+
**Atuin-style fuzzy history search for [Pi](https://github.com/earendil-works/pi-coding-agent)**
|
|
8
|
+
|
|
9
|
+
Press ↑ to search previous prompts with fuzzy matching. Backed by atuin DB + local JSONL.
|
|
10
|
+
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pi install npm:pi-atuin
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Or from source:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
git clone https://github.com/RealAlexandreAI/pi-atuin.git
|
|
25
|
+
cd pi-atuin && pi install .
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Setup
|
|
29
|
+
|
|
30
|
+
### Full atuin integration (recommended)
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
# Install atuin (if not already)
|
|
34
|
+
curl --proto '=https' --tlsv1.2 -LsSf https://setup.atuin.sh | sh
|
|
35
|
+
|
|
36
|
+
# Register / login
|
|
37
|
+
atuin register -u <username> -e <email>
|
|
38
|
+
|
|
39
|
+
# Install pi hook (tracks bash commands in atuin)
|
|
40
|
+
atuin hook install pi
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
With this, pi prompts and bash commands sync bidirectionally to atuin DB. Search in either pi or your shell.
|
|
44
|
+
|
|
45
|
+
### JSONL-only (no atuin)
|
|
46
|
+
|
|
47
|
+
No setup needed. History stored in `~/.pi/agent/pi-history.jsonl`.
|
|
48
|
+
|
|
49
|
+
## Usage
|
|
50
|
+
|
|
51
|
+
1. Press **↑** when your input is empty (or cursor is on the first line)
|
|
52
|
+
2. Type to fuzzy-filter
|
|
53
|
+
3. **↑↓** to navigate, **Enter** to select, **Esc** to cancel
|
|
54
|
+
|
|
55
|
+
All pi prompts are recorded automatically. Supports bash, zsh, and fish.
|
|
56
|
+
|
|
57
|
+
## Privacy
|
|
58
|
+
|
|
59
|
+
- History file permissions: `600` (owner-only)
|
|
60
|
+
- Max 1,000 entries, auto-trimmed
|
|
61
|
+
- Atuin calls use `execFile` (no shell injection)
|
|
62
|
+
- No hardcoded credentials or keys
|
|
63
|
+
|
|
64
|
+
## License
|
|
65
|
+
|
|
66
|
+
MIT
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
Built for [Pi Coding Agent](https://github.com/earendil-works/pi-coding-agent). Inspired by [atuin](https://github.com/atuinsh/atuin).
|
package/fuzzy-match.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simple fuzzy matching — no external dependencies.
|
|
3
|
+
*
|
|
4
|
+
* Returns a score and the matched character indices for highlight rendering.
|
|
5
|
+
* Higher score = better match.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface FuzzyResult {
|
|
9
|
+
score: number;
|
|
10
|
+
indices: number[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Match `query` against `target` (case-insensitive).
|
|
15
|
+
* Returns `null` if no match, otherwise a score and matched indices.
|
|
16
|
+
*/
|
|
17
|
+
export function fuzzyMatch(query: string, target: string): FuzzyResult | null {
|
|
18
|
+
if (!query) return { score: 1, indices: [] };
|
|
19
|
+
|
|
20
|
+
const q = query.toLowerCase();
|
|
21
|
+
const t = target.toLowerCase();
|
|
22
|
+
|
|
23
|
+
// Exact substring match — highest priority
|
|
24
|
+
const substringIdx = t.indexOf(q);
|
|
25
|
+
if (substringIdx !== -1) {
|
|
26
|
+
const indices = Array.from({ length: q.length }, (_, i) => substringIdx + i);
|
|
27
|
+
let score = 1000 + q.length;
|
|
28
|
+
if (substringIdx === 0) {
|
|
29
|
+
score += 100; // starts at beginning
|
|
30
|
+
} else if (/[\s_\-/]/.test(target[substringIdx - 1])) {
|
|
31
|
+
score += 50; // starts at word boundary
|
|
32
|
+
}
|
|
33
|
+
return { score, indices };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Fuzzy character match — characters in order, not necessarily contiguous
|
|
37
|
+
const indices: number[] = [];
|
|
38
|
+
let qi = 0;
|
|
39
|
+
let lastMatchIdx = -1;
|
|
40
|
+
let consecutiveBonus = 0;
|
|
41
|
+
let score = 0;
|
|
42
|
+
|
|
43
|
+
for (let ti = 0; ti < t.length && qi < q.length; ti++) {
|
|
44
|
+
if (t[ti] === q[qi]) {
|
|
45
|
+
indices.push(ti);
|
|
46
|
+
|
|
47
|
+
// Position bonus (earlier = better)
|
|
48
|
+
score += Math.max(0, 10 - ti);
|
|
49
|
+
|
|
50
|
+
// Consecutive match bonus
|
|
51
|
+
if (lastMatchIdx === ti - 1) {
|
|
52
|
+
consecutiveBonus += 5;
|
|
53
|
+
} else {
|
|
54
|
+
consecutiveBonus = 0;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Word boundary bonus (after space, dash, underscore, slash)
|
|
58
|
+
if (ti === 0 || /[ _\-/]/.test(target[ti - 1])) {
|
|
59
|
+
score += 15;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
lastMatchIdx = ti;
|
|
63
|
+
qi++;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Not all query characters matched
|
|
68
|
+
if (qi < q.length) return null;
|
|
69
|
+
|
|
70
|
+
score += consecutiveBonus + q.length * 2;
|
|
71
|
+
|
|
72
|
+
return { score, indices };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Sort and filter items by fuzzy match against query.
|
|
77
|
+
* Returns items in descending score order.
|
|
78
|
+
*/
|
|
79
|
+
export function fuzzySearch<T>(
|
|
80
|
+
query: string,
|
|
81
|
+
items: T[],
|
|
82
|
+
getText: (item: T) => string,
|
|
83
|
+
limit = 50,
|
|
84
|
+
): Array<{ item: T; result: FuzzyResult }> {
|
|
85
|
+
if (!query) {
|
|
86
|
+
return items.slice(0, limit).map((item) => ({
|
|
87
|
+
item,
|
|
88
|
+
result: { score: 1, indices: [] },
|
|
89
|
+
}));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const scored: Array<{ item: T; result: FuzzyResult }> = [];
|
|
93
|
+
|
|
94
|
+
for (const item of items) {
|
|
95
|
+
const result = fuzzyMatch(query, getText(item));
|
|
96
|
+
if (result) {
|
|
97
|
+
scored.push({ item, result });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
scored.sort((a, b) => b.result.score - a.result.score);
|
|
102
|
+
return scored.slice(0, limit);
|
|
103
|
+
}
|
package/history-store.ts
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* History storage layer — reads from atuin DB (if installed) and a local JSONL file.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { readFile, writeFile, appendFile, mkdir } from "node:fs/promises";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { execFile } from "node:child_process";
|
|
9
|
+
import { promisify } from "node:util";
|
|
10
|
+
import { fuzzySearch } from "./fuzzy-match.js";
|
|
11
|
+
|
|
12
|
+
const execFileAsync = promisify(execFile);
|
|
13
|
+
|
|
14
|
+
const JSONL_PATH = join(homedir(), ".pi", "agent", "pi-history.jsonl");
|
|
15
|
+
const ATUIN_TIMEOUT_MS = 5_000;
|
|
16
|
+
const MAX_ENTRIES = 1000;
|
|
17
|
+
|
|
18
|
+
export interface HistoryEntry {
|
|
19
|
+
id: string;
|
|
20
|
+
text: string;
|
|
21
|
+
timestamp: number;
|
|
22
|
+
cwd?: string;
|
|
23
|
+
source: "atuin" | "pi";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let writeQueue: Promise<void> = Promise.resolve();
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// JSONL persistence
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
async function ensureDir(): Promise<void> {
|
|
33
|
+
const dir = join(homedir(), ".pi", "agent");
|
|
34
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function addEntry(
|
|
38
|
+
text: string,
|
|
39
|
+
cwd?: string,
|
|
40
|
+
sessionId?: string,
|
|
41
|
+
): Promise<void> {
|
|
42
|
+
const entry: HistoryEntry = {
|
|
43
|
+
id: `pi-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
44
|
+
text,
|
|
45
|
+
timestamp: Date.now(),
|
|
46
|
+
cwd,
|
|
47
|
+
source: "pi",
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const line = JSON.stringify(entry) + "\n";
|
|
51
|
+
|
|
52
|
+
writeQueue = writeQueue.then(async () => {
|
|
53
|
+
await ensureDir();
|
|
54
|
+
await appendFile(JSONL_PATH, line, { encoding: "utf8", mode: 0o600 });
|
|
55
|
+
// Trim old entries if exceeding limit
|
|
56
|
+
await trimOldEntries();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
await writeQueue;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function trimOldEntries(): Promise<void> {
|
|
63
|
+
try {
|
|
64
|
+
const raw = await readFile(JSONL_PATH, "utf8");
|
|
65
|
+
const lines = raw.split("\n").filter((l) => l.trim());
|
|
66
|
+
if (lines.length <= MAX_ENTRIES) return;
|
|
67
|
+
// Keep the most recent MAX_ENTRIES
|
|
68
|
+
const trimmed = lines.slice(lines.length - MAX_ENTRIES);
|
|
69
|
+
await writeFile(JSONL_PATH, trimmed.join("\n") + "\n", { encoding: "utf8", mode: 0o600 });
|
|
70
|
+
} catch {
|
|
71
|
+
// Ignore trim errors
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function readJsonl(): Promise<HistoryEntry[]> {
|
|
76
|
+
try {
|
|
77
|
+
const raw = await readFile(JSONL_PATH, "utf8");
|
|
78
|
+
const lines = raw.split("\n").filter((l) => l.trim());
|
|
79
|
+
return lines.map((l) => JSON.parse(l) as HistoryEntry);
|
|
80
|
+
} catch {
|
|
81
|
+
return [];
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// Atuin CLI integration
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
async function readAtuinHistory(): Promise<HistoryEntry[]> {
|
|
90
|
+
try {
|
|
91
|
+
const { stdout } = await execFileAsync(
|
|
92
|
+
"atuin",
|
|
93
|
+
["search", "--format", "{time}\t{command}", "--limit", "200"],
|
|
94
|
+
{ timeout: ATUIN_TIMEOUT_MS },
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
const lines = stdout.split("\n").filter((l) => l.trim());
|
|
98
|
+
return lines.map((l) => {
|
|
99
|
+
const tab = l.indexOf("\t");
|
|
100
|
+
if (tab === -1) return null;
|
|
101
|
+
const timeRaw = l.slice(0, tab);
|
|
102
|
+
const command = l.slice(tab + 1);
|
|
103
|
+
if (!command.trim()) return null;
|
|
104
|
+
let timestamp = Date.now();
|
|
105
|
+
if (timeRaw) {
|
|
106
|
+
const d = new Date(timeRaw);
|
|
107
|
+
if (!isNaN(d.getTime())) timestamp = d.getTime();
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
id: `atuin-${timestamp}-${Math.random().toString(36).slice(2, 6)}`,
|
|
111
|
+
text: command.trim(),
|
|
112
|
+
timestamp,
|
|
113
|
+
source: "atuin" as const,
|
|
114
|
+
};
|
|
115
|
+
}).filter(Boolean) as HistoryEntry[];
|
|
116
|
+
} catch {
|
|
117
|
+
// atuin not installed or failed — fall back to JSONL only
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// Unified search API
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
let cachedEntries: HistoryEntry[] | null = null;
|
|
127
|
+
let cacheTime = 0;
|
|
128
|
+
const CACHE_TTL_MS = 5_000;
|
|
129
|
+
|
|
130
|
+
async function loadAllEntries(): Promise<HistoryEntry[]> {
|
|
131
|
+
const now = Date.now();
|
|
132
|
+
if (cachedEntries && now - cacheTime < CACHE_TTL_MS) {
|
|
133
|
+
return cachedEntries;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const [jsonl, atuin] = await Promise.all([readJsonl(), readAtuinHistory()]);
|
|
137
|
+
|
|
138
|
+
// Deduplicate: same text within 5s window
|
|
139
|
+
const seen = new Map<string, HistoryEntry>();
|
|
140
|
+
const all = [...atuin, ...jsonl];
|
|
141
|
+
|
|
142
|
+
for (const entry of all) {
|
|
143
|
+
const key = entry.text.trim();
|
|
144
|
+
const existing = seen.get(key);
|
|
145
|
+
if (!existing || Math.abs(entry.timestamp - existing.timestamp) > 5000) {
|
|
146
|
+
seen.set(`${key}-${entry.timestamp}`, entry);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const entries = [...seen.values()].sort(
|
|
151
|
+
(a, b) => b.timestamp - a.timestamp,
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
cachedEntries = entries;
|
|
155
|
+
cacheTime = now;
|
|
156
|
+
|
|
157
|
+
return entries;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export async function searchHistory(
|
|
161
|
+
query: string,
|
|
162
|
+
limit = 30,
|
|
163
|
+
): Promise<Array<{ item: HistoryEntry; score: number; indices: number[] }>> {
|
|
164
|
+
const entries = await loadAllEntries();
|
|
165
|
+
const results = fuzzySearch(
|
|
166
|
+
query,
|
|
167
|
+
entries,
|
|
168
|
+
(e) => e.text,
|
|
169
|
+
limit,
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
return results.map((r) => ({
|
|
173
|
+
item: r.item,
|
|
174
|
+
score: r.result.score,
|
|
175
|
+
indices: r.result.indices,
|
|
176
|
+
}));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export async function listRecent(
|
|
180
|
+
limit = 30,
|
|
181
|
+
): Promise<HistoryEntry[]> {
|
|
182
|
+
const entries = await loadAllEntries();
|
|
183
|
+
return entries.slice(0, limit);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function invalidateCache(): void {
|
|
187
|
+
cachedEntries = null;
|
|
188
|
+
cacheTime = 0;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
// Write back to atuin (so shell atuin search also sees pi prompts)
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
const ATUIN_AUTHOR = "pi";
|
|
196
|
+
|
|
197
|
+
export async function writeToAtuin(text: string, cwd: string): Promise<void> {
|
|
198
|
+
try {
|
|
199
|
+
const { stdout } = await execFileAsync(
|
|
200
|
+
"atuin",
|
|
201
|
+
["history", "start", "--author", ATUIN_AUTHOR, "--", text],
|
|
202
|
+
{ cwd, timeout: ATUIN_TIMEOUT_MS },
|
|
203
|
+
);
|
|
204
|
+
const id = stdout.trim();
|
|
205
|
+
if (id) {
|
|
206
|
+
await execFileAsync(
|
|
207
|
+
"atuin",
|
|
208
|
+
["history", "end", id, "--exit", "0"],
|
|
209
|
+
{ cwd, timeout: ATUIN_TIMEOUT_MS },
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
} catch {
|
|
213
|
+
// atuin not installed or failed — silently ignore
|
|
214
|
+
}
|
|
215
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-atuin — Atuin-style interactive history search for pi.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the default up-arrow behavior with a fuzzy search overlay.
|
|
5
|
+
* Stores history in ~/.pi/agent/pi-history.jsonl and optionally reads
|
|
6
|
+
* from atuin's database if installed.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { CustomEditor } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { matchesKey, Key } from "@earendil-works/pi-tui";
|
|
12
|
+
import { addEntry, invalidateCache, listRecent, writeToAtuin } from "./history-store.js";
|
|
13
|
+
import { HistorySearchComponent } from "./search-ui.js";
|
|
14
|
+
|
|
15
|
+
export default function piAtuin(pi: ExtensionAPI) {
|
|
16
|
+
// ---- 1. Record user inputs to history ----
|
|
17
|
+
pi.on("input", async (event, ctx) => {
|
|
18
|
+
if (event.source === "interactive" && event.text.trim()) {
|
|
19
|
+
const text = event.text.trim();
|
|
20
|
+
await addEntry(text, ctx.cwd);
|
|
21
|
+
// Also write to atuin DB so shell atuin can search pi prompts
|
|
22
|
+
writeToAtuin(text, ctx.cwd);
|
|
23
|
+
}
|
|
24
|
+
return { action: "continue" as const };
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// ---- 2. Install custom editor with up-arrow search ----
|
|
28
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
29
|
+
if (ctx.mode !== "tui") return;
|
|
30
|
+
|
|
31
|
+
ctx.ui.setEditorComponent((tui, theme, keybindings) => {
|
|
32
|
+
const editor = new CustomEditor(tui, theme, keybindings);
|
|
33
|
+
|
|
34
|
+
editor.onExtensionShortcut = (data: string) => {
|
|
35
|
+
if (matchesKey(data, Key.up)) {
|
|
36
|
+
const cursor = editor.getCursor();
|
|
37
|
+
if (cursor.line === 0) {
|
|
38
|
+
openSearch(ctx, editor);
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return false;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
return editor;
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function openSearch(
|
|
51
|
+
ctx: ExtensionContext,
|
|
52
|
+
editor: CustomEditor,
|
|
53
|
+
): Promise<void> {
|
|
54
|
+
const savedText = editor.getText();
|
|
55
|
+
invalidateCache();
|
|
56
|
+
|
|
57
|
+
// Preload entries so the first render already has data
|
|
58
|
+
const entries = await listRecent(100);
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
const result = await ctx.ui.custom<string | null>(
|
|
62
|
+
(_tui, theme, _keybindings, done) =>
|
|
63
|
+
new HistorySearchComponent(done, theme, entries),
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
if (result !== null && result !== undefined) {
|
|
67
|
+
editor.setText(result);
|
|
68
|
+
} else {
|
|
69
|
+
editor.setText(savedText);
|
|
70
|
+
}
|
|
71
|
+
} catch {
|
|
72
|
+
editor.setText(savedText);
|
|
73
|
+
}
|
|
74
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-atuin",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Atuin-style fuzzy history search for pi. Press ↑ for interactive history.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "slahser",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/slahser/pi-atuin.git"
|
|
10
|
+
},
|
|
11
|
+
"keywords": [
|
|
12
|
+
"pi-package",
|
|
13
|
+
"pi-extension",
|
|
14
|
+
"atuin",
|
|
15
|
+
"history",
|
|
16
|
+
"fuzzy-search"
|
|
17
|
+
],
|
|
18
|
+
"pi": {
|
|
19
|
+
"extensions": [
|
|
20
|
+
"./index.ts"
|
|
21
|
+
]
|
|
22
|
+
},
|
|
23
|
+
"peerDependencies": {
|
|
24
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"*.ts",
|
|
28
|
+
"test",
|
|
29
|
+
"screenshot.png",
|
|
30
|
+
"README.md",
|
|
31
|
+
"LICENSE"
|
|
32
|
+
]
|
|
33
|
+
}
|
package/screenshot.png
ADDED
|
Binary file
|
package/search-ui.ts
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atuin-style history search component for pi.
|
|
3
|
+
*
|
|
4
|
+
* Layout: newest entries at the bottom, up-arrow goes to older entries.
|
|
5
|
+
* Data is preloaded before the component renders.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { Component, Focusable } from "@earendil-works/pi-tui";
|
|
9
|
+
import { CURSOR_MARKER, matchesKey, Key, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
10
|
+
import { searchHistory, listRecent, type HistoryEntry } from "./history-store.js";
|
|
11
|
+
|
|
12
|
+
interface SearchResult {
|
|
13
|
+
item: HistoryEntry;
|
|
14
|
+
score: number;
|
|
15
|
+
indices: number[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function fmtTime(ts: number): string {
|
|
19
|
+
const d = Date.now() - ts;
|
|
20
|
+
const s = Math.floor(d / 1000);
|
|
21
|
+
if (s < 60) return `${s}s`;
|
|
22
|
+
const m = Math.floor(s / 60);
|
|
23
|
+
if (m < 60) return `${m}m`;
|
|
24
|
+
const h = Math.floor(m / 60);
|
|
25
|
+
if (h < 24) return `${h}h`;
|
|
26
|
+
return `${Math.floor(h / 24)}d`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function clean(t: string): string {
|
|
30
|
+
return t.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, " ").trim();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class HistorySearchComponent implements Component, Focusable {
|
|
34
|
+
private query = "";
|
|
35
|
+
private results: SearchResult[];
|
|
36
|
+
private sel: number;
|
|
37
|
+
private scroll = 0;
|
|
38
|
+
private cachedW?: number;
|
|
39
|
+
private cachedLines?: string[];
|
|
40
|
+
private _focused = false;
|
|
41
|
+
private visCount = 15;
|
|
42
|
+
|
|
43
|
+
private TS_COL = 5;
|
|
44
|
+
|
|
45
|
+
constructor(
|
|
46
|
+
private done: (r: string | null) => void,
|
|
47
|
+
private theme: any,
|
|
48
|
+
entries: HistoryEntry[],
|
|
49
|
+
) {
|
|
50
|
+
this.results = [...entries].reverse().map((item) => ({ item, score: 0, indices: [] }));
|
|
51
|
+
this.sel = Math.max(0, this.results.length - 1);
|
|
52
|
+
this.scroll = Math.max(0, this.results.length - this.visCount);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private async doSearch(): Promise<void> {
|
|
56
|
+
if (!this.query) {
|
|
57
|
+
const e = await listRecent(100);
|
|
58
|
+
this.results = [...e].reverse().map((item) => ({ item, score: 0, indices: [] }));
|
|
59
|
+
} else {
|
|
60
|
+
const raw = await searchHistory(this.query, 50);
|
|
61
|
+
this.results = [...raw].reverse().map((r) => ({
|
|
62
|
+
item: r.item,
|
|
63
|
+
score: r.score,
|
|
64
|
+
indices: r.indices,
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
67
|
+
this.sel = Math.max(0, this.results.length - 1);
|
|
68
|
+
this.scroll = Math.max(0, this.results.length - this.visCount);
|
|
69
|
+
this.invalidate();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
handleInput(data: string): void {
|
|
73
|
+
if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
|
|
74
|
+
this.done(null);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (matchesKey(data, Key.enter)) {
|
|
78
|
+
const r = this.results[this.sel];
|
|
79
|
+
this.done(r ? r.item.text : null);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (matchesKey(data, Key.up)) {
|
|
83
|
+
if (this.sel > 0) { this.sel--; this.fixScroll(); this.invalidate(); }
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (matchesKey(data, Key.down)) {
|
|
87
|
+
if (this.sel < this.results.length - 1) { this.sel++; this.fixScroll(); this.invalidate(); }
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (matchesKey(data, Key.backspace)) {
|
|
91
|
+
this.query = this.query.slice(0, -1);
|
|
92
|
+
this.doSearch();
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (data.length === 1 && data >= " ") {
|
|
96
|
+
this.query += data;
|
|
97
|
+
this.doSearch();
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private fixScroll(): void {
|
|
103
|
+
if (this.sel < this.scroll) this.scroll = this.sel;
|
|
104
|
+
else if (this.sel >= this.scroll + this.visCount) this.scroll = this.sel - this.visCount + 1;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Build a line that is exactly `w` visible characters wide.
|
|
109
|
+
* All content goes through this to guarantee no overflow.
|
|
110
|
+
*/
|
|
111
|
+
private line(content: string, w: number): string {
|
|
112
|
+
return truncateToWidth(content, w);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private B(ch: string): string {
|
|
116
|
+
return this.theme.fg("border", ch);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
private D(s: string): string {
|
|
120
|
+
return this.theme.fg("dim", s);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
render(width: number, height?: number): string[] {
|
|
124
|
+
this.visCount = Math.max(3, (height ?? 20) - 5);
|
|
125
|
+
|
|
126
|
+
if (this.cachedW === width && this.cachedLines) return this.cachedLines;
|
|
127
|
+
|
|
128
|
+
const W = width;
|
|
129
|
+
const iw = W - 2; // inner width between border chars
|
|
130
|
+
const lines: string[] = [];
|
|
131
|
+
|
|
132
|
+
const wrap = (inner: string) => {
|
|
133
|
+
// Total: │(1) + ' '(1) + inner + ' '(1) + │(1) = inner_vw + 4
|
|
134
|
+
// Want exactly W = iw + 2, so inner_vw must be iw - 2
|
|
135
|
+
const vw = visibleWidth(inner);
|
|
136
|
+
const innerW = iw - 2;
|
|
137
|
+
if (vw > innerW) {
|
|
138
|
+
inner = truncateToWidth(inner, innerW);
|
|
139
|
+
}
|
|
140
|
+
const pad = Math.max(0, innerW - visibleWidth(inner));
|
|
141
|
+
return this.B("\u2502") + " " + inner + " ".repeat(pad) + " " + this.B("\u2502");
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
// ── Top border ──
|
|
145
|
+
const title = " Search History ";
|
|
146
|
+
const tw = title.length;
|
|
147
|
+
const rb = Math.max(0, iw - tw);
|
|
148
|
+
const lrb = Math.floor(rb / 2);
|
|
149
|
+
const rrb = rb - lrb;
|
|
150
|
+
lines.push(this.line(
|
|
151
|
+
this.B("\u250C") + this.D("\u2500".repeat(lrb)) + this.theme.fg("accent", title) + this.D("\u2500".repeat(rrb)) + this.B("\u2510"),
|
|
152
|
+
W,
|
|
153
|
+
));
|
|
154
|
+
|
|
155
|
+
// ── Search input ──
|
|
156
|
+
const marker = this._focused ? CURSOR_MARKER : "";
|
|
157
|
+
const q = this.query;
|
|
158
|
+
const promptW = 2; // "> "
|
|
159
|
+
const innerW = iw - promptW - 1; // -1 for cursor space
|
|
160
|
+
const dq = truncateToWidth(q || "", innerW);
|
|
161
|
+
const cp = dq.length;
|
|
162
|
+
const inputStr =
|
|
163
|
+
"> " +
|
|
164
|
+
dq.slice(0, cp) +
|
|
165
|
+
marker +
|
|
166
|
+
`\x1b[7m${dq.slice(cp, cp + 1) || " "}\x1b[27m` +
|
|
167
|
+
dq.slice(cp + 1);
|
|
168
|
+
lines.push(wrap(inputStr));
|
|
169
|
+
|
|
170
|
+
// ── Separator ──
|
|
171
|
+
lines.push(this.line(this.B("\u251C") + this.D("\u2500".repeat(iw)) + this.B("\u2524"), W));
|
|
172
|
+
|
|
173
|
+
// ── Results ──
|
|
174
|
+
const vis = this.visCount;
|
|
175
|
+
|
|
176
|
+
if (this.results.length === 0) {
|
|
177
|
+
const msg = this.query ? " No matches " : " No history yet ";
|
|
178
|
+
lines.push(wrap(this.D(msg)));
|
|
179
|
+
} else {
|
|
180
|
+
const maxOff = Math.max(0, this.results.length - vis);
|
|
181
|
+
this.scroll = Math.min(this.scroll, maxOff);
|
|
182
|
+
this.scroll = Math.max(0, this.scroll);
|
|
183
|
+
const end = Math.min(this.results.length, this.scroll + vis);
|
|
184
|
+
|
|
185
|
+
for (let i = this.scroll; i < end; i++) {
|
|
186
|
+
const r = this.results[i];
|
|
187
|
+
const isSel = i === this.sel;
|
|
188
|
+
const pfx = isSel ? this.theme.fg("accent", " > ") : " ";
|
|
189
|
+
|
|
190
|
+
const ts = fmtTime(r.item.timestamp);
|
|
191
|
+
const tsPadded = ts.padStart(this.TS_COL);
|
|
192
|
+
const tsCol = this.theme.fg("dim", tsPadded);
|
|
193
|
+
const sep = this.theme.fg("border", " \u2502 ");
|
|
194
|
+
|
|
195
|
+
const cmd = clean(r.item.text);
|
|
196
|
+
const cmdAvail = iw - 3 - this.TS_COL - 3; // pfx + ts + " | "
|
|
197
|
+
let cmdStr: string;
|
|
198
|
+
if (r.indices.length > 0 && this.query) {
|
|
199
|
+
cmdStr = this.hlText(cmd, r.indices, isSel, cmdAvail);
|
|
200
|
+
} else {
|
|
201
|
+
cmdStr = this.theme.fg(isSel ? "accent" : "text", truncateToWidth(cmd, cmdAvail));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
lines.push(wrap(pfx + tsCol + sep + cmdStr));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// empty fill
|
|
208
|
+
for (let i = end - this.scroll; i < vis; i++) {
|
|
209
|
+
lines.push(wrap(""));
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ── Separator ──
|
|
214
|
+
lines.push(this.line(this.B("\u251C") + this.D("\u2500".repeat(iw)) + this.B("\u2524"), W));
|
|
215
|
+
|
|
216
|
+
// ── Footer ──
|
|
217
|
+
const total = this.results.length;
|
|
218
|
+
const pos = total > 0 ? ` ${this.sel + 1}/${total} ` : " 0/0 ";
|
|
219
|
+
const hints = " type to search \u2191\u2193 nav Enter ok Esc cancel";
|
|
220
|
+
lines.push(wrap(this.theme.fg("text", pos) + this.D(hints)));
|
|
221
|
+
|
|
222
|
+
// ── Bottom border ──
|
|
223
|
+
lines.push(this.line(this.B("\u2514") + this.D("\u2500".repeat(iw)) + this.B("\u2518"), W));
|
|
224
|
+
|
|
225
|
+
this.cachedW = W;
|
|
226
|
+
this.cachedLines = lines;
|
|
227
|
+
return lines;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private hlText(text: string, indices: number[], sel: boolean, maxW: number): string {
|
|
231
|
+
const set = new Set(indices);
|
|
232
|
+
let out = "";
|
|
233
|
+
for (let i = 0; i < text.length; i++) {
|
|
234
|
+
if (visibleWidth(out) >= maxW) break;
|
|
235
|
+
const ch = text[i] === "\t" ? " " : text[i];
|
|
236
|
+
if (set.has(i)) {
|
|
237
|
+
out += this.theme.fg("accent", this.theme.bold(ch));
|
|
238
|
+
} else {
|
|
239
|
+
out += this.theme.fg(sel ? "accent" : "muted", ch);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return out;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
invalidate(): void {
|
|
246
|
+
this.cachedW = undefined;
|
|
247
|
+
this.cachedLines = undefined;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
get focused(): boolean { return this._focused; }
|
|
251
|
+
set focused(v: boolean) {
|
|
252
|
+
if (this._focused !== v) {
|
|
253
|
+
this._focused = v;
|
|
254
|
+
this.invalidate();
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, it } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { fuzzyMatch, fuzzySearch } from "../fuzzy-match.js";
|
|
4
|
+
|
|
5
|
+
describe("fuzzyMatch", () => {
|
|
6
|
+
it("exact substring match", () => {
|
|
7
|
+
const r = fuzzyMatch("npm", "npm install");
|
|
8
|
+
assert.ok(r);
|
|
9
|
+
assert.ok(r.score > 1000);
|
|
10
|
+
assert.deepEqual(r.indices, [0, 1, 2]);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("substring at position > 0", () => {
|
|
14
|
+
const r = fuzzyMatch("install", "npm install");
|
|
15
|
+
assert.ok(r);
|
|
16
|
+
assert.ok(r.indices[0] === 4);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("fuzzy character match", () => {
|
|
20
|
+
const r = fuzzyMatch("ni", "npm install");
|
|
21
|
+
assert.ok(r);
|
|
22
|
+
assert.ok(r.indices.length === 2);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("word boundary bonus", () => {
|
|
26
|
+
const a = fuzzyMatch("in", "npm install");
|
|
27
|
+
const b = fuzzyMatch("in", "npmxinstall");
|
|
28
|
+
assert.ok(a);
|
|
29
|
+
assert.ok(b);
|
|
30
|
+
assert.ok(a.score > b.score);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("empty query matches everything", () => {
|
|
34
|
+
const r = fuzzyMatch("", "anything");
|
|
35
|
+
assert.ok(r);
|
|
36
|
+
assert.equal(r.score, 1);
|
|
37
|
+
assert.equal(r.indices.length, 0);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("no match returns null", () => {
|
|
41
|
+
const r = fuzzyMatch("xyz", "npm install");
|
|
42
|
+
assert.equal(r, null);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("case insensitive", () => {
|
|
46
|
+
const r = fuzzyMatch("NPM", "npm install");
|
|
47
|
+
assert.ok(r);
|
|
48
|
+
assert.ok(r.score > 0);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("single char query", () => {
|
|
52
|
+
const r = fuzzyMatch("n", "npm install");
|
|
53
|
+
assert.ok(r);
|
|
54
|
+
assert.equal(r.indices.length, 1);
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
describe("fuzzySearch", () => {
|
|
59
|
+
const items = [
|
|
60
|
+
{ text: "npm install" },
|
|
61
|
+
{ text: "git commit" },
|
|
62
|
+
{ text: "npm test" },
|
|
63
|
+
{ text: "git push" },
|
|
64
|
+
{ text: "ls -la" },
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
it("returns all items when query is empty", () => {
|
|
68
|
+
const results = fuzzySearch("", items, (i) => i.text);
|
|
69
|
+
assert.equal(results.length, items.length);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("filters by query", () => {
|
|
73
|
+
const results = fuzzySearch("npm", items, (i) => i.text);
|
|
74
|
+
assert.equal(results.length, 2);
|
|
75
|
+
assert.ok(results.every((r) => r.item.text.includes("npm")));
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("respects limit", () => {
|
|
79
|
+
const results = fuzzySearch("git", items, (i) => i.text, 1);
|
|
80
|
+
assert.equal(results.length, 1);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("sorted by score descending", () => {
|
|
84
|
+
const results = fuzzySearch("npm", items, (i) => i.text);
|
|
85
|
+
for (let i = 1; i < results.length; i++) {
|
|
86
|
+
assert.ok(results[i - 1].result.score >= results[i].result.score);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("returns empty for no matches", () => {
|
|
91
|
+
const results = fuzzySearch("zzz", items, (i) => i.text);
|
|
92
|
+
assert.equal(results.length, 0);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, it, before, after } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { readFile, writeFile, unlink } from "node:fs/promises";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
const JSONL_PATH = join(homedir(), ".pi", "agent", "pi-history-test.jsonl");
|
|
8
|
+
|
|
9
|
+
// Override the path for testing
|
|
10
|
+
const ORIGINAL_PATH = join(homedir(), ".pi", "agent", "pi-history.jsonl");
|
|
11
|
+
|
|
12
|
+
// We test by directly manipulating the JSONL file since the module
|
|
13
|
+
// uses a hardcoded path. For a more thorough test we'd mock the path,
|
|
14
|
+
// but for now we test the JSONL read/write logic directly.
|
|
15
|
+
|
|
16
|
+
describe("JSONL read/write", () => {
|
|
17
|
+
const testPath = join(homedir(), ".pi", "agent", "pi-history-test.jsonl");
|
|
18
|
+
|
|
19
|
+
after(async () => {
|
|
20
|
+
try { await unlink(testPath); } catch {}
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("writes and reads JSONL entries", async () => {
|
|
24
|
+
const entries = [
|
|
25
|
+
{ id: "1", text: "npm install", timestamp: 1000, source: "pi" },
|
|
26
|
+
{ id: "2", text: "git commit", timestamp: 2000, source: "pi" },
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
const lines = entries.map((e) => JSON.stringify(e)).join("\n") + "\n";
|
|
30
|
+
await writeFile(testPath, lines, "utf8");
|
|
31
|
+
|
|
32
|
+
const raw = await readFile(testPath, "utf8");
|
|
33
|
+
const parsed = raw.split("\n").filter((l) => l.trim()).map((l) => JSON.parse(l));
|
|
34
|
+
|
|
35
|
+
assert.equal(parsed.length, 2);
|
|
36
|
+
assert.equal(parsed[0].text, "npm install");
|
|
37
|
+
assert.equal(parsed[1].text, "git commit");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("handles empty file", async () => {
|
|
41
|
+
await writeFile(testPath, "", "utf8");
|
|
42
|
+
const raw = await readFile(testPath, "utf8");
|
|
43
|
+
const lines = raw.split("\n").filter((l) => l.trim());
|
|
44
|
+
assert.equal(lines.length, 0);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("trims old entries when exceeding limit", async () => {
|
|
48
|
+
const entries = Array.from({ length: 100 }, (_, i) => ({
|
|
49
|
+
id: String(i),
|
|
50
|
+
text: `command ${i}`,
|
|
51
|
+
timestamp: i * 1000,
|
|
52
|
+
source: "pi",
|
|
53
|
+
}));
|
|
54
|
+
|
|
55
|
+
const lines = entries.map((e) => JSON.stringify(e)).join("\n") + "\n";
|
|
56
|
+
await writeFile(testPath, lines, "utf8");
|
|
57
|
+
|
|
58
|
+
// Simulate trim: keep last 50
|
|
59
|
+
const raw = await readFile(testPath, "utf8");
|
|
60
|
+
const allLines = raw.split("\n").filter((l) => l.trim());
|
|
61
|
+
const trimmed = allLines.slice(allLines.length - 50);
|
|
62
|
+
await writeFile(testPath, trimmed.join("\n") + "\n", "utf8");
|
|
63
|
+
|
|
64
|
+
const result = await readFile(testPath, "utf8");
|
|
65
|
+
const resultLines = result.split("\n").filter((l) => l.trim());
|
|
66
|
+
assert.equal(resultLines.length, 50);
|
|
67
|
+
|
|
68
|
+
const first = JSON.parse(resultLines[0]);
|
|
69
|
+
assert.equal(first.text, "command 50");
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("file permissions", () => {
|
|
74
|
+
it("JSONL file has restrictive permissions", async () => {
|
|
75
|
+
try {
|
|
76
|
+
const stat = await import("node:fs/promises").then((fs) =>
|
|
77
|
+
fs.stat(ORIGINAL_PATH),
|
|
78
|
+
);
|
|
79
|
+
// Check that file is not world-readable (mode & 0o044 === 0)
|
|
80
|
+
const mode = stat.mode;
|
|
81
|
+
const worldRead = mode & 0o044; // group + others read
|
|
82
|
+
assert.equal(worldRead, 0, "File should not be group/others readable");
|
|
83
|
+
} catch {
|
|
84
|
+
// File might not exist yet, that's ok
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
});
|