Skip to content

Commit bd8637c

Browse files
committed
fix(archival): streaming text cache and markdown header parsing
v1.2.0 - Major archival system fixes: - Add messageTextCache Map to capture streaming text from message.part.updated - Update regex to handle markdown-formatted headers (**SUMMARY:** etc.) - Remove console.error calls that corrupted OpenCode TUI - Add deduplication via processedMessageIds Set - Route errors to file-based logging instead of stderr Fixes issue where structured responses weren't being archived due to: 1. Race condition: message.updated fired before streaming content cached 2. Regex mismatch: headers wrapped in markdown bold weren't matched
1 parent bcaac03 commit bd8637c

File tree

6 files changed

+424
-411
lines changed

6 files changed

+424
-411
lines changed

dist/index.js

Lines changed: 79 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,12 @@ function generateTabTitle(completedLine) {
9191
return 'PAI Task Done';
9292
}
9393
export const PAIPlugin = async ({ worktree }) => {
94-
let logger = null;
95-
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();
96100
// Auto-initialize PAI infrastructure if needed
97101
ensurePAIStructure();
98102
// Load CORE skill content from $PAI_DIR/skill/core/SKILL.md
@@ -131,17 +135,33 @@ export const PAIPlugin = async ({ worktree }) => {
131135
const hooks = {
132136
event: async ({ event }) => {
133137
const anyEvent = event;
134-
// Initialize Logger on session creation
135-
if (event.type === 'session.created') {
136-
currentSessionId = anyEvent.properties.info.id;
137-
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));
138148
}
139-
// Handle generic event logging
140-
if (logger &&
141-
event.type !== 'message.part.updated' &&
142-
!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') {
143152
logger.logOpenCodeEvent(event);
144153
}
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+
}
145165
// Handle real-time tab title updates (Pre-Tool Use)
146166
if (anyEvent.type === 'tool.call') {
147167
const props = anyEvent.properties;
@@ -158,39 +178,67 @@ export const PAIPlugin = async ({ worktree }) => {
158178
process.stderr.write(`\x1b]0;Agent: ${type}...\x07`);
159179
}
160180
}
161-
// Handle assistant completion (Tab Titles & UOCS)
181+
// Handle assistant message completion (Tab Titles & Artifact Archival)
162182
if (event.type === 'message.updated') {
163183
const info = anyEvent.properties?.info;
164184
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 : '';
169-
// Look for COMPLETED: line (can be prefaced by 🎯 or just text)
170-
const completedMatch = contentStr.match(/(?:🎯\s*)?COMPLETED:\s*(.+?)(?:\n|$)/i);
171-
if (completedMatch) {
172-
const completedLine = completedMatch[1].trim();
173-
// Set Tab Title
174-
const tabTitle = generateTabTitle(completedLine);
175-
process.stderr.write(`\x1b]0;${tabTitle}\x07`);
176-
// UOCS: Process response for artifact generation
177-
if (logger && contentStr) {
178-
await logger.processAssistantMessage(contentStr);
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('');
179207
}
180208
}
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);
223+
}
181224
}
182225
}
183226
// Handle session deletion / end or idle (for one-shot commands)
184227
if (event.type === 'session.deleted' || event.type === 'session.idle') {
185-
if (logger) {
186-
await logger.generateSessionSummary();
187-
logger.flush();
188-
}
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)
189233
}
190234
},
191235
"tool.execute.after": async (input, output) => {
192-
if (logger) {
193-
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);
194242
}
195243
},
196244
"permission.ask": async (permission) => {

dist/lib/logger.d.ts

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,21 @@
11
import type { Event } from '@opencode-ai/sdk';
22
export declare class Logger {
33
private sessionId;
4+
private worktree;
45
private toolsUsed;
56
private filesChanged;
67
private commandsExecuted;
8+
private processedMessageIds;
79
private startTime;
8-
constructor(sessionId: string);
10+
constructor(sessionId: string, worktree?: string);
11+
private getHistoryDir;
12+
processAssistantMessage(content: string, messageId?: string): Promise<void>;
913
private getPSTTimestamp;
1014
private getEventsFilePath;
1115
private getSessionMappingFile;
1216
private getAgentForSession;
1317
private setAgentForSession;
14-
logEvent(event: Event): void;
1518
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-
*/
2219
logToolExecution(input: {
2320
tool: string;
2421
sessionID: string;
@@ -29,7 +26,6 @@ export declare class Logger {
2926
metadata: any;
3027
}): void;
3128
generateSessionSummary(): Promise<string | null>;
32-
processAssistantMessage(content: string): Promise<void>;
3329
private parseStructuredResponse;
3430
private isLearningCapture;
3531
private determineArtifactType;

0 commit comments

Comments
 (0)