opencode-pilot 0.3.0 → 0.4.0

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.
@@ -10,16 +10,28 @@
10
10
 
11
11
  # =============================================================================
12
12
  # TOOLS - Field mappings for MCP servers (normalize different APIs)
13
+ #
14
+ # Each tool provider can specify:
15
+ # response_key: Key to extract array from response (e.g., "reminders", "nodes")
16
+ # mappings: Field name translations (e.g., { body: "description" })
13
17
  # =============================================================================
14
18
  tools:
15
19
  github:
20
+ response_key: items # GitHub search returns { items: [...] }
16
21
  mappings: {} # GitHub already uses standard field names
17
22
 
18
23
  linear:
24
+ response_key: nodes # Linear returns { nodes: [...] }
19
25
  mappings:
20
26
  body: title # Use title as body
21
27
  number: "url:/([A-Z0-9]+-[0-9]+)/" # Extract PROJ-123 from URL
22
28
 
29
+ apple-reminders:
30
+ response_key: reminders # Apple Reminders returns { reminders: [...] }
31
+ mappings:
32
+ title: name # Reminders uses 'name' not 'title'
33
+ body: notes # Reminders uses 'notes' for details
34
+
23
35
  # =============================================================================
24
36
  # SOURCES - What to poll (generic MCP tool references)
25
37
  # =============================================================================
@@ -0,0 +1,12 @@
1
+ Work on this issue in an isolated devcontainer:
2
+
3
+ {title}
4
+
5
+ {body}
6
+
7
+ First, set up an isolated workspace:
8
+ 1. Create a shallow clone in a temp directory for branch `issue-{number}`
9
+ 2. Open the devcontainer in that clone
10
+ 3. Work from inside the devcontainer
11
+
12
+ Complete the implementation and create a PR when ready.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-pilot",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "main": "plugin/index.js",
6
6
  "description": "Automation daemon for OpenCode - polls for work and spawns sessions",
@@ -9,7 +9,7 @@
9
9
  * 5. Track processed items to avoid duplicates
10
10
  */
11
11
 
12
- import { loadRepoConfig, getRepoConfig, getAllSources, getToolMappings } from "./repo-config.js";
12
+ import { loadRepoConfig, getRepoConfig, getAllSources, getToolProviderConfig } from "./repo-config.js";
13
13
  import { createPoller, pollGenericSource } from "./poller.js";
14
14
  import { evaluateReadiness, sortByPriority } from "./readiness.js";
15
15
  import { executeAction, buildCommand } from "./actions.js";
@@ -100,8 +100,8 @@ export async function pollOnce(options = {}) {
100
100
  // Fetch items from source
101
101
  if (!skipMcp) {
102
102
  try {
103
- const mappings = getToolMappings(source.tool.mcp);
104
- items = await pollGenericSource(source, { mappings });
103
+ const toolProviderConfig = getToolProviderConfig(source.tool.mcp);
104
+ items = await pollGenericSource(source, { toolProviderConfig });
105
105
  debug(`Fetched ${items.length} items from ${sourceName}`);
106
106
  } catch (err) {
107
107
  console.error(`[poll] Error fetching from ${sourceName}: ${err.message}`);
package/service/poller.js CHANGED
@@ -107,14 +107,28 @@ export function transformItems(items, idTemplate) {
107
107
 
108
108
  /**
109
109
  * Parse JSON text as an array with error handling
110
+ * @param {string} text - JSON text to parse
111
+ * @param {string} sourceName - Source name for error logging
112
+ * @param {string} [responseKey] - Key to extract array from response object
113
+ * @returns {Array} Parsed array of items
110
114
  */
111
- function parseJsonArray(text, sourceName) {
115
+ export function parseJsonArray(text, sourceName, responseKey) {
112
116
  try {
113
117
  const data = JSON.parse(text);
118
+
119
+ // If already an array, return it
114
120
  if (Array.isArray(data)) return data;
115
- if (data.items) return data.items;
116
- if (data.issues) return data.issues;
117
- if (data.nodes) return data.nodes;
121
+
122
+ // If response_key is configured, use it to extract the array
123
+ if (responseKey) {
124
+ const items = data[responseKey];
125
+ if (Array.isArray(items)) return items;
126
+ // response_key was specified but not found or not an array
127
+ console.error(`[poller] Response key '${responseKey}' not found or not an array in ${sourceName} response`);
128
+ return [];
129
+ }
130
+
131
+ // No response_key - wrap single object as array
118
132
  return [data];
119
133
  } catch (err) {
120
134
  console.error(`[poller] Failed to parse ${sourceName} response:`, err.message);
@@ -206,13 +220,15 @@ function createTimeout(ms, operation) {
206
220
  * @param {object} [options] - Additional options
207
221
  * @param {number} [options.timeout] - Timeout in ms (default: 30000)
208
222
  * @param {string} [options.opencodeConfigPath] - Path to opencode.json for MCP config
209
- * @param {object} [options.mappings] - Field mappings to apply to items
223
+ * @param {object} [options.toolProviderConfig] - Tool provider config (response_key, mappings)
210
224
  * @returns {Promise<Array>} Array of items from the source with IDs and mappings applied
211
225
  */
212
226
  export async function pollGenericSource(source, options = {}) {
213
227
  const toolConfig = getToolConfig(source);
214
228
  const timeout = options.timeout || DEFAULT_MCP_TIMEOUT;
215
- const mappings = options.mappings || null;
229
+ const toolProviderConfig = options.toolProviderConfig || {};
230
+ const responseKey = toolProviderConfig.response_key;
231
+ const mappings = toolProviderConfig.mappings || null;
216
232
  const mcpConfig = getMcpConfig(toolConfig.mcpServer, options.opencodeConfigPath);
217
233
  const client = new Client({ name: "opencode-pilot", version: "1.0.0" });
218
234
 
@@ -235,7 +251,7 @@ export async function pollGenericSource(source, options = {}) {
235
251
  const text = result.content?.[0]?.text;
236
252
  if (!text) return [];
237
253
 
238
- const rawItems = parseJsonArray(text, source.name);
254
+ const rawItems = parseJsonArray(text, source.name, responseKey);
239
255
 
240
256
  // Apply field mappings before transforming
241
257
  const mappedItems = mappings
@@ -134,6 +134,23 @@ export function getToolMappings(provider) {
134
134
  return toolConfig.mappings;
135
135
  }
136
136
 
137
+ /**
138
+ * Get full tool provider configuration (response_key, mappings, etc.)
139
+ * @param {string} provider - Tool provider name (e.g., "github", "linear", "apple-reminders")
140
+ * @returns {object|null} Tool config including response_key and mappings, or null if not configured
141
+ */
142
+ export function getToolProviderConfig(provider) {
143
+ const config = getRawConfig();
144
+ const tools = config.tools || {};
145
+ const toolConfig = tools[provider];
146
+
147
+ if (!toolConfig) {
148
+ return null;
149
+ }
150
+
151
+ return toolConfig;
152
+ }
153
+
137
154
  /**
138
155
  * Load a template from the templates directory
139
156
  * @param {string} templateName - Template name (without .md extension)
@@ -312,6 +312,65 @@ describe('poller.js', () => {
312
312
  });
313
313
  });
314
314
 
315
+ describe('parseJsonArray', () => {
316
+ test('parses direct array response', async () => {
317
+ const { parseJsonArray } = await import('../../service/poller.js');
318
+
319
+ const text = JSON.stringify([{ id: '1' }, { id: '2' }]);
320
+ const result = parseJsonArray(text, 'test');
321
+
322
+ assert.strictEqual(result.length, 2);
323
+ assert.strictEqual(result[0].id, '1');
324
+ });
325
+
326
+ test('extracts array using response_key', async () => {
327
+ const { parseJsonArray } = await import('../../service/poller.js');
328
+
329
+ const text = JSON.stringify({
330
+ reminders: [
331
+ { id: 'reminder-1', name: 'Task 1', completed: false },
332
+ { id: 'reminder-2', name: 'Task 2', completed: false },
333
+ { id: 'reminder-3', name: 'Task 3', completed: false }
334
+ ],
335
+ count: 3
336
+ });
337
+ const result = parseJsonArray(text, 'agent-tasks', 'reminders');
338
+
339
+ assert.strictEqual(result.length, 3);
340
+ assert.strictEqual(result[0].id, 'reminder-1');
341
+ assert.strictEqual(result[0].name, 'Task 1');
342
+ assert.strictEqual(result[1].id, 'reminder-2');
343
+ assert.strictEqual(result[2].id, 'reminder-3');
344
+ });
345
+
346
+ test('wraps single object as array when no response_key', async () => {
347
+ const { parseJsonArray } = await import('../../service/poller.js');
348
+
349
+ const text = JSON.stringify({ id: '1', title: 'Single item' });
350
+ const result = parseJsonArray(text, 'test');
351
+
352
+ assert.strictEqual(result.length, 1);
353
+ assert.strictEqual(result[0].id, '1');
354
+ });
355
+
356
+ test('returns empty array for invalid JSON', async () => {
357
+ const { parseJsonArray } = await import('../../service/poller.js');
358
+
359
+ const result = parseJsonArray('not valid json', 'test');
360
+
361
+ assert.strictEqual(result.length, 0);
362
+ });
363
+
364
+ test('returns empty array when response_key not found', async () => {
365
+ const { parseJsonArray } = await import('../../service/poller.js');
366
+
367
+ const text = JSON.stringify({ items: [{ id: '1' }] });
368
+ const result = parseJsonArray(text, 'test', 'reminders');
369
+
370
+ assert.strictEqual(result.length, 0);
371
+ });
372
+ });
373
+
315
374
  describe('transformItems with mappings', () => {
316
375
  test('applies mappings to all items', async () => {
317
376
  const { transformItems, applyMappings } = await import('../../service/poller.js');
@@ -314,6 +314,45 @@ sources: []
314
314
 
315
315
  assert.strictEqual(mappings, null);
316
316
  });
317
+
318
+ test('getToolProviderConfig returns full tool config with response_key', async () => {
319
+ writeFileSync(configPath, `
320
+ tools:
321
+ apple-reminders:
322
+ response_key: reminders
323
+ mappings:
324
+ body: notes
325
+
326
+ sources: []
327
+ `);
328
+
329
+ const { loadRepoConfig, getToolProviderConfig } = await import('../../service/repo-config.js');
330
+ loadRepoConfig(configPath);
331
+
332
+ const toolConfig = getToolProviderConfig('apple-reminders');
333
+
334
+ assert.strictEqual(toolConfig.response_key, 'reminders');
335
+ assert.deepStrictEqual(toolConfig.mappings, { body: 'notes' });
336
+ });
337
+
338
+ test('getToolProviderConfig returns config without response_key', async () => {
339
+ writeFileSync(configPath, `
340
+ tools:
341
+ github:
342
+ mappings:
343
+ url: html_url
344
+
345
+ sources: []
346
+ `);
347
+
348
+ const { loadRepoConfig, getToolProviderConfig } = await import('../../service/repo-config.js');
349
+ loadRepoConfig(configPath);
350
+
351
+ const toolConfig = getToolProviderConfig('github');
352
+
353
+ assert.strictEqual(toolConfig.response_key, undefined);
354
+ assert.deepStrictEqual(toolConfig.mappings, { url: 'html_url' });
355
+ });
317
356
  });
318
357
 
319
358
  describe('repo resolution for sources', () => {