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 +104 -0
- package/commands.ts +91 -0
- package/config.ts +35 -0
- package/docs/publish-md.md +55 -0
- package/docs/test-md.md +58 -0
- package/docs/workflow-md.md +53 -0
- package/index.ts +148 -0
- package/injector.ts +51 -0
- package/matcher.ts +80 -0
- package/package.json +27 -0
- package/registry.ts +137 -0
- package/types.ts +46 -0
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
|
package/docs/test-md.md
ADDED
|
@@ -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
|
+
};
|