@fpr1m3/opencode-pai-plugin 1.0.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/LICENSE +21 -0
- package/README.md +79 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +226 -0
- package/dist/lib/context-loader.d.ts +1 -0
- package/dist/lib/context-loader.js +21 -0
- package/dist/lib/logger.d.ts +35 -0
- package/dist/lib/logger.js +264 -0
- package/dist/lib/metadata-extraction.d.ts +51 -0
- package/dist/lib/metadata-extraction.js +130 -0
- package/dist/lib/notifier.d.ts +1 -0
- package/dist/lib/notifier.js +12 -0
- package/dist/lib/paths.d.ts +22 -0
- package/dist/lib/paths.js +55 -0
- package/dist/lib/redaction.d.ts +5 -0
- package/dist/lib/redaction.js +75 -0
- package/dist/lib/security.d.ts +10 -0
- package/dist/lib/security.js +57 -0
- package/package.json +33 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 fprime
|
|
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.
|
package/README.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# OpenCode PAI Plugin
|
|
2
|
+
|
|
3
|
+
A native OpenCode plugin that implements the **Personal AI Infrastructure (PAI)** logic, replacing legacy hook scripts with a cohesive, lifecycle-aware system.
|
|
4
|
+
|
|
5
|
+
## Credits & Inspiration
|
|
6
|
+
|
|
7
|
+
This project is an OpenCode-compatible clone of the hook system from **Dan Miessler's** [Personal AI Infrastructure (PAI)](https://github.com/danielmiessler/Personal_AI_Infrastructure) project. A massive shout out to Dan for the architectural vision and the original PAI patterns that this plugin brings to the OpenCode ecosystem.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
**Disclaimer**: This project is independent and is **not** supported by, affiliated with, or endorsed by Dan Miessler or the OpenCode team.
|
|
12
|
+
|
|
13
|
+
## Features
|
|
14
|
+
|
|
15
|
+
### 1. Identity & Context Injection
|
|
16
|
+
* **Core Skill Loading**: Automatically injects your `skills/core/SKILL.md` (from `PAI_DIR`) into the system prompt.
|
|
17
|
+
* **Dynamic Substitution**: Supports placeholders like `{{DA}}`, `{{DA_COLOR}}`, and `{{ENGINEER_NAME}}` for personalized interactions.
|
|
18
|
+
* **Project Requirements**: Automatically detects and loads `.opencode/dynamic-requirements.md` from your current project, allowing for task-specific instructions.
|
|
19
|
+
|
|
20
|
+
### 2. Intelligent History & Logging
|
|
21
|
+
* **Real-time Event Capture**: Logs all tool calls and SDK events to `PAI_DIR/history/raw-outputs` in an analytics-ready JSONL format.
|
|
22
|
+
* **Session Summaries**: Generates human-readable Markdown summaries in `PAI_DIR/history/sessions` at the end of every session, tracking files modified, tools used, and commands executed.
|
|
23
|
+
* **Agent Mapping**: Tracks session-to-agent relationships (e.g., mapping a subagent session to its specialized type).
|
|
24
|
+
|
|
25
|
+
### 3. Security & Safety
|
|
26
|
+
* **Security Validator**: A built-in firewall that scans Bash commands for dangerous patterns (reverse shells, recursive deletions, prompt injections) via the `permission.ask` hook.
|
|
27
|
+
* **Safe Confirmations**: Automatically triggers a confirmation prompt for risky but potentially legitimate operations like forced Git pushes.
|
|
28
|
+
|
|
29
|
+
### 4. Interactive Feedback
|
|
30
|
+
* **Real-time Tab Titles**: Updates your terminal tab title *instantly* when a tool starts (e.g., `Running bash...`, `Editing index.ts...`).
|
|
31
|
+
* **Post-Task Summaries**: Updates the tab title with a concise summary of what was accomplished when a task is completed.
|
|
32
|
+
|
|
33
|
+
## Configuration
|
|
34
|
+
|
|
35
|
+
The plugin centers around the `PAI_DIR` environment variable.
|
|
36
|
+
|
|
37
|
+
| Variable | Description | Default |
|
|
38
|
+
| :--- | :--- | :--- |
|
|
39
|
+
| `PAI_DIR` | Root directory for PAI skills and history | `$XDG_CONFIG_HOME/opencode` |
|
|
40
|
+
| `DA` | Name of your Digital Assistant | `PAI` |
|
|
41
|
+
| `ENGINEER_NAME` | Your name/identity | `Operator` |
|
|
42
|
+
| `DA_COLOR` | UI color theme for your DA | `blue` |
|
|
43
|
+
|
|
44
|
+
## Quick Start
|
|
45
|
+
|
|
46
|
+
Add the plugin to your global `opencode.json` configuration file (typically located at `~/.config/opencode/opencode.json`). OpenCode will automatically install the plugin from GitHub on its next startup.
|
|
47
|
+
|
|
48
|
+
```json
|
|
49
|
+
{
|
|
50
|
+
"plugins": [
|
|
51
|
+
"github:fpr1m3/opencode-pai-plugin"
|
|
52
|
+
]
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Upon first run, the plugin will automatically:
|
|
57
|
+
1. Detect or create your `PAI_DIR` (default: `$XDG_CONFIG_HOME/opencode`).
|
|
58
|
+
2. Initialize the required directory structure for skills and history.
|
|
59
|
+
3. Create a default `SKILL.md` core identity if one does not exist.
|
|
60
|
+
|
|
61
|
+
## Development & Testing
|
|
62
|
+
|
|
63
|
+
We provide scripts to verify the plugin in a pristine environment:
|
|
64
|
+
|
|
65
|
+
* `./scripts/create-test-env.sh`: Creates a fresh, isolated OpenCode project for testing.
|
|
66
|
+
* `./scripts/test-full-flow.sh`: Runs a complete E2E verification of the plugin lifecycle.
|
|
67
|
+
|
|
68
|
+
## Roadmap / TODO
|
|
69
|
+
|
|
70
|
+
- [ ] **Voice Server Integration**: Implementation of the PAI voice notification server to provide audible feedback on task completion.
|
|
71
|
+
- [ ] **Enhanced Agent Mapping**: More granular tracking of subagent state transitions.
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
**Note**: This plugin is designed to work with the PAI ecosystem. While it auto-initializes a basic structure, you can customize your identity by editing `$PAI_DIR/skills/core/SKILL.md`.
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
Vibe coded with ❤️ by a mix of **Claude Code** and **OpenCode**.
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { Logger } from './lib/logger';
|
|
2
|
+
import { PAI_DIR } from './lib/paths';
|
|
3
|
+
import { validateCommand } from './lib/security';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'fs';
|
|
6
|
+
/**
|
|
7
|
+
* Ensure the PAI directory structure exists.
|
|
8
|
+
*/
|
|
9
|
+
function ensurePAIStructure() {
|
|
10
|
+
const dirs = [
|
|
11
|
+
join(PAI_DIR, 'skills', 'core'),
|
|
12
|
+
join(PAI_DIR, 'history', 'raw-outputs'),
|
|
13
|
+
join(PAI_DIR, 'history', 'sessions'),
|
|
14
|
+
join(PAI_DIR, 'history', 'system-logs'),
|
|
15
|
+
];
|
|
16
|
+
for (const dir of dirs) {
|
|
17
|
+
if (!existsSync(dir)) {
|
|
18
|
+
try {
|
|
19
|
+
mkdirSync(dir, { recursive: true });
|
|
20
|
+
console.log(`PAI: Created directory ${dir}`);
|
|
21
|
+
}
|
|
22
|
+
catch (e) {
|
|
23
|
+
console.error(`PAI: Failed to create directory ${dir}:`, e);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const coreSkillPath = join(PAI_DIR, 'skills', 'core', 'SKILL.md');
|
|
28
|
+
if (!existsSync(coreSkillPath)) {
|
|
29
|
+
const defaultSkill = `# PAI Core Identity
|
|
30
|
+
You are {{DA}}, a Personal AI Infrastructure.
|
|
31
|
+
Your primary engineer is {{ENGINEER_NAME}}.
|
|
32
|
+
`;
|
|
33
|
+
try {
|
|
34
|
+
writeFileSync(coreSkillPath, defaultSkill, 'utf-8');
|
|
35
|
+
console.log(`PAI: Created default SKILL.md at ${coreSkillPath}`);
|
|
36
|
+
}
|
|
37
|
+
catch (e) {
|
|
38
|
+
console.error(`PAI: Failed to create default SKILL.md:`, e);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Check if an event should be skipped to prevent recursive logging.
|
|
44
|
+
*/
|
|
45
|
+
function shouldSkipEvent(event, sessionId) {
|
|
46
|
+
// Skip file watcher events for raw-outputs directory or history directory
|
|
47
|
+
if (event.type === 'file.watcher.updated') {
|
|
48
|
+
const file = event.properties?.file;
|
|
49
|
+
if (typeof file === 'string' && (file.includes('raw-outputs/') || file.includes('history/'))) {
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
// Skip message.updated events with self-referencing diffs
|
|
54
|
+
if (sessionId && event.type === 'message.updated') {
|
|
55
|
+
const info = event.properties?.info;
|
|
56
|
+
const diffs = info?.summary?.diffs;
|
|
57
|
+
if (Array.isArray(diffs)) {
|
|
58
|
+
const hasSelfRef = diffs.some((diff) => typeof diff?.file === 'string' &&
|
|
59
|
+
diff.file.includes('history/') &&
|
|
60
|
+
diff.file.includes(sessionId));
|
|
61
|
+
if (hasSelfRef)
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Generate a 4-word tab title summarizing what was done
|
|
69
|
+
*/
|
|
70
|
+
function generateTabTitle(completedLine) {
|
|
71
|
+
if (completedLine) {
|
|
72
|
+
const cleanCompleted = completedLine
|
|
73
|
+
.replace(/\*+/g, '')
|
|
74
|
+
.replace(/\[.*?\]/g, '')
|
|
75
|
+
.replace(/🎯\s*COMPLETED:\s*/gi, '')
|
|
76
|
+
.replace(/COMPLETED:\s*/gi, '')
|
|
77
|
+
.trim();
|
|
78
|
+
const words = cleanCompleted.split(/\s+/)
|
|
79
|
+
.filter(word => word.length > 2 && !['the', 'and', 'but', 'for', 'are', 'with', 'this', 'that'].includes(word.toLowerCase()))
|
|
80
|
+
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase());
|
|
81
|
+
if (words.length >= 2) {
|
|
82
|
+
return words.slice(0, 4).join(' ');
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return 'PAI Task Done';
|
|
86
|
+
}
|
|
87
|
+
export const PAIPlugin = async ({ worktree }) => {
|
|
88
|
+
let logger = null;
|
|
89
|
+
let currentSessionId = null;
|
|
90
|
+
// Auto-initialize PAI infrastructure if needed
|
|
91
|
+
ensurePAIStructure();
|
|
92
|
+
// Load CORE skill content from $PAI_DIR/skills/core/SKILL.md
|
|
93
|
+
let coreSkillContent = '';
|
|
94
|
+
const coreSkillPath = join(PAI_DIR, 'skills', 'core', 'SKILL.md');
|
|
95
|
+
if (existsSync(coreSkillPath)) {
|
|
96
|
+
try {
|
|
97
|
+
coreSkillContent = readFileSync(coreSkillPath, 'utf-8');
|
|
98
|
+
}
|
|
99
|
+
catch (e) {
|
|
100
|
+
console.error('PAI: Failed to read CORE skill:', e);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
// Dynamic Variable Substitution for System Prompt
|
|
104
|
+
const daName = process.env.DA || 'PAI';
|
|
105
|
+
const engineerName = process.env.ENGINEER_NAME || 'Operator';
|
|
106
|
+
const daColor = process.env.DA_COLOR || 'blue';
|
|
107
|
+
const personalizedSkillContent = coreSkillContent
|
|
108
|
+
.replace(/\{\{DA\}\}/g, daName)
|
|
109
|
+
.replace(/\{\{DA_COLOR\}\}/g, daColor)
|
|
110
|
+
.replace(/\{\{ENGINEER_NAME\}\}/g, engineerName);
|
|
111
|
+
// Load project-specific dynamic requirements if they exist
|
|
112
|
+
let projectRequirements = '';
|
|
113
|
+
const projectReqPath = join(worktree, '.opencode', 'dynamic-requirements.md');
|
|
114
|
+
if (existsSync(projectReqPath)) {
|
|
115
|
+
try {
|
|
116
|
+
projectRequirements = readFileSync(projectReqPath, 'utf-8');
|
|
117
|
+
console.log(`PAI: Loaded project requirements from ${projectReqPath}`);
|
|
118
|
+
}
|
|
119
|
+
catch (e) {
|
|
120
|
+
console.error('PAI: Failed to read project requirements:', e);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
console.log(`PAI Plugin Initialized (Personalized for ${engineerName} & ${daName})`);
|
|
124
|
+
// Ready to serve
|
|
125
|
+
const hooks = {
|
|
126
|
+
event: async ({ event }) => {
|
|
127
|
+
const anyEvent = event;
|
|
128
|
+
// Initialize Logger on session creation
|
|
129
|
+
if (event.type === 'session.created') {
|
|
130
|
+
currentSessionId = anyEvent.properties.info.id;
|
|
131
|
+
logger = new Logger(currentSessionId);
|
|
132
|
+
}
|
|
133
|
+
// Handle generic event logging
|
|
134
|
+
if (logger &&
|
|
135
|
+
event.type !== 'message.part.updated' &&
|
|
136
|
+
!shouldSkipEvent(event, currentSessionId)) {
|
|
137
|
+
logger.logOpenCodeEvent(event);
|
|
138
|
+
}
|
|
139
|
+
// Handle real-time tab title updates (Pre-Tool Use)
|
|
140
|
+
if (anyEvent.type === 'tool.call') {
|
|
141
|
+
const props = anyEvent.properties;
|
|
142
|
+
if (props?.tool === 'Bash' || props?.tool === 'bash') {
|
|
143
|
+
const cmd = props?.input?.command?.split(/\s+/)[0] || 'bash';
|
|
144
|
+
process.stderr.write(`\x1b]0;Running ${cmd}...\x07`);
|
|
145
|
+
}
|
|
146
|
+
else if (props?.tool === 'Edit' || props?.tool === 'Write') {
|
|
147
|
+
const file = props?.input?.file_path?.split('/').pop() || 'file';
|
|
148
|
+
process.stderr.write(`\x1b]0;Editing ${file}...\x07`);
|
|
149
|
+
}
|
|
150
|
+
else if (props?.tool === 'Task') {
|
|
151
|
+
const type = props?.input?.subagent_type || 'agent';
|
|
152
|
+
process.stderr.write(`\x1b]0;Agent: ${type}...\x07`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// Handle assistant completion (Tab Titles)
|
|
156
|
+
if (event.type === 'message.updated') {
|
|
157
|
+
const info = anyEvent.properties?.info;
|
|
158
|
+
if (info?.author === 'assistant' && info?.content) {
|
|
159
|
+
const content = typeof info.content === 'string' ? info.content : '';
|
|
160
|
+
// Look for COMPLETED: line (can be prefaced by 🎯 or just text)
|
|
161
|
+
const completedMatch = content.match(/(?:🎯\s*)?COMPLETED:\s*(.+?)(?:\n|$)/i);
|
|
162
|
+
if (completedMatch) {
|
|
163
|
+
const completedLine = completedMatch[1].trim();
|
|
164
|
+
// Set Tab Title
|
|
165
|
+
const tabTitle = generateTabTitle(completedLine);
|
|
166
|
+
process.stderr.write(`\x1b]0;${tabTitle}\x07`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
// Handle session deletion / end
|
|
171
|
+
if (event.type === 'session.deleted') {
|
|
172
|
+
if (logger) {
|
|
173
|
+
await logger.generateSessionSummary();
|
|
174
|
+
logger.flush();
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
"tool.execute.after": async (input, output) => {
|
|
179
|
+
if (logger) {
|
|
180
|
+
logger.logToolExecution(input, output);
|
|
181
|
+
}
|
|
182
|
+
},
|
|
183
|
+
"permission.ask": async (permission) => {
|
|
184
|
+
if (permission.tool === 'Bash' || permission.tool === 'bash') {
|
|
185
|
+
const command = permission.arguments?.command || '';
|
|
186
|
+
const result = validateCommand(command);
|
|
187
|
+
if (result.status === 'deny') {
|
|
188
|
+
return {
|
|
189
|
+
status: 'deny',
|
|
190
|
+
feedback: result.feedback
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
if (result.status === 'ask') {
|
|
194
|
+
return { status: 'ask' };
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return { status: 'allow' };
|
|
198
|
+
},
|
|
199
|
+
/**
|
|
200
|
+
* Experimental: Inject PAI Core identity into the system prompt
|
|
201
|
+
*/
|
|
202
|
+
...{
|
|
203
|
+
"experimental.chat.system.transform": async (input, output) => {
|
|
204
|
+
const skipAgents = ['title', 'summary', 'compaction'];
|
|
205
|
+
if (input.agent && skipAgents.includes(input.agent.name)) {
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (personalizedSkillContent && output.system && output.system.length > 0) {
|
|
209
|
+
// system[0] is typically the caching-sensitive header, so we inject into system[1] or push
|
|
210
|
+
let injection = `\n\n--- PAI CORE IDENTITY ---\n${personalizedSkillContent}\n--- END PAI CORE IDENTITY ---\n\n`;
|
|
211
|
+
if (projectRequirements) {
|
|
212
|
+
injection += `\n\n--- PROJECT DYNAMIC REQUIREMENTS ---\n${projectRequirements}\n--- END PROJECT DYNAMIC REQUIREMENTS ---\n\n`;
|
|
213
|
+
}
|
|
214
|
+
if (output.system.length >= 2) {
|
|
215
|
+
output.system[1] = injection + output.system[1];
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
output.system.push(injection);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
return hooks;
|
|
225
|
+
};
|
|
226
|
+
export default PAIPlugin;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function getCoreContext(baseDir: string, env: NodeJS.ProcessEnv): string;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { getSkillsDir } from './paths';
|
|
4
|
+
export function getCoreContext(baseDir, env) {
|
|
5
|
+
const skillsDir = getSkillsDir(baseDir);
|
|
6
|
+
const coreSkillPath = join(skillsDir, 'core', 'SKILL.md');
|
|
7
|
+
if (!existsSync(coreSkillPath)) {
|
|
8
|
+
console.warn(`Core skill file not found at ${coreSkillPath}`);
|
|
9
|
+
return '';
|
|
10
|
+
}
|
|
11
|
+
let content = readFileSync(coreSkillPath, 'utf-8');
|
|
12
|
+
// Variable replacement
|
|
13
|
+
const replacements = {
|
|
14
|
+
'{{DA}}': env.DA_NAME || 'PAI',
|
|
15
|
+
'{{ENGINEER_NAME}}': env.USER_NAME || env.USER || 'Engineer',
|
|
16
|
+
};
|
|
17
|
+
for (const [key, value] of Object.entries(replacements)) {
|
|
18
|
+
content = content.replaceAll(key, value);
|
|
19
|
+
}
|
|
20
|
+
return content;
|
|
21
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { Event } from '@opencode-ai/sdk';
|
|
2
|
+
export declare class Logger {
|
|
3
|
+
private sessionId;
|
|
4
|
+
private toolsUsed;
|
|
5
|
+
private filesChanged;
|
|
6
|
+
private commandsExecuted;
|
|
7
|
+
private startTime;
|
|
8
|
+
constructor(sessionId: string);
|
|
9
|
+
private getPSTTimestamp;
|
|
10
|
+
private getEventsFilePath;
|
|
11
|
+
private getSessionMappingFile;
|
|
12
|
+
private getAgentForSession;
|
|
13
|
+
private setAgentForSession;
|
|
14
|
+
logEvent(event: Event): void;
|
|
15
|
+
logOpenCodeEvent(event: Event): void;
|
|
16
|
+
/**
|
|
17
|
+
* Log tool execution from tool.execute.after hook
|
|
18
|
+
*
|
|
19
|
+
* Input structure: { tool: string; sessionID: string; callID: string }
|
|
20
|
+
* Output structure: { title: string; output: string; metadata: any }
|
|
21
|
+
*/
|
|
22
|
+
logToolExecution(input: {
|
|
23
|
+
tool: string;
|
|
24
|
+
sessionID: string;
|
|
25
|
+
callID: string;
|
|
26
|
+
}, output: {
|
|
27
|
+
title: string;
|
|
28
|
+
output: string;
|
|
29
|
+
metadata: any;
|
|
30
|
+
}): void;
|
|
31
|
+
generateSessionSummary(): Promise<string | null>;
|
|
32
|
+
logError(context: string, error: any): void;
|
|
33
|
+
private writeEvent;
|
|
34
|
+
flush(): void;
|
|
35
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, appendFileSync, readFileSync, writeFileSync } from 'fs';
|
|
2
|
+
import { dirname, join } from 'path';
|
|
3
|
+
import { PAI_DIR, getHistoryFilePath, HISTORY_DIR } from './paths';
|
|
4
|
+
import { enrichEventWithAgentMetadata, isAgentSpawningCall } from './metadata-extraction';
|
|
5
|
+
import { redactString, redactObject } from './redaction';
|
|
6
|
+
export class Logger {
|
|
7
|
+
sessionId;
|
|
8
|
+
toolsUsed = new Set();
|
|
9
|
+
filesChanged = new Set();
|
|
10
|
+
commandsExecuted = [];
|
|
11
|
+
startTime = Date.now();
|
|
12
|
+
constructor(sessionId) {
|
|
13
|
+
this.sessionId = sessionId;
|
|
14
|
+
}
|
|
15
|
+
// Get PST timestamp
|
|
16
|
+
getPSTTimestamp() {
|
|
17
|
+
const date = new Date();
|
|
18
|
+
const pstDate = new Date(date.toLocaleString('en-US', { timeZone: process.env.TIME_ZONE || 'America/Los_Angeles' }));
|
|
19
|
+
const year = pstDate.getFullYear();
|
|
20
|
+
const month = String(pstDate.getMonth() + 1).padStart(2, '0');
|
|
21
|
+
const day = String(pstDate.getDate()).padStart(2, '0');
|
|
22
|
+
const hours = String(pstDate.getHours()).padStart(2, '0');
|
|
23
|
+
const minutes = String(pstDate.getMinutes()).padStart(2, '0');
|
|
24
|
+
const seconds = String(pstDate.getSeconds()).padStart(2, '0');
|
|
25
|
+
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds} PST`;
|
|
26
|
+
}
|
|
27
|
+
getEventsFilePath() {
|
|
28
|
+
const now = new Date();
|
|
29
|
+
const pstDate = new Date(now.toLocaleString('en-US', { timeZone: process.env.TIME_ZONE || 'America/Los_Angeles' }));
|
|
30
|
+
const year = pstDate.getFullYear();
|
|
31
|
+
const month = String(pstDate.getMonth() + 1).padStart(2, '0');
|
|
32
|
+
const day = String(pstDate.getDate()).padStart(2, '0');
|
|
33
|
+
const filename = `${year}-${month}-${day}_all-events.jsonl`;
|
|
34
|
+
const filePath = getHistoryFilePath('raw-outputs', filename);
|
|
35
|
+
const dir = dirname(filePath);
|
|
36
|
+
if (!existsSync(dir)) {
|
|
37
|
+
mkdirSync(dir, { recursive: true });
|
|
38
|
+
}
|
|
39
|
+
return filePath;
|
|
40
|
+
}
|
|
41
|
+
getSessionMappingFile() {
|
|
42
|
+
return join(PAI_DIR, 'agent-sessions.json');
|
|
43
|
+
}
|
|
44
|
+
getAgentForSession(sessionId) {
|
|
45
|
+
try {
|
|
46
|
+
const mappingFile = this.getSessionMappingFile();
|
|
47
|
+
if (existsSync(mappingFile)) {
|
|
48
|
+
const mappings = JSON.parse(readFileSync(mappingFile, 'utf-8'));
|
|
49
|
+
return mappings[sessionId] || 'pai';
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
// Ignore errors, default to pai
|
|
54
|
+
}
|
|
55
|
+
return 'pai';
|
|
56
|
+
}
|
|
57
|
+
setAgentForSession(sessionId, agentName) {
|
|
58
|
+
try {
|
|
59
|
+
const mappingFile = this.getSessionMappingFile();
|
|
60
|
+
let mappings = {};
|
|
61
|
+
if (existsSync(mappingFile)) {
|
|
62
|
+
mappings = JSON.parse(readFileSync(mappingFile, 'utf-8'));
|
|
63
|
+
}
|
|
64
|
+
mappings[sessionId] = agentName;
|
|
65
|
+
writeFileSync(mappingFile, JSON.stringify(mappings, null, 2), 'utf-8');
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
// Silently fail - don't block
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
logEvent(event) {
|
|
72
|
+
// Legacy method, not used much as we use logOpenCodeEvent
|
|
73
|
+
// But might be called from index.ts if I didn't update all calls
|
|
74
|
+
this.logOpenCodeEvent(event);
|
|
75
|
+
}
|
|
76
|
+
// Method to log generic OpenCode event
|
|
77
|
+
logOpenCodeEvent(event) {
|
|
78
|
+
const anyEvent = event;
|
|
79
|
+
const timestamp = anyEvent.timestamp || Date.now();
|
|
80
|
+
const payload = {
|
|
81
|
+
...anyEvent.properties,
|
|
82
|
+
timestamp: timestamp
|
|
83
|
+
};
|
|
84
|
+
// Track stats for summary
|
|
85
|
+
if (anyEvent.type === 'tool.call' || anyEvent.type === 'tool.execute.before') {
|
|
86
|
+
const props = anyEvent.properties;
|
|
87
|
+
const tool = props?.tool || props?.tool_name;
|
|
88
|
+
if (tool) {
|
|
89
|
+
this.toolsUsed.add(tool);
|
|
90
|
+
if (tool === 'Bash' || tool === 'bash') {
|
|
91
|
+
const command = props?.input?.command || props?.tool_input?.command;
|
|
92
|
+
if (command)
|
|
93
|
+
this.commandsExecuted.push(redactString(command));
|
|
94
|
+
}
|
|
95
|
+
if (['Edit', 'Write', 'edit', 'write'].includes(tool)) {
|
|
96
|
+
const path = props?.input?.file_path || props?.input?.path ||
|
|
97
|
+
props?.tool_input?.file_path || props?.tool_input?.path;
|
|
98
|
+
if (path)
|
|
99
|
+
this.filesChanged.add(path);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
this.writeEvent(anyEvent.type, redactObject(payload));
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Log tool execution from tool.execute.after hook
|
|
107
|
+
*
|
|
108
|
+
* Input structure: { tool: string; sessionID: string; callID: string }
|
|
109
|
+
* Output structure: { title: string; output: string; metadata: any }
|
|
110
|
+
*/
|
|
111
|
+
logToolExecution(input, output) {
|
|
112
|
+
const toolName = input.tool;
|
|
113
|
+
const sessionId = this.sessionId;
|
|
114
|
+
this.toolsUsed.add(toolName);
|
|
115
|
+
// Extract metadata - may contain additional tool info
|
|
116
|
+
const metadata = output.metadata || {};
|
|
117
|
+
// Logic to update agent mapping based on Task tool spawning subagents
|
|
118
|
+
if (toolName === 'Task' && metadata?.subagent_type) {
|
|
119
|
+
this.setAgentForSession(sessionId, metadata.subagent_type);
|
|
120
|
+
}
|
|
121
|
+
else if (toolName === 'subagent_stop' || toolName === 'stop') {
|
|
122
|
+
this.setAgentForSession(sessionId, 'pai');
|
|
123
|
+
}
|
|
124
|
+
const payload = {
|
|
125
|
+
tool_name: toolName,
|
|
126
|
+
tool_title: output.title,
|
|
127
|
+
tool_output: output.output,
|
|
128
|
+
tool_metadata: metadata,
|
|
129
|
+
call_id: input.callID,
|
|
130
|
+
};
|
|
131
|
+
this.writeEvent('ToolUse', redactObject(payload), toolName, metadata);
|
|
132
|
+
}
|
|
133
|
+
async generateSessionSummary() {
|
|
134
|
+
try {
|
|
135
|
+
const now = new Date();
|
|
136
|
+
const timestamp = now.toISOString()
|
|
137
|
+
.replace(/:/g, '')
|
|
138
|
+
.replace(/\..+/, '')
|
|
139
|
+
.replace('T', '-'); // YYYY-MM-DD-HHMMSS
|
|
140
|
+
const yearMonth = timestamp.substring(0, 7);
|
|
141
|
+
const date = timestamp.substring(0, 10);
|
|
142
|
+
const time = timestamp.substring(11).replace(/-/g, ':');
|
|
143
|
+
const duration = Math.round((Date.now() - this.startTime) / 60000);
|
|
144
|
+
const sessionDir = join(HISTORY_DIR, 'sessions', yearMonth);
|
|
145
|
+
if (!existsSync(sessionDir)) {
|
|
146
|
+
mkdirSync(sessionDir, { recursive: true });
|
|
147
|
+
}
|
|
148
|
+
const focus = this.filesChanged.size > 0 ? 'development' : 'research';
|
|
149
|
+
const filename = `${timestamp}_SESSION_${focus}.md`;
|
|
150
|
+
const filePath = join(sessionDir, filename);
|
|
151
|
+
const summary = `---
|
|
152
|
+
capture_type: SESSION
|
|
153
|
+
timestamp: ${new Date().toISOString()}
|
|
154
|
+
session_id: ${this.sessionId}
|
|
155
|
+
duration_minutes: ${duration}
|
|
156
|
+
executor: pai
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
# Session: ${focus}
|
|
160
|
+
|
|
161
|
+
**Date:** ${date}
|
|
162
|
+
**Time:** ${time}
|
|
163
|
+
**Session ID:** ${this.sessionId}
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
## Session Overview
|
|
168
|
+
|
|
169
|
+
**Focus:** ${focus === 'development' ? 'Software development and code modification' : 'Research and general assistance'}
|
|
170
|
+
**Duration:** ${duration} minutes
|
|
171
|
+
|
|
172
|
+
---
|
|
173
|
+
|
|
174
|
+
## Tools Used
|
|
175
|
+
|
|
176
|
+
${this.toolsUsed.size > 0 ? Array.from(this.toolsUsed).map(t => `- ${t}`).sort().join('\n') : '- None recorded'}
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
## Files Modified
|
|
181
|
+
|
|
182
|
+
${this.filesChanged.size > 0 ? Array.from(this.filesChanged).map(f => `- \`${f}\``).sort().join('\n') : '- None recorded'}
|
|
183
|
+
|
|
184
|
+
**Total Files Changed:** ${this.filesChanged.size}
|
|
185
|
+
|
|
186
|
+
---
|
|
187
|
+
|
|
188
|
+
## Commands Executed
|
|
189
|
+
|
|
190
|
+
${this.commandsExecuted.length > 0 ? '```bash\n' + this.commandsExecuted.slice(0, 20).join('\n') + '\n```' : 'None recorded'}
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
## Notes
|
|
195
|
+
|
|
196
|
+
This session summary was automatically generated by the PAI OpenCode Plugin.
|
|
197
|
+
|
|
198
|
+
---
|
|
199
|
+
|
|
200
|
+
**Session Outcome:** Completed
|
|
201
|
+
**Generated:** ${new Date().toISOString()}
|
|
202
|
+
`;
|
|
203
|
+
writeFileSync(filePath, summary);
|
|
204
|
+
return filePath;
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
this.logError('SessionSummary', error);
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
logError(context, error) {
|
|
212
|
+
try {
|
|
213
|
+
const now = new Date();
|
|
214
|
+
const pstDate = new Date(now.toLocaleString('en-US', { timeZone: process.env.TIME_ZONE || 'America/Los_Angeles' }));
|
|
215
|
+
const year = pstDate.getFullYear();
|
|
216
|
+
const month = String(pstDate.getMonth() + 1).padStart(2, '0');
|
|
217
|
+
const day = String(pstDate.getDate()).padStart(2, '0');
|
|
218
|
+
const filename = `${year}-${month}-${day}_errors.log`;
|
|
219
|
+
const filePath = getHistoryFilePath('system-logs', filename);
|
|
220
|
+
const dir = dirname(filePath);
|
|
221
|
+
if (!existsSync(dir)) {
|
|
222
|
+
mkdirSync(dir, { recursive: true });
|
|
223
|
+
}
|
|
224
|
+
const timestamp = this.getPSTTimestamp();
|
|
225
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
226
|
+
const stack = error instanceof Error ? error.stack : '';
|
|
227
|
+
const logEntry = `[${timestamp}] [${context}] ${errorMessage}\n${stack}\n-------------------\n`;
|
|
228
|
+
appendFileSync(filePath, logEntry, 'utf-8');
|
|
229
|
+
}
|
|
230
|
+
catch (e) {
|
|
231
|
+
// Intentionally silent - TUI protection
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
// Core write method
|
|
235
|
+
writeEvent(eventType, payload, toolName, toolInput) {
|
|
236
|
+
const sessionId = this.sessionId;
|
|
237
|
+
let agentName = this.getAgentForSession(sessionId);
|
|
238
|
+
// Create base event object
|
|
239
|
+
let hookEvent = {
|
|
240
|
+
source_app: agentName,
|
|
241
|
+
session_id: sessionId,
|
|
242
|
+
hook_event_type: eventType,
|
|
243
|
+
payload: payload,
|
|
244
|
+
timestamp: Date.now(),
|
|
245
|
+
timestamp_pst: this.getPSTTimestamp()
|
|
246
|
+
};
|
|
247
|
+
// Enrich with agent instance metadata if this is a Task tool call
|
|
248
|
+
if (toolName && toolInput && isAgentSpawningCall(toolName, toolInput)) {
|
|
249
|
+
hookEvent = enrichEventWithAgentMetadata(hookEvent, toolInput, payload.description // Assuming description is available in payload if passed
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
try {
|
|
253
|
+
const eventsFile = this.getEventsFilePath();
|
|
254
|
+
const jsonLine = JSON.stringify(hookEvent) + '\n';
|
|
255
|
+
appendFileSync(eventsFile, jsonLine, 'utf-8');
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
this.logError('EventCapture', error);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
flush() {
|
|
262
|
+
// No-op for now as we append synchronously
|
|
263
|
+
}
|
|
264
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Metadata Extraction Library for UOCS Enhancement
|
|
3
|
+
*
|
|
4
|
+
* Extracts agent instance IDs, parent-child relationships, and session info
|
|
5
|
+
* from Task tool calls and other tool inputs.
|
|
6
|
+
*
|
|
7
|
+
* Design Philosophy: Optional extraction with graceful fallbacks
|
|
8
|
+
* - If instance IDs are present in descriptions/prompts, extract them
|
|
9
|
+
* - If not present, fall back to agent type only
|
|
10
|
+
* - Never fail - always return usable metadata
|
|
11
|
+
*/
|
|
12
|
+
export interface AgentInstanceMetadata {
|
|
13
|
+
agent_instance_id?: string;
|
|
14
|
+
agent_type?: string;
|
|
15
|
+
instance_number?: number;
|
|
16
|
+
parent_session_id?: string;
|
|
17
|
+
parent_task_id?: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Extract agent instance ID from Task tool input
|
|
21
|
+
*
|
|
22
|
+
* Looks for patterns in priority order:
|
|
23
|
+
* 1. [agent-type-N] in description (e.g., "Research topic [perplexity-researcher-1]")
|
|
24
|
+
* 2. [AGENT_INSTANCE: agent-type-N] in prompt
|
|
25
|
+
* 3. subagent_type field (fallback to just type, no instance number)
|
|
26
|
+
*
|
|
27
|
+
* @param toolInput The tool input object from PreToolUse/PostToolUse hooks
|
|
28
|
+
* @param description Optional description field from tool input
|
|
29
|
+
* @returns Metadata object with extracted information
|
|
30
|
+
*/
|
|
31
|
+
export declare function extractAgentInstanceId(toolInput: any, description?: string): AgentInstanceMetadata;
|
|
32
|
+
/**
|
|
33
|
+
* Enrich event with agent metadata
|
|
34
|
+
*
|
|
35
|
+
* Takes a base event object and adds agent instance metadata to it.
|
|
36
|
+
* Returns a new object with merged metadata.
|
|
37
|
+
*
|
|
38
|
+
* @param event Base event object (from PreToolUse/PostToolUse)
|
|
39
|
+
* @param toolInput Tool input object
|
|
40
|
+
* @param description Optional description field
|
|
41
|
+
* @returns Enriched event with agent metadata
|
|
42
|
+
*/
|
|
43
|
+
export declare function enrichEventWithAgentMetadata(event: any, toolInput: any, description?: string): any;
|
|
44
|
+
/**
|
|
45
|
+
* Check if a tool call is spawning a subagent
|
|
46
|
+
*
|
|
47
|
+
* @param toolName Name of the tool being called
|
|
48
|
+
* @param toolInput Tool input object
|
|
49
|
+
* @returns true if this is a Task tool call spawning an agent
|
|
50
|
+
*/
|
|
51
|
+
export declare function isAgentSpawningCall(toolName: string, toolInput: any): boolean;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Metadata Extraction Library for UOCS Enhancement
|
|
3
|
+
*
|
|
4
|
+
* Extracts agent instance IDs, parent-child relationships, and session info
|
|
5
|
+
* from Task tool calls and other tool inputs.
|
|
6
|
+
*
|
|
7
|
+
* Design Philosophy: Optional extraction with graceful fallbacks
|
|
8
|
+
* - If instance IDs are present in descriptions/prompts, extract them
|
|
9
|
+
* - If not present, fall back to agent type only
|
|
10
|
+
* - Never fail - always return usable metadata
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Validate that an ID string contains only safe characters
|
|
14
|
+
* Allows alphanumeric, hyphens, and underscores.
|
|
15
|
+
* Prevents path traversal and injection attacks.
|
|
16
|
+
*/
|
|
17
|
+
function isValidId(id) {
|
|
18
|
+
return /^[a-zA-Z0-9\-_]+$/.test(id);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Extract agent instance ID from Task tool input
|
|
22
|
+
*
|
|
23
|
+
* Looks for patterns in priority order:
|
|
24
|
+
* 1. [agent-type-N] in description (e.g., "Research topic [perplexity-researcher-1]")
|
|
25
|
+
* 2. [AGENT_INSTANCE: agent-type-N] in prompt
|
|
26
|
+
* 3. subagent_type field (fallback to just type, no instance number)
|
|
27
|
+
*
|
|
28
|
+
* @param toolInput The tool input object from PreToolUse/PostToolUse hooks
|
|
29
|
+
* @param description Optional description field from tool input
|
|
30
|
+
* @returns Metadata object with extracted information
|
|
31
|
+
*/
|
|
32
|
+
export function extractAgentInstanceId(toolInput, description) {
|
|
33
|
+
const result = {};
|
|
34
|
+
// Strategy 1: Extract from description [agent-type-N]
|
|
35
|
+
// Example: "Research consumer complaints [perplexity-researcher-1]"
|
|
36
|
+
if (description) {
|
|
37
|
+
const descMatch = description.match(/\[([a-z-]+-researcher)-(\d+)\]/);
|
|
38
|
+
if (descMatch) {
|
|
39
|
+
result.agent_type = descMatch[1];
|
|
40
|
+
result.instance_number = parseInt(descMatch[2], 10);
|
|
41
|
+
result.agent_instance_id = `${result.agent_type}-${result.instance_number}`;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// Strategy 2: Extract from prompt [AGENT_INSTANCE: ...]
|
|
45
|
+
// Example: "[AGENT_INSTANCE: perplexity-researcher-1]"
|
|
46
|
+
if (!result.agent_instance_id && toolInput?.prompt && typeof toolInput.prompt === 'string') {
|
|
47
|
+
const promptMatch = toolInput.prompt.match(/\[AGENT_INSTANCE:\s*([^\]]+)\]/);
|
|
48
|
+
if (promptMatch) {
|
|
49
|
+
const extractedId = promptMatch[1].trim();
|
|
50
|
+
// Security: Validate ID format to prevent injection
|
|
51
|
+
if (isValidId(extractedId)) {
|
|
52
|
+
result.agent_instance_id = extractedId;
|
|
53
|
+
// Parse agent type and instance number from ID
|
|
54
|
+
const parts = result.agent_instance_id.match(/^([a-z-]+)-(\d+)$/);
|
|
55
|
+
if (parts) {
|
|
56
|
+
result.agent_type = parts[1];
|
|
57
|
+
result.instance_number = parseInt(parts[2], 10);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// Strategy 3: Extract parent session from prompt
|
|
63
|
+
// Example: "[PARENT_SESSION: b7062b5a-03d3-4168-9555-a748e0b2efa3]"
|
|
64
|
+
if (toolInput?.prompt && typeof toolInput.prompt === 'string') {
|
|
65
|
+
const parentSessionMatch = toolInput.prompt.match(/\[PARENT_SESSION:\s*([^\]]+)\]/);
|
|
66
|
+
if (parentSessionMatch) {
|
|
67
|
+
const extractedId = parentSessionMatch[1].trim();
|
|
68
|
+
if (isValidId(extractedId)) {
|
|
69
|
+
result.parent_session_id = extractedId;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// Extract parent task from prompt
|
|
73
|
+
// Example: "[PARENT_TASK: research_1731445892345]"
|
|
74
|
+
const parentTaskMatch = toolInput.prompt.match(/\[PARENT_TASK:\s*([^\]]+)\]/);
|
|
75
|
+
if (parentTaskMatch) {
|
|
76
|
+
const extractedId = parentTaskMatch[1].trim();
|
|
77
|
+
if (isValidId(extractedId)) {
|
|
78
|
+
result.parent_task_id = extractedId;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
// Strategy 4: Fallback to subagent_type if available (no instance number)
|
|
83
|
+
// This ensures we at least capture the agent type even without instance IDs
|
|
84
|
+
if (!result.agent_type && toolInput?.subagent_type) {
|
|
85
|
+
result.agent_type = toolInput.subagent_type;
|
|
86
|
+
}
|
|
87
|
+
return result;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Enrich event with agent metadata
|
|
91
|
+
*
|
|
92
|
+
* Takes a base event object and adds agent instance metadata to it.
|
|
93
|
+
* Returns a new object with merged metadata.
|
|
94
|
+
*
|
|
95
|
+
* @param event Base event object (from PreToolUse/PostToolUse)
|
|
96
|
+
* @param toolInput Tool input object
|
|
97
|
+
* @param description Optional description field
|
|
98
|
+
* @returns Enriched event with agent metadata
|
|
99
|
+
*/
|
|
100
|
+
export function enrichEventWithAgentMetadata(event, toolInput, description) {
|
|
101
|
+
const metadata = extractAgentInstanceId(toolInput, description);
|
|
102
|
+
// Only add fields that have values (keep events clean)
|
|
103
|
+
const enrichedEvent = { ...event };
|
|
104
|
+
if (metadata.agent_instance_id) {
|
|
105
|
+
enrichedEvent.agent_instance_id = metadata.agent_instance_id;
|
|
106
|
+
}
|
|
107
|
+
if (metadata.agent_type) {
|
|
108
|
+
enrichedEvent.agent_type = metadata.agent_type;
|
|
109
|
+
}
|
|
110
|
+
if (metadata.instance_number !== undefined) {
|
|
111
|
+
enrichedEvent.instance_number = metadata.instance_number;
|
|
112
|
+
}
|
|
113
|
+
if (metadata.parent_session_id) {
|
|
114
|
+
enrichedEvent.parent_session_id = metadata.parent_session_id;
|
|
115
|
+
}
|
|
116
|
+
if (metadata.parent_task_id) {
|
|
117
|
+
enrichedEvent.parent_task_id = metadata.parent_task_id;
|
|
118
|
+
}
|
|
119
|
+
return enrichedEvent;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Check if a tool call is spawning a subagent
|
|
123
|
+
*
|
|
124
|
+
* @param toolName Name of the tool being called
|
|
125
|
+
* @param toolInput Tool input object
|
|
126
|
+
* @returns true if this is a Task tool call spawning an agent
|
|
127
|
+
*/
|
|
128
|
+
export function isAgentSpawningCall(toolName, toolInput) {
|
|
129
|
+
return toolName === 'Task' && toolInput?.subagent_type !== undefined;
|
|
130
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function notifyVoiceServer(message: string): Promise<void>;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export async function notifyVoiceServer(message) {
|
|
2
|
+
try {
|
|
3
|
+
await fetch('http://localhost:8888/notify', {
|
|
4
|
+
method: 'POST',
|
|
5
|
+
headers: { 'Content-Type': 'application/json' },
|
|
6
|
+
body: JSON.stringify({ message }),
|
|
7
|
+
});
|
|
8
|
+
}
|
|
9
|
+
catch (error) {
|
|
10
|
+
// Ignore errors if server is not running
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PAI Path Resolution - Single Source of Truth
|
|
3
|
+
*
|
|
4
|
+
* This module provides consistent path resolution across all PAI hooks.
|
|
5
|
+
* It handles PAI_DIR detection whether set explicitly or defaulting to ~/.claude
|
|
6
|
+
*
|
|
7
|
+
* Usage in hooks:
|
|
8
|
+
* import { PAI_DIR, HOOKS_DIR, SKILLS_DIR } from './lib/paths';
|
|
9
|
+
*/
|
|
10
|
+
export declare const PAI_DIR: string;
|
|
11
|
+
/**
|
|
12
|
+
* Common PAI directories
|
|
13
|
+
*/
|
|
14
|
+
export declare const HOOKS_DIR: string;
|
|
15
|
+
export declare const SKILLS_DIR: string;
|
|
16
|
+
export declare const AGENTS_DIR: string;
|
|
17
|
+
export declare const HISTORY_DIR: string;
|
|
18
|
+
export declare const COMMANDS_DIR: string;
|
|
19
|
+
/**
|
|
20
|
+
* Helper to get history file path with date-based organization
|
|
21
|
+
*/
|
|
22
|
+
export declare function getHistoryFilePath(subdir: string, filename: string): string;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PAI Path Resolution - Single Source of Truth
|
|
3
|
+
*
|
|
4
|
+
* This module provides consistent path resolution across all PAI hooks.
|
|
5
|
+
* It handles PAI_DIR detection whether set explicitly or defaulting to ~/.claude
|
|
6
|
+
*
|
|
7
|
+
* Usage in hooks:
|
|
8
|
+
* import { PAI_DIR, HOOKS_DIR, SKILLS_DIR } from './lib/paths';
|
|
9
|
+
*/
|
|
10
|
+
import { homedir } from 'os';
|
|
11
|
+
import { resolve, join } from 'path';
|
|
12
|
+
import { existsSync } from 'fs';
|
|
13
|
+
/**
|
|
14
|
+
* Smart PAI_DIR detection with fallback
|
|
15
|
+
* Priority:
|
|
16
|
+
* 1. PAI_DIR environment variable (if set)
|
|
17
|
+
* 2. $XDG_CONFIG_HOME/opencode (standard XDG location)
|
|
18
|
+
* 3. ~/.config/opencode (fallback if XDG_CONFIG_HOME is not set)
|
|
19
|
+
*/
|
|
20
|
+
const XDG_CONFIG_HOME = process.env.XDG_CONFIG_HOME || join(homedir(), '.config');
|
|
21
|
+
export const PAI_DIR = process.env.PAI_DIR
|
|
22
|
+
? resolve(process.env.PAI_DIR)
|
|
23
|
+
: resolve(XDG_CONFIG_HOME, 'opencode');
|
|
24
|
+
/**
|
|
25
|
+
* Common PAI directories
|
|
26
|
+
*/
|
|
27
|
+
export const HOOKS_DIR = join(PAI_DIR, 'hooks');
|
|
28
|
+
export const SKILLS_DIR = join(PAI_DIR, 'skills');
|
|
29
|
+
export const AGENTS_DIR = join(PAI_DIR, 'agents');
|
|
30
|
+
export const HISTORY_DIR = join(PAI_DIR, 'history');
|
|
31
|
+
export const COMMANDS_DIR = join(PAI_DIR, 'commands');
|
|
32
|
+
/**
|
|
33
|
+
* Validate PAI directory structure on first import
|
|
34
|
+
* This fails fast with a clear error if PAI is misconfigured
|
|
35
|
+
*/
|
|
36
|
+
function validatePAIStructure() {
|
|
37
|
+
// Only validate if we are actually in a context where we expect PAI to exist.
|
|
38
|
+
// For the plugin, we might not want to hard crash if the user hasn't set it up yet,
|
|
39
|
+
// but PAI plugin implies PAI usage.
|
|
40
|
+
// We will log a warning instead of exit(1) to be safer in a plugin environment.
|
|
41
|
+
if (!existsSync(PAI_DIR)) {
|
|
42
|
+
// console.warn(`⚠️ PAI_DIR does not exist: ${PAI_DIR}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
validatePAIStructure();
|
|
46
|
+
/**
|
|
47
|
+
* Helper to get history file path with date-based organization
|
|
48
|
+
*/
|
|
49
|
+
export function getHistoryFilePath(subdir, filename) {
|
|
50
|
+
const now = new Date();
|
|
51
|
+
const pstDate = new Date(now.toLocaleString('en-US', { timeZone: process.env.TIME_ZONE || 'America/Los_Angeles' }));
|
|
52
|
+
const year = pstDate.getFullYear();
|
|
53
|
+
const month = String(pstDate.getMonth() + 1).padStart(2, '0');
|
|
54
|
+
return join(HISTORY_DIR, subdir, `${year}-${month}`, filename);
|
|
55
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redaction utility to scrub sensitive data from logs
|
|
3
|
+
*/
|
|
4
|
+
const SENSITIVE_KEYS = [
|
|
5
|
+
'api_key', 'apikey', 'secret', 'token', 'password', 'passwd', 'pwd',
|
|
6
|
+
'auth', 'credential', 'private_key', 'client_secret', 'access_key'
|
|
7
|
+
];
|
|
8
|
+
const SECRET_PATTERNS = [
|
|
9
|
+
// AWS Access Key ID
|
|
10
|
+
/\b(AKIA|ASIA)[0-9A-Z]{16}\b/g,
|
|
11
|
+
// GitHub Personal Access Token (classic)
|
|
12
|
+
/\bghp_[a-zA-Z0-9]{36}\b/g,
|
|
13
|
+
// Generic Private Key
|
|
14
|
+
/-----BEGIN [A-Z ]+ PRIVATE KEY-----/g,
|
|
15
|
+
// Bearer Token (simple heuristic - starts with Bearer, followed by base64-ish chars)
|
|
16
|
+
/\bBearer\s+[a-zA-Z0-9\-\._~+/]+=*/g,
|
|
17
|
+
];
|
|
18
|
+
// Regex for Key-Value assignments like "key=value" or "key: value" where key is sensitive
|
|
19
|
+
// This catches "export AWS_SECRET_KEY=..." or JSON "password": "..."
|
|
20
|
+
// We construct this dynamically from SENSITIVE_KEYS
|
|
21
|
+
const SENSITIVE_KEY_PATTERN = new RegExp(`\\b([a-zA-Z0-9_]*(${SENSITIVE_KEYS.join('|')})[a-zA-Z0-9_]*)\\s*[:=]\\s*['"]?([^\\s'"]{8,})['"]?`, 'gi');
|
|
22
|
+
export function redactString(str) {
|
|
23
|
+
if (!str)
|
|
24
|
+
return str;
|
|
25
|
+
let redacted = str;
|
|
26
|
+
// 1. Redact specific patterns (like AWS keys)
|
|
27
|
+
for (const pattern of SECRET_PATTERNS) {
|
|
28
|
+
redacted = redacted.replace(pattern, '[REDACTED]');
|
|
29
|
+
}
|
|
30
|
+
// 2. Redact key-value pairs where key suggests sensitivity
|
|
31
|
+
// We use a callback to preserve the key and redact the value
|
|
32
|
+
redacted = redacted.replace(SENSITIVE_KEY_PATTERN, (match, key, keyword, value) => {
|
|
33
|
+
// If value is already redacted, skip
|
|
34
|
+
if (value === '[REDACTED]')
|
|
35
|
+
return match;
|
|
36
|
+
// Replace the value part with [REDACTED]
|
|
37
|
+
return match.replace(value, '[REDACTED]');
|
|
38
|
+
});
|
|
39
|
+
return redacted;
|
|
40
|
+
}
|
|
41
|
+
export function redactObject(obj, visited = new WeakSet()) {
|
|
42
|
+
if (obj === null || obj === undefined)
|
|
43
|
+
return obj;
|
|
44
|
+
if (typeof obj === 'string') {
|
|
45
|
+
return redactString(obj);
|
|
46
|
+
}
|
|
47
|
+
if (typeof obj !== 'object') {
|
|
48
|
+
return obj;
|
|
49
|
+
}
|
|
50
|
+
if (obj instanceof Date) {
|
|
51
|
+
return obj;
|
|
52
|
+
}
|
|
53
|
+
if (visited.has(obj)) {
|
|
54
|
+
return '[CIRCULAR]';
|
|
55
|
+
}
|
|
56
|
+
visited.add(obj);
|
|
57
|
+
if (Array.isArray(obj)) {
|
|
58
|
+
return obj.map(item => redactObject(item, visited));
|
|
59
|
+
}
|
|
60
|
+
if (typeof obj === 'object') {
|
|
61
|
+
const newObj = {};
|
|
62
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
63
|
+
// If the key itself is sensitive, redact the value blindly if it's a string/number
|
|
64
|
+
const isSensitiveKey = SENSITIVE_KEYS.some(k => key.toLowerCase().includes(k));
|
|
65
|
+
if (isSensitiveKey && (typeof value === 'string' || typeof value === 'number')) {
|
|
66
|
+
newObj[key] = '[REDACTED]';
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
newObj[key] = redactObject(value, visited);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return newObj;
|
|
73
|
+
}
|
|
74
|
+
return obj;
|
|
75
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security Library for PAI Plugin
|
|
3
|
+
* Ported from legacy security-validator.ts
|
|
4
|
+
*/
|
|
5
|
+
export interface SecurityResult {
|
|
6
|
+
status: 'allow' | 'deny' | 'ask';
|
|
7
|
+
category?: string;
|
|
8
|
+
feedback?: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function validateCommand(command: string): SecurityResult;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { redactString } from './redaction';
|
|
2
|
+
const REVERSE_SHELL_PATTERNS = [
|
|
3
|
+
/\/dev\/(tcp|udp)\/[0-9]/,
|
|
4
|
+
/bash\s+-i\s+>&?\s*\/dev\//,
|
|
5
|
+
];
|
|
6
|
+
const INSTRUCTION_OVERRIDE_PATTERNS = [
|
|
7
|
+
/ignore\s+(all\s+)?previous\s+instructions?/i,
|
|
8
|
+
/disregard\s+(all\s+)?(prior|previous)\s+(instructions?|rules?)/i,
|
|
9
|
+
];
|
|
10
|
+
const CATASTROPHIC_DELETION_PATTERNS = [
|
|
11
|
+
/\s+~\/?(\s*$|\s+)/,
|
|
12
|
+
/\brm\s+(-[rfivd]+\s+)*\S+\s+~\/?/,
|
|
13
|
+
/\brm\s+(-[rfivd]+\s+)*\.\/\s*$/,
|
|
14
|
+
/\brm\s+(-[rfivd]+\s+)*\.\.\/\s*$/,
|
|
15
|
+
/\brm\s+(-[rfivd]+\s+)*\/\s*$/,
|
|
16
|
+
];
|
|
17
|
+
const DANGEROUS_FILE_OPS_PATTERNS = [
|
|
18
|
+
/\bchmod\s+(-R\s+)?0{3,}/,
|
|
19
|
+
];
|
|
20
|
+
const DANGEROUS_GIT_PATTERNS = [
|
|
21
|
+
/\bgit\s+push\s+.*(-f\b|--force)/i,
|
|
22
|
+
/\bgit\s+reset\s+--hard/i,
|
|
23
|
+
];
|
|
24
|
+
const BLOCK_CATEGORIES = [
|
|
25
|
+
{ category: 'reverse_shell', patterns: REVERSE_SHELL_PATTERNS },
|
|
26
|
+
{ category: 'instruction_override', patterns: INSTRUCTION_OVERRIDE_PATTERNS },
|
|
27
|
+
{ category: 'catastrophic_deletion', patterns: CATASTROPHIC_DELETION_PATTERNS },
|
|
28
|
+
{ category: 'dangerous_file_ops', patterns: DANGEROUS_FILE_OPS_PATTERNS },
|
|
29
|
+
];
|
|
30
|
+
const ASK_CATEGORIES = [
|
|
31
|
+
{ category: 'dangerous_git', patterns: DANGEROUS_GIT_PATTERNS },
|
|
32
|
+
];
|
|
33
|
+
export function validateCommand(command) {
|
|
34
|
+
for (const { category, patterns } of BLOCK_CATEGORIES) {
|
|
35
|
+
for (const pattern of patterns) {
|
|
36
|
+
if (pattern.test(command)) {
|
|
37
|
+
return {
|
|
38
|
+
status: 'deny',
|
|
39
|
+
category,
|
|
40
|
+
feedback: `🚨 SECURITY: Blocked ${category} pattern. Command: ${redactString(command).slice(0, 50)}...`,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
for (const { category, patterns } of ASK_CATEGORIES) {
|
|
46
|
+
for (const pattern of patterns) {
|
|
47
|
+
if (pattern.test(command)) {
|
|
48
|
+
return {
|
|
49
|
+
status: 'ask',
|
|
50
|
+
category,
|
|
51
|
+
feedback: `⚠️ DANGEROUS: ${category} operation requires confirmation.`,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return { status: 'allow' };
|
|
57
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@fpr1m3/opencode-pai-plugin",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Personal AI Infrastructure (PAI) plugin for OpenCode",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"dist"
|
|
10
|
+
],
|
|
11
|
+
"publishConfig": {
|
|
12
|
+
"access": "public"
|
|
13
|
+
},
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/fpr1m3/opencode-pai-plugin.git"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsc",
|
|
20
|
+
"test": "bun test",
|
|
21
|
+
"dev": "tsc --watch",
|
|
22
|
+
"prepare": "bun run build"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@opencode-ai/plugin": "^1.0.180",
|
|
26
|
+
"@opencode-ai/sdk": "^1.0.180",
|
|
27
|
+
"glob": "^13.0.0"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/bun": "latest",
|
|
31
|
+
"typescript": "^5.0.0"
|
|
32
|
+
}
|
|
33
|
+
}
|