Skip to content

Commit 03a5aac

Browse files
committed
feat: implement UOCS and expand history tracking to sync with Dan's PAI structure
1 parent 93f2056 commit 03a5aac

File tree

8 files changed

+346
-18
lines changed

8 files changed

+346
-18
lines changed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,9 @@ This project is an OpenCode-compatible clone of the hook system from **Dan Miess
1717
* **Dynamic Substitution**: Supports placeholders like `{{DA}}`, `{{DA_COLOR}}`, and `{{ENGINEER_NAME}}` for personalized interactions.
1818
* **Project Requirements**: Automatically detects and loads `.opencode/dynamic-requirements.md` from your current project, allowing for task-specific instructions.
1919

20-
### 2. Intelligent History & Logging
20+
### 2. Intelligent History & Logging (UOCS)
2121
* **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.
2223
* **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.
2324
* **Agent Mapping**: Tracks session-to-agent relationships (e.g., mapping a subagent session to its specialized type).
2425

dist/index.js

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Logger } from './lib/logger';
2-
import { PAI_DIR } from './lib/paths';
2+
import { PAI_DIR, HISTORY_DIR } from './lib/paths';
33
import { validateCommand } from './lib/security';
44
import { join } from 'path';
55
import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'fs';
@@ -9,9 +9,15 @@ import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'fs';
99
function ensurePAIStructure() {
1010
const dirs = [
1111
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'),
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'),
1521
];
1622
for (const dir of dirs) {
1723
if (!existsSync(dir)) {
@@ -152,18 +158,25 @@ export const PAIPlugin = async ({ worktree }) => {
152158
process.stderr.write(`\x1b]0;Agent: ${type}...\x07`);
153159
}
154160
}
155-
// Handle assistant completion (Tab Titles)
161+
// Handle assistant completion (Tab Titles & UOCS)
156162
if (event.type === 'message.updated') {
157163
const info = anyEvent.properties?.info;
158-
if (info?.author === 'assistant' && info?.content) {
159-
const content = typeof info.content === 'string' ? info.content : '';
164+
const role = info?.role || info?.author;
165+
if (role === 'assistant') {
166+
// Robust content extraction
167+
const content = info?.content || info?.text || '';
168+
const contentStr = typeof content === 'string' ? content : '';
160169
// Look for COMPLETED: line (can be prefaced by 🎯 or just text)
161-
const completedMatch = content.match(/(?:🎯\s*)?COMPLETED:\s*(.+?)(?:\n|$)/i);
170+
const completedMatch = contentStr.match(/(?:🎯\s*)?COMPLETED:\s*(.+?)(?:\n|$)/i);
162171
if (completedMatch) {
163172
const completedLine = completedMatch[1].trim();
164173
// Set Tab Title
165174
const tabTitle = generateTabTitle(completedLine);
166175
process.stderr.write(`\x1b]0;${tabTitle}\x07`);
176+
// UOCS: Process response for artifact generation
177+
if (logger && contentStr) {
178+
await logger.processAssistantMessage(contentStr);
179+
}
167180
}
168181
}
169182
}

dist/lib/logger.d.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ export declare class Logger {
2929
metadata: any;
3030
}): void;
3131
generateSessionSummary(): Promise<string | null>;
32+
processAssistantMessage(content: string): Promise<void>;
33+
private parseStructuredResponse;
34+
private isLearningCapture;
35+
private determineArtifactType;
36+
private createArtifact;
3237
logError(context: string, error: any): void;
3338
private writeEvent;
3439
flush(): void;

dist/lib/logger.js

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,109 @@ This session summary was automatically generated by the PAI OpenCode Plugin.
208208
return null;
209209
}
210210
}
211+
async processAssistantMessage(content) {
212+
try {
213+
const sections = this.parseStructuredResponse(content);
214+
if (Object.keys(sections).length === 0)
215+
return;
216+
const agentRole = this.getAgentForSession(this.sessionId);
217+
const isLearning = this.isLearningCapture(sections);
218+
const type = this.determineArtifactType(agentRole, isLearning, sections);
219+
await this.createArtifact(type, content, sections);
220+
}
221+
catch (error) {
222+
this.logError('ProcessAssistantMessage', error);
223+
}
224+
}
225+
parseStructuredResponse(content) {
226+
const sections = {};
227+
const sectionHeaders = [
228+
'SUMMARY', 'ANALYSIS', 'ACTIONS', 'RESULTS', 'STATUS', 'CAPTURE', 'NEXT', 'STORY EXPLANATION', 'COMPLETED'
229+
];
230+
for (const header of sectionHeaders) {
231+
const regex = new RegExp(`${header}:\\s*([\\s\\S]*?)(?=\\n(?:${sectionHeaders.join('|')}):|$)`, 'i');
232+
const match = content.match(regex);
233+
if (match && match[1]) {
234+
sections[header] = match[1].trim();
235+
}
236+
}
237+
return sections;
238+
}
239+
isLearningCapture(sections) {
240+
const indicators = ['fixed', 'solved', 'discovered', 'lesson', 'troubleshoot', 'debug', 'root cause', 'learning', 'bug', 'issue', 'resolved'];
241+
const textToSearch = ((sections['ANALYSIS'] || '') + ' ' + (sections['RESULTS'] || '')).toLowerCase();
242+
let count = 0;
243+
for (const indicator of indicators) {
244+
if (textToSearch.includes(indicator)) {
245+
count++;
246+
}
247+
}
248+
return count >= 2;
249+
}
250+
determineArtifactType(agentRole, isLearning, sections) {
251+
const summary = (sections['SUMMARY'] || '').toLowerCase();
252+
if (agentRole === 'architect')
253+
return 'DECISION';
254+
if (agentRole === 'researcher' || agentRole === 'pentester')
255+
return 'RESEARCH';
256+
if (agentRole === 'engineer' || agentRole === 'designer') {
257+
if (summary.includes('fix') || summary.includes('bug') || summary.includes('issue'))
258+
return 'BUG';
259+
if (summary.includes('refactor') || summary.includes('improve') || summary.includes('cleanup'))
260+
return 'REFACTOR';
261+
return 'FEATURE';
262+
}
263+
return isLearning ? 'LEARNING' : 'WORK';
264+
}
265+
async createArtifact(type, content, sections) {
266+
const now = new Date();
267+
const timestamp = now.toISOString()
268+
.replace(/:/g, '')
269+
.replace(/\..+/, '')
270+
.replace('T', '-');
271+
const yearMonth = timestamp.substring(0, 7);
272+
const summary = sections['SUMMARY'] || 'no-summary';
273+
const slug = summary.toLowerCase()
274+
.replace(/[^\w\s-]/g, '')
275+
.replace(/\s+/g, '-')
276+
.substring(0, 50);
277+
const filename = `${timestamp}_${type}_${slug}.md`;
278+
let subdir = 'execution';
279+
if (type === 'LEARNING')
280+
subdir = 'learnings';
281+
else if (type === 'DECISION')
282+
subdir = 'decisions';
283+
else if (type === 'RESEARCH')
284+
subdir = 'research';
285+
else if (type === 'WORK')
286+
subdir = 'sessions';
287+
else {
288+
// For BUG, REFACTOR, FEATURE
289+
if (type === 'BUG')
290+
subdir = join('execution', 'bugs');
291+
else if (type === 'REFACTOR')
292+
subdir = join('execution', 'refactors');
293+
else
294+
subdir = join('execution', 'features');
295+
}
296+
const targetDir = join(HISTORY_DIR, subdir, yearMonth);
297+
if (!existsSync(targetDir)) {
298+
mkdirSync(targetDir, { recursive: true });
299+
}
300+
const filePath = join(targetDir, filename);
301+
const agentRole = this.getAgentForSession(this.sessionId);
302+
const frontmatter = `---
303+
capture_type: ${type}
304+
timestamp: ${new Date().toISOString()}
305+
session_id: ${this.sessionId}
306+
executor: ${agentRole}
307+
${sections['SUMMARY'] ? `summary: ${sections['SUMMARY'].replace(/"/g, '\\"')}` : ''}
308+
---
309+
310+
${content}
311+
`;
312+
writeFileSync(filePath, redactString(frontmatter), 'utf-8');
313+
}
211314
logError(context, error) {
212315
try {
213316
const now = new Date();

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@fpr1m3/opencode-pai-plugin",
3-
"version": "1.0.1",
3+
"version": "1.1.0",
44
"type": "module",
55
"description": "Personal AI Infrastructure (PAI) plugin for OpenCode",
66
"main": "dist/index.js",

src/index.ts

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { Plugin, Hooks } from '@opencode-ai/plugin';
22
import type { Event } from '@opencode-ai/sdk';
33
import { Logger } from './lib/logger';
4-
import { PAI_DIR } from './lib/paths';
4+
import { PAI_DIR, HISTORY_DIR } from './lib/paths';
55
import { validateCommand } from './lib/security';
66
import { join } from 'path';
77
import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'fs';
@@ -12,9 +12,15 @@ import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'fs';
1212
function ensurePAIStructure() {
1313
const dirs = [
1414
join(PAI_DIR, 'skills', 'core'),
15-
join(PAI_DIR, 'history', 'raw-outputs'),
16-
join(PAI_DIR, 'history', 'sessions'),
17-
join(PAI_DIR, 'history', 'system-logs'),
15+
join(HISTORY_DIR, 'raw-outputs'),
16+
join(HISTORY_DIR, 'sessions'),
17+
join(HISTORY_DIR, 'learnings'),
18+
join(HISTORY_DIR, 'decisions'),
19+
join(HISTORY_DIR, 'research'),
20+
join(HISTORY_DIR, 'execution', 'features'),
21+
join(HISTORY_DIR, 'execution', 'bugs'),
22+
join(HISTORY_DIR, 'execution', 'refactors'),
23+
join(HISTORY_DIR, 'system-logs'),
1824
];
1925

2026
for (const dir of dirs) {
@@ -171,20 +177,29 @@ export const PAIPlugin: Plugin = async ({ worktree }) => {
171177
}
172178
}
173179

174-
// Handle assistant completion (Tab Titles)
180+
// Handle assistant completion (Tab Titles & UOCS)
175181
if (event.type === 'message.updated') {
176182
const info = anyEvent.properties?.info;
177-
if (info?.author === 'assistant' && info?.content) {
178-
const content = typeof info.content === 'string' ? info.content : '';
183+
const role = info?.role || info?.author;
184+
185+
if (role === 'assistant') {
186+
// Robust content extraction
187+
const content = info?.content || info?.text || '';
188+
const contentStr = typeof content === 'string' ? content : '';
179189

180190
// Look for COMPLETED: line (can be prefaced by 🎯 or just text)
181-
const completedMatch = content.match(/(?:🎯\s*)?COMPLETED:\s*(.+?)(?:\n|$)/i);
191+
const completedMatch = contentStr.match(/(?:🎯\s*)?COMPLETED:\s*(.+?)(?:\n|$)/i);
182192
if (completedMatch) {
183193
const completedLine = completedMatch[1].trim();
184194

185195
// Set Tab Title
186196
const tabTitle = generateTabTitle(completedLine);
187197
process.stderr.write(`\x1b]0;${tabTitle}\x07`);
198+
199+
// UOCS: Process response for artifact generation
200+
if (logger && contentStr) {
201+
await logger.processAssistantMessage(contentStr);
202+
}
188203
}
189204
}
190205
}

src/lib/logger.ts

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,116 @@ This session summary was automatically generated by the PAI OpenCode Plugin.
249249
}
250250
}
251251

252+
public async processAssistantMessage(content: string): Promise<void> {
253+
try {
254+
const sections = this.parseStructuredResponse(content);
255+
if (Object.keys(sections).length === 0) return;
256+
257+
const agentRole = this.getAgentForSession(this.sessionId);
258+
const isLearning = this.isLearningCapture(sections);
259+
const type = this.determineArtifactType(agentRole, isLearning, sections);
260+
261+
await this.createArtifact(type, content, sections);
262+
} catch (error) {
263+
this.logError('ProcessAssistantMessage', error);
264+
}
265+
}
266+
267+
private parseStructuredResponse(content: string): Record<string, string> {
268+
const sections: Record<string, string> = {};
269+
const sectionHeaders = [
270+
'SUMMARY', 'ANALYSIS', 'ACTIONS', 'RESULTS', 'STATUS', 'CAPTURE', 'NEXT', 'STORY EXPLANATION', 'COMPLETED'
271+
];
272+
273+
for (const header of sectionHeaders) {
274+
const regex = new RegExp(`${header}:\\s*([\\s\\S]*?)(?=\\n(?:${sectionHeaders.join('|')}):|$)`, 'i');
275+
const match = content.match(regex);
276+
if (match && match[1]) {
277+
sections[header] = match[1].trim();
278+
}
279+
}
280+
281+
return sections;
282+
}
283+
284+
private isLearningCapture(sections: Record<string, string>): boolean {
285+
const indicators = ['fixed', 'solved', 'discovered', 'lesson', 'troubleshoot', 'debug', 'root cause', 'learning', 'bug', 'issue', 'resolved'];
286+
const textToSearch = ((sections['ANALYSIS'] || '') + ' ' + (sections['RESULTS'] || '')).toLowerCase();
287+
288+
let count = 0;
289+
for (const indicator of indicators) {
290+
if (textToSearch.includes(indicator)) {
291+
count++;
292+
}
293+
}
294+
295+
return count >= 2;
296+
}
297+
298+
private determineArtifactType(agentRole: string, isLearning: boolean, sections: Record<string, string>): string {
299+
const summary = (sections['SUMMARY'] || '').toLowerCase();
300+
301+
if (agentRole === 'architect') return 'DECISION';
302+
if (agentRole === 'researcher' || agentRole === 'pentester') return 'RESEARCH';
303+
if (agentRole === 'engineer' || agentRole === 'designer') {
304+
if (summary.includes('fix') || summary.includes('bug') || summary.includes('issue')) return 'BUG';
305+
if (summary.includes('refactor') || summary.includes('improve') || summary.includes('cleanup')) return 'REFACTOR';
306+
return 'FEATURE';
307+
}
308+
309+
return isLearning ? 'LEARNING' : 'WORK';
310+
}
311+
312+
private async createArtifact(type: string, content: string, sections: Record<string, string>): Promise<void> {
313+
const now = new Date();
314+
const timestamp = now.toISOString()
315+
.replace(/:/g, '')
316+
.replace(/\..+/, '')
317+
.replace('T', '-');
318+
319+
const yearMonth = timestamp.substring(0, 7);
320+
const summary = sections['SUMMARY'] || 'no-summary';
321+
const slug = summary.toLowerCase()
322+
.replace(/[^\w\s-]/g, '')
323+
.replace(/\s+/g, '-')
324+
.substring(0, 50);
325+
326+
const filename = `${timestamp}_${type}_${slug}.md`;
327+
328+
let subdir = 'execution';
329+
if (type === 'LEARNING') subdir = 'learnings';
330+
else if (type === 'DECISION') subdir = 'decisions';
331+
else if (type === 'RESEARCH') subdir = 'research';
332+
else if (type === 'WORK') subdir = 'sessions';
333+
else {
334+
// For BUG, REFACTOR, FEATURE
335+
if (type === 'BUG') subdir = join('execution', 'bugs');
336+
else if (type === 'REFACTOR') subdir = join('execution', 'refactors');
337+
else subdir = join('execution', 'features');
338+
}
339+
340+
const targetDir = join(HISTORY_DIR, subdir, yearMonth);
341+
if (!existsSync(targetDir)) {
342+
mkdirSync(targetDir, { recursive: true });
343+
}
344+
345+
const filePath = join(targetDir, filename);
346+
const agentRole = this.getAgentForSession(this.sessionId);
347+
348+
const frontmatter = `---
349+
capture_type: ${type}
350+
timestamp: ${new Date().toISOString()}
351+
session_id: ${this.sessionId}
352+
executor: ${agentRole}
353+
${sections['SUMMARY'] ? `summary: ${sections['SUMMARY'].replace(/"/g, '\\"')}` : ''}
354+
---
355+
356+
${content}
357+
`;
358+
359+
writeFileSync(filePath, redactString(frontmatter), 'utf-8');
360+
}
361+
252362
public logError(context: string, error: any): void {
253363
try {
254364
const now = new Date();

0 commit comments

Comments
 (0)