pi-doc-injector 0.1.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 ADDED
@@ -0,0 +1,104 @@
1
+ # Pi Doc Injector
2
+
3
+ A [Pi](https://pi.dev) extension that automatically injects relevant project documentation into the LLM system prompt by monitoring streaming output for keyword matches.
4
+
5
+ ## Installation
6
+
7
+ ### Via npm (recommended)
8
+
9
+ ```bash
10
+ pi install npm:pi-doc-injector
11
+ ```
12
+
13
+ ### Via git
14
+
15
+ ```bash
16
+ pi install git:github.com/yourname/pi-doc-injector
17
+ ```
18
+
19
+ ### Manual
20
+
21
+ Copy this repository into your project's `.pi/extensions/doc-injector/` folder, or clone directly:
22
+
23
+ ```bash
24
+ git clone https://github.com/yourname/pi-doc-injector.git .pi/extensions/doc-injector
25
+ ```
26
+
27
+ ## Quick Start
28
+
29
+ 1. Create a `docs/` folder in your project root.
30
+ 2. Add markdown files with YAML frontmatter:
31
+
32
+ ```md
33
+ ---
34
+ title: "Testing Workflow"
35
+ keywords: [test, testing, jest, vitest]
36
+ ---
37
+
38
+ # Testing Workflow
39
+ Your documentation here...
40
+ ```
41
+
42
+ Keywords can also be specified in block format:
43
+
44
+ ```md
45
+ ---
46
+ title: "Testing Workflow"
47
+ keywords:
48
+ - test
49
+ - testing
50
+ - jest
51
+ - vitest
52
+ ---
53
+ ```
54
+
55
+ 3. Start Pi. The extension scans `docs/` on session start.
56
+ 4. When the LLM mentions keywords from your docs, the relevant document is injected into the next turn's system prompt.
57
+
58
+ ## Configuration
59
+
60
+ Create `.pi/doc-injector.json` to customize behavior:
61
+
62
+ ```json
63
+ {
64
+ "docsPath": "./docs",
65
+ "matchThreshold": 2
66
+ }
67
+ ```
68
+
69
+ | Option | Default | Description |
70
+ |--------|---------|-------------|
71
+ | `docsPath` | `./docs` | Path to your documentation folder |
72
+ | `matchThreshold` | `2` | Minimum keyword matches before injecting |
73
+
74
+ ### Keyword Matching
75
+
76
+ Matching is case-insensitive and respects word boundaries by default. Once a document is injected, it won't re-match until you run `/doc-inject reset`.
77
+
78
+ Injection is also skipped if the current context usage exceeds 80% of the token budget.
79
+
80
+ ## Commands
81
+
82
+ | Command | Description |
83
+ |---------|-------------|
84
+ | `/doc-inject on` | Enable auto-injection |
85
+ | `/doc-inject off` | Disable auto-injection |
86
+ | `/doc-inject toggle` | Toggle on/off |
87
+ | `/doc-inject status` | Show injector status, doc count, keyword count |
88
+ | `/doc-inject list` | List all registered documents |
89
+ | `/doc-inject reset` | Reset injection state (allows re-matching docs) |
90
+ | `/doc-reload` | Re-scan docs folder |
91
+
92
+ ## Development
93
+
94
+ ```bash
95
+ # Run tests
96
+ bun test
97
+
98
+ # Run tests in watch mode
99
+ bun test --watch
100
+ ```
101
+
102
+ ## License
103
+
104
+ MIT
package/commands.ts ADDED
@@ -0,0 +1,91 @@
1
+ /**
2
+ * Slash commands for the Doc Injector extension.
3
+ */
4
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
5
+ import type { DocRegistry } from "./registry";
6
+
7
+ export function registerCommands(
8
+ pi: ExtensionAPI,
9
+ getRegistry: () => DocRegistry | null,
10
+ getEnabled: () => boolean,
11
+ setEnabled: (v: boolean) => void,
12
+ ): void {
13
+ const cmd = (name: string, desc: string, handler: (args: string, ctx: ExtensionContext) => void) => {
14
+ pi.registerCommand(name, { description: desc, handler });
15
+ };
16
+
17
+ cmd("doc-inject", "Doc injector: on|off|toggle|list|reset|status", (args, ctx) => {
18
+ const a = args.trim().toLowerCase();
19
+ if (a === "on") {
20
+ setEnabled(true);
21
+ ctx.ui.notify("📄 Doc injection enabled", "success");
22
+ } else if (a === "off") {
23
+ setEnabled(false);
24
+ ctx.ui.notify("📄 Doc injection disabled", "warning");
25
+ } else if (a === "toggle") {
26
+ const next = !getEnabled();
27
+ setEnabled(next);
28
+ ctx.ui.notify(`📄 Doc injection ${next ? "enabled" : "disabled"}`, "info");
29
+ } else if (a === "reset") {
30
+ const reg = getRegistry();
31
+ if (reg) {
32
+ reg.reset();
33
+ ctx.ui.notify("📄 Injection state reset", "success");
34
+ } else {
35
+ ctx.ui.notify("📄 No registry loaded", "warning");
36
+ }
37
+ } else if (a === "list") {
38
+ const reg = getRegistry();
39
+ if (!reg) {
40
+ ctx.ui.notify("📄 No docs loaded", "warning");
41
+ return;
42
+ }
43
+ const entries = reg.getEntries();
44
+ if (entries.length === 0) {
45
+ ctx.ui.notify("📄 No documents found in docs folder", "info");
46
+ return;
47
+ }
48
+ const lines = entries.map((e) => {
49
+ const status = e.injected ? "✅" : "⬜";
50
+ return `${status} ${e.fileName}: "${e.title}" — keywords: [${e.keywords.join(", ")}]`;
51
+ });
52
+ ctx.ui.notify(`📄 Registered docs:\n${lines.join("\n")}`, "info");
53
+ } else {
54
+ // status (default)
55
+ const reg = getRegistry();
56
+ if (!reg) {
57
+ ctx.ui.notify("📄 Status: No registry loaded", "warning");
58
+ return;
59
+ }
60
+ const entries = reg.getEntries();
61
+ const injected = entries.filter((e) => e.injected).length;
62
+ const kwCount = entries.reduce((sum, e) => sum + e.keywords.length, 0);
63
+ ctx.ui.notify(
64
+ `📄 Doc Injector Status:\n` +
65
+ ` Enabled: ${getEnabled() ? "✅" : "❌"}\n` +
66
+ ` Docs: ${entries.length}\n` +
67
+ ` Keywords: ${kwCount}\n` +
68
+ ` Injected: ${injected}`,
69
+ "info",
70
+ );
71
+ }
72
+ });
73
+
74
+ cmd("doc-reload", "Re-scan docs folder and rebuild registry", (_args, ctx) => {
75
+ const reg = getRegistry();
76
+ if (!reg) {
77
+ ctx.ui.notify("📄 No registry to reload", "warning");
78
+ return;
79
+ }
80
+ // We can't call rebuild() async from a command handler easily,
81
+ // so we notify and trigger via the event system
82
+ ctx.ui.notify("📄 Triggering docs reload…", "info");
83
+ // Trigger a resources_discover-like reload by rebuilding directly
84
+ reg.rebuild().then(() => {
85
+ const count = reg.getEntries().length;
86
+ ctx.ui.notify(`📄 Reloaded: ${count} documents found`, "success");
87
+ }).catch((err) => {
88
+ ctx.ui.notify(`📄 Reload failed: ${err instanceof Error ? err.message : String(err)}`, "error");
89
+ });
90
+ });
91
+ }
package/config.ts ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Configuration loader for the Doc Injector extension.
3
+ * Reads from `.pi/doc-injector.json` with fallback to defaults.
4
+ */
5
+ import { existsSync, readFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { DEFAULT_CONFIG, type DocInjectorConfig } from "./types";
8
+
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.
12
+ */
13
+ export function loadConfig(cwd: string): DocInjectorConfig {
14
+ const configPath = join(cwd, ".pi", "doc-injector.json");
15
+
16
+ if (!existsSync(configPath)) {
17
+ return { ...DEFAULT_CONFIG };
18
+ }
19
+
20
+ try {
21
+ const raw = readFileSync(configPath, "utf-8");
22
+ const parsed = JSON.parse(raw) as Partial<DocInjectorConfig>;
23
+
24
+ return {
25
+ docsPath: parsed.docsPath ?? DEFAULT_CONFIG.docsPath,
26
+ matchThreshold: parsed.matchThreshold ?? DEFAULT_CONFIG.matchThreshold,
27
+ };
28
+ } catch (err) {
29
+ console.warn(
30
+ `[doc-injector] Failed to parse config at ${configPath}:`,
31
+ err instanceof Error ? err.message : String(err),
32
+ );
33
+ return { ...DEFAULT_CONFIG };
34
+ }
35
+ }
@@ -0,0 +1,55 @@
1
+ ---
2
+ title: "Publishing Workflow"
3
+ keywords: [publish, release, deploy, version, npm, changelog, tag, semantic versioning, production, staging]
4
+ ---
5
+
6
+ # Publishing Workflow
7
+
8
+ ## Overview
9
+
10
+ This document covers the process for publishing releases and deploying to production.
11
+
12
+ ## Versioning
13
+
14
+ We follow [Semantic Versioning](https://semver.org/):
15
+ - **MAJOR** — incompatible API changes
16
+ - **MINOR** — backwards-compatible functionality additions
17
+ - **PATCH** — backwards-compatible bug fixes
18
+
19
+ ## Release Process
20
+
21
+ 1. Update `CHANGELOG.md` with all changes since last release
22
+ 2. Bump version in `package.json`
23
+ 3. Create a git tag: `git tag -a v1.2.3 -m "Release v1.2.3"`
24
+ 4. Push tags: `git push origin --tags`
25
+ 5. CI will automatically build and publish
26
+
27
+ ## Publishing to npm
28
+
29
+ ```bash
30
+ # Dry run first
31
+ npm publish --dry-run
32
+
33
+ # Actual publish
34
+ npm publish
35
+ ```
36
+
37
+ ## Deployment
38
+
39
+ ### Staging
40
+ - Deployed automatically on merge to `main`
41
+ - URL: staging.example.com
42
+ - Used for QA and integration testing
43
+
44
+ ### Production
45
+ - Deployed from release tags only
46
+ - URL: app.example.com
47
+ - Requires manual approval in CI pipeline
48
+
49
+ ## Rollback Procedure
50
+
51
+ If a release causes issues:
52
+ 1. Identify the problematic version
53
+ 2. Revert the git tag
54
+ 3. Re-deploy the previous version
55
+ 4. Post-mortem within 48 hours
@@ -0,0 +1,58 @@
1
+ ---
2
+ title: "Testing Workflow"
3
+ keywords: [test, testing, unit test, integration test, tdd, jest, vitest, assert, mock, stub]
4
+ ---
5
+
6
+ # Testing Workflow
7
+
8
+ ## Overview
9
+
10
+ This document covers the testing workflow for this project. All new features must include tests.
11
+
12
+ ## Testing Principles
13
+
14
+ 1. **Test-driven development** — write tests before implementation when possible
15
+ 2. **Unit tests** for pure functions and isolated logic
16
+ 3. **Integration tests** for interactions between modules
17
+ 4. **E2E tests** for critical user flows
18
+
19
+ ## Running Tests
20
+
21
+ ```bash
22
+ # Run all tests
23
+ npm test
24
+
25
+ # Run tests in watch mode
26
+ npm run test:watch
27
+
28
+ # Run tests with coverage
29
+ npm run test:coverage
30
+ ```
31
+
32
+ ## Writing Tests
33
+
34
+ - Place test files next to source files with `.test.ts` extension
35
+ - Use descriptive test names that explain the expected behavior
36
+ - One assertion per test when possible
37
+ - Use `describe` blocks to group related tests
38
+
39
+ ## Test Categories
40
+
41
+ ### Unit Tests
42
+ - Test individual functions in isolation
43
+ - Mock external dependencies
44
+ - Fast execution (< 100ms per test)
45
+
46
+ ### Integration Tests
47
+ - Test module interactions
48
+ - May use real dependencies
49
+ - Slower than unit tests
50
+
51
+ ### End-to-End Tests
52
+ - Test complete user workflows
53
+ - Run against a staging environment
54
+ - Slowest but most valuable
55
+
56
+ ## Code Coverage Target
57
+
58
+ Maintain at least 80% code coverage for all new code.
@@ -0,0 +1,53 @@
1
+ ---
2
+ title: "Development Workflow"
3
+ keywords: [workflow, development, coding, git, branch, commit, pull request, review, ci, cd, pipeline]
4
+ ---
5
+
6
+ # Development Workflow
7
+
8
+ ## Overview
9
+
10
+ This document describes the standard development workflow for this project.
11
+
12
+ ## Git Workflow
13
+
14
+ 1. Create a feature branch from `main`
15
+ 2. Make atomic commits with conventional commit messages
16
+ 3. Push and create a pull request
17
+ 4. Request review from at least one team member
18
+ 5. Merge after approval
19
+
20
+ ## Branch Naming
21
+
22
+ - `feat/short-description` — new features
23
+ - `fix/short-description` — bug fixes
24
+ - `refactor/short-description` — code refactoring
25
+ - `docs/short-description` — documentation changes
26
+ - `test/short-description` — test additions
27
+
28
+ ## Commit Messages
29
+
30
+ Follow Conventional Commits:
31
+ ```
32
+ type(scope): description
33
+
34
+ feat(auth): add OAuth2 login support
35
+ fix(api): handle null response gracefully
36
+ docs(readme): update installation steps
37
+ ```
38
+
39
+ ## Code Review Guidelines
40
+
41
+ - Review within 24 hours of PR creation
42
+ - Focus on correctness, readability, and test coverage
43
+ - Approve only when all CI checks pass
44
+ - Leave constructive feedback with specific suggestions
45
+
46
+ ## CI/CD Pipeline
47
+
48
+ The pipeline runs on every push:
49
+ 1. Lint check
50
+ 2. Type check
51
+ 3. Unit tests
52
+ 4. Integration tests
53
+ 5. Build
package/index.ts ADDED
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Doc Injector Extension for Pi
3
+ *
4
+ * Automatically injects relevant project documentation into the LLM context
5
+ * by monitoring streaming output for keyword matches.
6
+ */
7
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
8
+ import { resolve } from "node:path";
9
+ import { loadConfig } from "./config";
10
+ import { buildSystemPromptAppend, notifyInjection } from "./injector";
11
+ import { extractText, KeywordMatcher } from "./matcher";
12
+ import { DocRegistry } from "./registry";
13
+ import { DEFAULT_MATCHER_OPTIONS, type DocEntry, type MatchResult } from "./types";
14
+ import { registerCommands } from "./commands";
15
+
16
+ export default async function docInjectorExtension(pi: ExtensionAPI) {
17
+ // ---- State ----
18
+ let config = loadConfig(process.cwd());
19
+ let registry: DocRegistry | null = null;
20
+ let enabled = true;
21
+ let textBuffer = "";
22
+ let pendingMatches = new Map<string, string[]>(); // filePath → matchedKeywords
23
+
24
+ // ---- Helpers ----
25
+ const getRegistry = () => registry;
26
+ const getEnabled = () => enabled;
27
+ const setEnabled = (v: boolean) => {
28
+ enabled = v;
29
+ };
30
+
31
+ const initRegistry = async (cwd: string) => {
32
+ config = loadConfig(cwd);
33
+ const docsPath = resolve(cwd, config.docsPath);
34
+ registry = await DocRegistry.create(docsPath);
35
+ const count = registry.getEntries().length;
36
+ if (count > 0) {
37
+ console.log(`[doc-injector] Loaded ${count} documents from ${docsPath}`);
38
+ } else {
39
+ console.warn(`[doc-injector] No documents found at ${docsPath}`);
40
+ }
41
+ };
42
+
43
+ const buildMatcher = (): KeywordMatcher | null => {
44
+ if (!registry) return null;
45
+ return new KeywordMatcher(
46
+ registry.getNonInjectedEntries(),
47
+ { matchThreshold: config.matchThreshold },
48
+ );
49
+ };
50
+
51
+ // ---- Event: session_start ----
52
+ pi.on("session_start", async (_event, ctx) => {
53
+ await initRegistry(ctx.cwd);
54
+ });
55
+
56
+ // ---- Event: resources_discover (reload) ----
57
+ pi.on("resources_discover", async (_event, ctx) => {
58
+ if (registry) {
59
+ await registry.rebuild();
60
+ const count = registry.getEntries().length;
61
+ console.log(`[doc-injector] Reloaded: ${count} documents`);
62
+ }
63
+ });
64
+
65
+ // ---- Event: message_update (streaming detection) ----
66
+ pi.on("message_update", async (event, _ctx) => {
67
+ if (!enabled || !registry) return;
68
+
69
+ // Only process assistant messages
70
+ const msg = event.message as Record<string, unknown> | undefined;
71
+ if (!msg || msg.role !== "assistant") return;
72
+
73
+ // Replace buffer with full message text (message_update contains full content)
74
+ textBuffer = extractText(msg.content);
75
+ if (!textBuffer) return;
76
+
77
+ // Run matcher
78
+ const matcher = buildMatcher();
79
+ if (!matcher) return;
80
+
81
+ const results = matcher.match(textBuffer);
82
+
83
+ // Store matches (dedup by filePath)
84
+ for (const result of results) {
85
+ pendingMatches.set(result.entry.filePath, result.matchedKeywords);
86
+ }
87
+ });
88
+
89
+ // ---- Event: message_end (finalize matches) ----
90
+ pi.on("message_end", async (event, ctx) => {
91
+ if (!enabled || !registry) return;
92
+
93
+ const msg = event.message as Record<string, unknown> | undefined;
94
+ if (!msg || msg.role !== "assistant") return;
95
+
96
+ // Clear buffer
97
+ textBuffer = "";
98
+
99
+ // Notify user about pending injections
100
+ if (pendingMatches.size > 0) {
101
+ const matchedEntries: DocEntry[] = [];
102
+ for (const [filePath] of pendingMatches) {
103
+ const entry = registry.getEntries().find((e) => e.filePath === filePath);
104
+ if (entry) matchedEntries.push(entry);
105
+ }
106
+ notifyInjection(ctx.ui, matchedEntries, pendingMatches);
107
+ }
108
+ });
109
+
110
+ // ---- Event: before_agent_start (inject into system prompt) ----
111
+ pi.on("before_agent_start", async (_event, _ctx) => {
112
+ if (!enabled || !registry || pendingMatches.size === 0) return;
113
+
114
+ const matchedEntries: DocEntry[] = [];
115
+ for (const [filePath] of pendingMatches) {
116
+ const entry = registry.getEntries().find((e) => e.filePath === filePath);
117
+ if (entry) matchedEntries.push(entry);
118
+ }
119
+
120
+ if (matchedEntries.length === 0) {
121
+ pendingMatches.clear();
122
+ return;
123
+ }
124
+
125
+ // Check context budget before injecting
126
+ const usage = _ctx.getContextUsage();
127
+ if (usage && usage.tokens > 0 && usage.percentage && usage.percentage > 80) {
128
+ console.warn("[doc-injector] Skipping injection: context usage > 80%");
129
+ pendingMatches.clear();
130
+ return;
131
+ }
132
+
133
+ const append = buildSystemPromptAppend(matchedEntries, pendingMatches);
134
+
135
+ // Mark as injected only after confirming injection will happen
136
+ for (const entry of matchedEntries) {
137
+ entry.injected = true;
138
+ }
139
+ pendingMatches.clear();
140
+
141
+ return {
142
+ systemPrompt: (_event.systemPrompt || "") + "\n\n" + append,
143
+ };
144
+ });
145
+
146
+ // ---- Commands ----
147
+ registerCommands(pi, getRegistry, getEnabled, setEnabled);
148
+ }
package/injector.ts ADDED
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Context Injector — formats matched docs into system prompt append
3
+ * and sends TUI notifications.
4
+ */
5
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
6
+ import type { DocEntry } from "./types";
7
+
8
+ /**
9
+ * Build a system prompt append string from matched documents.
10
+ */
11
+ export function buildSystemPromptAppend(
12
+ entries: DocEntry[],
13
+ matchedKeywords: Map<string, string[]>,
14
+ ): string {
15
+ if (entries.length === 0) return "";
16
+
17
+ const sections: string[] = [
18
+ "## Relevant Context Documents\n",
19
+ "The following documents from the project docs folder are relevant to the current conversation context. Use them as reference when responding.",
20
+ "",
21
+ ];
22
+
23
+ for (const entry of entries) {
24
+ const keywords = matchedKeywords.get(entry.filePath) ?? [];
25
+ sections.push(`### ${entry.title}`);
26
+ sections.push(`Source: \`${entry.fileName}\``);
27
+ if (keywords.length > 0) {
28
+ sections.push(`Matched keywords: ${keywords.join(", ")}`);
29
+ }
30
+ sections.push("---");
31
+ sections.push(entry.content);
32
+ sections.push("");
33
+ }
34
+
35
+ return sections.join("\n");
36
+ }
37
+
38
+ /**
39
+ * Notify the user via TUI when documents are injected.
40
+ */
41
+ export function notifyInjection(
42
+ ui: { notify: (msg: string, type?: "info" | "warning" | "error" | "success") => void },
43
+ entries: DocEntry[],
44
+ matchedKeywords: Map<string, string[]>,
45
+ ): void {
46
+ for (const entry of entries) {
47
+ const keywords = matchedKeywords.get(entry.filePath) ?? [];
48
+ const kwList = keywords.join(", ");
49
+ ui.notify(`📄 Injected: ${entry.fileName} (matched: ${kwList})`, "info");
50
+ }
51
+ }
package/matcher.ts ADDED
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Keyword Matcher — matches streaming output text against document keywords.
3
+ */
4
+ import type { DocEntry, MatchResult, MatcherOptions } from "./types";
5
+ import { DEFAULT_MATCHER_OPTIONS } from "./types";
6
+
7
+ /**
8
+ * Extract text from message content. Handles:
9
+ * - Plain string
10
+ * - Content array with text/thinking blocks
11
+ */
12
+ export function extractText(content: unknown): string {
13
+ if (typeof content === "string") {
14
+ return content;
15
+ }
16
+ if (!Array.isArray(content)) {
17
+ return "";
18
+ }
19
+
20
+ const parts: string[] = [];
21
+ for (const block of content) {
22
+ if (!block || typeof block !== "object") continue;
23
+ const b = block as Record<string, unknown>;
24
+ if ((b.type === "text" || b.type === "thinking") && typeof b.text === "string") {
25
+ parts.push(b.text);
26
+ }
27
+ }
28
+ return parts.join("\n");
29
+ }
30
+
31
+ export class KeywordMatcher {
32
+ private options: MatcherOptions;
33
+
34
+ constructor(private entries: DocEntry[], options?: Partial<MatcherOptions>) {
35
+ this.options = { ...DEFAULT_MATCHER_OPTIONS, ...options };
36
+ }
37
+
38
+ /** Match text against keyword index. Returns matching docs with hit details. */
39
+ match(text: string): MatchResult[] {
40
+ if (!text || this.entries.length === 0) return [];
41
+
42
+ const results: MatchResult[] = [];
43
+
44
+ for (const entry of this.entries) {
45
+ if (entry.injected) continue;
46
+
47
+ const matchedKeywords: string[] = [];
48
+ for (const keyword of entry.keywords) {
49
+ if (this.keywordMatches(text, keyword)) {
50
+ matchedKeywords.push(keyword);
51
+ }
52
+ }
53
+
54
+ if (matchedKeywords.length >= this.options.matchThreshold) {
55
+ results.push({
56
+ entry,
57
+ matchedKeywords,
58
+ hitCount: matchedKeywords.length,
59
+ });
60
+ }
61
+ }
62
+
63
+ return results;
64
+ }
65
+
66
+ private keywordMatches(text: string, keyword: string): boolean {
67
+ const search = this.options.caseSensitive ? text : text.toLowerCase();
68
+ const kw = this.options.caseSensitive ? keyword : keyword.toLowerCase();
69
+
70
+ if (this.options.wordBoundary) {
71
+ // Escape special regex chars in keyword, then apply word boundary
72
+ const escaped = kw.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
73
+ const flags = this.options.caseSensitive ? "" : "i";
74
+ const regex = new RegExp(`\\b${escaped}\\b`, flags);
75
+ return regex.test(search);
76
+ }
77
+
78
+ return search.includes(kw);
79
+ }
80
+ }
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "pi-doc-injector",
3
+ "version": "0.1.0",
4
+ "description": "Auto-inject relevant project documentation into Pi's LLM context based on keyword matching",
5
+ "type": "module",
6
+ "main": "./index.ts",
7
+ "exports": {
8
+ ".": "./index.ts"
9
+ },
10
+ "files": [
11
+ "*.ts",
12
+ "docs/**/*.md",
13
+ "README.md"
14
+ ],
15
+ "scripts": {
16
+ "test": "bun test",
17
+ "test:watch": "bun test --watch"
18
+ },
19
+ "keywords": ["pi-package", "pi-extension", "docs", "context", "llm"],
20
+ "license": "MIT",
21
+ "pi": {
22
+ "extensions": ["./index.ts"]
23
+ },
24
+ "peerDependencies": {
25
+ "@mariozechner/pi-coding-agent": "*"
26
+ }
27
+ }
package/registry.ts ADDED
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Document Registry — scans a docs folder, parses frontmatter, maintains index.
3
+ */
4
+ import { readdirSync, readFileSync } from "node:fs";
5
+ import { basename, join, resolve } from "node:path";
6
+ import type { DocEntry } from "./types";
7
+
8
+ /**
9
+ * Parse YAML frontmatter from markdown content.
10
+ * Returns { title, keywords, body } or null if no valid frontmatter found.
11
+ */
12
+ export function parseFrontmatter(
13
+ content: string,
14
+ ): { title: string; keywords: string[]; body: string } | null {
15
+ if (!content.startsWith("---")) {
16
+ return null;
17
+ }
18
+
19
+ const secondDash = content.indexOf("---", 3);
20
+ if (secondDash === -1) {
21
+ return null;
22
+ }
23
+
24
+ const frontmatterBlock = content.slice(3, secondDash).trim();
25
+ const body = content.slice(secondDash + 3).trim();
26
+
27
+ // Extract title
28
+ const titleMatch = frontmatterBlock.match(/^title:\s*["']?([^"'\n]+)["']?$/m);
29
+ const title = titleMatch ? titleMatch[1].trim() : "";
30
+
31
+ // Extract keywords — supports both flow array [a, b] and block array
32
+ const keywords: string[] = [];
33
+
34
+ // Try flow array: keywords: [a, b, c]
35
+ const flowMatch = frontmatterBlock.match(/keywords:\s*\[([^\]]*)\]/);
36
+ if (flowMatch) {
37
+ keywords.push(
38
+ ...flowMatch[1]
39
+ .split(",")
40
+ .map((k) => k.trim().replace(/^["']|["']$/g, ""))
41
+ .filter(Boolean),
42
+ );
43
+ } else {
44
+ // Try block array: keywords:\n - a\n - b
45
+ const blockMatches = frontmatterBlock.matchAll(/keywords:\s*\n((?:\s*-\s*.+\n?)+)/g);
46
+ for (const bm of blockMatches) {
47
+ const items = bm[1].matchAll(/^\s*-\s*["']?([^"'\n]+)["']?$/gm);
48
+ for (const im of items) {
49
+ const k = im[1].trim();
50
+ if (k) keywords.push(k);
51
+ }
52
+ }
53
+ }
54
+
55
+ if (keywords.length === 0) {
56
+ return null;
57
+ }
58
+
59
+ return { title: title || "Untitled", keywords, body };
60
+ }
61
+
62
+ /**
63
+ * Document Registry class. Scans a docs folder and maintains an index of DocEntry.
64
+ */
65
+ export class DocRegistry {
66
+ private entries: DocEntry[] = [];
67
+ private docsPath: string;
68
+
69
+ private constructor(docsPath: string) {
70
+ this.docsPath = docsPath;
71
+ }
72
+
73
+ /** Create a registry by scanning the docs folder. */
74
+ static async create(docsPath: string): Promise<DocRegistry> {
75
+ const registry = new DocRegistry(docsPath);
76
+ await registry.rebuild();
77
+ return registry;
78
+ }
79
+
80
+ /** Re-scan the docs folder and rebuild the index. */
81
+ async rebuild(): Promise<void> {
82
+ const resolved = resolve(this.docsPath);
83
+ const preserved = new Map<string, boolean>();
84
+ for (const e of this.entries) {
85
+ preserved.set(e.filePath, e.injected);
86
+ }
87
+
88
+ try {
89
+ const files = readdirSync(resolved).filter((f) => f.endsWith(".md"));
90
+
91
+ const newEntries: DocEntry[] = [];
92
+ for (const file of files) {
93
+ const filePath = join(resolved, file);
94
+ try {
95
+ const raw = readFileSync(filePath, "utf-8");
96
+ const parsed = parseFrontmatter(raw);
97
+ if (!parsed) {
98
+ console.warn(`[doc-injector] Skipping ${file}: no valid frontmatter with keywords`);
99
+ continue;
100
+ }
101
+ newEntries.push({
102
+ filePath,
103
+ fileName: file,
104
+ title: parsed.title,
105
+ keywords: parsed.keywords,
106
+ content: raw,
107
+ injected: preserved.get(filePath) ?? false,
108
+ });
109
+ } catch (err) {
110
+ console.warn(`[doc-injector] Error reading ${file}:`, err);
111
+ }
112
+ }
113
+
114
+ this.entries = newEntries;
115
+ } catch {
116
+ console.warn(`[doc-injector] Docs folder not found: ${resolved}`);
117
+ this.entries = [];
118
+ }
119
+ }
120
+
121
+ /** Get all registered entries. */
122
+ getEntries(): DocEntry[] {
123
+ return [...this.entries];
124
+ }
125
+
126
+ /** Get entries that haven't been injected yet. */
127
+ getNonInjectedEntries(): DocEntry[] {
128
+ return this.entries.filter((e) => !e.injected);
129
+ }
130
+
131
+ /** Reset all injected flags. */
132
+ reset(): void {
133
+ for (const e of this.entries) {
134
+ e.injected = false;
135
+ }
136
+ }
137
+ }
package/types.ts ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Shared type definitions for the Doc Injector extension.
3
+ */
4
+
5
+ /** A parsed document from the docs folder. */
6
+ export interface DocEntry {
7
+ filePath: string;
8
+ fileName: string;
9
+ title: string;
10
+ keywords: string[];
11
+ content: string;
12
+ injected: boolean;
13
+ }
14
+
15
+ /** Options for the keyword matcher. */
16
+ export interface MatcherOptions {
17
+ matchThreshold: number;
18
+ caseSensitive: boolean;
19
+ wordBoundary: boolean;
20
+ }
21
+
22
+ /** Result from a keyword match. */
23
+ export interface MatchResult {
24
+ entry: DocEntry;
25
+ matchedKeywords: string[];
26
+ hitCount: number;
27
+ }
28
+
29
+ /** Extension configuration. */
30
+ export interface DocInjectorConfig {
31
+ docsPath: string;
32
+ matchThreshold: number;
33
+ }
34
+
35
+ /** Default configuration values. */
36
+ export const DEFAULT_CONFIG: DocInjectorConfig = {
37
+ docsPath: "./docs",
38
+ matchThreshold: 2,
39
+ };
40
+
41
+ /** Default matcher options derived from config. */
42
+ export const DEFAULT_MATCHER_OPTIONS: MatcherOptions = {
43
+ matchThreshold: DEFAULT_CONFIG.matchThreshold,
44
+ caseSensitive: false,
45
+ wordBoundary: true,
46
+ };