@forloop-cc/forloop-opencode-plugin-planner 2.2.22 → 2.2.24

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.22",
4
+ "version": "2.2.24",
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",
@@ -144,6 +144,7 @@ export class ForLoopAPIClient {
144
144
  'organization:read',
145
145
  'organization:write',
146
146
  'organization:delete',
147
+ 'a2a:read',
147
148
  ];
148
149
 
149
150
  const name = `lambda-planner-${Date.now()}`;
@@ -369,6 +370,33 @@ export class ForLoopAPIClient {
369
370
  });
370
371
  }
371
372
 
373
+ async searchCoreServices(params: {
374
+ query?: string;
375
+ tool?: string;
376
+ directOnly?: boolean;
377
+ limit?: number;
378
+ offset?: number;
379
+ sprintId?: number;
380
+ subjectKey?: string;
381
+ } = {}): Promise<{ entries: any[]; total: number }> {
382
+ const search = new URLSearchParams();
383
+ if (params.query) search.set('q', params.query);
384
+ if (params.tool) search.set('tool', params.tool);
385
+ if (params.directOnly) search.set('direct', '1');
386
+ if (params.limit !== undefined) search.set('limit', String(params.limit));
387
+ if (params.offset !== undefined) search.set('offset', String(params.offset));
388
+ if (params.sprintId !== undefined) search.set('sprintId', String(params.sprintId));
389
+ if (params.subjectKey) search.set('subjectKey', params.subjectKey);
390
+ const query = search.toString();
391
+ return this.request(`/api/opencode/a2a/registry${query ? `?${query}` : ''}`);
392
+ }
393
+
394
+ async getCoreServiceContract(agentId: string, version: string): Promise<any> {
395
+ return this.request(
396
+ `/api/opencode/a2a/contracts/${encodeURIComponent(agentId)}/${encodeURIComponent(version)}`
397
+ );
398
+ }
399
+
372
400
  async summarizeConversation(sprintId: number): Promise<any> {
373
401
  return this.request(`/api/opencode/sprints/${sprintId}/summarize`, {
374
402
  method: 'POST',
@@ -17,7 +17,10 @@ const MIME_TYPES: Record<string, string> = {
17
17
  };
18
18
 
19
19
  export function contentTypeFromExtension(ext: string): string {
20
- return MIME_TYPES[String(ext || '').toLowerCase()] || 'application/octet-stream';
20
+ const base = MIME_TYPES[String(ext || '').toLowerCase()] || 'application/octet-stream';
21
+ // Text payloads are always UTF-8; without an explicit charset browsers fall
22
+ // back to Latin-1 and render multi-byte characters as mojibake.
23
+ return base.startsWith('text/') && !/charset/i.test(base) ? `${base}; charset=utf-8` : base;
21
24
  }
22
25
 
23
26
  export function formatFileSize(bytes: number): string {
@@ -68,6 +68,11 @@ import {
68
68
  } from './tools/agent-sprint-tools';
69
69
  import { createAITriggerTools } from './tools/ai-trigger-tools';
70
70
  import { createDeveloperStatusTool } from './tools/developer-status-tool';
71
+ import {
72
+ createCoreServicesSearchTool,
73
+ createCoreServiceGetTool,
74
+ createCoreServiceIntegrationRequestTool,
75
+ } from './tools/core-service-tools';
71
76
  import { injectEnvironment } from './hooks/shell-env';
72
77
  import { createChatMessageHook, createEventHook } from './hooks/message-recorder';
73
78
 
@@ -167,6 +172,11 @@ export default async (ctx: any) => {
167
172
  // AI Execution Tools
168
173
  ...aiTriggerTools,
169
174
  'forloopDeveloperStatus': createDeveloperStatusTool(client),
175
+
176
+ // Core-service discovery (read-only) + user-approval request
177
+ 'core_services_search': createCoreServicesSearchTool(client),
178
+ 'core_service_get': createCoreServiceGetTool(client),
179
+ 'core_service_integration_request': createCoreServiceIntegrationRequestTool(client),
170
180
  },
171
181
  event: createEventHook(client),
172
182
  'chat.message': createChatMessageHook(client),
@@ -0,0 +1,274 @@
1
+ import { tool } from '@opencode-ai/plugin';
2
+ import { ForLoopAPIClient } from '../capabilities/api-client';
3
+ import { validateToken } from '../capabilities/auth';
4
+ import { resolveSprintId } from '../capabilities/context-resolver';
5
+ import { getConfig } from '../capabilities/config';
6
+
7
+ type RegistryEntry = {
8
+ agentId: string;
9
+ sprintId: number;
10
+ name: string;
11
+ kind?: 'system' | 'user';
12
+ description: string;
13
+ serviceCode?: string | null;
14
+ baseUrl?: string | null;
15
+ billingPrice?: number | null;
16
+ billingUnit?: string | null;
17
+ billingCurrency?: string | null;
18
+ version?: string | null;
19
+ versionStatus?: string | null;
20
+ sunsetAt?: string | null;
21
+ deprecatedAt?: string | null;
22
+ authMode?: 'none' | 'app' | 'user';
23
+ scopes?: { id: string; tier?: string; description?: string }[];
24
+ tools?: { id: string; name: string; description?: string; whenToUse?: string }[];
25
+ directIntegrable?: boolean;
26
+ };
27
+
28
+ const formatPrice = (entry: RegistryEntry): string => {
29
+ if (entry.billingPrice == null) return 'price TBD';
30
+ const currency = entry.billingCurrency || 'FLC';
31
+ const unit = entry.billingUnit ? ` per ${entry.billingUnit}` : ' per success';
32
+ return `${entry.billingPrice} ${currency}${unit}`;
33
+ };
34
+
35
+ const formatEntry = (entry: RegistryEntry, index: number): string => {
36
+ const lines = [
37
+ `${index + 1}. **${entry.name}** — ${entry.kind === 'system' ? 'ForLoop core service' : 'user app'}`,
38
+ ` - agentId: \`${entry.agentId}\``,
39
+ ` - kind: ${entry.kind ?? 'unknown'}`,
40
+ ` - serviceCode: ${entry.serviceCode ?? 'unknown'}`,
41
+ ` - baseUrl: ${entry.baseUrl ?? 'not published'}`,
42
+ ` - version: ${entry.version ?? 'unspecified'} (${entry.versionStatus ?? 'unknown'})${entry.sunsetAt ? `; sunset ${entry.sunsetAt}` : ''}`,
43
+ ` - price: ${formatPrice(entry)}`,
44
+ ` - auth: ${entry.authMode ?? 'none'}${entry.directIntegrable ? '; direct-integration enabled' : ''}`,
45
+ ` - scopes: ${(entry.scopes ?? []).map((scope) => scope.id).join(', ') || 'none declared'}`,
46
+ ` - description: ${entry.description || '(none)'}`,
47
+ ];
48
+ if (entry.tools?.length) {
49
+ lines.push(' - tools:');
50
+ for (const serviceTool of entry.tools) {
51
+ const guidance = serviceTool.whenToUse
52
+ ? ` — ${serviceTool.whenToUse}`
53
+ : serviceTool.description
54
+ ? ` — ${serviceTool.description}`
55
+ : '';
56
+ lines.push(` - \`${serviceTool.id}\` (${serviceTool.name})${guidance}`);
57
+ }
58
+ }
59
+ return lines.join('\n');
60
+ };
61
+
62
+ const clientBaseUrl = (): string => {
63
+ const apiUrl = getConfig().apiUrl.replace(/\/+$/, '');
64
+ if (apiUrl.endsWith('api.dev.forloop.cc')) return 'https://dev.forloop.cc';
65
+ if (apiUrl.endsWith('api.forloop.cc')) return 'https://forloop.cc';
66
+ return apiUrl;
67
+ };
68
+
69
+ export function createCoreServicesSearchTool(client: ForLoopAPIClient) {
70
+ return tool({
71
+ description:
72
+ 'Search ForLoop core services and directly-integrable peers by requirement. Use before designing a capability that may already exist (scheduling, notifications, payments). Results include kind, serviceCode, baseUrl, price, scopes, pinned version and tool guidance. Read-only.',
73
+ args: {
74
+ query: tool.schema
75
+ .string()
76
+ .optional()
77
+ .describe('Requirement sentence or keywords (tokenized; not a whole-phrase match)'),
78
+ tool: tool.schema.string().optional().describe('Tool id to match (e.g. create_schedule)'),
79
+ directOnly: tool.schema
80
+ .boolean()
81
+ .optional()
82
+ .describe('Only return peers that accept direct app integration'),
83
+ limit: tool.schema.number().optional().describe('Maximum results (default 10, max 100)'),
84
+ },
85
+ async execute(args, context) {
86
+ const tokenResult = await validateToken();
87
+ if (!tokenResult.valid) return tokenResult.error;
88
+ client.setToken?.(tokenResult.token);
89
+
90
+ try {
91
+ const resolution = await resolveSprintId(undefined, context?.directory);
92
+ const result = await client.searchCoreServices({
93
+ ...(args.query ? { query: args.query } : {}),
94
+ ...(args.tool ? { tool: args.tool } : {}),
95
+ ...(args.directOnly ? { directOnly: true } : {}),
96
+ limit: Math.min(Math.max(args.limit ?? 10, 1), 100),
97
+ ...(resolution.sprintId ? { sprintId: resolution.sprintId } : {}),
98
+ });
99
+
100
+ if (!result.entries?.length) {
101
+ return 'No matching core service was found. Say so plainly and plan to build the capability instead of inventing a service.';
102
+ }
103
+
104
+ return [
105
+ `Found ${result.entries.length} matching service${result.entries.length === 1 ? '' : 's'} (${result.total} total):`,
106
+ '',
107
+ ...result.entries.map((entry, index) => formatEntry(entry as RegistryEntry, index)),
108
+ '',
109
+ 'Quote these fields when presenting the option; do not invent endpoints, scopes or versions.',
110
+ ].join('\n');
111
+ } catch (error: any) {
112
+ return `❌ Error searching core services: ${error.message}`;
113
+ }
114
+ },
115
+ });
116
+ }
117
+
118
+ export function createCoreServiceGetTool(client: ForLoopAPIClient) {
119
+ return tool({
120
+ description:
121
+ 'Read a core service contract (OpenAPI + tool index) plus version status, deprecation and sunset. If version is omitted, the current registry version is resolved first. Read-only.',
122
+ args: {
123
+ agentId: tool.schema.string().describe('Service agentId (sprint public id)'),
124
+ version: tool.schema
125
+ .string()
126
+ .optional()
127
+ .describe('Pinned contract version; omit to use the current registry version'),
128
+ },
129
+ async execute(args) {
130
+ const tokenResult = await validateToken();
131
+ if (!tokenResult.valid) return tokenResult.error;
132
+ client.setToken?.(tokenResult.token);
133
+
134
+ try {
135
+ let version = args.version?.trim();
136
+ let entry: RegistryEntry | undefined;
137
+ if (!version) {
138
+ const found = await client.searchCoreServices({ query: args.agentId, limit: 50 });
139
+ entry = (found.entries ?? []).find(
140
+ (candidate: RegistryEntry) => candidate.agentId === args.agentId
141
+ ) as RegistryEntry | undefined;
142
+ if (!entry) {
143
+ return `❌ Core service \`${args.agentId}\` was not found in the registry.`;
144
+ }
145
+ if (!entry.version) {
146
+ return `❌ Core service \`${args.agentId}\` has no published contract version.`;
147
+ }
148
+ version = entry.version;
149
+ }
150
+
151
+ const contract = await client.getCoreServiceContract(args.agentId, version);
152
+ if (!entry && contract.sunsetAt === undefined) {
153
+ try {
154
+ const found = await client.searchCoreServices({ query: args.agentId, limit: 50 });
155
+ entry = (found.entries ?? []).find(
156
+ (candidate: RegistryEntry) => candidate.agentId === args.agentId
157
+ ) as RegistryEntry | undefined;
158
+ } catch {
159
+ entry = undefined;
160
+ }
161
+ }
162
+ const sunsetAt = contract.sunsetAt ?? entry?.sunsetAt ?? null;
163
+ const deprecatedAt = contract.deprecatedAt ?? entry?.deprecatedAt ?? null;
164
+ const status = contract.status ?? entry?.versionStatus ?? 'unknown';
165
+ const sunset = sunsetAt ? `\n- sunsetAt: ${sunsetAt}` : '\n- sunsetAt: none';
166
+ const deprecated = deprecatedAt ? `\n- deprecatedAt: ${deprecatedAt}` : '';
167
+ return [
168
+ `# ${args.agentId} — contract ${contract.version}`,
169
+ `- status: ${status}`,
170
+ `${sunset}${deprecated}`,
171
+ `- checksum: ${contract.checksum ?? 'unknown'}`,
172
+ '',
173
+ '## OpenAPI',
174
+ '```json',
175
+ JSON.stringify(contract.openapi, null, 2),
176
+ '```',
177
+ '',
178
+ '## Tool index',
179
+ '```json',
180
+ JSON.stringify(contract.index, null, 2),
181
+ '```',
182
+ ].join('\n');
183
+ } catch (error: any) {
184
+ return `❌ Error reading core service contract: ${error.message}`;
185
+ }
186
+ },
187
+ });
188
+ }
189
+
190
+ export function createCoreServiceIntegrationRequestTool(client: ForLoopAPIClient) {
191
+ return tool({
192
+ description:
193
+ 'Record an integration request for a core service and return the exact user-approval steps plus the app-subject connection payload. This never creates a connection, never touches credentials, and never constructs URLs by hand.',
194
+ args: {
195
+ agentId: tool.schema.string().describe('Target core-service agentId'),
196
+ scopes: tool.schema
197
+ .array(tool.schema.string())
198
+ .describe('Exact scopes needed (must be declared by the service)'),
199
+ reason: tool.schema
200
+ .string()
201
+ .describe('Why the app needs this integration (shown to the user)'),
202
+ },
203
+ async execute(args, context) {
204
+ const tokenResult = await validateToken();
205
+ if (!tokenResult.valid) return tokenResult.error;
206
+ client.setToken?.(tokenResult.token);
207
+
208
+ try {
209
+ const found = await client.searchCoreServices({ query: args.agentId, limit: 50 });
210
+ const target = (found.entries ?? []).find(
211
+ (entry: RegistryEntry) => entry.agentId === args.agentId
212
+ ) as RegistryEntry | undefined;
213
+ if (!target) {
214
+ return `❌ Core service \`${args.agentId}\` was not found in the registry. Do not invent a connection request.`;
215
+ }
216
+
217
+ const declared = (target.scopes ?? []).map((scope) => scope.id);
218
+ const requested = Array.from(
219
+ new Set((args.scopes ?? []).filter((scope) => typeof scope === 'string' && scope.trim()))
220
+ );
221
+ if (!requested.length) {
222
+ return [
223
+ `❌ No scopes requested for \`${args.agentId}\`.`,
224
+ `Declared scopes: ${declared.join(', ') || 'none'}.`,
225
+ ].join('\n');
226
+ }
227
+ const undeclared = requested.filter((scope) => !declared.includes(scope));
228
+ if (undeclared.length) {
229
+ return [
230
+ `❌ These scopes are not declared by \`${args.agentId}\`: ${undeclared.join(', ')}`,
231
+ `Declared scopes: ${declared.join(', ') || 'none'}.`,
232
+ ].join('\n');
233
+ }
234
+
235
+ const resolution = await resolveSprintId(undefined, context?.directory);
236
+ const callerSprintId = resolution.sprintId ?? null;
237
+ const connectionPayload = {
238
+ callerSprintId,
239
+ targetAgentId: args.agentId,
240
+ subjectType: 'app' as const,
241
+ scopes: requested,
242
+ };
243
+ const connectUrl = `${clientBaseUrl()}/myspace?connect=${encodeURIComponent(args.agentId)}`;
244
+
245
+ return [
246
+ `## Integration request — pending user approval`,
247
+ `- Service: **${target.name}** (\`${target.agentId}\`)`,
248
+ `- serviceCode: ${target.serviceCode ?? 'unknown'}`,
249
+ `- reason: ${args.reason}`,
250
+ `- scopes: ${requested.join(', ')}`,
251
+ `- cost: ${formatPrice(target)}`,
252
+ '',
253
+ 'The planner does not create connections or handle credentials. Ask the user to approve this request in the UI.',
254
+ '',
255
+ '### Approve in the UI',
256
+ `1. Open ${connectUrl}`,
257
+ '2. Review the service, scopes and price shown above.',
258
+ '3. Approve the app-subject connection.',
259
+ '4. Copy the resulting connection id into the integration brief.',
260
+ '',
261
+ '### Connection payload (owner/developer handoff)',
262
+ '```json',
263
+ JSON.stringify(connectionPayload, null, 2),
264
+ '```',
265
+ '',
266
+ 'Only the owner/developer may submit this payload to `POST /api/a2a/connections` (Clerk session auth). Never send credentials or create the connection from the planner.',
267
+ 'If the UI cannot set `subjectType: "app"`, hand this payload to the user and wait for the connection id before writing the integration brief.',
268
+ ].join('\n');
269
+ } catch (error: any) {
270
+ return `❌ Error creating integration request: ${error.message}`;
271
+ }
272
+ },
273
+ });
274
+ }