pi-doc-injector 0.5.3 → 0.6.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/README.md CHANGED
@@ -112,7 +112,8 @@ Create `.pi/doc-injector.json` in your project root to customize behavior:
112
112
  ```json
113
113
  {
114
114
  "docsPath": "./docs",
115
- "matchThreshold": 1,
115
+ "matchThreshold": 2,
116
+ "streamWindowSize": 500,
116
117
  "contextThreshold": 80,
117
118
  "recursive": true,
118
119
  "autoKeywords": true,
@@ -124,7 +125,8 @@ Create `.pi/doc-injector.json` in your project root to customize behavior:
124
125
  | Option | Default | Description |
125
126
  | ------------------ | ---------- | -------------------------------------------------------- |
126
127
  | `docsPath` | `"./docs"` | Path to docs folder (relative to project root) |
127
- | `matchThreshold` | `1` | Minimum keyword matches required to inject a doc |
128
+ | `matchThreshold` | `2` | Minimum distinct keyword matches required to inject a doc |
129
+ | `streamWindowSize` | `500` | Rolling tail window (chars) scanned per streaming chunk; matches accumulate across chunks. `0` scans the full buffer |
128
130
  | `contextThreshold` | `80` | Skip injection when context usage exceeds this % (0–100) |
129
131
  | `recursive` | `true` | Scan docs subdirectories recursively |
130
132
  | `autoKeywords` | `true` | Generate keywords heuristically when frontmatter is missing |
@@ -243,7 +245,6 @@ the registry mid-injection).
243
245
  The `injected` flag is per-session: it's reset on `session_start` and can
244
246
  be manually cleared with `/doc-inject reset`.
245
247
 
246
- For the full source-level verification, see the JSDoc block in `index.ts`.
247
248
  For the full source-level verification, see the JSDoc block in `index.ts`.
248
249
 
249
250
  ## Development
package/commands.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Slash commands for the Doc Injector extension.
3
3
  */
4
- import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
4
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
5
5
  import type { DocRegistry } from "./registry";
6
6
  import type { DocInjectorConfig } from "./types";
7
7
 
@@ -43,7 +43,7 @@ export function registerCommands(pi: ExtensionAPI, deps: CommandDeps): void {
43
43
  } else if (a === "reset") {
44
44
  const reg = deps.getRegistry();
45
45
  if (reg) {
46
- reg.reset();
46
+ reg.markAllNotInjected();
47
47
  ctx.ui.notify("📄 Injection state reset", "info");
48
48
  } else {
49
49
  ctx.ui.notify("📄 No registry loaded", "warning");
package/config.ts CHANGED
@@ -32,7 +32,6 @@ function clampInt(
32
32
  return intVal;
33
33
  }
34
34
 
35
- /**
36
35
  /**
37
36
  * Validate a glob pattern array.
38
37
  * Rejects non-array or entries that aren't strings. Returns default on error.
@@ -57,12 +56,6 @@ function validateGlobArray(
57
56
  return result.length > 0 ? result : [...defaultVal];
58
57
  }
59
58
 
60
- /**
61
- * Load config from `.pi/doc-injector.json` relative to the given cwd.
62
- * Now async — uses readFile from fs/promises.
63
- * Validates and clamps all numeric fields. Falls back to DEFAULT_CONFIG
64
- * if file doesn't exist or is invalid.
65
- */
66
59
  /**
67
60
  * Load config from `.pi/doc-injector.json` relative to the given cwd.
68
61
  * Async — uses readFile from fs/promises. Validates and clamps all numeric
@@ -79,6 +72,7 @@ export async function loadConfig(cwd: string, notifier: Notifier): Promise<DocIn
79
72
  return {
80
73
  docsPath: parsed.docsPath ?? DEFAULT_CONFIG.docsPath,
81
74
  matchThreshold: clampInt(parsed.matchThreshold, DEFAULT_CONFIG.matchThreshold, 1, Infinity, "matchThreshold", notifier),
75
+ streamWindowSize: clampInt(parsed.streamWindowSize, DEFAULT_CONFIG.streamWindowSize, 0, Infinity, "streamWindowSize", notifier),
82
76
  contextThreshold: clampInt(parsed.contextThreshold, DEFAULT_CONFIG.contextThreshold, 0, 100, "contextThreshold", notifier),
83
77
  recursive: parsed.recursive ?? DEFAULT_CONFIG.recursive,
84
78
  include: validateGlobArray(parsed.include, DEFAULT_CONFIG.include, notifier),
package/index.ts CHANGED
@@ -63,8 +63,8 @@
63
63
  * is cleared after injection, and `markInjected()` operates on the registry's
64
64
  * current entries, not the stale array.
65
65
  */
66
- import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
67
- import { Type } from "@sinclair/typebox";
66
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
67
+ import { Type } from "typebox";
68
68
  import { resolve } from "node:path";
69
69
  import { loadCache, saveCache } from "./cache";
70
70
  import { loadConfig } from "./config";
@@ -73,7 +73,7 @@ import { buildKeywordGenPrompt } from "./keyword-llm";
73
73
  import { extractText, KeywordMatcher } from "./matcher";
74
74
  import { ExtensionNotifier, type Notifier } from "./notifier";
75
75
  import { DocRegistry } from "./registry";
76
- import { DEFAULT_MATCHER_OPTIONS, LLM_CACHE_SENTINEL, type DocEntry, type MatchResult, type KeywordCache, type CacheEntry } from "./types";
76
+ import { LLM_CACHE_SENTINEL, type DocEntry, type KeywordCache, type CacheEntry, type MatcherOptions } from "./types";
77
77
  import { registerCommands } from "./commands";
78
78
 
79
79
  export default async function docInjectorExtension(pi: ExtensionAPI) {
@@ -88,7 +88,12 @@ export default async function docInjectorExtension(pi: ExtensionAPI) {
88
88
  let initRegistryPromise: Promise<void> | null = null;
89
89
  let enabled = true;
90
90
  let textBuffer = "";
91
- let pendingMatches = new Map<string, string[]>(); // filePath → matchedKeywords
91
+ let pendingMatches = new Map<string, string[]>(); // filePath → matchedKeywords (docs that met the threshold)
92
+ // filePath → distinct keywords seen so far in the CURRENT streaming message.
93
+ // The streaming matcher scans only a rolling tail window each chunk, so a
94
+ // keyword can scroll out of the window before another appears. Accumulating
95
+ // the union here lets matchThreshold be met across chunks; reset at message_end.
96
+ let streamHits = new Map<string, Set<string>>();
92
97
  let abortingForInjection = false; // guard against cascading aborts
93
98
 
94
99
  // P5.4b — Guard flags for LLM keyword generation
@@ -134,15 +139,13 @@ export default async function docInjectorExtension(pi: ExtensionAPI) {
134
139
  if (Object.keys(dirty).length > 0) {
135
140
  await safeSaveCache(cwd, dirty);
136
141
  }
137
-
138
- const count = registry.getEntries().length;
139
142
  };
140
143
 
141
- const buildMatcher = (): KeywordMatcher | null => {
144
+ const buildMatcher = (options?: Partial<MatcherOptions>): KeywordMatcher | null => {
142
145
  if (!registry) return null;
143
146
  return new KeywordMatcher(
144
147
  registry.getNonInjectedEntries(),
145
- { matchThreshold: config.matchThreshold },
148
+ { matchThreshold: config.matchThreshold, ...options },
146
149
  );
147
150
  };
148
151
 
@@ -303,17 +306,27 @@ export default async function docInjectorExtension(pi: ExtensionAPI) {
303
306
  textBuffer = extractText(content);
304
307
  if (!textBuffer) return;
305
308
 
306
- const matcher = buildMatcher();
309
+ // Discovery matcher: threshold 1 finds any keyword present in the rolling
310
+ // tail window. The real matchThreshold is applied below against the
311
+ // keywords accumulated across all chunks of this message.
312
+ const matcher = buildMatcher({ matchThreshold: 1, windowSize: config.streamWindowSize });
307
313
  if (!matcher) return;
308
314
 
309
315
  const results = matcher.match(textBuffer);
310
316
 
311
317
  let hasNew = false;
312
318
  for (const result of results) {
313
- if (!pendingMatches.has(result.entry.filePath)) {
319
+ const filePath = result.entry.filePath;
320
+ const seen = streamHits.get(filePath) ?? new Set<string>();
321
+ for (const kw of result.matchedKeywords) seen.add(kw);
322
+ streamHits.set(filePath, seen);
323
+
324
+ // Promote to a pending injection once enough distinct keywords have been
325
+ // seen across the message. Only the first crossing counts as "new".
326
+ if (seen.size >= config.matchThreshold && !pendingMatches.has(filePath)) {
327
+ pendingMatches.set(filePath, [...seen]);
314
328
  hasNew = true;
315
329
  }
316
- pendingMatches.set(result.entry.filePath, result.matchedKeywords);
317
330
  }
318
331
 
319
332
  if (hasNew && !ctx.isIdle() && !abortingForInjection) {
@@ -325,11 +338,15 @@ export default async function docInjectorExtension(pi: ExtensionAPI) {
325
338
  // ---- Event: message_end (finalize matches) ----
326
339
  // Notification moved to before_agent_start so it fires for both user-triggered
327
340
  // and auto-abort-triggered injections. message_end now just resets state.
341
+ // State resets run BEFORE the role/registry guards so a non-assistant
342
+ // message_end (e.g. tool result, user message) can never leak a stale
343
+ // textBuffer or streamHits into the next assistant turn.
328
344
  pi.on("message_end", async (event, _ctx) => {
345
+ textBuffer = "";
346
+ streamHits.clear();
329
347
  if (!enabled || !registry) return;
330
348
  const msg = event.message;
331
349
  if (msg.role !== "assistant") return;
332
- textBuffer = "";
333
350
  });
334
351
 
335
352
  // ---- Event: before_agent_start (inject as CustomMessage) ----
package/matcher.ts CHANGED
@@ -28,34 +28,78 @@ export function extractText(content: unknown): string {
28
28
  return parts.join("\n");
29
29
  }
30
30
 
31
+ /** Characters treated as part of a "word" for boundary detection. */
32
+ const WORD_CHAR = /[A-Za-z0-9_]/;
33
+
34
+ /** Escape a string so it can be embedded literally in a RegExp. */
35
+ function escapeRegExp(s: string): string {
36
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
37
+ }
38
+
39
+ /**
40
+ * Compile a keyword into a regex that matches it literally with smart word
41
+ * boundaries.
42
+ *
43
+ * Boundaries are only asserted on an edge when the adjacent keyword character
44
+ * is a word character ([A-Za-z0-9_]). This gives word-boundary semantics for
45
+ * normal keywords ("test" won't match "latest" or "testing") while still
46
+ * matching symbol keywords literally ("$", "C++", "func()", "[test]", "a|b").
47
+ */
48
+ function buildKeywordRegex(keyword: string, caseSensitive: boolean): RegExp {
49
+ const escaped = escapeRegExp(keyword);
50
+ const left = WORD_CHAR.test(keyword[0]) ? "(?<![A-Za-z0-9_])" : "";
51
+ const right = WORD_CHAR.test(keyword[keyword.length - 1]) ? "(?![A-Za-z0-9_])" : "";
52
+ return new RegExp(`${left}${escaped}${right}`, caseSensitive ? "" : "i");
53
+ }
54
+
55
+ interface CompiledEntry {
56
+ entry: DocEntry;
57
+ patterns: Array<{ keyword: string; regex: RegExp }>;
58
+ }
59
+
31
60
  export class KeywordMatcher {
32
61
  private options: MatcherOptions;
62
+ // Regexes are precompiled once per matcher so streaming re-matches (which run
63
+ // on every chunk) don't recompile patterns on each call.
64
+ private compiled: CompiledEntry[];
33
65
 
34
66
  /**
35
67
  * @param entries - The document entries to match against
36
68
  * @param options - Optional matcher settings (merged with defaults)
37
69
  */
38
- constructor(private entries: DocEntry[], options?: Partial<MatcherOptions>) {
70
+ constructor(entries: DocEntry[], options?: Partial<MatcherOptions>) {
39
71
  this.options = { ...DEFAULT_MATCHER_OPTIONS, ...options };
72
+ this.compiled = entries.map((entry) => ({
73
+ entry,
74
+ patterns: (entry.keywords ?? [])
75
+ // Skip empty/whitespace keywords — they'd match everything.
76
+ .filter((kw) => kw && kw.trim().length > 0)
77
+ .map((keyword) => ({ keyword, regex: buildKeywordRegex(keyword, this.options.caseSensitive) })),
78
+ }));
40
79
  }
41
80
 
42
81
  /** Match text against keyword index. Returns matching docs with hit details. */
43
82
  match(text: string): MatchResult[] {
44
- if (!text || this.entries.length === 0) return [];
83
+ if (!text || this.compiled.length === 0) return [];
84
+
85
+ // Rolling tail window: only scan the last `windowSize` chars. Keeps the
86
+ // per-chunk cost bounded on long streaming messages. Callers that need the
87
+ // full text (one-shot user input) pass windowSize 0. Matches still
88
+ // accumulate across chunks in the caller, so keywords that scroll past the
89
+ // window aren't lost.
90
+ const { windowSize } = this.options;
91
+ const scanText =
92
+ windowSize > 0 && text.length > windowSize ? text.slice(-windowSize) : text;
45
93
 
46
94
  const results: MatchResult[] = [];
47
95
 
48
- for (const entry of this.entries) {
96
+ for (const { entry, patterns } of this.compiled) {
49
97
  if (entry.injected) continue;
50
-
51
- // Skip entries with no keywords (empty array or falsy)
52
- if (!entry.keywords || entry.keywords.length === 0) continue;
98
+ if (patterns.length === 0) continue;
53
99
 
54
100
  const matchedKeywords: string[] = [];
55
- for (const keyword of entry.keywords) {
56
- // Skip empty keywords — they'd match everything with word boundaries
57
- if (!keyword || keyword.trim().length === 0) continue;
58
- if (this.keywordMatches(text, keyword)) {
101
+ for (const { keyword, regex } of patterns) {
102
+ if (regex.test(scanText)) {
59
103
  matchedKeywords.push(keyword);
60
104
  }
61
105
  }
@@ -71,14 +115,4 @@ export class KeywordMatcher {
71
115
 
72
116
  return results;
73
117
  }
74
-
75
- /**
76
- * Check if a single keyword matches the given text.
77
- * Uses simple substring inclusion (case-insensitive by default).
78
- */
79
- private keywordMatches(text: string, keyword: string): boolean {
80
- const search = this.options.caseSensitive ? text : text.toLowerCase();
81
- const kw = this.options.caseSensitive ? keyword : keyword.toLowerCase();
82
- return search.includes(kw);
83
- }
84
118
  }
package/notifier.ts CHANGED
@@ -16,7 +16,7 @@
16
16
  * satisfying the `Notifier` interface (or a `vi.fn()` spy) — no real
17
17
  * extension context is needed.
18
18
  */
19
- import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
19
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
20
20
 
21
21
  export type NotifierLevel = "info" | "warning" | "error";
22
22
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-doc-injector",
3
- "version": "0.5.3",
3
+ "version": "0.6.0",
4
4
  "description": "Auto-inject relevant project documentation into Pi's LLM context based on keyword matching",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -15,7 +15,8 @@
15
15
  ],
16
16
  "scripts": {
17
17
  "test": "vitest run",
18
- "test:watch": "vitest"
18
+ "test:watch": "vitest",
19
+ "typecheck": "tsc --noEmit"
19
20
  },
20
21
  "keywords": [
21
22
  "pi-package",
@@ -35,10 +36,11 @@
35
36
  ]
36
37
  },
37
38
  "dependencies": {
38
- "picomatch": "^4.0.2"
39
+ "picomatch": "^4.0.2",
40
+ "typebox": "^1.1.38"
39
41
  },
40
42
  "peerDependencies": {
41
- "@mariozechner/pi-coding-agent": "*"
43
+ "@earendil-works/pi-coding-agent": "^0.79.0"
42
44
  },
43
45
  "devDependencies": {
44
46
  "@types/node": "^22.0.0",
package/registry.ts CHANGED
@@ -325,8 +325,9 @@ export class DocRegistry {
325
325
  * 4. Skip (no frontmatter, no cache, autoKeywords disabled)
326
326
  *
327
327
  * LLM-generated keywords populate the cache via the `_doc_injector_keywords`
328
- * tool, so they surface as `keywordSource: "cache"` on the next rebuild
329
- * (their `mtimeMs` is set to the file's current mtime when written).
328
+ * tool with `mtimeMs` set to LLM_CACHE_SENTINEL, so they surface as
329
+ * `keywordSource: "llm"` on the next rebuild (the sentinel never matches a
330
+ * real file mtime, keeping them distinct from heuristic cache hits).
330
331
  */
331
332
  private async processFile(
332
333
  { filePath, relativePath, fileName }: ScanResult,
package/types.ts CHANGED
@@ -29,6 +29,12 @@ export interface DocEntry {
29
29
  export interface MatcherOptions {
30
30
  matchThreshold: number;
31
31
  caseSensitive: boolean;
32
+ /**
33
+ * If > 0, only the last `windowSize` characters of the text are scanned.
34
+ * Used by the streaming path to bound per-chunk scan cost; 0 disables
35
+ * windowing (scan the full text), which is the default for one-shot matches.
36
+ */
37
+ windowSize: number;
32
38
  }
33
39
 
34
40
  /** Result from a keyword match. */
@@ -68,6 +74,13 @@ export interface DocInjectorConfig {
68
74
  docsPath: string;
69
75
  /** Minimum keyword matches to trigger injection */
70
76
  matchThreshold: number;
77
+ /**
78
+ * Size (in chars) of the rolling tail window scanned on each streaming chunk.
79
+ * Bounds per-chunk scan cost; matches still accumulate across chunks so the
80
+ * threshold can be met even when keywords scroll out of the window. 0 = scan
81
+ * the full accumulated buffer.
82
+ */
83
+ streamWindowSize: number;
71
84
  /** Skip injection if context usage exceeds this % (0-100) */
72
85
  contextThreshold: number;
73
86
  /** Whether to scan subdirectories */
@@ -91,7 +104,8 @@ export interface DocInjectorConfig {
91
104
  /** Default configuration values. */
92
105
  export const DEFAULT_CONFIG: DocInjectorConfig = {
93
106
  docsPath: "./docs",
94
- matchThreshold: 1,
107
+ matchThreshold: 2,
108
+ streamWindowSize: 500,
95
109
  contextThreshold: 80,
96
110
  recursive: true,
97
111
  include: ["**/*.md", "**/*.txt"],
@@ -107,6 +121,8 @@ export const DEFAULT_CONFIG: DocInjectorConfig = {
107
121
  export const DEFAULT_MATCHER_OPTIONS: MatcherOptions = {
108
122
  matchThreshold: DEFAULT_CONFIG.matchThreshold,
109
123
  caseSensitive: false,
124
+ // Default to no windowing; the streaming caller opts in via streamWindowSize.
125
+ windowSize: 0,
110
126
  };
111
127
 
112
128
  /**