@xynogen/pix-search 0.1.1

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 xynogen
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 all
13
+ 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 THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,17 @@
1
+ # pix-search
2
+
3
+ Smarter `@` file search for [Pi Coding Agent](https://github.com/earendil-works/pi).
4
+
5
+ Wraps the built-in autocomplete provider to improve file ranking when you type `@` in the prompt:
6
+
7
+ - **Fuzzy scoring** — proper character-order matching with gap penalties and word-boundary bonuses (uses pi-tui's `fuzzyMatch`)
8
+ - **Git recency** — recently modified files (from `git log`) rank higher
9
+ - **Depth penalty** — shallower files win ties
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pi install npm:@xynogen/pix-search
15
+ ```
16
+
17
+ Standalone — **not** bundled by `@xynogen/pix-core`.
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@xynogen/pix-search",
3
+ "version": "0.1.1",
4
+ "description": "Pi extension — smarter @ file search with fuzzy + git recency ranking",
5
+ "type": "module",
6
+ "main": "src/index.ts",
7
+ "exports": {
8
+ ".": "./src/index.ts",
9
+ "./extension": "./src/extension.ts"
10
+ },
11
+ "scripts": {
12
+ "test": "bun test"
13
+ },
14
+ "files": [
15
+ "src",
16
+ "!src/**/*.test.*",
17
+ "README.md",
18
+ "LICENSE"
19
+ ],
20
+ "pi": {
21
+ "extensions": [
22
+ "src/extension.ts"
23
+ ]
24
+ },
25
+ "keywords": [
26
+ "pi",
27
+ "pi-package",
28
+ "pi-extension",
29
+ "search",
30
+ "autocomplete"
31
+ ],
32
+ "author": "xynogen",
33
+ "license": "MIT",
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/xynogen/pix-mono.git",
37
+ "directory": "packages/pix-search"
38
+ },
39
+ "homepage": "https://github.com/xynogen/pix-mono/tree/main/packages/pix-search#readme",
40
+ "bugs": {
41
+ "url": "https://github.com/xynogen/pix-mono/issues"
42
+ },
43
+ "publishConfig": {
44
+ "access": "public"
45
+ },
46
+ "peerDependencies": {
47
+ "@earendil-works/pi-coding-agent": "*",
48
+ "@earendil-works/pi-tui": "*"
49
+ },
50
+ "dependencies": {
51
+ "@vscode/ripgrep": "^1.18.0"
52
+ }
53
+ }
@@ -0,0 +1,19 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { createSearchProvider } from "./provider.ts";
3
+ import { loadRecency, type RecencyMap } from "./recency.ts";
4
+
5
+ export default function (pi: ExtensionAPI): void {
6
+ pi.on("session_start", async (_event, ctx) => {
7
+ const cwd = ctx.cwd;
8
+
9
+ // Warm recency cache in background — non-blocking
10
+ let recency: RecencyMap = new Map();
11
+ const recencyReady = loadRecency(cwd).then((map) => {
12
+ recency = map;
13
+ });
14
+ // Fire-and-forget; if git isn't available, recency stays empty
15
+ void recencyReady;
16
+
17
+ ctx.ui.addAutocompleteProvider((current) => createSearchProvider(current, cwd, () => recency));
18
+ });
19
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { default } from "./extension.ts";
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Autocomplete provider wrapper — intercepts @ suggestions and re-ranks
3
+ * using rg file discovery, fuzzy scoring, and git recency.
4
+ *
5
+ * Non-@ completions (slash commands, path completions) pass through to the
6
+ * built-in provider unchanged.
7
+ */
8
+
9
+ import { basename } from "node:path";
10
+ import type {
11
+ AutocompleteItem,
12
+ AutocompleteProvider,
13
+ AutocompleteSuggestions,
14
+ } from "@earendil-works/pi-tui";
15
+ import type { RecencyMap } from "./recency.ts";
16
+ import { rgContent, rgFiles, scoreFilename } from "./rg.ts";
17
+
18
+ const MAX_SUGGESTIONS = 20;
19
+ /** Minimum query length before we also search file contents */
20
+ const CONTENT_SEARCH_MIN = 2;
21
+
22
+ /** Weight factors for the combined score */
23
+ const W_FILENAME = 1.0;
24
+ const W_RECENCY = 30;
25
+ const W_CONTENT = 25;
26
+ const W_DEPTH = -0.5; // penalty per path depth level
27
+
28
+ interface ScoredEntry {
29
+ path: string;
30
+ filenameScore: number;
31
+ recencyScore: number;
32
+ contentMatch: boolean;
33
+ depth: number;
34
+ }
35
+
36
+ function depthOf(path: string): number {
37
+ return path.split("/").length - 1;
38
+ }
39
+
40
+ function combinedScore(entry: ScoredEntry): number {
41
+ return (
42
+ entry.filenameScore * W_FILENAME +
43
+ entry.recencyScore * W_RECENCY +
44
+ (entry.contentMatch ? W_CONTENT : 0) +
45
+ entry.depth * W_DEPTH
46
+ );
47
+ }
48
+
49
+ function isAtPrefix(prefix: string): boolean {
50
+ return prefix.startsWith("@") || prefix.startsWith('@"');
51
+ }
52
+
53
+ function extractRawQuery(prefix: string): string {
54
+ let raw = prefix;
55
+ if (raw.startsWith('@"')) raw = raw.slice(2);
56
+ else if (raw.startsWith("@")) raw = raw.slice(1);
57
+ if (raw.endsWith('"')) raw = raw.slice(0, -1);
58
+ return raw;
59
+ }
60
+
61
+ function buildValue(path: string, isDir: boolean, quoted: boolean): string {
62
+ const displayPath = isDir ? `${path}/` : path;
63
+ const needsQuotes = quoted || path.includes(" ");
64
+ if (!needsQuotes) return `@${displayPath}`;
65
+ return `@"${displayPath}"`;
66
+ }
67
+
68
+ export function createSearchProvider(
69
+ inner: AutocompleteProvider,
70
+ cwd: string,
71
+ getRecency: () => RecencyMap,
72
+ ): AutocompleteProvider {
73
+ return {
74
+ triggerCharacters: inner.triggerCharacters,
75
+
76
+ async getSuggestions(
77
+ lines,
78
+ cursorLine,
79
+ cursorCol,
80
+ options,
81
+ ): Promise<AutocompleteSuggestions | null> {
82
+ // Let inner figure out if this is an @ prefix
83
+ const result = await inner.getSuggestions(lines, cursorLine, cursorCol, options);
84
+
85
+ // Not an @ prefix, or inner returned nothing — pass through
86
+ if (!result || !isAtPrefix(result.prefix)) return result;
87
+
88
+ const query = extractRawQuery(result.prefix);
89
+ const isQuoted = result.prefix.includes('"');
90
+ const { signal } = options;
91
+
92
+ if (signal.aborted) return result;
93
+
94
+ // Run rg in parallel: file listing + content search
95
+ const [allFiles, contentFiles] = await Promise.all([
96
+ rgFiles(cwd, signal),
97
+ query.length >= CONTENT_SEARCH_MIN ? rgContent(query, cwd, signal) : Promise.resolve([]),
98
+ ]);
99
+
100
+ if (signal.aborted) return result;
101
+
102
+ const contentSet = new Set(contentFiles);
103
+ const recency = getRecency();
104
+
105
+ // Score every file
106
+ const scored: ScoredEntry[] = [];
107
+ for (const path of allFiles) {
108
+ const fnScore = scoreFilename(path, query);
109
+ const isContent = contentSet.has(path);
110
+ // Skip if neither filename nor content match
111
+ if (fnScore === 0 && !isContent) continue;
112
+
113
+ scored.push({
114
+ path,
115
+ filenameScore: fnScore,
116
+ recencyScore: recency.get(path) ?? 0,
117
+ contentMatch: isContent,
118
+ depth: depthOf(path),
119
+ });
120
+ }
121
+
122
+ // Add content-only matches not in allFiles
123
+ for (const path of contentFiles) {
124
+ if (scored.some((e) => e.path === path)) continue;
125
+ scored.push({
126
+ path,
127
+ filenameScore: 0,
128
+ recencyScore: recency.get(path) ?? 0,
129
+ contentMatch: true,
130
+ depth: depthOf(path),
131
+ });
132
+ }
133
+
134
+ // Sort by combined score descending
135
+ scored.sort((a, b) => combinedScore(b) - combinedScore(a));
136
+
137
+ const items: AutocompleteItem[] = scored.slice(0, MAX_SUGGESTIONS).map((entry) => {
138
+ const isDir = entry.path.endsWith("/");
139
+ const cleanPath = isDir ? entry.path.slice(0, -1) : entry.path;
140
+ const label = basename(cleanPath) + (isDir ? "/" : "");
141
+ const desc =
142
+ entry.contentMatch && entry.filenameScore === 0
143
+ ? `${entry.path} (content match)`
144
+ : entry.path;
145
+ return {
146
+ value: buildValue(cleanPath, isDir, isQuoted),
147
+ label,
148
+ description: desc,
149
+ };
150
+ });
151
+
152
+ if (items.length === 0) return result; // fall back to built-in
153
+
154
+ return { items, prefix: result.prefix };
155
+ },
156
+
157
+ applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
158
+ return inner.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
159
+ },
160
+
161
+ shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
162
+ return inner.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true;
163
+ },
164
+ };
165
+ }
package/src/recency.ts ADDED
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Git recency cache — maps relative file paths to a recency score (0–1).
3
+ * Uses `git log --name-only` to find recently touched files.
4
+ * Cached per session, refreshed on demand.
5
+ */
6
+
7
+ import { execFile } from "node:child_process";
8
+
9
+ const MAX_COMMITS = 200;
10
+ const DECAY = 0.97; // exponential decay per commit position
11
+
12
+ export type RecencyMap = Map<string, number>;
13
+
14
+ export function buildRecencyScores(gitOutput: string): RecencyMap {
15
+ const scores: RecencyMap = new Map();
16
+ // git log --name-only output: commit header, blank, files, blank, repeat
17
+ const files = gitOutput
18
+ .split("\n")
19
+ .map((l) => l.trim())
20
+ .filter((l) => l.length > 0 && !l.startsWith("commit "));
21
+
22
+ let position = 0;
23
+ for (const file of files) {
24
+ if (scores.has(file)) continue; // first occurrence = most recent
25
+ scores.set(file, DECAY ** position);
26
+ position++;
27
+ }
28
+ return scores;
29
+ }
30
+
31
+ export async function loadRecency(cwd: string, signal?: AbortSignal): Promise<RecencyMap> {
32
+ return new Promise((resolve) => {
33
+ if (signal?.aborted) {
34
+ resolve(new Map());
35
+ return;
36
+ }
37
+
38
+ const child = execFile(
39
+ "git",
40
+ ["log", "--name-only", "--pretty=format:", `-n${MAX_COMMITS}`, "--diff-filter=ACMR"],
41
+ { cwd, maxBuffer: 2 * 1024 * 1024, signal },
42
+ (err, stdout) => {
43
+ if (err || !stdout) {
44
+ resolve(new Map());
45
+ return;
46
+ }
47
+ resolve(buildRecencyScores(stdout));
48
+ },
49
+ );
50
+
51
+ signal?.addEventListener(
52
+ "abort",
53
+ () => {
54
+ child.kill("SIGKILL");
55
+ },
56
+ { once: true },
57
+ );
58
+ });
59
+ }
package/src/rg.ts ADDED
@@ -0,0 +1,189 @@
1
+ /**
2
+ * Ripgrep-based file discovery.
3
+ * - `rgFiles(query)` — filename matches via `rg --files` piped through fuzzy
4
+ * - `rgContent(query)` — files containing query text via `rg -l`
5
+ *
6
+ * Both respect .gitignore, hidden-file exclusions, and abort signals.
7
+ */
8
+
9
+ import { spawn } from "node:child_process";
10
+ import { basename } from "node:path";
11
+
12
+ // Bundled ripgrep: @vscode/ripgrep pulls a prebuilt rg per-platform on install
13
+ // and exposes its absolute path, so users no longer need rg on PATH. Fall back
14
+ // to a bare "rg" (PATH lookup) if the postinstall binary download was skipped
15
+ // (e.g. npm_config_ignore_scripts).
16
+ let RG_BIN = "rg";
17
+ try {
18
+ RG_BIN = (require("@vscode/ripgrep") as { rgPath: string }).rgPath;
19
+ } catch {
20
+ /* bundled binary unavailable — fall back to PATH `rg` */
21
+ }
22
+
23
+ export interface RgEntry {
24
+ /** Relative path from basePath */
25
+ path: string;
26
+ /** Whether this matched by content (true) or filename (false) */
27
+ contentMatch: boolean;
28
+ }
29
+
30
+ const TIMEOUT_MS = 3_000;
31
+
32
+ function spawnRg(args: string[], cwd: string, signal: AbortSignal): Promise<string[]> {
33
+ return new Promise((resolve) => {
34
+ if (signal.aborted) {
35
+ resolve([]);
36
+ return;
37
+ }
38
+
39
+ const child = spawn(RG_BIN, args, {
40
+ cwd,
41
+ stdio: ["ignore", "pipe", "ignore"],
42
+ // ponytail: no env filtering — inherits .gitignore respect from rg defaults
43
+ });
44
+
45
+ let stdout = "";
46
+ let resolved = false;
47
+ const timer = setTimeout(() => {
48
+ if (!resolved) child.kill("SIGKILL");
49
+ }, TIMEOUT_MS);
50
+
51
+ const finish = (lines: string[]) => {
52
+ if (resolved) return;
53
+ resolved = true;
54
+ clearTimeout(timer);
55
+ signal.removeEventListener("abort", onAbort);
56
+ resolve(lines);
57
+ };
58
+
59
+ const onAbort = () => {
60
+ if (child.exitCode === null) child.kill("SIGKILL");
61
+ };
62
+ signal.addEventListener("abort", onAbort, { once: true });
63
+
64
+ child.stdout.setEncoding("utf-8");
65
+ child.stdout.on("data", (chunk: string) => {
66
+ stdout += chunk;
67
+ });
68
+ child.on("error", () => finish([]));
69
+ child.on("close", () => {
70
+ const lines = stdout
71
+ .trim()
72
+ .split("\n")
73
+ .filter(Boolean)
74
+ .map((l) => l.replace(/\\/g, "/"));
75
+ finish(lines);
76
+ });
77
+ });
78
+ }
79
+
80
+ /**
81
+ * List all files via `rg --files`, return raw paths.
82
+ * Fast — just enumerates, rg does .gitignore filtering.
83
+ */
84
+ export async function rgFiles(cwd: string, signal: AbortSignal): Promise<string[]> {
85
+ return spawnRg(["--files", "--hidden", "--glob", "!.git"], cwd, signal);
86
+ }
87
+
88
+ /**
89
+ * Search file contents for `query`, return matching file paths.
90
+ * Uses fixed-string search (no regex) for speed + safety.
91
+ */
92
+ export async function rgContent(
93
+ query: string,
94
+ cwd: string,
95
+ signal: AbortSignal,
96
+ ): Promise<string[]> {
97
+ if (!query || query.length < 2) return [];
98
+ return spawnRg(
99
+ [
100
+ "--files-with-matches",
101
+ "--fixed-strings",
102
+ "--ignore-case",
103
+ "--hidden",
104
+ "--glob",
105
+ "!.git",
106
+ "--max-count",
107
+ "1", // stop after first match per file
108
+ "--",
109
+ query,
110
+ ],
111
+ cwd,
112
+ signal,
113
+ );
114
+ }
115
+
116
+ /**
117
+ * Combined file search: filename fuzzy + content grep.
118
+ * Returns deduplicated entries, filename matches first.
119
+ */
120
+ export async function rgSearch(
121
+ query: string,
122
+ cwd: string,
123
+ signal: AbortSignal,
124
+ ): Promise<RgEntry[]> {
125
+ // Run both in parallel
126
+ const [allFiles, contentFiles] = await Promise.all([
127
+ rgFiles(cwd, signal),
128
+ rgContent(query, cwd, signal),
129
+ ]);
130
+
131
+ if (signal.aborted) return [];
132
+
133
+ const seen = new Set<string>();
134
+ const results: RgEntry[] = [];
135
+
136
+ // Filename matches (all files, will be fuzzy-scored by caller)
137
+ for (const path of allFiles) {
138
+ if (seen.has(path)) continue;
139
+ seen.add(path);
140
+ results.push({ path, contentMatch: false });
141
+ }
142
+
143
+ // Content-only matches (files not already in filename results)
144
+ for (const path of contentFiles) {
145
+ if (seen.has(path)) continue;
146
+ seen.add(path);
147
+ results.push({ path, contentMatch: true });
148
+ }
149
+
150
+ return results;
151
+ }
152
+
153
+ /**
154
+ * Score a file path against a query for filename relevance.
155
+ * Returns 0 for no match. Higher = better.
156
+ */
157
+ export function scoreFilename(filePath: string, query: string): number {
158
+ if (!query) return 1;
159
+
160
+ const fileName = basename(filePath);
161
+ const lowerFile = fileName.toLowerCase();
162
+ const lowerPath = filePath.toLowerCase();
163
+ const lowerQuery = query.toLowerCase();
164
+
165
+ // Exact filename
166
+ if (lowerFile === lowerQuery) return 100;
167
+ // Filename starts with query
168
+ if (lowerFile.startsWith(lowerQuery)) return 80;
169
+ // Filename contains query
170
+ if (lowerFile.includes(lowerQuery)) return 60;
171
+ // Full path contains query
172
+ if (lowerPath.includes(lowerQuery)) return 30;
173
+
174
+ // Fuzzy: all query chars appear in order in filename
175
+ let qi = 0;
176
+ for (let i = 0; i < lowerFile.length && qi < lowerQuery.length; i++) {
177
+ if (lowerFile[i] === lowerQuery[qi]) qi++;
178
+ }
179
+ if (qi === lowerQuery.length) return 20;
180
+
181
+ // Fuzzy on full path
182
+ qi = 0;
183
+ for (let i = 0; i < lowerPath.length && qi < lowerQuery.length; i++) {
184
+ if (lowerPath[i] === lowerQuery[qi]) qi++;
185
+ }
186
+ if (qi === lowerQuery.length) return 10;
187
+
188
+ return 0;
189
+ }