pi-doc-injector 0.2.1 → 0.3.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/cache.ts ADDED
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Keyword cache persistence — load/save the `.pi/doc-injector-cache.json` file.
3
+ *
4
+ * Cache format:
5
+ * { version: 1, files: { [relativePath]: { mtimeMs: number, keywords: string[] } } }
6
+ *
7
+ * Invalid files (wrong version, bad JSON, ENOENT) result in an empty cache.
8
+ */
9
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
10
+ import { dirname, join } from "node:path";
11
+ import type { KeywordCache } from "./types";
12
+
13
+ const CACHE_FILENAME = ".pi/doc-injector-cache.json";
14
+ const CACHE_VERSION = 1;
15
+
16
+ /**
17
+ * Load the keyword cache from disk.
18
+ * Returns an empty cache (version 1, no files) if the file doesn't exist,
19
+ * has wrong version, or is corrupted.
20
+ */
21
+ export async function loadCache(cwd: string): Promise<KeywordCache> {
22
+ const cachePath = join(cwd, CACHE_FILENAME);
23
+
24
+ try {
25
+ const raw = await readFile(cachePath, "utf-8");
26
+ const parsed: unknown = JSON.parse(raw);
27
+
28
+ if (!isValidCache(parsed)) {
29
+ console.warn(
30
+ `[doc-injector] Invalid cache format or version at ${cachePath}, resetting.`,
31
+ );
32
+ return emptyCache();
33
+ }
34
+
35
+ return parsed;
36
+ } catch (err) {
37
+ // ENOENT = no cache file yet, that's fine
38
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
39
+ console.warn(
40
+ `[doc-injector] Failed to read cache at ${cachePath}:`,
41
+ err instanceof Error ? err.message : String(err),
42
+ );
43
+ }
44
+ return emptyCache();
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Save the keyword cache to disk.
50
+ * Creates parent directories if needed.
51
+ */
52
+ export async function saveCache(
53
+ cwd: string,
54
+ cache: KeywordCache,
55
+ ): Promise<void> {
56
+ const cachePath = join(cwd, CACHE_FILENAME);
57
+
58
+ try {
59
+ await mkdir(dirname(cachePath), { recursive: true });
60
+ } catch {
61
+ // Ignore — directory may already exist
62
+ }
63
+
64
+ await writeFile(cachePath, JSON.stringify(cache, null, 2), "utf-8");
65
+ }
66
+
67
+ /** Check that a parsed value matches the KeywordCache shape. */
68
+ function isValidCache(value: unknown): value is KeywordCache {
69
+ if (!value || typeof value !== "object") return false;
70
+ const c = value as Record<string, unknown>;
71
+ if (c.version !== CACHE_VERSION) return false;
72
+ if (!c.files || typeof c.files !== "object") return false;
73
+ return true;
74
+ }
75
+
76
+ /** Return a fresh empty cache. */
77
+ function emptyCache(): KeywordCache {
78
+ return { version: CACHE_VERSION, files: {} };
79
+ }
package/commands.ts CHANGED
@@ -3,14 +3,26 @@
3
3
  */
4
4
  import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
5
5
  import type { DocRegistry } from "./registry";
6
+ import type { DocInjectorConfig } from "./types";
6
7
 
8
+ /** Dependencies injected into the command registrar. */
7
9
  export interface CommandDeps {
8
10
  getRegistry: () => DocRegistry | null;
9
11
  getEnabled: () => boolean;
10
12
  setEnabled: (v: boolean) => void;
11
13
  reloadRegistry: () => Promise<number>;
14
+ getConfig: () => DocInjectorConfig;
15
+ generateKeywordsLLM: (files: Array<{ path: string; snippet: string; existingKeywords: string[] }>) => Promise<void>;
12
16
  }
13
17
 
18
+ /**
19
+ * Register all doc-injector slash commands on the given ExtensionAPI.
20
+ *
21
+ * Commands:
22
+ * - `/doc-inject [on|off|toggle|list|reset|status]` — manage injection state
23
+ * - `/doc-reload` — re-scan docs folder
24
+ * - `/doc-keywords-gen [path]` — generate LLM keywords for keyword-less files
25
+ */
14
26
  export function registerCommands(pi: ExtensionAPI, deps: CommandDeps): void {
15
27
  const cmd = (name: string, desc: string, handler: (args: string, ctx: ExtensionContext) => Promise<void>) => {
16
28
  pi.registerCommand(name, { description: desc, handler });
@@ -49,7 +61,8 @@ export function registerCommands(pi: ExtensionAPI, deps: CommandDeps): void {
49
61
  }
50
62
  const lines = entries.map((e) => {
51
63
  const status = e.injected ? "✅" : "⬜";
52
- return `${status} ${e.relativePath}: "${e.title}" — keywords: [${e.keywords.join(", ")}]`;
64
+ const sourceTag = `[${e.keywordSource}]`;
65
+ return `${status} ${sourceTag} ${e.relativePath}: "${e.title}" — keywords: [${e.keywords.join(", ")}]`;
53
66
  });
54
67
  ctx.ui.notify(`📄 Registered docs:\n${lines.join("\n")}`, "info");
55
68
  } else {
@@ -81,4 +94,58 @@ export function registerCommands(pi: ExtensionAPI, deps: CommandDeps): void {
81
94
  ctx.ui.notify(`📄 Reload failed: ${err instanceof Error ? err.message : String(err)}`, "error");
82
95
  }
83
96
  });
97
+
98
+ cmd("doc-keywords-gen", "Generate LLM keywords: /doc-keywords-gen [path] — no arg = all keyword-less files", async (args, ctx) => {
99
+ const reg = deps.getRegistry();
100
+ if (!reg) {
101
+ ctx.ui.notify("📄 No registry loaded", "warning");
102
+ return;
103
+ }
104
+
105
+ const config = deps.getConfig();
106
+ if (!config.llmKeywords) {
107
+ ctx.ui.notify("📄 LLM keyword generation is disabled (llmKeywords: false in config)", "warning");
108
+ return;
109
+ }
110
+
111
+ const targetPath = args.trim();
112
+
113
+ // Filter to keyword-less entries (keywordSource !== "frontmatter", "cache", or "llm")
114
+ let candidates = reg.getEntries().filter((e) => {
115
+ if (e.keywordSource === "frontmatter") return false;
116
+ if (e.keywordSource === "cache") return false;
117
+ if (e.keywordSource === "llm") return false; // already LLM-generated
118
+ return true;
119
+ });
120
+
121
+ if (targetPath) {
122
+ candidates = candidates.filter((e) => e.relativePath.includes(targetPath));
123
+ if (candidates.length === 0) {
124
+ ctx.ui.notify(`📄 No keyword-less files matching "${targetPath}"`, "info");
125
+ return;
126
+ }
127
+ }
128
+
129
+ if (candidates.length === 0) {
130
+ ctx.ui.notify("📄 All files already have keywords", "info");
131
+ return;
132
+ }
133
+
134
+ const batchSize = config.llmBatchSize;
135
+ const batches: Array<Array<{ path: string; snippet: string; existingKeywords: string[] }>> = [];
136
+ for (let i = 0; i < candidates.length; i += batchSize) {
137
+ const batch = candidates.slice(i, i + batchSize).map((e) => ({
138
+ path: e.relativePath,
139
+ snippet: e.content.slice(0, 500),
140
+ existingKeywords: e.keywords,
141
+ }));
142
+ batches.push(batch);
143
+ }
144
+
145
+ ctx.ui.notify(`📄 Sending ${batches.length} keyword-generation batch(es) for ${candidates.length} file(s)...`, "info");
146
+
147
+ for (const batch of batches) {
148
+ await deps.generateKeywordsLLM(batch);
149
+ }
150
+ });
84
151
  }
package/config.ts CHANGED
@@ -2,50 +2,85 @@
2
2
  * Configuration loader for the Doc Injector extension.
3
3
  * Reads from `.pi/doc-injector.json` with fallback to defaults.
4
4
  */
5
- import { existsSync, readFileSync } from "node:fs";
5
+ import { readFile } from "node:fs/promises";
6
6
  import { join } from "node:path";
7
7
  import { DEFAULT_CONFIG, type DocInjectorConfig } from "./types";
8
8
 
9
9
  /**
10
- * Load config from `.pi/doc-injector.json` relative to the given cwd.
11
- * Falls back to DEFAULT_CONFIG if file doesn't exist or is invalid.
10
+ * Clamp an integer value to [min, max] range.
11
+ * Warns and clamps if out of range. Returns the default if not a number.
12
12
  */
13
- export function loadConfig(cwd: string): DocInjectorConfig {
14
- const configPath = join(cwd, ".pi", "doc-injector.json");
13
+ function clampInt(
14
+ value: unknown,
15
+ defaultVal: number,
16
+ min: number,
17
+ max: number,
18
+ fieldName: string,
19
+ ): number {
20
+ if (typeof value !== "number" || Number.isNaN(value)) {
21
+ return defaultVal;
22
+ }
23
+ const intVal = Math.trunc(value);
24
+ if (intVal < min || intVal > max) {
25
+ const clamped = Math.max(min, Math.min(max, intVal));
26
+ console.warn(`[doc-injector] ${fieldName} must be ${min}-${max}, got ${intVal}. Clamping to ${clamped}.`);
27
+ return clamped;
28
+ }
29
+ return intVal;
30
+ }
15
31
 
16
- if (!existsSync(configPath)) {
17
- return { ...DEFAULT_CONFIG };
32
+ /**
33
+ * Validate a glob pattern array.
34
+ * Rejects non-array or entries that aren't strings. Returns default on error.
35
+ */
36
+ function validateGlobArray(value: unknown, defaultVal: string[]): string[] {
37
+ if (!Array.isArray(value)) {
38
+ return [...defaultVal];
18
39
  }
40
+ const result: string[] = [];
41
+ for (const item of value) {
42
+ if (typeof item === "string") {
43
+ result.push(item);
44
+ } else {
45
+ console.warn(`[doc-injector] Non-string entry in glob array ignored: ${String(item)}`);
46
+ }
47
+ }
48
+ return result.length > 0 ? result : [...defaultVal];
49
+ }
50
+
51
+ /**
52
+ * Load config from `.pi/doc-injector.json` relative to the given cwd.
53
+ * Now async — uses readFile from fs/promises.
54
+ * Validates and clamps all numeric fields. Falls back to DEFAULT_CONFIG
55
+ * if file doesn't exist or is invalid.
56
+ */
57
+ export async function loadConfig(cwd: string): Promise<DocInjectorConfig> {
58
+ const configPath = join(cwd, ".pi", "doc-injector.json");
19
59
 
20
60
  try {
21
- const raw = readFileSync(configPath, "utf-8");
61
+ const raw = await readFile(configPath, "utf-8");
22
62
  const parsed = JSON.parse(raw) as Partial<DocInjectorConfig>;
23
63
 
24
- // Clamp contextThreshold to 0-100 range
25
- let contextThreshold = parsed.contextThreshold ?? DEFAULT_CONFIG.contextThreshold;
26
- if (typeof contextThreshold === "number" && (contextThreshold < 0 || contextThreshold > 100)) {
27
- console.warn(`[doc-injector] contextThreshold must be 0-100, got ${contextThreshold}. Clamping.`);
28
- contextThreshold = Math.max(0, Math.min(100, contextThreshold));
29
- }
30
-
31
- // Clamp matchThreshold to positive integers
32
- let matchThreshold = parsed.matchThreshold ?? DEFAULT_CONFIG.matchThreshold;
33
- if (typeof matchThreshold === "number" && matchThreshold < 1) {
34
- console.warn(`[doc-injector] matchThreshold must be >= 1, got ${matchThreshold}. Using 1.`);
35
- matchThreshold = 1;
36
- }
37
-
38
64
  return {
39
65
  docsPath: parsed.docsPath ?? DEFAULT_CONFIG.docsPath,
40
- matchThreshold,
41
- contextThreshold,
66
+ matchThreshold: clampInt(parsed.matchThreshold, DEFAULT_CONFIG.matchThreshold, 1, Infinity, "matchThreshold"),
67
+ contextThreshold: clampInt(parsed.contextThreshold, DEFAULT_CONFIG.contextThreshold, 0, 100, "contextThreshold"),
42
68
  recursive: parsed.recursive ?? DEFAULT_CONFIG.recursive,
69
+ include: validateGlobArray(parsed.include, DEFAULT_CONFIG.include),
70
+ exclude: validateGlobArray(parsed.exclude, DEFAULT_CONFIG.exclude),
71
+ maxFileSize: clampInt(parsed.maxFileSize, DEFAULT_CONFIG.maxFileSize, 1024, 10 * 1024 * 1024, "maxFileSize"),
72
+ autoKeywords: parsed.autoKeywords ?? DEFAULT_CONFIG.autoKeywords,
73
+ llmKeywords: parsed.llmKeywords ?? DEFAULT_CONFIG.llmKeywords,
74
+ maxConcurrent: clampInt(parsed.maxConcurrent, DEFAULT_CONFIG.maxConcurrent, 1, 100, "maxConcurrent"),
75
+ llmBatchSize: clampInt(parsed.llmBatchSize, DEFAULT_CONFIG.llmBatchSize, 1, 100, "llmBatchSize"),
43
76
  };
44
77
  } catch (err) {
45
- console.warn(
46
- `[doc-injector] Failed to parse config at ${configPath}:`,
47
- err instanceof Error ? err.message : String(err),
48
- );
78
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") {
79
+ console.warn(
80
+ `[doc-injector] Failed to parse config at ${configPath}:`,
81
+ err instanceof Error ? err.message : String(err),
82
+ );
83
+ }
49
84
  return { ...DEFAULT_CONFIG };
50
85
  }
51
86
  }
@@ -0,0 +1,199 @@
1
+ # Doc-Injector & Async Subagents Bug Analysis
2
+
3
+ ## Overview
4
+
5
+ The doc-injector extension causes spinner hangs when used alongside async subagent workflows (e.g., `subagent_isolated`, `subagent_with_context` with `async: true`).
6
+
7
+ ## Root Cause
8
+
9
+ ### The Flow
10
+
11
+ 1. Parent spawns async subagents with `async: true, notifyOnComplete: "inject"`
12
+ 2. Parent streams output (e.g., "I've launched 5 agents...")
13
+ 3. Doc-injector's `message_update` fires, detects keyword match
14
+ 4. Doc-injector calls `ctx.abort()` to abort current turn
15
+ 5. `agent_end` fires → extension injects `"continue"` via `setTimeout()`
16
+ 6. Parent receives `"continue"` → immediately calls `get_subagent_result x5`
17
+ 7. Subagents still running → results not available → **spinner stuck**
18
+
19
+ ### Why This Happens
20
+
21
+ The current doc-injector design assumes it can abort the current turn and restart immediately with injected docs. This works for simple single-turn workflows but breaks async subagent workflows because:
22
+
23
+ 1. **The injected `"continue"` disrupts the expected flow** - parent should wait for `notifyOnComplete: "inject"` messages, not immediately poll for results
24
+ 2. **`get_subagent_result` blocks** - when called before subagents finish, it hangs the parent thread
25
+ 3. **The abort signal is shared** - in some subagent implementations (e.g., the `subagent` tool in pi-subagentura), async subagents explicitly don't inherit the parent abort signal. But the injected `"continue"` still breaks the flow
26
+
27
+ ### Key Code Path (doc-injector/index.ts)
28
+
29
+ ```typescript
30
+ // message_update handler - triggers abort on keyword match
31
+ pi.on("message_update", async (event, ctx) => {
32
+ if (hasNew && !ctx.isIdle() && !abortingForInjection) {
33
+ abortingForInjection = true;
34
+ ctx.abort(); // ← PROBLEM: aborts current turn
35
+ }
36
+ });
37
+
38
+ // agent_end handler - injects "continue" after abort
39
+ pi.on("agent_end", async (event, ctx) => {
40
+ if (abortingForInjection) {
41
+ abortingForInjection = false;
42
+ setTimeout(() => {
43
+ pi.sendUserMessage("continue"); // ← PROBLEM: disrupts async flow
44
+ }, 0);
45
+ }
46
+ });
47
+ ```
48
+
49
+ ## Subagentura System Context
50
+
51
+ ### How Async Subagents Work
52
+
53
+ The subagentura system (`/Users/applesucks/dev/pi-agents/subagent.ts`) spawns async subagents that:
54
+
55
+ 1. Run in the same process (not subprocess)
56
+ 2. Have independent session/context
57
+ 3. Signal via `notifyOnComplete: "inject"` - results injected as user messages when complete
58
+ 4. Use `get_subagent_result` to poll/block for results
59
+
60
+ ### Key Line (subagent.ts line 541, 744)
61
+
62
+ ```typescript
63
+ signal: undefined, // async: don't inherit parent signal (would abort subagent when tool returns)
64
+ ```
65
+
66
+ ### Option A: Remove ctx.abort() Entirely (Recommended)
67
+
68
+ **Change:** Never call `ctx.abort()` in `message_update`. Just record matches and inject on next turn.
69
+
70
+ **Pros:**
71
+ - ✅ Simplest fix
72
+ - ✅ Works with ANY async workflow automatically
73
+ - ✅ No special-casing for subagents
74
+
75
+ **Cons:**
76
+ - ❌ Loses "immediate injection during streaming" optimization
77
+ - ❌ Docs injected on next turn instead of immediately
78
+
79
+ **Implementation:**
80
+ ```typescript
81
+ pi.on("message_update", async (event, ctx) => {
82
+ if (keywordGenInFlight) return;
83
+ if (!enabled || !registry) return;
84
+ if (msg.role !== "assistant") return;
85
+
86
+ const content = (msg as unknown as { content: unknown }).content;
87
+ textBuffer = extractText(content);
88
+ if (!textBuffer) return;
89
+
90
+ const matcher = buildMatcher();
91
+ if (!matcher) return;
92
+
93
+ const results = matcher.match(textBuffer);
94
+ for (const result of results) {
95
+ if (!pendingMatches.has(result.entry.filePath)) {
96
+ hasNew = true; // Just record - don't abort
97
+ }
98
+ pendingMatches.set(result.entry.filePath, result.matchedKeywords);
99
+ }
100
+
101
+ // REMOVE: ctx.abort() entirely
102
+ // Just let the turn complete naturally
103
+ });
104
+ ```
105
+
106
+ ### Option B: Detect Async Subagents Before Aborting
107
+
108
+ **Change:** Check if assistant just spawned async subagents, skip abort if so.
109
+
110
+ **Pros:**
111
+ - ✅ Preserves immediate injection behavior
112
+ - ✅ Subagents continue running
113
+
114
+ **Cons:**
115
+ - ❌ Fragile - depends on detecting specific tool call patterns
116
+ - ❌ Doesn't scale to other async workflows
117
+
118
+ ### Option C: Only Inject on User Input
119
+
120
+ **Change:** Only trigger immediate injection on `input` event (user messages), not on assistant streaming.
121
+
122
+ **Pros:**
123
+ - ✅ Clean separation of concerns
124
+ - ✅ User intent gets immediate response
125
+
126
+ **Cons:**
127
+ - ❌ Loses streaming injection optimization
128
+ - ❌ Still may not handle all async edge cases
129
+
130
+ ## Recommendation
131
+
132
+ **Use Option A:** Remove `ctx.abort()` entirely.
133
+
134
+ The streaming injection optimization is not worth the complexity and the risk of breaking async workflows. Most keyword matches from assistant streaming are not time-critical - they can wait for the next turn.
135
+
136
+ ## Additional Notes
137
+
138
+ ### The notifyOnComplete:"inject" Flow
139
+
140
+ 1. Parent spawns `async: true, notifyOnComplete: "inject"`
141
+ 2. Parent continues immediately (doesn't wait)
142
+ 3. Each subagent runs independently
143
+ 4. When subagent completes, result is injected as user message via `pi.sendUserMessage()`
144
+ 5. Parent receives the injected result and continues naturally
145
+
146
+ This flow is designed to work without any intervention from the parent. Breaking it with `ctx.abort()` + `"continue"` defeats the purpose.
147
+
148
+ ### Session Context
149
+
150
+ Each subagent runs in its **own session** with its own doc-injector instance. Doc injection in subagent sessions is fine - they inject their own docs into their own context. The problem is only in the **parent session**.
151
+
152
+ ## References
153
+
154
+ - doc-injector: `/Users/applesucks/dev/pi-docs/index.ts`
155
+ - subagentura: `/Users/applesucks/dev/pi-agents/subagent.ts`
156
+ - subagentura helpers: `/Users/applesucks/dev/pi-agents/helpers.ts`
157
+
158
+ ## Web Research Summary
159
+
160
+ ### Official Documentation
161
+
162
+ From `pi.dev/docs/latest/extensions`, the key lifecycle events are:
163
+
164
+ - `before_agent_start` - Fires after user submits prompt, **before** agent loop. Can inject a message and/or modify system prompt.
165
+ - `agent_start` / `agent_end` - Fires once per user prompt
166
+ - `message_update` - Fires during streaming output
167
+ - `input` - Fires on user input
168
+
169
+ **Important note from docs:** "For critical events (before_agent_start, context, tool_call), if all handlers fail, the system continues with default behavior."
170
+
171
+ ### Key Extension API Points
172
+
173
+ 1. **`ctx.abort()`** - "Request a graceful shutdown of pi. Interactive mode: Deferred until the agent becomes idle (after processing all queued steering and follow-up messages)."
174
+ 2. **`ctx.signal`** - Available for extensions to forward cancellation into nested model calls, fetch(), and other abort-aware work.
175
+ 3. **`before_agent_start`** - The recommended injection point for context, as it runs before the agent loop starts.
176
+
177
+ ### Related GitHub Issues
178
+
179
+ - [Issue #624](https://github.com/earendil-works/pi/issues/624): "before_agent_start event does not properly inject message" - indicates there have been historical issues with injection timing
180
+ - [Issue #2660](https://github.com/earendil-works/pi/issues/2660): "Expose abort signal on ExtensionContext" - led to `ctx.signal` being added
181
+
182
+ ### Subagent Extensions Found
183
+
184
+ 1. **tintinweb/pi-subagents** (`pi.dev/packages/pi-subagents`)
185
+ - Claude Code-style autonomous subagents
186
+ - Spawns agents in isolated sessions
187
+ - Run in foreground or background
188
+ - Similar to subagentura
189
+
190
+ 2. **nicobailon/pi-subagents** (GitHub)
191
+ - Async subagent delegation with truncation, artifacts, session sharing
192
+
193
+ ### Conclusion from Web Research
194
+
195
+ No documented solutions exist for the specific conflict between streaming injection (via `ctx.abort()`) and async subagent workflows. The recommended approach based on official docs is:
196
+
197
+ 1. Use `before_agent_start` for context injection (runs before agent loop)
198
+ 2. Avoid `ctx.abort()` during streaming - it disrupts any workflow that expects continuous streaming
199
+ 3. The `input` event is the cleanest injection point for user-driven context since it represents new user intent
package/globber.ts ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Glob filter for include/exclude pattern matching.
3
+ * Uses picomatch (0 deps, ~18 KB) to compile patterns once for O(1) matching.
4
+ */
5
+ import picomatch from "picomatch";
6
+ import type { GlobFilter } from "./types";
7
+
8
+ /**
9
+ * Create a glob filter from include and exclude patterns.
10
+ *
11
+ * A path matches if it matches at least one `include` pattern AND
12
+ * does not match any `exclude` pattern.
13
+ *
14
+ * When `include` is empty, all files are considered included
15
+ * (subject to exclude filtering).
16
+ *
17
+ * @param include - Glob patterns for files to include
18
+ * @param exclude - Glob patterns for files/dirs to exclude
19
+ * @returns A GlobFilter with a `match` method
20
+ */
21
+ export function createGlobFilter(
22
+ include: string[],
23
+ exclude: string[],
24
+ ): GlobFilter {
25
+ const includeMatcher =
26
+ include.length > 0
27
+ ? picomatch(include, { dot: true })
28
+ : null;
29
+
30
+ const excludeMatcher =
31
+ exclude.length > 0
32
+ ? picomatch(exclude, { dot: true })
33
+ : null;
34
+
35
+ return {
36
+ match(relativePath: string): boolean {
37
+ // If include patterns are specified, path must match at least one
38
+ if (includeMatcher && !includeMatcher(relativePath)) {
39
+ return false;
40
+ }
41
+ // Path must not match any exclude pattern
42
+ if (excludeMatcher && excludeMatcher(relativePath)) {
43
+ return false;
44
+ }
45
+ return true;
46
+ },
47
+ };
48
+ }