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 +4 -3
- package/commands.ts +2 -2
- package/config.ts +1 -7
- package/index.ts +29 -12
- package/matcher.ts +54 -20
- package/notifier.ts +1 -1
- package/package.json +6 -4
- package/registry.ts +3 -2
- package/types.ts +17 -1
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":
|
|
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` | `
|
|
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 "@
|
|
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.
|
|
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 "@
|
|
67
|
-
import { Type } from "
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|
|
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.
|
|
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
|
|
56
|
-
|
|
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 "@
|
|
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.
|
|
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
|
-
"@
|
|
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
|
|
329
|
-
*
|
|
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:
|
|
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
|
/**
|