pi-openspec-context 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/CHANGELOG.md +64 -0
- package/LICENSE +25 -0
- package/README.md +242 -0
- package/dist/cache.d.ts +11 -0
- package/dist/cache.js +38 -0
- package/dist/detector.d.ts +15 -0
- package/dist/detector.js +83 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +69 -0
- package/dist/runner.d.ts +14 -0
- package/dist/runner.js +39 -0
- package/dist/types.d.ts +28 -0
- package/dist/types.js +1 -0
- package/package.json +53 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.1.0] - 2026-01-15
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **Initial Release**: Lightweight Pi extension for automatic OpenSpec context injection
|
|
13
|
+
- **Core Feature**: Single-responsibility automatic context extraction and system prompt injection at `before_agent_start` hook
|
|
14
|
+
- **Smart Detection**: Two-tier detection strategy
|
|
15
|
+
- Traverses upward to find `openspec/root` directories
|
|
16
|
+
- Queries `openspec store list` to match registered stores
|
|
17
|
+
- **Flexible Context Extraction**:
|
|
18
|
+
- Supports both OpenSpec roots (`openspec context`)
|
|
19
|
+
- Supports OpenSpec stores (`openspec context --store <id>`)
|
|
20
|
+
- **In-Memory Caching**: Per-workspace context caching to minimize CLI invocations
|
|
21
|
+
- Root targets cached by absolute path
|
|
22
|
+
- Store targets cached by ID
|
|
23
|
+
- **Resilient Execution**: Hardened with timeouts and error handling
|
|
24
|
+
- 10-second default timeout (configurable via `OPENSPEC_CONTEXT_TIMEOUT_MS`)
|
|
25
|
+
- Graceful fallback on missing `openspec` CLI
|
|
26
|
+
- Graceful fallback on timeouts and CLI errors
|
|
27
|
+
- Graceful fallback on empty output
|
|
28
|
+
- **Output Sanitization**: ANSI color code stripping for clean context injection
|
|
29
|
+
- **Type Safety**: Fully typed TypeScript with strict mode
|
|
30
|
+
- Discriminated unions for target types
|
|
31
|
+
- Strict null checks enabled
|
|
32
|
+
- No implicit `any` types
|
|
33
|
+
- **Complete Test Coverage**: 85+ unit, integration, and E2E tests
|
|
34
|
+
- Unit tests for core modules (cache, detector, runner)
|
|
35
|
+
- Extension lifecycle tests with mock Pi API
|
|
36
|
+
- In-process harness tests with TestSession
|
|
37
|
+
- Docker-based E2E tests simulating real Pi environment
|
|
38
|
+
- Resilience tests for timeout, error, and missing CLI scenarios
|
|
39
|
+
- **Documentation**:
|
|
40
|
+
- Comprehensive README with architecture, installation, and troubleshooting
|
|
41
|
+
- API reference for extension and cache usage
|
|
42
|
+
- Development guide with build/test/lint commands
|
|
43
|
+
- SOLID principles documentation
|
|
44
|
+
- **Package Metadata**:
|
|
45
|
+
- MIT License with attribution to tobias-weiss-ai-xr/pi-openspec
|
|
46
|
+
- Keywords: pi-package, pi, openspec, extension, context, spec-driven-development
|
|
47
|
+
- Pi package configuration for automatic discovery
|
|
48
|
+
|
|
49
|
+
### Technical Details
|
|
50
|
+
|
|
51
|
+
- **Target Platform**: Node.js with ES2022 module support
|
|
52
|
+
- **Module System**: TypeScript NodeNext ESM with .js import extensions
|
|
53
|
+
- **Parser**: TypeScript with strict mode enabled
|
|
54
|
+
- **Linting**: ESLint with TypeScript support
|
|
55
|
+
- **Testing**: Vitest with Docker-based E2E capabilities
|
|
56
|
+
- **Architecture**: Single-responsibility modules (types, runner, detector, cache, index)
|
|
57
|
+
|
|
58
|
+
### Breaking Changes
|
|
59
|
+
|
|
60
|
+
None (initial release)
|
|
61
|
+
|
|
62
|
+
### Migration Guide
|
|
63
|
+
|
|
64
|
+
None (initial release)
|
package/LICENSE
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Raphael Bahat
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
Based on work from tobias-weiss-ai-xr/pi-openspec
|
package/README.md
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# pi-openspec-context
|
|
2
|
+
|
|
3
|
+
A lightweight Pi extension that automatically injects OpenSpec context into system prompts at the start of each agent session.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
`pi-openspec-context` enhances the Pi coding agent by seamlessly integrating OpenSpec specification data into the system prompt. This enables the AI agent to understand project structure, APIs, data models, and other specification-driven context without explicit user prompts.
|
|
8
|
+
|
|
9
|
+
### Key Features
|
|
10
|
+
|
|
11
|
+
- **Automatic Context Injection**: Detects OpenSpec projects and injects context into `before_agent_start` hook
|
|
12
|
+
- **Smart Caching**: Caches extracted context per workspace to minimize CLI calls
|
|
13
|
+
- **Flexible Detection**: Works with both OpenSpec roots and registered OpenSpec stores
|
|
14
|
+
- **Resilient Fallback**: Gracefully handles missing `openspec` CLI, timeouts, and errors
|
|
15
|
+
- **ANSI Sanitization**: Strips color codes from context output for clean integration
|
|
16
|
+
- **Type-Safe**: Fully typed TypeScript implementation with strict mode enabled
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
### As a Pi Package
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pi install pi-openspec-context
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
### Global Installation (for local development)
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
npm install -g pi-openspec-context
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Then add to your Pi configuration:
|
|
33
|
+
|
|
34
|
+
```json
|
|
35
|
+
{
|
|
36
|
+
"extensions": [
|
|
37
|
+
"pi-openspec-context"
|
|
38
|
+
]
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Project-Local Installation
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
npm install --save-dev pi-openspec-context
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Prerequisites
|
|
49
|
+
|
|
50
|
+
### Required
|
|
51
|
+
- **openspec CLI**: Must be installed and accessible in your PATH
|
|
52
|
+
- Installation: `npm install -g @tobias-weiss-ai-xr/openspec` or `pip install openspec`
|
|
53
|
+
- Verify: `openspec --version`
|
|
54
|
+
|
|
55
|
+
### Optional
|
|
56
|
+
- Pi coding agent v0.84.0 or later (typically included with Pi installation)
|
|
57
|
+
|
|
58
|
+
## Architecture & Behavior
|
|
59
|
+
|
|
60
|
+
### Detection Strategy
|
|
61
|
+
|
|
62
|
+
The extension uses a two-tier detection approach:
|
|
63
|
+
|
|
64
|
+
1. **OpenSpec Root Detection**: Traverses upward from the current working directory looking for `openspec/config.yaml`
|
|
65
|
+
2. **Store Detection**: If no root is found, queries `openspec store list --json` to match the current directory against registered stores
|
|
66
|
+
|
|
67
|
+
### Context Extraction
|
|
68
|
+
|
|
69
|
+
Once a target is detected:
|
|
70
|
+
- **Root Target**: Executes `openspec context` at the root path
|
|
71
|
+
- **Store Target**: Executes `openspec context --store <store-id>` from the current directory
|
|
72
|
+
|
|
73
|
+
### Caching
|
|
74
|
+
|
|
75
|
+
Extracted context is cached in-memory per target:
|
|
76
|
+
- Root targets are keyed by absolute path
|
|
77
|
+
- Store targets are keyed by store ID
|
|
78
|
+
- Cache persists for the lifetime of the Pi session
|
|
79
|
+
|
|
80
|
+
### Injection
|
|
81
|
+
|
|
82
|
+
The cleaned context is injected into the system prompt using a marker:
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
[OpenSpec context]
|
|
86
|
+
<extracted context data>
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Configuration
|
|
90
|
+
|
|
91
|
+
The extension works with zero configuration. Simply install and enable it.
|
|
92
|
+
|
|
93
|
+
### Environment Variables
|
|
94
|
+
|
|
95
|
+
Optional environment variable to override the default timeout:
|
|
96
|
+
- `OPENSPEC_CONTEXT_TIMEOUT_MS`: Timeout for `openspec` CLI execution (default: 10000ms)
|
|
97
|
+
|
|
98
|
+
## Troubleshooting
|
|
99
|
+
|
|
100
|
+
### "openspec: command not found"
|
|
101
|
+
|
|
102
|
+
**Problem**: The extension runs but no context is injected.
|
|
103
|
+
|
|
104
|
+
**Solution**:
|
|
105
|
+
1. Verify OpenSpec is installed: `which openspec`
|
|
106
|
+
2. Install if missing: `npm install -g @tobias-weiss-ai-xr/openspec`
|
|
107
|
+
3. Check PATH is configured: `echo $PATH`
|
|
108
|
+
4. Restart Pi after installing OpenSpec
|
|
109
|
+
|
|
110
|
+
### Context Not Appearing in Prompts
|
|
111
|
+
|
|
112
|
+
**Problem**: Extension is loaded but context not injected.
|
|
113
|
+
|
|
114
|
+
**Checklist**:
|
|
115
|
+
1. Confirm you're in an OpenSpec root or registered store: `openspec store list`
|
|
116
|
+
2. Verify OpenSpec can extract context: `openspec context`
|
|
117
|
+
3. Check extension is loaded: Look for "pi-openspec-context" in Pi logs
|
|
118
|
+
4. Inspect cache behavior: Add debug logging to extension
|
|
119
|
+
|
|
120
|
+
### Timeout Errors
|
|
121
|
+
|
|
122
|
+
**Problem**: Extension times out and falls back silently.
|
|
123
|
+
|
|
124
|
+
**Solution**:
|
|
125
|
+
1. Test OpenSpec manually: `time openspec context`
|
|
126
|
+
2. Increase timeout if needed: Set `OPENSPEC_CONTEXT_TIMEOUT_MS=30000`
|
|
127
|
+
3. Check for hung `openspec` processes: `ps aux | grep openspec`
|
|
128
|
+
|
|
129
|
+
### Empty or Whitespace-Only Context
|
|
130
|
+
|
|
131
|
+
**Problem**: OpenSpec runs but returns empty output.
|
|
132
|
+
|
|
133
|
+
**Solution**:
|
|
134
|
+
1. Verify OpenSpec project is valid: `openspec validate`
|
|
135
|
+
2. Check for malformed YAML in `openspec/` directory
|
|
136
|
+
3. Try manual context extraction: `openspec context`
|
|
137
|
+
|
|
138
|
+
## API Reference
|
|
139
|
+
|
|
140
|
+
### Extension Export
|
|
141
|
+
|
|
142
|
+
The package exports a default extension factory:
|
|
143
|
+
|
|
144
|
+
```typescript
|
|
145
|
+
import extension from 'pi-openspec-context';
|
|
146
|
+
|
|
147
|
+
// Used automatically by Pi's extension loader
|
|
148
|
+
extension(pi);
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### Custom Cache Usage
|
|
152
|
+
|
|
153
|
+
For advanced use cases, you can create an extension with a custom cache:
|
|
154
|
+
|
|
155
|
+
```typescript
|
|
156
|
+
import { createExtension, contextCache } from 'pi-openspec-context';
|
|
157
|
+
|
|
158
|
+
// Use default shared cache
|
|
159
|
+
const ext = createExtension();
|
|
160
|
+
ext(pi);
|
|
161
|
+
|
|
162
|
+
// Or provide custom cache
|
|
163
|
+
import { OpenSpecContextCache } from 'pi-openspec-context/cache';
|
|
164
|
+
const customCache = new OpenSpecContextCache();
|
|
165
|
+
const extWithCustomCache = createExtension(customCache);
|
|
166
|
+
extWithCustomCache(pi);
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### Cache Methods
|
|
170
|
+
|
|
171
|
+
- `get(target): string | null` - Retrieve cached context
|
|
172
|
+
- `set(target, context): void` - Store context in cache
|
|
173
|
+
- `has(target): boolean` - Check if target is cached
|
|
174
|
+
- `delete(target): boolean` - Remove specific target from cache
|
|
175
|
+
- `clear(): void` - Clear all cached contexts
|
|
176
|
+
- `size(): number` - Get cache entry count
|
|
177
|
+
|
|
178
|
+
## Development
|
|
179
|
+
|
|
180
|
+
### Building
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
npm run build
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
### Testing
|
|
187
|
+
|
|
188
|
+
```bash
|
|
189
|
+
npm run test # Run all tests
|
|
190
|
+
npm run test:watch # Watch mode
|
|
191
|
+
npm run test:e2e # Docker E2E tests only
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### Type Checking
|
|
195
|
+
|
|
196
|
+
```bash
|
|
197
|
+
npm run typecheck
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### Linting
|
|
201
|
+
|
|
202
|
+
```bash
|
|
203
|
+
npm run lint
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
## Architecture Notes
|
|
207
|
+
|
|
208
|
+
### Modules
|
|
209
|
+
|
|
210
|
+
- **`src/types.ts`**: Core type definitions (discriminated unions, interfaces)
|
|
211
|
+
- **`src/runner.ts`**: OpenSpec CLI execution and ANSI sanitization
|
|
212
|
+
- **`src/detector.ts`**: Root and store detection logic
|
|
213
|
+
- **`src/cache.ts`**: In-memory caching with path normalization
|
|
214
|
+
- **`src/index.ts`**: Pi extension lifecycle hook implementation
|
|
215
|
+
|
|
216
|
+
### SOLID Principles
|
|
217
|
+
|
|
218
|
+
- **Single Responsibility**: Each module has one clear purpose
|
|
219
|
+
- **Open/Closed**: Extension logic is frozen but cacheable via factory function
|
|
220
|
+
- **Liskov Substitution**: Cache implements a standard interface
|
|
221
|
+
- **Interface Segregation**: Types are minimal and focused
|
|
222
|
+
- **Dependency Inversion**: Depends on `PiExecContext` interface, not concrete implementations
|
|
223
|
+
|
|
224
|
+
### Error Handling
|
|
225
|
+
|
|
226
|
+
All errors are caught and logged silently. The extension falls back gracefully:
|
|
227
|
+
- Missing `openspec` CLI → returns `{}`
|
|
228
|
+
- CLI errors → returns `{}`
|
|
229
|
+
- Timeout → returns `{}`
|
|
230
|
+
- Empty output → returns `{}`
|
|
231
|
+
|
|
232
|
+
This ensures the extension never breaks the agent workflow.
|
|
233
|
+
|
|
234
|
+
## License
|
|
235
|
+
|
|
236
|
+
MIT License
|
|
237
|
+
|
|
238
|
+
## Attribution
|
|
239
|
+
|
|
240
|
+
Based on original work by [tobias-weiss-ai-xr/pi-openspec](https://github.com/tobias-weiss-ai-xr/pi-openspec)
|
|
241
|
+
|
|
242
|
+
Created by [Raphael Bahat](https://earendil.works)
|
package/dist/cache.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { OpenSpecTarget } from "./types.js";
|
|
2
|
+
export declare function targetToKey(target: OpenSpecTarget): string;
|
|
3
|
+
export declare class OpenSpecContextCache {
|
|
4
|
+
private map;
|
|
5
|
+
get(target: OpenSpecTarget): string | null;
|
|
6
|
+
set(target: OpenSpecTarget, context: string): void;
|
|
7
|
+
has(target: OpenSpecTarget): boolean;
|
|
8
|
+
delete(target: OpenSpecTarget): boolean;
|
|
9
|
+
clear(): void;
|
|
10
|
+
size(): number;
|
|
11
|
+
}
|
package/dist/cache.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import * as path from "path";
|
|
2
|
+
export function targetToKey(target) {
|
|
3
|
+
switch (target.type) {
|
|
4
|
+
case "root":
|
|
5
|
+
return "root:" + path.resolve(target.path);
|
|
6
|
+
case "store":
|
|
7
|
+
return "store:" + target.id;
|
|
8
|
+
default:
|
|
9
|
+
// Exhaustiveness check: if we reach here, there's an unhandled case
|
|
10
|
+
const exhaustiveCheck = target;
|
|
11
|
+
throw new Error(`Unknown target type: ${exhaustiveCheck}`);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export class OpenSpecContextCache {
|
|
15
|
+
map = new Map();
|
|
16
|
+
get(target) {
|
|
17
|
+
const key = targetToKey(target);
|
|
18
|
+
return this.map.get(key) ?? null;
|
|
19
|
+
}
|
|
20
|
+
set(target, context) {
|
|
21
|
+
const key = targetToKey(target);
|
|
22
|
+
this.map.set(key, context);
|
|
23
|
+
}
|
|
24
|
+
has(target) {
|
|
25
|
+
const key = targetToKey(target);
|
|
26
|
+
return this.map.has(key);
|
|
27
|
+
}
|
|
28
|
+
delete(target) {
|
|
29
|
+
const key = targetToKey(target);
|
|
30
|
+
return this.map.delete(key);
|
|
31
|
+
}
|
|
32
|
+
clear() {
|
|
33
|
+
this.map.clear();
|
|
34
|
+
}
|
|
35
|
+
size() {
|
|
36
|
+
return this.map.size;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import type { OpenSpecTarget, PiExecContext } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* Finds the OpenSpec root directory by traversing upwards from cwd
|
|
5
|
+
* looking for openspec/config.yaml
|
|
6
|
+
*/
|
|
7
|
+
export declare function findOpenSpecRoot(cwd: string, existsFn?: typeof fs.existsSync): string | null;
|
|
8
|
+
/**
|
|
9
|
+
* Finds a store ID that matches the given cwd by querying openspec store list
|
|
10
|
+
*/
|
|
11
|
+
export declare function findStoreId(pi: PiExecContext, cwd: string): Promise<string | null>;
|
|
12
|
+
/**
|
|
13
|
+
* Detects the OpenSpec target (root or store) for the given directory
|
|
14
|
+
*/
|
|
15
|
+
export declare function detectOpenSpecTarget(pi: PiExecContext, cwd: string): Promise<OpenSpecTarget | null>;
|
package/dist/detector.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import * as path from "path";
|
|
2
|
+
import * as fs from "fs";
|
|
3
|
+
import { runOpenSpec } from "./runner.js";
|
|
4
|
+
/**
|
|
5
|
+
* Finds the OpenSpec root directory by traversing upwards from cwd
|
|
6
|
+
* looking for openspec/config.yaml
|
|
7
|
+
*/
|
|
8
|
+
export function findOpenSpecRoot(cwd, existsFn = fs.existsSync) {
|
|
9
|
+
let current = path.resolve(cwd);
|
|
10
|
+
while (true) {
|
|
11
|
+
const configPath = path.join(current, "openspec", "config.yaml");
|
|
12
|
+
if (existsFn(configPath)) {
|
|
13
|
+
return current;
|
|
14
|
+
}
|
|
15
|
+
const parent = path.dirname(current);
|
|
16
|
+
if (parent === current) {
|
|
17
|
+
// Reached root directory (/ on Unix, C:\ on Windows)
|
|
18
|
+
break;
|
|
19
|
+
}
|
|
20
|
+
current = parent;
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Finds a store ID that matches the given cwd by querying openspec store list
|
|
26
|
+
*/
|
|
27
|
+
export async function findStoreId(pi, cwd) {
|
|
28
|
+
// Execute openspec store list --json command
|
|
29
|
+
const output = await runOpenSpec(pi, ["store", "list", "--json"], cwd);
|
|
30
|
+
if (output === null) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
// Parse JSON output
|
|
34
|
+
let parsed;
|
|
35
|
+
try {
|
|
36
|
+
parsed = JSON.parse(output);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
// Verify stores is an array
|
|
42
|
+
if (!Array.isArray(parsed.stores)) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
// Resolve cwd to absolute path
|
|
46
|
+
const resolvedCwd = path.resolve(cwd);
|
|
47
|
+
// Match against each store's root/path/dir
|
|
48
|
+
for (const store of parsed.stores) {
|
|
49
|
+
const storeRoot = store.root || store.path || store.dir;
|
|
50
|
+
if (!storeRoot)
|
|
51
|
+
continue;
|
|
52
|
+
const resolvedRoot = path.resolve(storeRoot);
|
|
53
|
+
// Check for exact match or subdirectory match
|
|
54
|
+
if (resolvedCwd === resolvedRoot ||
|
|
55
|
+
resolvedCwd.startsWith(resolvedRoot + path.sep)) {
|
|
56
|
+
return store.id || null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Detects the OpenSpec target (root or store) for the given directory
|
|
63
|
+
*/
|
|
64
|
+
export async function detectOpenSpecTarget(pi, cwd) {
|
|
65
|
+
// First try to find an OpenSpec root
|
|
66
|
+
const root = findOpenSpecRoot(cwd);
|
|
67
|
+
if (root) {
|
|
68
|
+
return {
|
|
69
|
+
type: "root",
|
|
70
|
+
path: root,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
// Then try to find a store ID
|
|
74
|
+
const storeId = await findStoreId(pi, cwd);
|
|
75
|
+
if (storeId) {
|
|
76
|
+
return {
|
|
77
|
+
type: "store",
|
|
78
|
+
id: storeId,
|
|
79
|
+
cwd,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { OpenSpecContextCache } from "./cache.js";
|
|
3
|
+
export declare const contextCache: OpenSpecContextCache;
|
|
4
|
+
/**
|
|
5
|
+
* Creates an extension factory with optional custom cache
|
|
6
|
+
* @param cache Optional custom cache instance (defaults to shared contextCache)
|
|
7
|
+
*/
|
|
8
|
+
export declare function createExtension(cache?: OpenSpecContextCache): (pi: ExtensionAPI) => void;
|
|
9
|
+
/**
|
|
10
|
+
* Default extension function that registers the before_agent_start listener
|
|
11
|
+
*/
|
|
12
|
+
export default function (pi: ExtensionAPI): void;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { detectOpenSpecTarget } from "./detector.js";
|
|
2
|
+
import { runOpenSpec, sanitizeContext } from "./runner.js";
|
|
3
|
+
import { OpenSpecContextCache } from "./cache.js";
|
|
4
|
+
// Global cache instance for the extension
|
|
5
|
+
export const contextCache = new OpenSpecContextCache();
|
|
6
|
+
/**
|
|
7
|
+
* Creates a before_agent_start handler with the given cache and PI instance
|
|
8
|
+
* @param pi The ExtensionAPI instance
|
|
9
|
+
* @param cache The cache instance to use
|
|
10
|
+
*/
|
|
11
|
+
function createBeforeAgentStartHandler(pi, cache) {
|
|
12
|
+
return async (event, ctx) => {
|
|
13
|
+
// Get working directory with fallback chain
|
|
14
|
+
const cwd = event.systemPromptOptions?.cwd ||
|
|
15
|
+
ctx?.cwd ||
|
|
16
|
+
process.cwd();
|
|
17
|
+
// Detect OpenSpec target (root or store)
|
|
18
|
+
const target = await detectOpenSpecTarget(pi, cwd);
|
|
19
|
+
if (!target) {
|
|
20
|
+
return {};
|
|
21
|
+
}
|
|
22
|
+
// Check cache first
|
|
23
|
+
let contextText = cache.get(target);
|
|
24
|
+
// If not cached, run openspec to retrieve context
|
|
25
|
+
if (!contextText) {
|
|
26
|
+
if (target.type === "root") {
|
|
27
|
+
contextText = await runOpenSpec(pi, ["context"], target.path);
|
|
28
|
+
}
|
|
29
|
+
else if (target.type === "store") {
|
|
30
|
+
contextText = await runOpenSpec(pi, ["context", "--store", target.id], target.cwd);
|
|
31
|
+
}
|
|
32
|
+
// Cache the result if we got one
|
|
33
|
+
if (contextText) {
|
|
34
|
+
cache.set(target, contextText);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
// Return empty if no context available
|
|
38
|
+
if (!contextText) {
|
|
39
|
+
return {};
|
|
40
|
+
}
|
|
41
|
+
// Sanitize the context (strip ANSI codes, trim whitespace)
|
|
42
|
+
const cleaned = sanitizeContext(contextText);
|
|
43
|
+
// Return empty if sanitized content is empty
|
|
44
|
+
if (!cleaned) {
|
|
45
|
+
return {};
|
|
46
|
+
}
|
|
47
|
+
// Inject context into system prompt
|
|
48
|
+
return {
|
|
49
|
+
systemPrompt: event.systemPrompt + "\n\n[OpenSpec context]\n" + cleaned,
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Creates an extension factory with optional custom cache
|
|
55
|
+
* @param cache Optional custom cache instance (defaults to shared contextCache)
|
|
56
|
+
*/
|
|
57
|
+
export function createExtension(cache) {
|
|
58
|
+
const cacheInstance = cache || contextCache;
|
|
59
|
+
return (pi) => {
|
|
60
|
+
pi.on("before_agent_start", createBeforeAgentStartHandler(pi, cacheInstance));
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Default extension function that registers the before_agent_start listener
|
|
65
|
+
*/
|
|
66
|
+
export default function (pi) {
|
|
67
|
+
const handler = createBeforeAgentStartHandler(pi, contextCache);
|
|
68
|
+
pi.on("before_agent_start", handler);
|
|
69
|
+
}
|
package/dist/runner.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { PiExecContext } from "./types.js";
|
|
2
|
+
export declare const DEFAULT_TIMEOUT_MS = 10000;
|
|
3
|
+
/**
|
|
4
|
+
* Strips ANSI color/control codes and trims whitespace
|
|
5
|
+
*/
|
|
6
|
+
export declare function stripAnsi(text: string): string;
|
|
7
|
+
/**
|
|
8
|
+
* Sanitizes context output for injection (alias for stripAnsi)
|
|
9
|
+
*/
|
|
10
|
+
export declare function sanitizeContext(text: string): string;
|
|
11
|
+
/**
|
|
12
|
+
* Executes openspec CLI command and returns output or null on failure
|
|
13
|
+
*/
|
|
14
|
+
export declare function runOpenSpec(pi: PiExecContext, args: string[], cwd: string, timeoutMs?: number): Promise<string | null>;
|
package/dist/runner.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export const DEFAULT_TIMEOUT_MS = 10000;
|
|
2
|
+
/**
|
|
3
|
+
* Strips ANSI color/control codes and trims whitespace
|
|
4
|
+
*/
|
|
5
|
+
export function stripAnsi(text) {
|
|
6
|
+
return text.replace(/\u001b\[[0-9;]*m/g, "").trim();
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Sanitizes context output for injection (alias for stripAnsi)
|
|
10
|
+
*/
|
|
11
|
+
export function sanitizeContext(text) {
|
|
12
|
+
return stripAnsi(text);
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Executes openspec CLI command and returns output or null on failure
|
|
16
|
+
*/
|
|
17
|
+
export async function runOpenSpec(pi, args, cwd, timeoutMs = DEFAULT_TIMEOUT_MS) {
|
|
18
|
+
try {
|
|
19
|
+
const result = await pi.exec("openspec", args, {
|
|
20
|
+
timeout: timeoutMs,
|
|
21
|
+
cwd,
|
|
22
|
+
});
|
|
23
|
+
// Return null if exit code is not 0
|
|
24
|
+
if (result.code !== 0) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
// Strip ANSI codes and trim
|
|
28
|
+
const cleaned = stripAnsi(result.stdout);
|
|
29
|
+
// Return null if output is empty
|
|
30
|
+
if (!cleaned) {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
return cleaned;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// Catch all exceptions (ENOENT, timeout, etc.) and return null
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export type OpenSpecTarget = {
|
|
2
|
+
type: "root";
|
|
3
|
+
path: string;
|
|
4
|
+
} | {
|
|
5
|
+
type: "store";
|
|
6
|
+
id: string;
|
|
7
|
+
cwd: string;
|
|
8
|
+
};
|
|
9
|
+
export interface OpenSpecStoreEntry {
|
|
10
|
+
id?: string;
|
|
11
|
+
root?: string;
|
|
12
|
+
path?: string;
|
|
13
|
+
dir?: string;
|
|
14
|
+
}
|
|
15
|
+
export interface OpenSpecStoreListOutput {
|
|
16
|
+
stores?: OpenSpecStoreEntry[];
|
|
17
|
+
}
|
|
18
|
+
export interface OpenSpecExecutionResult {
|
|
19
|
+
stdout: string;
|
|
20
|
+
stderr: string;
|
|
21
|
+
code: number;
|
|
22
|
+
}
|
|
23
|
+
export interface PiExecContext {
|
|
24
|
+
exec(cmd: string, args: string[], opts?: {
|
|
25
|
+
timeout?: number;
|
|
26
|
+
cwd?: string;
|
|
27
|
+
}): Promise<OpenSpecExecutionResult>;
|
|
28
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"type": "module",
|
|
3
|
+
"name": "pi-openspec-context",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"description": "Lightweight Pi extension for automatic OpenSpec context injection into system prompts",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Raphael Bahat <raphael@earendil.works>",
|
|
8
|
+
"keywords": [
|
|
9
|
+
"pi-package",
|
|
10
|
+
"pi",
|
|
11
|
+
"openspec",
|
|
12
|
+
"extension",
|
|
13
|
+
"context",
|
|
14
|
+
"spec-driven-development"
|
|
15
|
+
],
|
|
16
|
+
"main": "dist/index.js",
|
|
17
|
+
"types": "dist/index.d.ts",
|
|
18
|
+
"files": [
|
|
19
|
+
"dist",
|
|
20
|
+
"README.md",
|
|
21
|
+
"CHANGELOG.md",
|
|
22
|
+
"LICENSE"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "tsc -p tsconfig.build.json",
|
|
26
|
+
"typecheck": "tsc -p tsconfig.build.json --noEmit",
|
|
27
|
+
"test": "vitest run",
|
|
28
|
+
"test:watch": "vitest",
|
|
29
|
+
"test:e2e": "vitest run docker/test-e2e.test.ts",
|
|
30
|
+
"lint": "eslint ."
|
|
31
|
+
},
|
|
32
|
+
"pi": {
|
|
33
|
+
"extensions": [
|
|
34
|
+
"./dist/index.js"
|
|
35
|
+
]
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@earendil-works/pi-coding-agent": "^0.84.0",
|
|
42
|
+
"@types/node": "^20.0.0",
|
|
43
|
+
"@typescript-eslint/eslint-plugin": "^8.67.0",
|
|
44
|
+
"@typescript-eslint/parser": "^8.67.0",
|
|
45
|
+
"eslint": "^9.0.0",
|
|
46
|
+
"typescript": "^5.4.0",
|
|
47
|
+
"typescript-eslint": "^8.67.0",
|
|
48
|
+
"vitest": "^1.6.0"
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"@gaodes/pi-test-harness": "file:../pi-test-harness"
|
|
52
|
+
}
|
|
53
|
+
}
|