@forloop-cc/forloop-opencode-plugin-planner 2.2.5 → 2.2.7
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.7",
|
|
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",
|
|
@@ -24,6 +24,10 @@
|
|
|
24
24
|
".": {
|
|
25
25
|
"import": "./plugins/forloop-plugin.ts",
|
|
26
26
|
"types": "./plugins/forloop-plugin.ts"
|
|
27
|
+
},
|
|
28
|
+
"./exports": {
|
|
29
|
+
"import": "./plugins/exports.ts",
|
|
30
|
+
"types": "./plugins/exports.ts"
|
|
27
31
|
}
|
|
28
32
|
},
|
|
29
33
|
"files": [
|
|
@@ -519,6 +519,19 @@ export class ForLoopAPIClient {
|
|
|
519
519
|
body: JSON.stringify({ sprintId, sessionId }),
|
|
520
520
|
});
|
|
521
521
|
}
|
|
522
|
+
|
|
523
|
+
async sendStreamChunk(data: {
|
|
524
|
+
taskId: string;
|
|
525
|
+
trackingId: string;
|
|
526
|
+
sprintId: number;
|
|
527
|
+
chunk: string;
|
|
528
|
+
index: number;
|
|
529
|
+
}): Promise<any> {
|
|
530
|
+
return this.request('/api/opencode/agents/stream', {
|
|
531
|
+
method: 'POST',
|
|
532
|
+
body: JSON.stringify(data),
|
|
533
|
+
});
|
|
534
|
+
}
|
|
522
535
|
}
|
|
523
536
|
|
|
524
537
|
export interface AgentSuggestionRequest {
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Re-exports for @forloop-cc/forloop-cli consumption.
|
|
2
|
+
// These were moved out of forloop-plugin.ts because Bun's TypeScript
|
|
3
|
+
// transpiler in opencode-ai@1.15.6 fails when a file has both
|
|
4
|
+
// `export default async` and additional `export { }` / `export type { }` lines.
|
|
5
|
+
export { ForLoopAPIClient } from './capabilities/api-client';
|
|
6
|
+
export type { APIError, CreateStoryRequest, CreateSprintRequest, ListSprintsParams, SprintOptions } from './capabilities/api-client';
|
|
7
|
+
export { getConfig, isLambdaExecution, PROD_API_URL, DEV_API_URL } from './capabilities/config';
|
|
8
|
+
export type { ForLoopConfig } from './capabilities/config';
|
|
9
|
+
export { getToken, setToken, clearToken } from './capabilities/token-storage';
|
|
10
|
+
export type { TokenProfile } from './capabilities/token-storage';
|
|
11
|
+
export { validateToken, detectTokenType } from './capabilities/auth';
|
|
12
|
+
export type { TokenValidationResult } from './capabilities/auth';
|
|
13
|
+
export { resolveSprintId, getCurrentBranch, resolveSprintFromBranch, getForloopRoot, getManifestPath } from './capabilities/context-resolver';
|
|
14
|
+
export type { ContextResolution } from './capabilities/context-resolver';
|
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
createFileListTool,
|
|
36
36
|
createFileDeleteTool,
|
|
37
37
|
createFileDownloadUrlTool,
|
|
38
|
+
createFileDownloadTool,
|
|
38
39
|
} from './tools/file-tools';
|
|
39
40
|
import {
|
|
40
41
|
createScheduleTool,
|
|
@@ -66,6 +67,18 @@ import { createDeveloperStatusTool } from './tools/developer-status-tool';
|
|
|
66
67
|
import { injectEnvironment } from './hooks/shell-env';
|
|
67
68
|
import { createChatMessageHook, createEventHook } from './hooks/message-recorder';
|
|
68
69
|
|
|
70
|
+
// Named re-exports — see plugins/exports.ts for details
|
|
71
|
+
export { ForLoopAPIClient } from './capabilities/api-client';
|
|
72
|
+
export type { APIError, CreateStoryRequest, CreateSprintRequest, ListSprintsParams, SprintOptions } from './capabilities/api-client';
|
|
73
|
+
export { getConfig, isLambdaExecution, PROD_API_URL, DEV_API_URL } from './capabilities/config';
|
|
74
|
+
export type { ForLoopConfig } from './capabilities/config';
|
|
75
|
+
export { getToken, setToken, clearToken } from './capabilities/token-storage';
|
|
76
|
+
export type { TokenProfile } from './capabilities/token-storage';
|
|
77
|
+
export { validateToken, detectTokenType } from './capabilities/auth';
|
|
78
|
+
export type { TokenValidationResult } from './capabilities/auth';
|
|
79
|
+
export { resolveSprintId, getCurrentBranch, resolveSprintFromBranch, getForloopRoot, getManifestPath } from './capabilities/context-resolver';
|
|
80
|
+
export type { ContextResolution } from './capabilities/context-resolver';
|
|
81
|
+
|
|
69
82
|
export default async (ctx: any) => {
|
|
70
83
|
const config = getConfig();
|
|
71
84
|
const token = (await getToken()) || '';
|
|
@@ -123,7 +136,8 @@ export default async (ctx: any) => {
|
|
|
123
136
|
'forloopFileUpload': createFileUploadTool(client),
|
|
124
137
|
'forloopFileList': createFileListTool(client),
|
|
125
138
|
'forloopFileDelete': createFileDeleteTool(client),
|
|
126
|
-
'
|
|
139
|
+
'forloopFileDownloadUrl': createFileDownloadUrlTool(client),
|
|
140
|
+
'forloopFileDownload': createFileDownloadTool(client),
|
|
127
141
|
|
|
128
142
|
// Schedule Meetings
|
|
129
143
|
'forloopScheduleCreate': createScheduleTool(client),
|
|
@@ -174,13 +188,3 @@ export default async (ctx: any) => {
|
|
|
174
188
|
};
|
|
175
189
|
};
|
|
176
190
|
|
|
177
|
-
export { ForLoopAPIClient } from './capabilities/api-client';
|
|
178
|
-
export type { APIError, CreateStoryRequest, CreateSprintRequest, ListSprintsParams, SprintOptions } from './capabilities/api-client';
|
|
179
|
-
export { getConfig, isLambdaExecution, PROD_API_URL, DEV_API_URL } from './capabilities/config';
|
|
180
|
-
export type { ForLoopConfig } from './capabilities/config';
|
|
181
|
-
export { getToken, setToken, clearToken } from './capabilities/token-storage';
|
|
182
|
-
export type { TokenProfile } from './capabilities/token-storage';
|
|
183
|
-
export { validateToken, detectTokenType } from './capabilities/auth';
|
|
184
|
-
export type { TokenValidationResult } from './capabilities/auth';
|
|
185
|
-
export { resolveSprintId, getCurrentBranch, resolveSprintFromBranch, getForloopRoot, getManifestPath } from './capabilities/context-resolver';
|
|
186
|
-
export type { ContextResolution } from './capabilities/context-resolver';
|
|
@@ -49,8 +49,13 @@ async function recordAssistantMessage(
|
|
|
49
49
|
await new Promise((resolve) => setTimeout(resolve, 600 * i));
|
|
50
50
|
}
|
|
51
51
|
const result = await client.recordMessage(data);
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
// "no matching user message" is expected in Lambda — the server's
|
|
53
|
+
// handleAICallback creates the full Conversation record separately.
|
|
54
|
+
if (result?.message === 'no matching user message to update') {
|
|
55
|
+
console.log(`[ForLoop] No matching user message (expected in Lambda) — callback handles persistence`);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
return;
|
|
54
59
|
} catch (err: any) {
|
|
55
60
|
console.warn(`[ForLoop] Attempt ${i + 1}/${attempts} failed:`, err.message);
|
|
56
61
|
if (i === attempts - 1) {
|
|
@@ -72,19 +77,26 @@ export function createChatMessageHook(client: ForLoopAPIClient) {
|
|
|
72
77
|
const sprintId = readActiveSprintId();
|
|
73
78
|
if (!sprintId) return;
|
|
74
79
|
|
|
80
|
+
const { sessionID, agent, model, messageID } = input;
|
|
81
|
+
const { message, parts } = output;
|
|
82
|
+
const content = extractTextFromParts(parts);
|
|
83
|
+
|
|
84
|
+
// Log incoming user message for debug visibility (Lambda)
|
|
85
|
+
if (content && messageID) {
|
|
86
|
+
console.log(`[ForLoop] REQUEST — user message (${content.length} chars)`, {
|
|
87
|
+
sprintId,
|
|
88
|
+
agent: agent || 'unknown',
|
|
89
|
+
sessionId: sessionID,
|
|
90
|
+
preview: content.substring(0, 300),
|
|
91
|
+
});
|
|
92
|
+
}
|
|
75
93
|
// Only record on user's local machine; Lambda records via its own system
|
|
76
94
|
if (isLambdaExecution()) return;
|
|
77
95
|
|
|
78
|
-
const { sessionID, agent, model, messageID } = input;
|
|
79
|
-
|
|
80
96
|
// Only record messages with a model (indicates actual LLM processing,
|
|
81
97
|
// not system events like context compaction or summary generation)
|
|
82
98
|
if (!model || !model.providerID) return;
|
|
83
99
|
|
|
84
|
-
const { message, parts } = output;
|
|
85
|
-
const content = extractTextFromParts(parts);
|
|
86
|
-
if (!content) return;
|
|
87
|
-
|
|
88
100
|
const conversationId = buildConversationId(sprintId, agent || 'unknown', sessionID);
|
|
89
101
|
|
|
90
102
|
// Fire-and-forget: don't await - must not block the LLM from receiving the message
|
|
@@ -107,28 +119,23 @@ export function createChatMessageHook(client: ForLoopAPIClient) {
|
|
|
107
119
|
export function createEventHook(client: ForLoopAPIClient) {
|
|
108
120
|
const textBuffer = new Map<string, string>();
|
|
109
121
|
const readyToStream = new Set<string>(); // messageIDs that have passed the context phase
|
|
122
|
+
const directSentText = new Map<string, string>(); // trackingId → last full text sent via direct HTTP
|
|
110
123
|
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
lastUpdated: Date.now(),
|
|
127
|
-
};
|
|
128
|
-
fs.writeFileSync(filePath, JSON.stringify(data));
|
|
129
|
-
} catch (err: any) {
|
|
130
|
-
console.warn('[ForLoop] Failed to write stream chunk:', err.message);
|
|
131
|
-
}
|
|
124
|
+
const sendStreamDelta = (sprintId: number, trackingId: string, taskId: string, fullText: string) => {
|
|
125
|
+
const lastSent = directSentText.get(trackingId) || '';
|
|
126
|
+
if (!fullText || fullText.length <= lastSent.length) return;
|
|
127
|
+
const delta = fullText.startsWith(lastSent) ? fullText.substring(lastSent.length) : fullText;
|
|
128
|
+
if (!delta) return;
|
|
129
|
+
directSentText.set(trackingId, fullText);
|
|
130
|
+
client.sendStreamChunk({
|
|
131
|
+
taskId: taskId || '',
|
|
132
|
+
trackingId,
|
|
133
|
+
sprintId,
|
|
134
|
+
chunk: delta,
|
|
135
|
+
index: 0,
|
|
136
|
+
}).catch((err: Error) => {
|
|
137
|
+
console.warn('[ForLoop] Direct stream chunk failed:', err.message);
|
|
138
|
+
});
|
|
132
139
|
};
|
|
133
140
|
|
|
134
141
|
return async ({ event }: { event: { type: string; properties: any } }) => {
|
|
@@ -138,14 +145,15 @@ export function createEventHook(client: ForLoopAPIClient) {
|
|
|
138
145
|
const trackingId = process.env.FORLOOP_TRACKING_ID;
|
|
139
146
|
const taskId = process.env.FORLOOP_TASK_ID;
|
|
140
147
|
const isLambda = isLambdaExecution();
|
|
148
|
+
const debugStream = process.env.FORLOOP_DEBUG_STREAM === 'true';
|
|
141
149
|
|
|
142
|
-
// Lambda with streaming:
|
|
150
|
+
// Lambda with streaming: send chunks directly via HTTP
|
|
143
151
|
if (isLambda && trackingId && process.env.FORLOOP_STREAM_ENABLED === 'true') {
|
|
144
152
|
switch (event.type) {
|
|
145
153
|
case 'message.part.updated': {
|
|
146
154
|
const part = event.properties?.part;
|
|
147
155
|
const messageID = part?.messageID || '';
|
|
148
|
-
console.error('[stream-event] message.part.updated', { type: part?.type, trackingId, sprintId, messageID });
|
|
156
|
+
if (debugStream) console.error('[stream-event] message.part.updated', { type: part?.type, trackingId, sprintId, messageID });
|
|
149
157
|
|
|
150
158
|
// Skip non-text parts — but track that this messageID is now in "generation" phase
|
|
151
159
|
if (!part || part.type !== 'text') {
|
|
@@ -160,7 +168,7 @@ export function createEventHook(client: ForLoopAPIClient) {
|
|
|
160
168
|
// Also track that we've seen the context and subsequent text is streamable.
|
|
161
169
|
const textLen = (part.text || '').length;
|
|
162
170
|
if (!readyToStream.has(messageID) && textLen > 5000) {
|
|
163
|
-
console.error('[stream-event] skipping context text', { trackingId, messageID, textLen });
|
|
171
|
+
if (debugStream) console.error('[stream-event] skipping context text', { trackingId, messageID, textLen });
|
|
164
172
|
readyToStream.add(messageID); // mark as past context
|
|
165
173
|
break;
|
|
166
174
|
}
|
|
@@ -168,8 +176,10 @@ export function createEventHook(client: ForLoopAPIClient) {
|
|
|
168
176
|
readyToStream.add(messageID);
|
|
169
177
|
const current = textBuffer.get(part.messageID) || '';
|
|
170
178
|
textBuffer.set(part.messageID, part.text || current);
|
|
171
|
-
console.error('[stream-event] writing chunk', { trackingId, textLen });
|
|
172
|
-
|
|
179
|
+
if (debugStream) console.error('[stream-event] writing chunk', { trackingId, textLen });
|
|
180
|
+
|
|
181
|
+
// Send delta directly for real-time streaming (primary path)
|
|
182
|
+
sendStreamDelta(sprintId, trackingId, taskId || '', part.text || '');
|
|
173
183
|
break;
|
|
174
184
|
}
|
|
175
185
|
case 'message.updated': {
|
|
@@ -177,7 +187,14 @@ export function createEventHook(client: ForLoopAPIClient) {
|
|
|
177
187
|
if (!info || info.role !== 'assistant') break;
|
|
178
188
|
const bufferedText = textBuffer.get(info.id);
|
|
179
189
|
if (!bufferedText) break;
|
|
180
|
-
|
|
190
|
+
|
|
191
|
+
console.log(`[ForLoop] RESPONSE — planner message (${bufferedText.length} chars)`, {
|
|
192
|
+
sprintId,
|
|
193
|
+
trackingId,
|
|
194
|
+
sessionId: info.sessionID,
|
|
195
|
+
agent: info.agent || 'unknown',
|
|
196
|
+
preview: bufferedText.substring(0, 500),
|
|
197
|
+
});
|
|
181
198
|
|
|
182
199
|
const conversationId = buildConversationId(
|
|
183
200
|
sprintId,
|
|
@@ -176,10 +176,10 @@ export function createFileDeleteTool(client: ForLoopAPIClient) {
|
|
|
176
176
|
|
|
177
177
|
export function createFileDownloadUrlTool(client: ForLoopAPIClient) {
|
|
178
178
|
return tool({
|
|
179
|
-
description: 'Get a download URL for a file',
|
|
179
|
+
description: 'Get a presigned download URL for a file (does not download the file itself)',
|
|
180
180
|
args: {
|
|
181
181
|
fileId: tool.schema.number()
|
|
182
|
-
.describe('File ID to download'),
|
|
182
|
+
.describe('File ID to get download URL for'),
|
|
183
183
|
},
|
|
184
184
|
async execute(args, _context) {
|
|
185
185
|
const tokenResult = await validateToken();
|
|
@@ -189,10 +189,9 @@ export function createFileDownloadUrlTool(client: ForLoopAPIClient) {
|
|
|
189
189
|
client.setToken?.(tokenResult.token);
|
|
190
190
|
|
|
191
191
|
try {
|
|
192
|
-
//
|
|
193
|
-
|
|
192
|
+
// Fire-and-forget audit log — don't block download on audit failure
|
|
193
|
+
client.logFileRead(args.fileId).catch(() => {});
|
|
194
194
|
|
|
195
|
-
// Get download URL
|
|
196
195
|
const download = await client.getFileDownloadUrl(args.fileId);
|
|
197
196
|
|
|
198
197
|
return [
|
|
@@ -209,3 +208,59 @@ export function createFileDownloadUrlTool(client: ForLoopAPIClient) {
|
|
|
209
208
|
},
|
|
210
209
|
});
|
|
211
210
|
}
|
|
211
|
+
|
|
212
|
+
export function createFileDownloadTool(client: ForLoopAPIClient) {
|
|
213
|
+
return tool({
|
|
214
|
+
description: 'Download a sprint file by ID to the local sandbox',
|
|
215
|
+
args: {
|
|
216
|
+
fileId: tool.schema.number()
|
|
217
|
+
.describe('File ID to download (from forloopFileList)'),
|
|
218
|
+
destPath: tool.schema.string()
|
|
219
|
+
.optional()
|
|
220
|
+
.describe('Destination path relative to sandbox downloads dir (default: <filename>)'),
|
|
221
|
+
},
|
|
222
|
+
async execute(args, _context) {
|
|
223
|
+
const tokenResult = await validateToken();
|
|
224
|
+
if (!tokenResult.valid) {
|
|
225
|
+
return tokenResult.error ?? '❌ No API token configured.';
|
|
226
|
+
}
|
|
227
|
+
client.setToken?.(tokenResult.token);
|
|
228
|
+
|
|
229
|
+
try {
|
|
230
|
+
const open = await client.getFileOpenUrl(Number(args.fileId));
|
|
231
|
+
const url = String(open?.openUrl || open?.url || '');
|
|
232
|
+
if (!url) {
|
|
233
|
+
return `❌ Could not get download URL for file #${args.fileId}.`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const response = await fetch(url);
|
|
237
|
+
if (!response.ok) {
|
|
238
|
+
return `❌ Download failed: HTTP ${response.status}`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const buf = Buffer.from(await response.arrayBuffer());
|
|
242
|
+
const fileName = open?.originalName || open?.filename || `file-${args.fileId}`;
|
|
243
|
+
const downloadDir = path.join(process.env.HOME || '/tmp', 'downloads');
|
|
244
|
+
if (!fs.existsSync(downloadDir)) {
|
|
245
|
+
fs.mkdirSync(downloadDir, { recursive: true });
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const destFile = args.destPath
|
|
249
|
+
? path.join(downloadDir, args.destPath)
|
|
250
|
+
: path.join(downloadDir, fileName);
|
|
251
|
+
const destDir = path.dirname(destFile);
|
|
252
|
+
if (!fs.existsSync(destDir)) {
|
|
253
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
254
|
+
}
|
|
255
|
+
fs.writeFileSync(destFile, buf);
|
|
256
|
+
|
|
257
|
+
// Log file read (fire-and-forget audit)
|
|
258
|
+
client.logFileRead(args.fileId).catch(() => {});
|
|
259
|
+
|
|
260
|
+
return `✅ Downloaded file #${args.fileId} → ${destFile} (${buf.length} bytes)`;
|
|
261
|
+
} catch (error: any) {
|
|
262
|
+
return `❌ File download failed: ${error.message}`;
|
|
263
|
+
}
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
}
|