@aaroncql/pim-agent 0.0.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.
Files changed (155) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +212 -0
  3. package/bin/pim.ts +109 -0
  4. package/package.json +49 -0
  5. package/src/extensions/_init/index.ts +109 -0
  6. package/src/extensions/bash/capture.test.ts +126 -0
  7. package/src/extensions/bash/capture.ts +80 -0
  8. package/src/extensions/bash/format.test.ts +240 -0
  9. package/src/extensions/bash/format.ts +76 -0
  10. package/src/extensions/bash/index.ts +86 -0
  11. package/src/extensions/bash/run.test.ts +262 -0
  12. package/src/extensions/bash/run.ts +207 -0
  13. package/src/extensions/bash/schema.ts +54 -0
  14. package/src/extensions/command-picker/index.ts +52 -0
  15. package/src/extensions/command-picker/ranker.test.ts +46 -0
  16. package/src/extensions/command-picker/ranker.ts +17 -0
  17. package/src/extensions/edit/edit.test.ts +285 -0
  18. package/src/extensions/edit/edit.ts +382 -0
  19. package/src/extensions/edit/index.ts +54 -0
  20. package/src/extensions/edit/schema.ts +37 -0
  21. package/src/extensions/file-picker/catalog.test.ts +263 -0
  22. package/src/extensions/file-picker/catalog.ts +219 -0
  23. package/src/extensions/file-picker/index.test.ts +168 -0
  24. package/src/extensions/file-picker/index.ts +119 -0
  25. package/src/extensions/file-picker/ranker.test.ts +94 -0
  26. package/src/extensions/file-picker/ranker.ts +76 -0
  27. package/src/extensions/footer/git.test.ts +76 -0
  28. package/src/extensions/footer/git.ts +87 -0
  29. package/src/extensions/footer/index.test.ts +161 -0
  30. package/src/extensions/footer/index.ts +148 -0
  31. package/src/extensions/footer/powerline.ts +87 -0
  32. package/src/extensions/footer/segments.test.ts +164 -0
  33. package/src/extensions/footer/segments.ts +234 -0
  34. package/src/extensions/glob/glob.test.ts +171 -0
  35. package/src/extensions/glob/glob.ts +34 -0
  36. package/src/extensions/glob/index.test.ts +68 -0
  37. package/src/extensions/glob/index.ts +136 -0
  38. package/src/extensions/glob/render.test.ts +126 -0
  39. package/src/extensions/glob/render.ts +74 -0
  40. package/src/extensions/glob/schema.ts +52 -0
  41. package/src/extensions/grep/grep.test.ts +387 -0
  42. package/src/extensions/grep/grep.ts +215 -0
  43. package/src/extensions/grep/index.test.ts +68 -0
  44. package/src/extensions/grep/index.ts +158 -0
  45. package/src/extensions/grep/render.test.ts +269 -0
  46. package/src/extensions/grep/render.ts +243 -0
  47. package/src/extensions/grep/schema.ts +92 -0
  48. package/src/extensions/read/index.ts +84 -0
  49. package/src/extensions/read/read.test.ts +177 -0
  50. package/src/extensions/read/read.ts +206 -0
  51. package/src/extensions/read/render.test.ts +61 -0
  52. package/src/extensions/read/render.ts +33 -0
  53. package/src/extensions/read/schema.ts +27 -0
  54. package/src/extensions/subagent/index.test.ts +44 -0
  55. package/src/extensions/subagent/index.ts +30 -0
  56. package/src/extensions/subagent/render.test.ts +292 -0
  57. package/src/extensions/subagent/render.ts +359 -0
  58. package/src/extensions/subagent/schema.ts +9 -0
  59. package/src/extensions/subagent/subagent.test.ts +315 -0
  60. package/src/extensions/subagent/subagent.ts +418 -0
  61. package/src/extensions/system-prompt/index.ts +28 -0
  62. package/src/extensions/system-prompt/prompt.test.ts +64 -0
  63. package/src/extensions/system-prompt/prompt.ts +213 -0
  64. package/src/extensions/todo/index.test.ts +244 -0
  65. package/src/extensions/todo/index.ts +122 -0
  66. package/src/extensions/todo/render.test.ts +180 -0
  67. package/src/extensions/todo/render.ts +172 -0
  68. package/src/extensions/todo/schema.ts +24 -0
  69. package/src/extensions/todo/todo.test.ts +222 -0
  70. package/src/extensions/todo/todo.ts +188 -0
  71. package/src/extensions/tps/index.test.ts +254 -0
  72. package/src/extensions/tps/index.ts +136 -0
  73. package/src/extensions/web-fetch/JinaReaderClient.ts +230 -0
  74. package/src/extensions/web-fetch/WebViewFetchClient.ts +186 -0
  75. package/src/extensions/web-fetch/WebViewMarkdownSnapshot.test.ts +119 -0
  76. package/src/extensions/web-fetch/WebViewMarkdownSnapshot.ts +511 -0
  77. package/src/extensions/web-fetch/fetch.test.ts +244 -0
  78. package/src/extensions/web-fetch/fetch.ts +249 -0
  79. package/src/extensions/web-fetch/index.ts +107 -0
  80. package/src/extensions/web-fetch/render.test.ts +56 -0
  81. package/src/extensions/web-fetch/render.ts +39 -0
  82. package/src/extensions/web-fetch/schema.ts +23 -0
  83. package/src/extensions/web-search/ExaMcpClient.test.ts +143 -0
  84. package/src/extensions/web-search/ExaMcpClient.ts +258 -0
  85. package/src/extensions/web-search/index.ts +118 -0
  86. package/src/extensions/web-search/render.test.ts +21 -0
  87. package/src/extensions/web-search/render.ts +9 -0
  88. package/src/extensions/web-search/schema.ts +21 -0
  89. package/src/extensions/web-search/search.test.ts +53 -0
  90. package/src/extensions/web-search/search.ts +23 -0
  91. package/src/extensions/working-indicator/index.test.ts +21 -0
  92. package/src/extensions/working-indicator/index.ts +77 -0
  93. package/src/extensions/write/index.ts +76 -0
  94. package/src/extensions/write/render.test.ts +64 -0
  95. package/src/extensions/write/schema.ts +14 -0
  96. package/src/extensions/write/write.test.ts +108 -0
  97. package/src/extensions/write/write.ts +104 -0
  98. package/src/shared/DiffLines.test.ts +193 -0
  99. package/src/shared/DiffLines.ts +307 -0
  100. package/src/shared/DiffRenderer.test.ts +206 -0
  101. package/src/shared/DiffRenderer.ts +396 -0
  102. package/src/shared/DiffView.ts +199 -0
  103. package/src/shared/EditMatcher.test.ts +123 -0
  104. package/src/shared/EditMatcher.ts +826 -0
  105. package/src/shared/FileScanner.test.ts +158 -0
  106. package/src/shared/FileScanner.ts +41 -0
  107. package/src/shared/Fs.ts +46 -0
  108. package/src/shared/FsErrors.ts +72 -0
  109. package/src/shared/FuzzyMatcher.test.ts +114 -0
  110. package/src/shared/FuzzyMatcher.ts +73 -0
  111. package/src/shared/GitignoreFilter.test.ts +64 -0
  112. package/src/shared/GitignoreFilter.ts +142 -0
  113. package/src/shared/GlobExclusions.ts +23 -0
  114. package/src/shared/Levenshtein.ts +33 -0
  115. package/src/shared/Lines.test.ts +25 -0
  116. package/src/shared/Lines.ts +77 -0
  117. package/src/shared/McpClient.test.ts +235 -0
  118. package/src/shared/McpClient.ts +406 -0
  119. package/src/shared/OutputBudget.test.ts +99 -0
  120. package/src/shared/OutputBudget.ts +79 -0
  121. package/src/shared/Paths.test.ts +51 -0
  122. package/src/shared/Paths.ts +52 -0
  123. package/src/shared/PimSettings.test.ts +90 -0
  124. package/src/shared/PimSettings.ts +124 -0
  125. package/src/shared/Renderer.test.ts +190 -0
  126. package/src/shared/Renderer.ts +256 -0
  127. package/src/shared/SpillCache.test.ts +94 -0
  128. package/src/shared/SpillCache.ts +89 -0
  129. package/src/shared/Tools.test.ts +392 -0
  130. package/src/shared/Tools.ts +636 -0
  131. package/src/telegram/Bot.ts +198 -0
  132. package/src/telegram/Commands.ts +721 -0
  133. package/src/telegram/Config.test.ts +275 -0
  134. package/src/telegram/Config.ts +162 -0
  135. package/src/telegram/Markdown.test.ts +143 -0
  136. package/src/telegram/Markdown.ts +177 -0
  137. package/src/telegram/Message.ts +211 -0
  138. package/src/telegram/Renderer.test.ts +216 -0
  139. package/src/telegram/Renderer.ts +713 -0
  140. package/src/telegram/SendFileSchema.ts +19 -0
  141. package/src/telegram/SendFileTool.ts +94 -0
  142. package/src/telegram/Session.ts +579 -0
  143. package/src/telegram/SessionRegistry.test.ts +89 -0
  144. package/src/telegram/SessionRegistry.ts +170 -0
  145. package/src/telegram/Supervisor.ts +357 -0
  146. package/src/telegram/TaskScheduler.test.ts +278 -0
  147. package/src/telegram/TaskScheduler.ts +293 -0
  148. package/src/telegram/TaskSchema.ts +88 -0
  149. package/src/telegram/TaskStore.ts +73 -0
  150. package/src/telegram/TaskTool.test.ts +179 -0
  151. package/src/telegram/TaskTool.ts +159 -0
  152. package/src/telegram/TypingIndicator.ts +43 -0
  153. package/src/telegram/index.ts +32 -0
  154. package/src/themes/pim-dark.json +84 -0
  155. package/src/themes/pim-light.json +84 -0
@@ -0,0 +1,158 @@
1
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterAll, describe, expect, test } from "bun:test";
5
+ import { FileScanner } from "./FileScanner";
6
+
7
+ const tempRoots: string[] = [];
8
+
9
+ const createTempDir = async (): Promise<string> => {
10
+ const root = await mkdtemp(join(tmpdir(), "pim-file-scanner-"));
11
+ tempRoots.push(root);
12
+ return root;
13
+ };
14
+
15
+ afterAll(async () => {
16
+ await Promise.all(
17
+ tempRoots.map((root) => rm(root, { force: true, recursive: true }))
18
+ );
19
+ });
20
+
21
+ const defaultOptions = {
22
+ includeDotfiles: false,
23
+ includeIgnored: false,
24
+ } as const;
25
+
26
+ describe("FileScanner.scan", () => {
27
+ test("scans a directory and returns absolute file paths", async () => {
28
+ const root = await createTempDir();
29
+ await writeFile(join(root, "a.ts"), "", "utf8");
30
+ await writeFile(join(root, "b.ts"), "", "utf8");
31
+
32
+ const files = await FileScanner.scan(root, "**/*.ts", defaultOptions);
33
+
34
+ expect(files.toSorted()).toEqual(
35
+ [join(root, "a.ts"), join(root, "b.ts")].sort()
36
+ );
37
+ });
38
+
39
+ test("respects gitignore patterns", async () => {
40
+ const root = await createTempDir();
41
+ await writeFile(join(root, ".gitignore"), "ignored.ts\n", "utf8");
42
+ await writeFile(join(root, "kept.ts"), "", "utf8");
43
+ await writeFile(join(root, "ignored.ts"), "", "utf8");
44
+
45
+ const files = await FileScanner.scan(root, "**/*.ts", defaultOptions);
46
+
47
+ expect(files).toEqual([join(root, "kept.ts")]);
48
+ });
49
+
50
+ test("respects always-ignored defaults like node_modules", async () => {
51
+ const root = await createTempDir();
52
+ await mkdir(join(root, "node_modules", "pkg"), { recursive: true });
53
+ await writeFile(join(root, "node_modules", "pkg", "x.ts"), "", "utf8");
54
+ await writeFile(join(root, "kept.ts"), "", "utf8");
55
+
56
+ const files = await FileScanner.scan(root, "**/*.ts", defaultOptions);
57
+
58
+ expect(files).toEqual([join(root, "kept.ts")]);
59
+ });
60
+
61
+ test("skips dotfiles by default", async () => {
62
+ const root = await createTempDir();
63
+ await mkdir(join(root, ".hidden"), { recursive: true });
64
+ await writeFile(join(root, ".hidden", "secret.ts"), "", "utf8");
65
+ await writeFile(join(root, "visible.ts"), "", "utf8");
66
+
67
+ const files = await FileScanner.scan(root, "**/*.ts", defaultOptions);
68
+
69
+ expect(files).toEqual([join(root, "visible.ts")]);
70
+ });
71
+
72
+ test("includes dotfiles when requested", async () => {
73
+ const root = await createTempDir();
74
+ await mkdir(join(root, ".hidden"), { recursive: true });
75
+ await writeFile(join(root, ".hidden", "secret.ts"), "", "utf8");
76
+ await writeFile(join(root, "visible.ts"), "", "utf8");
77
+
78
+ const files = await FileScanner.scan(root, "**/*.ts", {
79
+ ...defaultOptions,
80
+ includeDotfiles: true,
81
+ });
82
+
83
+ expect(files.toSorted()).toEqual(
84
+ [join(root, ".hidden", "secret.ts"), join(root, "visible.ts")].sort()
85
+ );
86
+ });
87
+
88
+ test("includes ignored files when requested", async () => {
89
+ const root = await createTempDir();
90
+ await writeFile(join(root, ".gitignore"), "ignored.ts\n", "utf8");
91
+ await writeFile(join(root, "kept.ts"), "", "utf8");
92
+ await writeFile(join(root, "ignored.ts"), "", "utf8");
93
+
94
+ const files = await FileScanner.scan(root, "**/*.ts", {
95
+ ...defaultOptions,
96
+ includeIgnored: true,
97
+ });
98
+
99
+ expect(files.toSorted()).toEqual(
100
+ [join(root, "ignored.ts"), join(root, "kept.ts")].sort()
101
+ );
102
+ });
103
+
104
+ test("excludes patterns via the exclude option", async () => {
105
+ const root = await createTempDir();
106
+ await mkdir(join(root, "src"), { recursive: true });
107
+ await writeFile(join(root, "src", "app.ts"), "", "utf8");
108
+ await writeFile(join(root, "src", "app.test.ts"), "", "utf8");
109
+
110
+ const files = await FileScanner.scan(root, "**/*.ts", {
111
+ ...defaultOptions,
112
+ exclude: ["**/*.test.ts"],
113
+ });
114
+
115
+ expect(files).toEqual([join(root, "src", "app.ts")]);
116
+ });
117
+
118
+ test("excludes multiple patterns", async () => {
119
+ const root = await createTempDir();
120
+ await mkdir(join(root, "src", "generated"), { recursive: true });
121
+ await writeFile(join(root, "src", "app.ts"), "", "utf8");
122
+ await writeFile(join(root, "src", "app.test.ts"), "", "utf8");
123
+ await writeFile(join(root, "src", "generated", "types.ts"), "", "utf8");
124
+
125
+ const files = await FileScanner.scan(root, "**/*.ts", {
126
+ ...defaultOptions,
127
+ exclude: ["**/*.test.ts", "src/generated/**"],
128
+ });
129
+
130
+ expect(files).toEqual([join(root, "src", "app.ts")]);
131
+ });
132
+
133
+ test("returns an empty array for an empty directory", async () => {
134
+ const root = await createTempDir();
135
+
136
+ const files = await FileScanner.scan(root, "**/*", defaultOptions);
137
+
138
+ expect(files).toEqual([]);
139
+ });
140
+
141
+ test("scans nested directories", async () => {
142
+ const root = await createTempDir();
143
+ await mkdir(join(root, "a", "b", "c"), { recursive: true });
144
+ await writeFile(join(root, "a", "top.ts"), "", "utf8");
145
+ await writeFile(join(root, "a", "b", "mid.ts"), "", "utf8");
146
+ await writeFile(join(root, "a", "b", "c", "deep.ts"), "", "utf8");
147
+
148
+ const files = await FileScanner.scan(root, "**/*.ts", defaultOptions);
149
+
150
+ expect(files.toSorted()).toEqual(
151
+ [
152
+ join(root, "a", "top.ts"),
153
+ join(root, "a", "b", "mid.ts"),
154
+ join(root, "a", "b", "c", "deep.ts"),
155
+ ].sort()
156
+ );
157
+ });
158
+ });
@@ -0,0 +1,41 @@
1
+ import { resolve } from "node:path";
2
+ import { GitignoreFilter } from "./GitignoreFilter";
3
+ import { GlobExclusions } from "./GlobExclusions";
4
+
5
+ export type FileScanOptions = {
6
+ readonly exclude?: readonly string[];
7
+ readonly includeDotfiles: boolean;
8
+ readonly includeIgnored: boolean;
9
+ };
10
+
11
+ export class FileScanner {
12
+ static async scan(
13
+ root: string,
14
+ pattern: string,
15
+ options: FileScanOptions
16
+ ): Promise<readonly string[]> {
17
+ const absoluteRoot = resolve(root);
18
+ const filter = options.includeIgnored
19
+ ? undefined
20
+ : await GitignoreFilter.for(absoluteRoot);
21
+ const excludes = GlobExclusions.compile(options.exclude);
22
+ const glob = new Bun.Glob(pattern);
23
+ const files: string[] = [];
24
+
25
+ for await (const filePath of glob.scan({
26
+ cwd: absoluteRoot,
27
+ absolute: true,
28
+ onlyFiles: true,
29
+ dot: options.includeDotfiles,
30
+ })) {
31
+ if (
32
+ (filter === undefined || !filter.ignores(filePath)) &&
33
+ !GlobExclusions.ignores(excludes, absoluteRoot, filePath)
34
+ ) {
35
+ files.push(filePath);
36
+ }
37
+ }
38
+
39
+ return files;
40
+ }
41
+ }
@@ -0,0 +1,46 @@
1
+ import { chmod, mkdir, rename, stat } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+
4
+ export class Fs {
5
+ public static async readJsonOrEmpty<T>(
6
+ filePath: string,
7
+ fallback: T
8
+ ): Promise<T> {
9
+ try {
10
+ return (await Bun.file(filePath).json()) as T;
11
+ } catch (err) {
12
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
13
+ return fallback;
14
+ }
15
+ throw new Error(`Failed to parse ${filePath}: ${(err as Error).message}`);
16
+ }
17
+ }
18
+
19
+ public static async writeAtomic(
20
+ filePath: string,
21
+ data: string,
22
+ mode?: number
23
+ ): Promise<void> {
24
+ await mkdir(dirname(filePath), { recursive: true });
25
+ const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
26
+ await Bun.write(tmp, data);
27
+ const resolvedMode = mode ?? (await Fs.existingMode(filePath));
28
+ if (resolvedMode !== undefined) {
29
+ await chmod(tmp, resolvedMode);
30
+ }
31
+ await rename(tmp, filePath);
32
+ }
33
+
34
+ private static async existingMode(
35
+ filePath: string
36
+ ): Promise<number | undefined> {
37
+ try {
38
+ return (await stat(filePath)).mode & 0o777;
39
+ } catch (err) {
40
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") {
41
+ return undefined;
42
+ }
43
+ throw err;
44
+ }
45
+ }
46
+ }
@@ -0,0 +1,72 @@
1
+ import type { Stats } from "node:fs";
2
+ import { readdir, stat } from "node:fs/promises";
3
+ import { basename, dirname, extname, join } from "node:path";
4
+
5
+ export class FsErrors {
6
+ public static code(error: unknown): string | undefined {
7
+ return typeof error === "object" && error !== null && "code" in error
8
+ ? String((error as { code: unknown }).code)
9
+ : undefined;
10
+ }
11
+
12
+ public static async statOrThrow(path: string): Promise<Stats> {
13
+ try {
14
+ return await stat(path);
15
+ } catch (error) {
16
+ const code = FsErrors.code(error);
17
+
18
+ if (code === "ENOENT") {
19
+ throw new Error(await FsErrors.renderMissing(path));
20
+ }
21
+
22
+ if (code === "EACCES" || code === "EPERM") {
23
+ throw new Error(`Permission denied accessing ${path}.`);
24
+ }
25
+
26
+ throw new Error(
27
+ `Cannot stat ${path}: ${code ?? (error instanceof Error ? error.message : "unknown error")}.`
28
+ );
29
+ }
30
+ }
31
+
32
+ public static async renderMissing(path: string): Promise<string> {
33
+ const suggestions = await FsErrors.suggestSiblings(path);
34
+ const headline = `Path not found: ${path}. Use glob to locate the file or directory, or verify the path.`;
35
+ if (suggestions.length === 0) {
36
+ return headline;
37
+ }
38
+ return [headline, "", "Did you mean one of these?", ...suggestions].join(
39
+ "\n"
40
+ );
41
+ }
42
+
43
+ private static async suggestSiblings(
44
+ path: string
45
+ ): Promise<readonly string[]> {
46
+ const dir = dirname(path);
47
+ const base = basename(path).toLowerCase();
48
+ const stem = base.slice(0, base.length - extname(base).length);
49
+
50
+ try {
51
+ const entries = await readdir(dir);
52
+ return entries
53
+ .filter((entry) => {
54
+ const lower = entry.toLowerCase();
55
+ const lowerStem = lower.slice(
56
+ 0,
57
+ lower.length - extname(lower).length
58
+ );
59
+ return (
60
+ lower.includes(base) ||
61
+ base.includes(lower) ||
62
+ (stem.length > 0 &&
63
+ (lowerStem.includes(stem) || stem.includes(lowerStem)))
64
+ );
65
+ })
66
+ .slice(0, 3)
67
+ .map((entry) => join(dir, entry));
68
+ } catch {
69
+ return [];
70
+ }
71
+ }
72
+ }
@@ -0,0 +1,114 @@
1
+ import { expect, test } from "bun:test";
2
+ import { FuzzyMatcher, type FuzzyCandidate } from "./FuzzyMatcher";
3
+
4
+ type Command = {
5
+ readonly name: string;
6
+ readonly description: string;
7
+ };
8
+
9
+ const commandCandidates = (
10
+ commands: readonly Command[]
11
+ ): readonly FuzzyCandidate<Command>[] =>
12
+ commands.map((command) => ({
13
+ item: command,
14
+ haystacks: [command.name, command.description],
15
+ }));
16
+
17
+ test("empty query returns candidates sorted alphabetically by first haystack", () => {
18
+ const candidates = commandCandidates([
19
+ { name: "rename", description: "rename" },
20
+ { name: "clear", description: "clear" },
21
+ { name: "help", description: "help" },
22
+ ]);
23
+
24
+ const hits = FuzzyMatcher.rank("", candidates);
25
+
26
+ expect(hits.map((hit) => hit.item.name)).toEqual(["clear", "help", "rename"]);
27
+ expect(hits[0]?.score).toBe(0);
28
+ expect(hits[0]?.positions.size).toBe(0);
29
+ });
30
+
31
+ test("whitespace-only query is treated as empty", () => {
32
+ const candidates = commandCandidates([
33
+ { name: "b", description: "b" },
34
+ { name: "a", description: "a" },
35
+ ]);
36
+
37
+ const hits = FuzzyMatcher.rank(" ", candidates);
38
+
39
+ expect(hits.map((hit) => hit.item.name)).toEqual(["a", "b"]);
40
+ });
41
+
42
+ test("non-empty query orders results by fzf score", () => {
43
+ const candidates = commandCandidates([
44
+ { name: "clear", description: "Clear the session." },
45
+ { name: "rename", description: "Rename the session." },
46
+ { name: "resume", description: "Resume a session." },
47
+ { name: "help", description: "Show help." },
48
+ ]);
49
+
50
+ const hits = FuzzyMatcher.rank("cl", candidates);
51
+
52
+ expect(hits.length).toBeGreaterThan(0);
53
+ expect(hits[0]?.item.name).toBe("clear");
54
+ });
55
+
56
+ test("limit truncates ranked results", () => {
57
+ const candidates = commandCandidates([
58
+ { name: "alpha", description: "" },
59
+ { name: "alphabet", description: "" },
60
+ { name: "alphanumeric", description: "" },
61
+ ]);
62
+
63
+ const hits = FuzzyMatcher.rank("a", candidates, { limit: 2 });
64
+
65
+ expect(hits.length).toBe(2);
66
+ });
67
+
68
+ test("limit also applies to the empty-query alphabetical case", () => {
69
+ const candidates = commandCandidates([
70
+ { name: "c", description: "" },
71
+ { name: "a", description: "" },
72
+ { name: "b", description: "" },
73
+ ]);
74
+
75
+ const hits = FuzzyMatcher.rank("", candidates, { limit: 2 });
76
+
77
+ expect(hits.map((hit) => hit.item.name)).toEqual(["a", "b"]);
78
+ });
79
+
80
+ test("ties on score break toward the earlier match start", () => {
81
+ const candidates = commandCandidates([
82
+ { name: "new", description: "Start a new session." },
83
+ { name: "status", description: "Show the current session status." },
84
+ ]);
85
+
86
+ const hits = FuzzyMatcher.rank("sta", candidates);
87
+
88
+ expect(hits[0]?.item.name).toBe("status");
89
+ });
90
+
91
+ test("ties on score and start break toward the shorter haystack", () => {
92
+ const candidates: readonly FuzzyCandidate<string>[] = [
93
+ {
94
+ item: "src/shared/DiffLines.test.ts",
95
+ haystacks: ["src/shared/DiffLines.test.ts"],
96
+ },
97
+ { item: "src/shared/DiffLines.ts", haystacks: ["src/shared/DiffLines.ts"] },
98
+ ];
99
+
100
+ const hits = FuzzyMatcher.rank("difflines", candidates);
101
+
102
+ expect(hits[0]?.item).toBe("src/shared/DiffLines.ts");
103
+ });
104
+
105
+ test("matches against the second haystack when the first does not contain the query", () => {
106
+ const candidates = commandCandidates([
107
+ { name: "noop", description: "fully unrelated" },
108
+ { name: "x", description: "rename the session" },
109
+ ]);
110
+
111
+ const hits = FuzzyMatcher.rank("rename", candidates);
112
+
113
+ expect(hits[0]?.item.name).toBe("x");
114
+ });
@@ -0,0 +1,73 @@
1
+ import { byLengthAsc, byStartAsc, Fzf } from "fzf";
2
+
3
+ export type FuzzyCandidate<T> = {
4
+ readonly item: T;
5
+ readonly haystacks: readonly string[];
6
+ };
7
+
8
+ export type FuzzyHit<T> = {
9
+ readonly item: T;
10
+ readonly score: number;
11
+ readonly positions: ReadonlySet<number>;
12
+ };
13
+
14
+ export type FuzzyRankOptions = {
15
+ readonly limit?: number;
16
+ };
17
+
18
+ export type FuzzyIndex<T> = {
19
+ readonly find: (
20
+ query: string,
21
+ options?: FuzzyRankOptions
22
+ ) => readonly FuzzyHit<T>[];
23
+ };
24
+
25
+ const HAYSTACK_SEPARATOR = " ";
26
+
27
+ export class FuzzyMatcher {
28
+ public static rank<T>(
29
+ query: string,
30
+ candidates: readonly FuzzyCandidate<T>[],
31
+ options: FuzzyRankOptions = {}
32
+ ): readonly FuzzyHit<T>[] {
33
+ return FuzzyMatcher.prepare(candidates).find(query, options);
34
+ }
35
+
36
+ public static prepare<T>(
37
+ candidates: readonly FuzzyCandidate<T>[]
38
+ ): FuzzyIndex<T> {
39
+ const fzf = new Fzf<readonly FuzzyCandidate<T>[]>(candidates, {
40
+ selector: (candidate) => candidate.haystacks.join(HAYSTACK_SEPARATOR),
41
+ tiebreakers: [byStartAsc, byLengthAsc],
42
+ });
43
+
44
+ let emptyHits: readonly FuzzyHit<T>[] | undefined;
45
+
46
+ return {
47
+ find: (query, options = {}) => {
48
+ const trimmed = query.trim();
49
+ const limit = options.limit ?? Infinity;
50
+ if (trimmed.length === 0) {
51
+ if (emptyHits === undefined) {
52
+ emptyHits = [...candidates]
53
+ .sort((a, b) =>
54
+ (a.haystacks[0] ?? "").localeCompare(b.haystacks[0] ?? "")
55
+ )
56
+ .map((candidate) => ({
57
+ item: candidate.item,
58
+ score: 0,
59
+ positions: new Set<number>(),
60
+ }));
61
+ }
62
+ return limit === Infinity ? emptyHits : emptyHits.slice(0, limit);
63
+ }
64
+ const hits = fzf.find(trimmed).map((result) => ({
65
+ item: result.item.item,
66
+ score: result.score,
67
+ positions: result.positions,
68
+ }));
69
+ return limit === Infinity ? hits : hits.slice(0, limit);
70
+ },
71
+ };
72
+ }
73
+ }
@@ -0,0 +1,64 @@
1
+ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { afterAll, expect, test } from "bun:test";
5
+ import { GitignoreFilter } from "./GitignoreFilter";
6
+
7
+ const tempRoots: string[] = [];
8
+
9
+ const createTempDir = async (): Promise<string> => {
10
+ const root = await mkdtemp(join(tmpdir(), "pim-gitignore-filter-"));
11
+ tempRoots.push(root);
12
+ return root;
13
+ };
14
+
15
+ afterAll(async () => {
16
+ await Promise.all(
17
+ tempRoots.map((root) => rm(root, { force: true, recursive: true }))
18
+ );
19
+ });
20
+
21
+ test("applies hardcoded defaults", async () => {
22
+ const root = await createTempDir();
23
+ const filter = await GitignoreFilter.for(root);
24
+
25
+ expect(filter.ignores(join(root, "node_modules", "pkg", "index.js"))).toBe(
26
+ true
27
+ );
28
+ expect(filter.ignores(join(root, ".git", "config"))).toBe(true);
29
+ expect(filter.ignores(join(root, "src", "index.ts"))).toBe(false);
30
+ });
31
+
32
+ test("loads gitignore patterns from root up to the git boundary", async () => {
33
+ const root = await createTempDir();
34
+ const project = join(root, "project");
35
+ const nested = join(project, "packages", "app");
36
+
37
+ await mkdir(join(project, ".git"), { recursive: true });
38
+ await mkdir(nested, { recursive: true });
39
+ await writeFile(
40
+ join(project, ".gitignore"),
41
+ ["*.tmp", "*.log", "!important.log", "logs/", "/anchored.txt"].join("\n"),
42
+ "utf8"
43
+ );
44
+ await writeFile(join(nested, ".gitignore"), "local.txt\n", "utf8");
45
+
46
+ const filter = await GitignoreFilter.for(nested);
47
+
48
+ expect(filter.ignores(join(nested, "scratch.tmp"))).toBe(true);
49
+ expect(filter.ignores(join(nested, "logs", "app.log"))).toBe(true);
50
+ expect(filter.ignores(join(project, "anchored.txt"))).toBe(true);
51
+ expect(filter.ignores(join(nested, "anchored.txt"))).toBe(false);
52
+ expect(filter.ignores(join(nested, "drop.log"))).toBe(true);
53
+ expect(filter.ignores(join(nested, "important.log"))).toBe(false);
54
+ expect(filter.ignores(join(nested, "local.txt"))).toBe(true);
55
+ });
56
+
57
+ test("rejects relative paths", async () => {
58
+ const root = await createTempDir();
59
+ const filter = await GitignoreFilter.for(root);
60
+
61
+ expect(() => filter.ignores("src/index.ts")).toThrow(
62
+ "Expected absolute path"
63
+ );
64
+ });