@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.16",
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
- return response.json();
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/messages?${query}`);
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: 0,
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
- // Skip the initial context text (the conversation history loaded by the agent).
157
- // Context text is massive (30K+ chars), assistant responses are small (<5K).
158
- // Also track that we've seen the context and subsequent text is streamable.
159
- const textLen = (part.text || '').length;
160
- if (!readyToStream.has(messageID) && textLen > 5000) {
161
- if (debugStream) console.error('[stream-event] skipping context text', { trackingId, messageID, textLen });
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 messages = history.messages || [];
183
- if (messages.length === 0) {
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 msg of messages.slice(0, args.limit)) {
193
- const preview = (msg.userMessage || 'No message').substring(0, 80);
194
- const hasResponse = msg.agentResponse && msg.agentResponse.trim();
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: args.message,
122
+ message,
117
123
  selectedAgentKey: 'forLoopCreator',
118
124
  type: 'creator.generate',
119
125
  metadata: { channel: 'creator_generate', storyId: args.storyId },