@fpr1m3/opencode-pai-plugin 1.0.1 → 1.2.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 CHANGED
@@ -13,12 +13,13 @@ This project is an OpenCode-compatible clone of the hook system from **Dan Miess
13
13
  ## Features
14
14
 
15
15
  ### 1. Identity & Context Injection
16
- * **Core Skill Loading**: Automatically injects your `skills/core/SKILL.md` (from `PAI_DIR`) into the system prompt.
16
+ * **Core Skill Loading**: Automatically injects your `skill/core/SKILL.md` (from `PAI_DIR`) into the system prompt.
17
17
  * **Dynamic Substitution**: Supports placeholders like `{{DA}}`, `{{DA_COLOR}}`, and `{{ENGINEER_NAME}}` for personalized interactions.
18
18
  * **Project Requirements**: Automatically detects and loads `.opencode/dynamic-requirements.md` from your current project, allowing for task-specific instructions.
19
19
 
20
- ### 2. Intelligent History & Logging
20
+ ### 2. Intelligent History & Logging (UOCS)
21
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
+ * **Universal Output Capture System (UOCS)**: Automatically parses assistant responses for structured sections (SUMMARY, ANALYSIS, etc.) and generates artifacts in `decisions/`, `learnings/`, `research/`, or `execution/` based on context.
22
23
  * **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
24
  * **Agent Mapping**: Tracks session-to-agent relationships (e.g., mapping a subagent session to its specialized type).
24
25
 
@@ -36,7 +37,7 @@ The plugin centers around the `PAI_DIR` environment variable.
36
37
 
37
38
  | Variable | Description | Default |
38
39
  | :--- | :--- | :--- |
39
- | `PAI_DIR` | Root directory for PAI skills and history | `$XDG_CONFIG_HOME/opencode` |
40
+ | `PAI_DIR` | Root directory for PAI skill and history | `$XDG_CONFIG_HOME/opencode` |
40
41
  | `DA` | Name of your Digital Assistant | `PAI` |
41
42
  | `ENGINEER_NAME` | Your name/identity | `Operator` |
42
43
  | `DA_COLOR` | UI color theme for your DA | `blue` |
@@ -55,7 +56,7 @@ Add the plugin to your global `opencode.json` configuration file (typically loca
55
56
 
56
57
  Upon first run, the plugin will automatically:
57
58
  1. Detect or create your `PAI_DIR` (default: `$XDG_CONFIG_HOME/opencode`).
58
- 2. Initialize the required directory structure for skills and history.
59
+ 2. Initialize the required directory structure for skill and history.
59
60
  3. Create a default `SKILL.md` core identity if one does not exist.
60
61
 
61
62
  ## Development & Testing
@@ -72,7 +73,7 @@ We provide scripts to verify the plugin in a pristine environment:
72
73
 
73
74
  ---
74
75
 
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
+ **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/skill/core/SKILL.md`.
76
77
 
77
78
  ---
78
79
 
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { Logger } from './lib/logger';
2
- import { PAI_DIR } from './lib/paths';
2
+ import { PAI_DIR, HISTORY_DIR } from './lib/paths';
3
3
  import { validateCommand } from './lib/security';
4
4
  import { join } from 'path';
5
5
  import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'fs';
@@ -8,10 +8,16 @@ import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'fs';
8
8
  */
9
9
  function ensurePAIStructure() {
10
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'),
11
+ join(PAI_DIR, 'skill', 'core'),
12
+ join(HISTORY_DIR, 'raw-outputs'),
13
+ join(HISTORY_DIR, 'sessions'),
14
+ join(HISTORY_DIR, 'learnings'),
15
+ join(HISTORY_DIR, 'decisions'),
16
+ join(HISTORY_DIR, 'research'),
17
+ join(HISTORY_DIR, 'execution', 'features'),
18
+ join(HISTORY_DIR, 'execution', 'bugs'),
19
+ join(HISTORY_DIR, 'execution', 'refactors'),
20
+ join(HISTORY_DIR, 'system-logs'),
15
21
  ];
16
22
  for (const dir of dirs) {
17
23
  if (!existsSync(dir)) {
@@ -24,7 +30,7 @@ function ensurePAIStructure() {
24
30
  }
25
31
  }
26
32
  }
27
- const coreSkillPath = join(PAI_DIR, 'skills', 'core', 'SKILL.md');
33
+ const coreSkillPath = join(PAI_DIR, 'skill', 'core', 'SKILL.md');
28
34
  if (!existsSync(coreSkillPath)) {
29
35
  const defaultSkill = `# PAI Core Identity
30
36
  You are {{DA}}, a Personal AI Infrastructure.
@@ -85,13 +91,17 @@ function generateTabTitle(completedLine) {
85
91
  return 'PAI Task Done';
86
92
  }
87
93
  export const PAIPlugin = async ({ worktree }) => {
88
- let logger = null;
89
- let currentSessionId = null;
94
+ const loggers = new Map();
95
+ // Track the latest text content for each message (from streaming parts)
96
+ // Key: messageID, Value: latest full text from part.text
97
+ const messageTextCache = new Map();
98
+ // Track which messages we've already processed for archival (deduplication)
99
+ const processedMessageIds = new Set();
90
100
  // Auto-initialize PAI infrastructure if needed
91
101
  ensurePAIStructure();
92
- // Load CORE skill content from $PAI_DIR/skills/core/SKILL.md
102
+ // Load CORE skill content from $PAI_DIR/skill/core/SKILL.md
93
103
  let coreSkillContent = '';
94
- const coreSkillPath = join(PAI_DIR, 'skills', 'core', 'SKILL.md');
104
+ const coreSkillPath = join(PAI_DIR, 'skill', 'core', 'SKILL.md');
95
105
  if (existsSync(coreSkillPath)) {
96
106
  try {
97
107
  coreSkillContent = readFileSync(coreSkillPath, 'utf-8');
@@ -125,17 +135,33 @@ export const PAIPlugin = async ({ worktree }) => {
125
135
  const hooks = {
126
136
  event: async ({ event }) => {
127
137
  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);
138
+ // Get Session ID from event (try multiple locations)
139
+ const sessionId = anyEvent.properties?.part?.sessionID ||
140
+ anyEvent.properties?.info?.sessionID ||
141
+ anyEvent.properties?.sessionID ||
142
+ anyEvent.sessionID;
143
+ if (!sessionId)
144
+ return;
145
+ // Initialize Logger if needed
146
+ if (!loggers.has(sessionId)) {
147
+ loggers.set(sessionId, new Logger(sessionId, worktree));
132
148
  }
133
- // Handle generic event logging
134
- if (logger &&
135
- event.type !== 'message.part.updated' &&
136
- !shouldSkipEvent(event, currentSessionId)) {
149
+ const logger = loggers.get(sessionId);
150
+ // Handle generic event logging (skip streaming parts to reduce noise)
151
+ if (!shouldSkipEvent(event, sessionId) && event.type !== 'message.part.updated') {
137
152
  logger.logOpenCodeEvent(event);
138
153
  }
154
+ // STREAMING CAPTURE: Cache the latest text from message.part.updated
155
+ // The part.text field contains the FULL accumulated text, not a delta
156
+ if (event.type === 'message.part.updated') {
157
+ const part = anyEvent.properties?.part;
158
+ const messageId = part?.messageID;
159
+ const partType = part?.type;
160
+ // Only cache text parts (not tool parts)
161
+ if (messageId && partType === 'text' && part?.text) {
162
+ messageTextCache.set(messageId, part.text);
163
+ }
164
+ }
139
165
  // Handle real-time tab title updates (Pre-Tool Use)
140
166
  if (anyEvent.type === 'tool.call') {
141
167
  const props = anyEvent.properties;
@@ -152,32 +178,67 @@ export const PAIPlugin = async ({ worktree }) => {
152
178
  process.stderr.write(`\x1b]0;Agent: ${type}...\x07`);
153
179
  }
154
180
  }
155
- // Handle assistant completion (Tab Titles)
181
+ // Handle assistant message completion (Tab Titles & Artifact Archival)
156
182
  if (event.type === 'message.updated') {
157
183
  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`);
184
+ const role = info?.role || info?.author;
185
+ const messageId = info?.id;
186
+ if (role === 'assistant' && messageId) {
187
+ // Get content from our streaming cache first, fallback to info.content
188
+ let contentStr = messageTextCache.get(messageId) || '';
189
+ // Fallback: try to get content from the event itself
190
+ if (!contentStr) {
191
+ const content = info?.content || info?.text || '';
192
+ if (typeof content === 'string') {
193
+ contentStr = content;
194
+ }
195
+ else if (Array.isArray(content)) {
196
+ contentStr = content
197
+ .map((p) => {
198
+ if (typeof p === 'string')
199
+ return p;
200
+ if (p?.text)
201
+ return p.text;
202
+ if (p?.content)
203
+ return p.content;
204
+ return '';
205
+ })
206
+ .join('');
207
+ }
208
+ }
209
+ // Process if we have content and haven't processed this message yet
210
+ if (contentStr && !processedMessageIds.has(messageId)) {
211
+ processedMessageIds.add(messageId);
212
+ // Look for COMPLETED: line for tab title
213
+ const completedMatch = contentStr.match(/(?:🎯\s*)?COMPLETED:\s*(.+?)(?:\n|$)/i);
214
+ if (completedMatch) {
215
+ const completedLine = completedMatch[1].trim();
216
+ const tabTitle = generateTabTitle(completedLine);
217
+ process.stderr.write(`\x1b]0;${tabTitle}\x07`);
218
+ }
219
+ // Archive structured response
220
+ await logger.processAssistantMessage(contentStr, messageId);
221
+ // Clean up cache for this message
222
+ messageTextCache.delete(messageId);
167
223
  }
168
224
  }
169
225
  }
170
226
  // Handle session deletion / end or idle (for one-shot commands)
171
227
  if (event.type === 'session.deleted' || event.type === 'session.idle') {
172
- if (logger) {
173
- await logger.generateSessionSummary();
174
- logger.flush();
175
- }
228
+ await logger.generateSessionSummary();
229
+ logger.flush();
230
+ loggers.delete(sessionId);
231
+ // Clean up any stale cache entries for this session
232
+ // (In practice, messages are cleaned up after processing)
176
233
  }
177
234
  },
178
235
  "tool.execute.after": async (input, output) => {
179
- if (logger) {
180
- logger.logToolExecution(input, output);
236
+ const sessionId = input.sessionID;
237
+ if (sessionId) {
238
+ if (!loggers.has(sessionId)) {
239
+ loggers.set(sessionId, new Logger(sessionId, worktree));
240
+ }
241
+ loggers.get(sessionId).logToolExecution(input, output);
181
242
  }
182
243
  },
183
244
  "permission.ask": async (permission) => {
@@ -1,24 +1,21 @@
1
1
  import type { Event } from '@opencode-ai/sdk';
2
2
  export declare class Logger {
3
3
  private sessionId;
4
+ private worktree;
4
5
  private toolsUsed;
5
6
  private filesChanged;
6
7
  private commandsExecuted;
8
+ private processedMessageIds;
7
9
  private startTime;
8
- constructor(sessionId: string);
10
+ constructor(sessionId: string, worktree?: string);
11
+ private getHistoryDir;
12
+ processAssistantMessage(content: string, messageId?: string): Promise<void>;
9
13
  private getPSTTimestamp;
10
14
  private getEventsFilePath;
11
15
  private getSessionMappingFile;
12
16
  private getAgentForSession;
13
17
  private setAgentForSession;
14
- logEvent(event: Event): void;
15
18
  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
19
  logToolExecution(input: {
23
20
  tool: string;
24
21
  sessionID: string;
@@ -29,6 +26,10 @@ export declare class Logger {
29
26
  metadata: any;
30
27
  }): void;
31
28
  generateSessionSummary(): Promise<string | null>;
29
+ private parseStructuredResponse;
30
+ private isLearningCapture;
31
+ private determineArtifactType;
32
+ private createArtifact;
32
33
  logError(context: string, error: any): void;
33
34
  private writeEvent;
34
35
  flush(): void;
@@ -1,18 +1,52 @@
1
1
  import { existsSync, mkdirSync, appendFileSync, readFileSync, writeFileSync } from 'fs';
2
2
  import { dirname, join } from 'path';
3
- import { PAI_DIR, getHistoryFilePath, HISTORY_DIR } from './paths';
4
- import { enrichEventWithAgentMetadata, isAgentSpawningCall } from './metadata-extraction';
3
+ import { PAI_DIR, HISTORY_DIR } from './paths';
4
+ import { isAgentSpawningCall, enrichEventWithAgentMetadata } from './metadata-extraction';
5
5
  import { redactString, redactObject } from './redaction';
6
6
  export class Logger {
7
7
  sessionId;
8
+ worktree;
8
9
  toolsUsed = new Set();
9
10
  filesChanged = new Set();
10
11
  commandsExecuted = [];
12
+ processedMessageIds = new Set();
11
13
  startTime = Date.now();
12
- constructor(sessionId) {
14
+ constructor(sessionId, worktree = '/') {
13
15
  this.sessionId = sessionId;
16
+ this.worktree = worktree;
17
+ }
18
+ getHistoryDir() {
19
+ if (process.env.HISTORY_DIR)
20
+ return process.env.HISTORY_DIR;
21
+ if (existsSync(PAI_DIR))
22
+ return HISTORY_DIR;
23
+ return join(this.worktree, '.opencode', 'history');
24
+ }
25
+ async processAssistantMessage(content, messageId) {
26
+ try {
27
+ // Deduplication: skip if we've already processed this message
28
+ if (messageId) {
29
+ if (this.processedMessageIds.has(messageId))
30
+ return;
31
+ this.processedMessageIds.add(messageId);
32
+ }
33
+ // Parse structured response sections
34
+ const sections = this.parseStructuredResponse(content);
35
+ // Require at least SUMMARY or COMPLETED to be a valid structured response
36
+ // This prevents archiving every random message
37
+ const hasRequiredSection = sections['SUMMARY'] || sections['COMPLETED'];
38
+ if (!hasRequiredSection || Object.keys(sections).length < 2) {
39
+ return;
40
+ }
41
+ const agentRole = this.getAgentForSession(this.sessionId);
42
+ const isLearning = this.isLearningCapture(sections);
43
+ const type = this.determineArtifactType(agentRole, isLearning, sections);
44
+ await this.createArtifact(type, content, sections);
45
+ }
46
+ catch (error) {
47
+ this.logError('ProcessAssistantMessage', error);
48
+ }
14
49
  }
15
- // Get PST timestamp
16
50
  getPSTTimestamp() {
17
51
  const date = new Date();
18
52
  const pstDate = new Date(date.toLocaleString('en-US', { timeZone: process.env.TIME_ZONE || 'America/Los_Angeles' }));
@@ -31,11 +65,11 @@ export class Logger {
31
65
  const month = String(pstDate.getMonth() + 1).padStart(2, '0');
32
66
  const day = String(pstDate.getDate()).padStart(2, '0');
33
67
  const filename = `${year}-${month}-${day}_all-events.jsonl`;
34
- const filePath = getHistoryFilePath('raw-outputs', filename);
68
+ const historyDir = this.getHistoryDir();
69
+ const filePath = join(historyDir, 'raw-outputs', filename);
35
70
  const dir = dirname(filePath);
36
- if (!existsSync(dir)) {
71
+ if (!existsSync(dir))
37
72
  mkdirSync(dir, { recursive: true });
38
- }
39
73
  return filePath;
40
74
  }
41
75
  getSessionMappingFile() {
@@ -49,39 +83,24 @@ export class Logger {
49
83
  return mappings[sessionId] || 'pai';
50
84
  }
51
85
  }
52
- catch (error) {
53
- // Ignore errors, default to pai
54
- }
86
+ catch (error) { }
55
87
  return 'pai';
56
88
  }
57
89
  setAgentForSession(sessionId, agentName) {
58
90
  try {
59
91
  const mappingFile = this.getSessionMappingFile();
60
92
  let mappings = {};
61
- if (existsSync(mappingFile)) {
93
+ if (existsSync(mappingFile))
62
94
  mappings = JSON.parse(readFileSync(mappingFile, 'utf-8'));
63
- }
64
95
  mappings[sessionId] = agentName;
65
96
  writeFileSync(mappingFile, JSON.stringify(mappings, null, 2), 'utf-8');
66
97
  }
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);
98
+ catch (error) { }
75
99
  }
76
- // Method to log generic OpenCode event
77
100
  logOpenCodeEvent(event) {
78
101
  const anyEvent = event;
79
102
  const timestamp = anyEvent.timestamp || Date.now();
80
- const payload = {
81
- ...anyEvent.properties,
82
- timestamp: timestamp
83
- };
84
- // Track stats for summary
103
+ const payload = { ...anyEvent.properties, timestamp };
85
104
  if (anyEvent.type === 'tool.call' || anyEvent.type === 'tool.execute.before') {
86
105
  const props = anyEvent.properties;
87
106
  const tool = props?.tool || props?.tool_name;
@@ -93,8 +112,8 @@ export class Logger {
93
112
  this.commandsExecuted.push(redactString(command));
94
113
  }
95
114
  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;
115
+ const path = props?.input?.file_path || props?.input?.path || props?.input?.filePath ||
116
+ props?.tool_input?.file_path || props?.tool_input?.path || props?.tool_input?.filePath;
98
117
  if (path)
99
118
  this.filesChanged.add(path);
100
119
  }
@@ -102,19 +121,11 @@ export class Logger {
102
121
  }
103
122
  this.writeEvent(anyEvent.type, redactObject(payload));
104
123
  }
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
124
  logToolExecution(input, output) {
112
125
  const toolName = input.tool;
113
126
  const sessionId = this.sessionId;
114
127
  this.toolsUsed.add(toolName);
115
- // Extract metadata - may contain additional tool info
116
128
  const metadata = output.metadata || {};
117
- // Logic to update agent mapping based on Task tool spawning subagents
118
129
  if (toolName === 'Task' && metadata?.subagent_type) {
119
130
  this.setAgentForSession(sessionId, metadata.subagent_type);
120
131
  }
@@ -129,22 +140,23 @@ export class Logger {
129
140
  call_id: input.callID,
130
141
  };
131
142
  this.writeEvent('ToolUse', redactObject(payload), toolName, metadata);
143
+ if (toolName === 'task' || toolName === 'Task') {
144
+ if (output.output)
145
+ this.processAssistantMessage(output.output);
146
+ }
132
147
  }
133
148
  async generateSessionSummary() {
134
149
  try {
135
150
  const now = new Date();
136
- const timestamp = now.toISOString()
137
- .replace(/:/g, '')
138
- .replace(/\..+/, '')
139
- .replace('T', '-'); // YYYY-MM-DD-HHMMSS
151
+ const timestamp = now.toISOString().replace(/:/g, '').replace(/\..+/, '').replace('T', '-');
140
152
  const yearMonth = timestamp.substring(0, 7);
141
153
  const date = timestamp.substring(0, 10);
142
154
  const time = timestamp.substring(11).replace(/-/g, ':');
143
155
  const duration = Math.round((Date.now() - this.startTime) / 60000);
144
- const sessionDir = join(HISTORY_DIR, 'sessions', yearMonth);
145
- if (!existsSync(sessionDir)) {
156
+ const historyDir = this.getHistoryDir();
157
+ const sessionDir = join(historyDir, 'sessions', yearMonth);
158
+ if (!existsSync(sessionDir))
146
159
  mkdirSync(sessionDir, { recursive: true });
147
- }
148
160
  const focus = this.filesChanged.size > 0 ? 'development' : 'research';
149
161
  const filename = `${timestamp}_SESSION_${focus}.md`;
150
162
  const filePath = join(sessionDir, filename);
@@ -204,10 +216,93 @@ This session summary was automatically generated by the PAI OpenCode Plugin.
204
216
  return filePath;
205
217
  }
206
218
  catch (error) {
207
- this.logError('SessionSummary', error);
208
219
  return null;
209
220
  }
210
221
  }
222
+ parseStructuredResponse(content) {
223
+ const sections = {};
224
+ const sectionHeaders = ['SUMMARY', 'ANALYSIS', 'ACTIONS', 'RESULTS', 'STATUS', 'CAPTURE', 'NEXT', 'STORY EXPLANATION', 'COMPLETED'];
225
+ for (const header of sectionHeaders) {
226
+ // Match header with optional markdown bold (**) or other formatting
227
+ // Handles: "SUMMARY:", "**SUMMARY:**", "**SUMMARY**:", "* SUMMARY:", etc.
228
+ const regex = new RegExp(`(?:^|\\n)\\*{0,2}\\s*${header}\\s*\\*{0,2}:\\s*([\\s\\S]*?)(?=\\n\\*{0,2}\\s*(?:${sectionHeaders.join('|')})\\s*\\*{0,2}:|$)`, 'i');
229
+ const match = content.match(regex);
230
+ if (match && match[1])
231
+ sections[header] = match[1].trim();
232
+ }
233
+ return sections;
234
+ }
235
+ isLearningCapture(sections) {
236
+ const indicators = ['fixed', 'solved', 'discovered', 'lesson', 'troubleshoot', 'debug', 'root cause', 'learning', 'bug', 'issue', 'resolved'];
237
+ const textToSearch = ((sections['ANALYSIS'] || '') + ' ' + (sections['RESULTS'] || '')).toLowerCase();
238
+ let count = 0;
239
+ for (const indicator of indicators)
240
+ if (textToSearch.includes(indicator))
241
+ count++;
242
+ return count >= 2;
243
+ }
244
+ determineArtifactType(agentRole, isLearning, sections) {
245
+ const summary = (sections['SUMMARY'] || '').toLowerCase();
246
+ if (agentRole === 'architect')
247
+ return 'DECISION';
248
+ if (agentRole === 'researcher' || agentRole === 'pentester')
249
+ return 'RESEARCH';
250
+ if (agentRole === 'engineer' || agentRole === 'designer') {
251
+ if (summary.includes('fix') || summary.includes('bug') || summary.includes('issue'))
252
+ return 'BUG';
253
+ if (summary.includes('refactor') || summary.includes('improve') || summary.includes('cleanup'))
254
+ return 'REFACTOR';
255
+ return 'FEATURE';
256
+ }
257
+ return isLearning ? 'LEARNING' : 'WORK';
258
+ }
259
+ async createArtifact(type, content, sections) {
260
+ try {
261
+ const now = new Date();
262
+ const timestamp = now.toISOString().replace(/:/g, '').replace(/\..+/, '').replace('T', '-');
263
+ const yearMonth = timestamp.substring(0, 7);
264
+ const summary = sections['SUMMARY'] || 'no-summary';
265
+ const slug = summary.toLowerCase().replace(/[^\w\s-]/g, '').replace(/\s+/g, '-').substring(0, 50);
266
+ const filename = `${timestamp}_${type}_${slug}.md`;
267
+ let subdir = 'execution';
268
+ if (type === 'LEARNING')
269
+ subdir = 'learnings';
270
+ else if (type === 'DECISION')
271
+ subdir = 'decisions';
272
+ else if (type === 'RESEARCH')
273
+ subdir = 'research';
274
+ else if (type === 'WORK')
275
+ subdir = 'sessions';
276
+ else {
277
+ if (type === 'BUG')
278
+ subdir = join('execution', 'bugs');
279
+ else if (type === 'REFACTOR')
280
+ subdir = join('execution', 'refactors');
281
+ else
282
+ subdir = join('execution', 'features');
283
+ }
284
+ const historyDir = this.getHistoryDir();
285
+ const targetDir = join(historyDir, subdir, yearMonth);
286
+ if (!existsSync(targetDir))
287
+ mkdirSync(targetDir, { recursive: true });
288
+ const filePath = join(targetDir, filename);
289
+ const agentRole = this.getAgentForSession(this.sessionId);
290
+ const frontmatter = `---
291
+ capture_type: ${type}
292
+ timestamp: ${new Date().toISOString()}
293
+ session_id: ${this.sessionId}
294
+ executor: ${agentRole}
295
+ ${sections['SUMMARY'] ? `summary: ${sections['SUMMARY'].replace(/"/g, '\\"')}` : ''}
296
+ ---
297
+
298
+ ${content}
299
+ `;
300
+ writeFileSync(filePath, redactString(frontmatter), 'utf-8');
301
+ }
302
+ catch (e) {
303
+ this.logError('CreateArtifact', e);
304
+ }
305
+ }
211
306
  logError(context, error) {
212
307
  try {
213
308
  const now = new Date();
@@ -216,26 +311,22 @@ This session summary was automatically generated by the PAI OpenCode Plugin.
216
311
  const month = String(pstDate.getMonth() + 1).padStart(2, '0');
217
312
  const day = String(pstDate.getDate()).padStart(2, '0');
218
313
  const filename = `${year}-${month}-${day}_errors.log`;
219
- const filePath = getHistoryFilePath('system-logs', filename);
314
+ const historyDir = this.getHistoryDir();
315
+ const filePath = join(historyDir, 'system-logs', filename);
220
316
  const dir = dirname(filePath);
221
- if (!existsSync(dir)) {
317
+ if (!existsSync(dir))
222
318
  mkdirSync(dir, { recursive: true });
223
- }
224
319
  const timestamp = this.getPSTTimestamp();
225
320
  const errorMessage = error instanceof Error ? error.message : String(error);
226
321
  const stack = error instanceof Error ? error.stack : '';
227
322
  const logEntry = `[${timestamp}] [${context}] ${errorMessage}\n${stack}\n-------------------\n`;
228
323
  appendFileSync(filePath, logEntry, 'utf-8');
229
324
  }
230
- catch (e) {
231
- // Intentionally silent - TUI protection
232
- }
325
+ catch (e) { }
233
326
  }
234
- // Core write method
235
327
  writeEvent(eventType, payload, toolName, toolInput) {
236
328
  const sessionId = this.sessionId;
237
329
  let agentName = this.getAgentForSession(sessionId);
238
- // Create base event object
239
330
  let hookEvent = {
240
331
  source_app: agentName,
241
332
  session_id: sessionId,
@@ -244,10 +335,8 @@ This session summary was automatically generated by the PAI OpenCode Plugin.
244
335
  timestamp: Date.now(),
245
336
  timestamp_pst: this.getPSTTimestamp()
246
337
  };
247
- // Enrich with agent instance metadata if this is a Task tool call
248
338
  if (toolName && toolInput && isAgentSpawningCall(toolName, toolInput)) {
249
- hookEvent = enrichEventWithAgentMetadata(hookEvent, toolInput, payload.description // Assuming description is available in payload if passed
250
- );
339
+ hookEvent = enrichEventWithAgentMetadata(hookEvent, toolInput, payload.description);
251
340
  }
252
341
  try {
253
342
  const eventsFile = this.getEventsFilePath();
@@ -258,7 +347,5 @@ This session summary was automatically generated by the PAI OpenCode Plugin.
258
347
  this.logError('EventCapture', error);
259
348
  }
260
349
  }
261
- flush() {
262
- // No-op for now as we append synchronously
263
- }
350
+ flush() { }
264
351
  }
package/dist/lib/paths.js CHANGED
@@ -25,7 +25,7 @@ export const PAI_DIR = process.env.PAI_DIR
25
25
  * Common PAI directories
26
26
  */
27
27
  export const HOOKS_DIR = join(PAI_DIR, 'hooks');
28
- export const SKILLS_DIR = join(PAI_DIR, 'skills');
28
+ export const SKILLS_DIR = join(PAI_DIR, 'skill');
29
29
  export const AGENTS_DIR = join(PAI_DIR, 'agents');
30
30
  export const HISTORY_DIR = join(PAI_DIR, 'history');
31
31
  export const COMMANDS_DIR = join(PAI_DIR, 'commands');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fpr1m3/opencode-pai-plugin",
3
- "version": "1.0.1",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
5
  "description": "Personal AI Infrastructure (PAI) plugin for OpenCode",
6
6
  "main": "dist/index.js",
@@ -1 +0,0 @@
1
- export declare function getCoreContext(baseDir: string, env: NodeJS.ProcessEnv): string;
@@ -1,21 +0,0 @@
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
- }
@@ -1 +0,0 @@
1
- export declare function notifyVoiceServer(message: string): Promise<void>;
@@ -1,12 +0,0 @@
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
- }