@forloop-cc/forloop-opencode-plugin-planner 2.2.16 → 2.2.18
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/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "@forloop-cc/forloop-opencode-plugin-planner",
|
|
4
|
-
"version": "2.2.
|
|
4
|
+
"version": "2.2.18",
|
|
5
5
|
"description": "ForLoop integration plugin for opencode — Sprint, story, and agent tools for the ForLoop platform",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"license": "MIT",
|
|
@@ -119,7 +119,6 @@ export class ForLoopAPIClient {
|
|
|
119
119
|
|
|
120
120
|
private async ensureOpencodeApiToken(endpoint: string): Promise<void> {
|
|
121
121
|
if (this.tokenType !== 'jwt') return;
|
|
122
|
-
if (!endpoint.startsWith('/api/opencode/')) return;
|
|
123
122
|
if (endpoint.startsWith('/api/opencode/auth/tokens')) return;
|
|
124
123
|
|
|
125
124
|
if (!this.jwtExchangePromise) {
|
|
@@ -221,7 +220,20 @@ export class ForLoopAPIClient {
|
|
|
221
220
|
throw error;
|
|
222
221
|
}
|
|
223
222
|
|
|
224
|
-
|
|
223
|
+
if (response.status === 204) {
|
|
224
|
+
return undefined as unknown as T;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const text = await response.text();
|
|
228
|
+
if (!text) {
|
|
229
|
+
return undefined as unknown as T;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
try {
|
|
233
|
+
return JSON.parse(text) as T;
|
|
234
|
+
} catch {
|
|
235
|
+
throw new APIError('invalid_response', 'Failed to parse response body as JSON', 502);
|
|
236
|
+
}
|
|
225
237
|
} catch (error) {
|
|
226
238
|
lastError = error as Error;
|
|
227
239
|
|
|
@@ -456,8 +468,9 @@ export class ForLoopAPIClient {
|
|
|
456
468
|
async getConversationHistory(params?: ConversationHistoryParams): Promise<any> {
|
|
457
469
|
const query = new URLSearchParams();
|
|
458
470
|
if (params?.sprintId) query.append('sprintId', String(params.sprintId));
|
|
471
|
+
if (params?.conversationId) query.append('conversationId', String(params.conversationId));
|
|
459
472
|
if (params?.limit) query.append('limit', String(params.limit));
|
|
460
|
-
return this.request(`/api/opencode/
|
|
473
|
+
return this.request(`/api/opencode/conversations/context?${query}`);
|
|
461
474
|
}
|
|
462
475
|
|
|
463
476
|
async clearConversationHistory(params?: ClearHistoryParams): Promise<any> {
|
|
@@ -623,6 +636,7 @@ export interface WriteConversationEventInput {
|
|
|
623
636
|
|
|
624
637
|
export interface ConversationHistoryParams {
|
|
625
638
|
sprintId?: number;
|
|
639
|
+
conversationId?: string;
|
|
626
640
|
limit?: number;
|
|
627
641
|
}
|
|
628
642
|
|
|
@@ -75,6 +75,22 @@ function extractTextFromParts(parts: any[]): string {
|
|
|
75
75
|
.join('');
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
function isPendingUserMessagePart(messageID: string): boolean {
|
|
79
|
+
if (!messageID) return false;
|
|
80
|
+
for (const pending of userMessageBuffer.values()) {
|
|
81
|
+
if (pending.messageId === messageID) return true;
|
|
82
|
+
}
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function looksLikeInjectedContext(text: string): boolean {
|
|
87
|
+
return (
|
|
88
|
+
text.includes('Current user request:') ||
|
|
89
|
+
text.includes('Conversation history:') ||
|
|
90
|
+
text.includes('Conversation summary:')
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
78
94
|
export function createChatMessageHook(client: ForLoopAPIClient) {
|
|
79
95
|
return async (input: any, output: any) => {
|
|
80
96
|
const sprintId = readActiveSprintId();
|
|
@@ -110,6 +126,7 @@ export function createEventHook(client: ForLoopAPIClient) {
|
|
|
110
126
|
const textBuffer = new Map<string, string>();
|
|
111
127
|
const readyToStream = new Set<string>(); // messageIDs that have passed the context phase
|
|
112
128
|
const directSentText = new Map<string, string>(); // trackingId → last full text sent via direct HTTP
|
|
129
|
+
const directSentIndex = new Map<string, number>(); // trackingId → next chunk index
|
|
113
130
|
|
|
114
131
|
const sendStreamDelta = (sprintId: number, trackingId: string, taskId: string, fullText: string) => {
|
|
115
132
|
const lastSent = directSentText.get(trackingId) || '';
|
|
@@ -117,12 +134,14 @@ export function createEventHook(client: ForLoopAPIClient) {
|
|
|
117
134
|
const delta = fullText.startsWith(lastSent) ? fullText.substring(lastSent.length) : fullText;
|
|
118
135
|
if (!delta) return;
|
|
119
136
|
directSentText.set(trackingId, fullText);
|
|
137
|
+
const index = directSentIndex.get(trackingId) || 0;
|
|
138
|
+
directSentIndex.set(trackingId, index + 1);
|
|
120
139
|
client.sendStreamChunk({
|
|
121
140
|
taskId: taskId || '',
|
|
122
141
|
trackingId,
|
|
123
142
|
sprintId,
|
|
124
143
|
chunk: delta,
|
|
125
|
-
index
|
|
144
|
+
index,
|
|
126
145
|
}).catch((err: Error) => {
|
|
127
146
|
console.warn('[ForLoop] Direct stream chunk failed:', err.message);
|
|
128
147
|
});
|
|
@@ -153,12 +172,19 @@ export function createEventHook(client: ForLoopAPIClient) {
|
|
|
153
172
|
break;
|
|
154
173
|
}
|
|
155
174
|
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
175
|
+
// Text parts that belong to the pending user message carry the
|
|
176
|
+
// injected conversation history — never stream them as agent output.
|
|
177
|
+
if (isPendingUserMessagePart(messageID || '')) {
|
|
178
|
+
if (debugStream) console.error('[stream-event] skipping user-message text', { trackingId, messageID });
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Fallback: the very first text part of a message that carries the
|
|
183
|
+
// injected-prompt markers is a replay of the loaded context, not a
|
|
184
|
+
// genuine response. A length threshold alone would discard long
|
|
185
|
+
// assistant replies.
|
|
186
|
+
if (!readyToStream.has(messageID) && looksLikeInjectedContext(part.text || '')) {
|
|
187
|
+
if (debugStream) console.error('[stream-event] skipping injected context text', { trackingId, messageID });
|
|
162
188
|
readyToStream.add(messageID); // mark as past context
|
|
163
189
|
break;
|
|
164
190
|
}
|
|
@@ -166,6 +192,7 @@ export function createEventHook(client: ForLoopAPIClient) {
|
|
|
166
192
|
readyToStream.add(messageID);
|
|
167
193
|
const current = textBuffer.get(part.messageID) || '';
|
|
168
194
|
textBuffer.set(part.messageID, part.text || current);
|
|
195
|
+
const textLen = (part.text || '').length;
|
|
169
196
|
if (debugStream) console.error('[stream-event] writing chunk', { trackingId, textLen });
|
|
170
197
|
|
|
171
198
|
// Send delta directly for real-time streaming (primary path)
|
|
@@ -173,14 +173,26 @@ export function createConversationHistoryTool(client: ForLoopAPIClient) {
|
|
|
173
173
|
const resolution = await resolveSprintId(args.sprintId, context.directory);
|
|
174
174
|
const sprintId = resolution.sprintId || args.sprintId;
|
|
175
175
|
|
|
176
|
+
if (typeof sprintId !== 'number' || !Number.isInteger(sprintId) || sprintId <= 0) {
|
|
177
|
+
return [
|
|
178
|
+
'❌ No sprint ID provided.',
|
|
179
|
+
'',
|
|
180
|
+
'Please specify a sprint ID using one of these methods:',
|
|
181
|
+
' 1. Pass --sprintId flag',
|
|
182
|
+
' 2. Set FORLOOP_SPRINT_ID environment variable',
|
|
183
|
+
' 3. Use a git branch named sprint-XXX (e.g., sprint-123)',
|
|
184
|
+
].join('\n');
|
|
185
|
+
}
|
|
186
|
+
|
|
176
187
|
try {
|
|
177
188
|
const history = await client.getConversationHistory({
|
|
178
189
|
sprintId,
|
|
179
190
|
limit: args.limit,
|
|
180
191
|
});
|
|
181
192
|
|
|
182
|
-
const
|
|
183
|
-
|
|
193
|
+
const summaries = history?.summaries || [];
|
|
194
|
+
const messages = history?.messages || [];
|
|
195
|
+
if (summaries.length === 0 && messages.length === 0) {
|
|
184
196
|
return 'No conversation history found.';
|
|
185
197
|
}
|
|
186
198
|
|
|
@@ -189,28 +201,15 @@ export function createConversationHistoryTool(client: ForLoopAPIClient) {
|
|
|
189
201
|
'',
|
|
190
202
|
];
|
|
191
203
|
|
|
192
|
-
for (const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
lines.push(`📌 **${preview}**${preview.length >= 80 ? '...' : ''}`);
|
|
197
|
-
|
|
198
|
-
if (msg.conversationId) {
|
|
199
|
-
const agentMatch = msg.conversationId.match(/agent:([^:]+)/);
|
|
200
|
-
if (agentMatch) {
|
|
201
|
-
lines.push(` Agent: ${agentMatch[1]} | ${formatDate(msg.timestamp)}`);
|
|
202
|
-
} else {
|
|
203
|
-
lines.push(` ${formatDate(msg.timestamp)}`);
|
|
204
|
-
}
|
|
205
|
-
} else {
|
|
206
|
-
lines.push(` ${formatDate(msg.timestamp)}`);
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
if (hasResponse) {
|
|
210
|
-
const respPreview = msg.agentResponse.substring(0, 100);
|
|
211
|
-
lines.push(` → ${respPreview}${msg.agentResponse.length > 100 ? '...' : ''}`);
|
|
212
|
-
}
|
|
204
|
+
for (const summary of summaries) {
|
|
205
|
+
lines.push('📋 **Sprint summary**', '', summary, '');
|
|
206
|
+
}
|
|
213
207
|
|
|
208
|
+
for (const msg of messages.slice(0, args.limit)) {
|
|
209
|
+
const badge = msg.role === 'assistant' ? '🤖 Assistant' : '👤 User';
|
|
210
|
+
const content = String(msg.content || '');
|
|
211
|
+
const preview = content.substring(0, 120);
|
|
212
|
+
lines.push(`${badge}: ${preview}${content.length > 120 ? '...' : ''}`);
|
|
214
213
|
lines.push('');
|
|
215
214
|
}
|
|
216
215
|
|
|
@@ -264,12 +263,3 @@ export function createClearHistoryTool(client: ForLoopAPIClient) {
|
|
|
264
263
|
},
|
|
265
264
|
});
|
|
266
265
|
}
|
|
267
|
-
|
|
268
|
-
function formatDate(dateString: string): string {
|
|
269
|
-
return new Date(dateString).toLocaleDateString('en-US', {
|
|
270
|
-
month: 'short',
|
|
271
|
-
day: 'numeric',
|
|
272
|
-
hour: '2-digit',
|
|
273
|
-
minute: '2-digit',
|
|
274
|
-
});
|
|
275
|
-
}
|
|
@@ -111,9 +111,15 @@ function createCreatorGenerateTool(client: ForLoopAPIClient) {
|
|
|
111
111
|
storyId: args.storyId,
|
|
112
112
|
});
|
|
113
113
|
|
|
114
|
+
// The ECS pipeline (EventBridge → SFN → ECS) does not carry `metadata`,
|
|
115
|
+
// so the Creator agent only ever sees the `message`. Bake the storyId into
|
|
116
|
+
// the message so the Creator can forloopContextFetch the correct story.
|
|
117
|
+
const baseMessage = args.message || 'Generate the files described in the assigned story';
|
|
118
|
+
const message = `${baseMessage}\n\nTarget story ID: ${args.storyId}`;
|
|
119
|
+
|
|
114
120
|
const response = await client.chatWithAI({
|
|
115
121
|
sprintId: args.sprintId,
|
|
116
|
-
message
|
|
122
|
+
message,
|
|
117
123
|
selectedAgentKey: 'forLoopCreator',
|
|
118
124
|
type: 'creator.generate',
|
|
119
125
|
metadata: { channel: 'creator_generate', storyId: args.storyId },
|