opencode-irf 0.0.1

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.
Files changed (3) hide show
  1. package/README.md +139 -0
  2. package/dist/index.js +631 -0
  3. package/package.json +46 -0
package/README.md ADDED
@@ -0,0 +1,139 @@
1
+ # Instruction Rule Formatter (IRF)
2
+
3
+ An OpenCode plugin that converts unstructured instruction text into structured, consistent rules using speech act theory and deontic logic.
4
+
5
+ ## Quick Start
6
+
7
+ Once installed, just tell OpenCode what you want:
8
+
9
+ ```
10
+ Use IRF to rewrite my instruction files
11
+ Rewrite instructions.md with IRF in verbose mode
12
+ Use IRF to reformat docs/rules.md, concise
13
+ ```
14
+
15
+ ## Overview
16
+
17
+ IRF takes raw instruction files and processes them through a two-step AI pipeline:
18
+
19
+ 1. **Parse** - Converts raw text into structured rule components (strength, action, target, context, reason)
20
+ 2. **Format** - Converts structured rules into one of three output modes: verbose, balanced, or concise
21
+
22
+ ### Example
23
+
24
+ **Input:**
25
+ ```
26
+ Always use return await when returning promises from async functions. This provides
27
+ better stack traces and error handling. Arrow functions are the standard function
28
+ syntax. Do not use function declarations or function expressions because arrow
29
+ functions provide lexical this binding and a more compact syntax.
30
+ ```
31
+
32
+ **verbose** - Full Rule/Reason pairs for every rule.
33
+ ```
34
+ Rule: Always use return await when returning promises from async functions.
35
+ Reason: Provides better stack traces and error handling.
36
+
37
+ Rule: Use arrow functions as the standard function syntax.
38
+ Reason: Arrow functions provide lexical this binding and a more compact syntax.
39
+
40
+ Rule: Never use function declarations or function expressions.
41
+ Reason: Arrow functions are the standard syntax for the project.
42
+ ```
43
+
44
+ **balanced** (default) - The LLM decides which rules need reasons.
45
+ ```
46
+ Rule: Always use return await when returning promises from async functions.
47
+ Reason: Provides better stack traces and error handling.
48
+
49
+ Rule: Use arrow functions as the standard function syntax.
50
+
51
+ Rule: Never use function declarations or function expressions.
52
+ Reason: Arrow functions provide lexical this binding and a more compact syntax.
53
+ ```
54
+
55
+ **concise** - Bullet list of directives only, no reasons.
56
+ ```
57
+ - Always use return await when returning promises from async functions.
58
+ - Use arrow functions as the standard function syntax.
59
+ - Never use function declarations or function expressions.
60
+ ```
61
+
62
+ ## Installation
63
+
64
+ Add IRF as a global OpenCode plugin:
65
+
66
+ ```ts
67
+ // ~/.config/opencode/plugins/irf.ts
68
+ export { IRFPlugin } from '/path/to/irf/src/index.ts'
69
+ ```
70
+
71
+ ## Usage
72
+
73
+ The `irf-rewrite` tool reads the `instructions` array from your project's `opencode.json` and processes each matched file:
74
+
75
+ ```json
76
+ {
77
+ "instructions": ["docs/*.md", "rules/*.md"]
78
+ }
79
+ ```
80
+
81
+ ```
82
+ irf-rewrite # discover from opencode.json, balanced mode
83
+ irf-rewrite --mode concise # discover, concise output
84
+ irf-rewrite --files fixtures/testing.md # single file, balanced mode
85
+ irf-rewrite --files a.md,b.md --mode verbose # multiple files, verbose output
86
+ ```
87
+
88
+ ## Theoretical Foundation
89
+
90
+ IRF is grounded in [speech act theory](https://en.wikipedia.org/wiki/Speech_act) and [deontic logic](https://en.wikipedia.org/wiki/Deontic_logic).
91
+
92
+ Instructions contain performative utterances that create obligations, permissions, and prohibitions. IRF identifies the illocutionary force of each instruction by extracting action verbs, target objects, contextual conditions, and justifications.
93
+
94
+ Rules are categorized using deontic strength:
95
+
96
+ - **Obligatory** - Required actions that create strong obligations
97
+ - **Forbidden** - Prohibited actions with clear boundaries
98
+ - **Permissible** - Allowed actions within acceptable bounds
99
+ - **Optional** - Discretionary choices left to the actor
100
+ - **Supererogatory** - Actions that exceed normal expectations
101
+ - **Indifferent** - Actions with no normative preference
102
+ - **Omissible** - Actions that can be reasonably omitted
103
+
104
+ ### Rule Schema
105
+
106
+ ```ts
107
+ type ParsedRule = {
108
+ strength: 'obligatory' | 'forbidden' | 'permissible' | 'optional' | 'supererogatory' | 'indifferent' | 'omissible'
109
+ action: string
110
+ target: string
111
+ context?: string
112
+ reason: string
113
+ }
114
+ ```
115
+
116
+ ### Parsed Example
117
+
118
+ ```
119
+ Always use return await when returning promises from async functions.
120
+ This provides better stack traces and error handling.
121
+ ```
122
+
123
+ ```json
124
+ {
125
+ "strength": "obligatory",
126
+ "action": "use",
127
+ "target": "return await",
128
+ "context": "when returning promises from async functions",
129
+ "reason": "better stack traces and error handling"
130
+ }
131
+ ```
132
+
133
+ ## Disclaimer
134
+
135
+ I'm not an NLP expert. I stumbled onto speech act theory and deontic logic while researching NLP and thought it could be a good fit for structuring instructions. I was annoyed trying to write consistent rules, thinking about phrasing and grammar, so I thought there might be a better way to approach this systematically. The implementation may not perfectly align with academic definitions, but the goal is practical utility.
136
+
137
+ ## License
138
+
139
+ MIT
package/dist/index.js ADDED
@@ -0,0 +1,631 @@
1
+ // src/index.ts
2
+ import { tool } from "@opencode-ai/plugin";
3
+
4
+ // src/discover.ts
5
+ import { glob, readFile } from "node:fs/promises";
6
+ import { join, resolve } from "node:path";
7
+
8
+ // src/utils/safe.ts
9
+ var safe = (fn) => {
10
+ try {
11
+ const data = fn();
12
+ return {
13
+ data,
14
+ error: null
15
+ };
16
+ } catch (error) {
17
+ return {
18
+ data: null,
19
+ error: error instanceof Error ? error : new Error(String(error))
20
+ };
21
+ }
22
+ };
23
+ var safeAsync = async (fn) => {
24
+ try {
25
+ const data = await fn();
26
+ return {
27
+ data,
28
+ error: null
29
+ };
30
+ } catch (error) {
31
+ return {
32
+ data: null,
33
+ error: error instanceof Error ? error : new Error(String(error))
34
+ };
35
+ }
36
+ };
37
+
38
+ // src/discover.ts
39
+ var readConfig = async (directory) => {
40
+ const configPath = join(directory, "opencode.json");
41
+ const { data, error } = await safeAsync(() => readFile(configPath, "utf-8"));
42
+ if (error) {
43
+ return {
44
+ data: null,
45
+ error: "Could not read " + configPath + ": " + error.message
46
+ };
47
+ }
48
+ const { data: parsed, error: parseError } = safe(() => JSON.parse(data));
49
+ if (parseError) {
50
+ return {
51
+ data: null,
52
+ error: "Invalid JSON in " + configPath + ": " + parseError.message
53
+ };
54
+ }
55
+ const instructions = parsed.instructions;
56
+ if (!instructions || !Array.isArray(instructions) || instructions.length === 0) {
57
+ return {
58
+ data: null,
59
+ error: 'No "instructions" array found in ' + configPath
60
+ };
61
+ }
62
+ const patterns = instructions.filter((entry) => typeof entry === "string");
63
+ if (patterns.length === 0) {
64
+ return {
65
+ data: null,
66
+ error: 'No valid string patterns in "instructions" in ' + configPath
67
+ };
68
+ }
69
+ return {
70
+ data: patterns,
71
+ error: null
72
+ };
73
+ };
74
+ var resolveFiles = async (directory, patterns) => {
75
+ const seen = new Set;
76
+ const files = [];
77
+ for (const pattern of patterns) {
78
+ for await (const path of glob(pattern, { cwd: directory })) {
79
+ const full = join(directory, path);
80
+ if (!seen.has(full)) {
81
+ seen.add(full);
82
+ files.push(full);
83
+ }
84
+ }
85
+ }
86
+ return files;
87
+ };
88
+ var readFiles = async (files) => {
89
+ const results = [];
90
+ for (const file of files) {
91
+ const { data, error } = await safeAsync(() => readFile(file, "utf-8"));
92
+ if (error) {
93
+ results.push({ path: file, content: "", error: error.message });
94
+ continue;
95
+ }
96
+ results.push({ path: file, content: data });
97
+ }
98
+ return results;
99
+ };
100
+ var readFilePaths = async (directory, paths) => {
101
+ const resolved = paths.map((p) => resolve(directory, p));
102
+ return await readFiles(resolved);
103
+ };
104
+ var discover = async (directory) => {
105
+ const config = await readConfig(directory);
106
+ if (config.error !== null) {
107
+ return {
108
+ data: null,
109
+ error: config.error
110
+ };
111
+ }
112
+ const patterns = config.data;
113
+ const files = await resolveFiles(directory, patterns);
114
+ if (files.length === 0) {
115
+ return {
116
+ data: null,
117
+ error: "No instruction files found matching patterns: " + patterns.join(", ")
118
+ };
119
+ }
120
+ const results = await readFiles(files);
121
+ return {
122
+ data: results,
123
+ error: null
124
+ };
125
+ };
126
+
127
+ // src/process.ts
128
+ import { writeFile } from "node:fs/promises";
129
+ import { basename } from "node:path";
130
+
131
+ // src/schema.ts
132
+ import { z } from "zod";
133
+ var StrengthSchema = z.enum([
134
+ "obligatory",
135
+ "permissible",
136
+ "forbidden",
137
+ "optional",
138
+ "supererogatory",
139
+ "indifferent",
140
+ "omissible"
141
+ ]).describe("Deontic modality expressing enforcement strength");
142
+ var ActionSchema = z.string().describe("Imperative verb describing the action to take");
143
+ var TargetSchema = z.string().describe("Object or subject the action applies to");
144
+ var ContextSchema = z.string().describe("Condition, scope, or circumstance when the rule applies");
145
+ var ReasonSchema = z.string().describe("Justification for why the rule exists");
146
+ var ParsedRuleSchema = z.object({
147
+ strength: StrengthSchema,
148
+ action: ActionSchema,
149
+ target: TargetSchema,
150
+ context: ContextSchema.optional(),
151
+ reason: ReasonSchema
152
+ }).describe("Single instruction decomposed into deontic components");
153
+ var RuleSchema = z.string().describe("Human-readable rule derived from parsed components");
154
+ var ParsedSchema = z.array(ParsedRuleSchema).describe("Array of parsed rules");
155
+ var ParseResponseSchema = z.object({
156
+ rules: ParsedSchema
157
+ });
158
+ var FormatResponseSchema = z.object({
159
+ rules: z.array(RuleSchema)
160
+ });
161
+ var parseSchemaExample = JSON.stringify({
162
+ rules: [{
163
+ strength: StrengthSchema.options.join("/"),
164
+ action: ActionSchema.description || "verb",
165
+ target: TargetSchema.description || "object",
166
+ context: ContextSchema.description || "optional condition",
167
+ reason: ReasonSchema.description || "justification"
168
+ }]
169
+ });
170
+
171
+ // src/prompt.ts
172
+ var FORMAT_MODES = ["verbose", "balanced", "concise"];
173
+ var isFormatMode = (v) => typeof v === "string" && FORMAT_MODES.includes(v);
174
+ var buildParsePrompt = (input) => {
175
+ const instructions = [
176
+ "You are a rule parser that converts raw instructions into structured parsed rules.",
177
+ "Take the provided instructions and break them down into structured components.",
178
+ "Each rule should have: strength, action (verb), target (object), context (optional condition/scope), and reason (justification).",
179
+ "Focus on extracting the core components without adding extra details.",
180
+ "",
181
+ "Return ONLY valid JSON matching this exact schema:",
182
+ parseSchemaExample,
183
+ "",
184
+ "Do not include any text outside the JSON object. Do not wrap it in markdown code fences."
185
+ ].join(`
186
+ `);
187
+ return [instructions, "Instructions to parse:", input].join(`
188
+
189
+ `);
190
+ };
191
+ var verboseInstructions = [
192
+ "You are a rule formatter that converts structured parsed rules into human-readable rules.",
193
+ "Take the provided parsed rule components and create natural language versions.",
194
+ "Every rule must include both a Rule line and a Reason line.",
195
+ "Each rule must follow this exact format:",
196
+ "",
197
+ "Rule: <clear, concise, actionable statement>",
198
+ "Reason: <justification from the parsed rule>",
199
+ "",
200
+ "Each human-readable rule should directly correspond to the parsed components without adding extra details.",
201
+ "Make the rules clear, concise, and actionable.",
202
+ "",
203
+ "Return ONLY valid JSON matching this exact schema:",
204
+ '{"rules": ["Rule: ...\\nReason: ...", "Rule: ...\\nReason: ..."]}',
205
+ "",
206
+ "Do not include any text outside the JSON object. Do not wrap it in markdown code fences."
207
+ ];
208
+ var balancedInstructions = [
209
+ "You are a rule formatter that converts structured parsed rules into human-readable rules.",
210
+ "Take the provided parsed rule components and create natural language versions.",
211
+ "Use your judgment for each rule:",
212
+ "- If the rule is non-obvious or counterintuitive, include both the Rule and Reason lines.",
213
+ "- If the rule is self-explanatory, include only the Rule line and omit the Reason.",
214
+ "",
215
+ "Format rules that include a reason:",
216
+ "Rule: <clear, concise, actionable statement>",
217
+ "Reason: <justification from the parsed rule>",
218
+ "",
219
+ "Format rules that are self-explanatory:",
220
+ "Rule: <clear, concise, actionable statement>",
221
+ "",
222
+ "Each human-readable rule should directly correspond to the parsed components without adding extra details.",
223
+ "Make the rules clear, concise, and actionable.",
224
+ "",
225
+ "Return ONLY valid JSON matching this exact schema:",
226
+ '{"rules": ["Rule: ...\\nReason: ...", "Rule: ..."]}',
227
+ "",
228
+ "Do not include any text outside the JSON object. Do not wrap it in markdown code fences."
229
+ ];
230
+ var conciseInstructions = [
231
+ "You are a rule formatter that converts structured parsed rules into concise directives.",
232
+ "Take the provided parsed rule components and create a bullet list of clear directives.",
233
+ "Do not include reasons or justifications. Output only the actionable statement.",
234
+ 'Each rule must be a single line starting with "- " (dash space).',
235
+ "",
236
+ "Each directive should directly correspond to the parsed components without adding extra details.",
237
+ "Make the directives clear, concise, and actionable.",
238
+ "",
239
+ "Return ONLY valid JSON matching this exact schema:",
240
+ '{"rules": ["- ...", "- ..."]}',
241
+ "",
242
+ "Do not include any text outside the JSON object. Do not wrap it in markdown code fences."
243
+ ];
244
+ var formatInstructions = {
245
+ verbose: verboseInstructions,
246
+ balanced: balancedInstructions,
247
+ concise: conciseInstructions
248
+ };
249
+ var buildFormatPrompt = (parsedRulesJson, mode = "balanced") => {
250
+ const instructions = formatInstructions[mode].join(`
251
+ `);
252
+ return [instructions, "Parsed rules to convert:", parsedRulesJson].join(`
253
+
254
+ `);
255
+ };
256
+ var buildRetryPrompt = (errorMessage) => {
257
+ return "Your previous response was invalid. " + errorMessage + `
258
+
259
+ Return ONLY valid JSON. Do not include any text outside the JSON object.`;
260
+ };
261
+
262
+ // src/utils/compare.ts
263
+ var compareBytes = (file, original, generated) => {
264
+ const originalBytes = new TextEncoder().encode(original).length;
265
+ const generatedBytes = new TextEncoder().encode(generated).length;
266
+ const difference = originalBytes - generatedBytes;
267
+ const percentChange = originalBytes === 0 ? 0 : difference / originalBytes * 100;
268
+ return {
269
+ file,
270
+ originalBytes,
271
+ generatedBytes,
272
+ difference,
273
+ percentChange
274
+ };
275
+ };
276
+ var formatChange = (difference, percentChange) => {
277
+ if (difference > 0) {
278
+ return "−" + percentChange.toFixed(1) + "%";
279
+ }
280
+ return "+" + Math.abs(percentChange).toFixed(1) + "%";
281
+ };
282
+ var formatRow = (result) => {
283
+ const changeStr = formatChange(result.difference, result.percentChange);
284
+ return result.file.padEnd(25) + result.originalBytes.toString().padStart(10) + result.generatedBytes.toString().padStart(12) + result.difference.toString().padStart(8) + changeStr.padStart(10);
285
+ };
286
+ var summarize = (results) => {
287
+ const totalOriginal = results.reduce((sum, r) => sum + r.originalBytes, 0);
288
+ const totalGenerated = results.reduce((sum, r) => sum + r.generatedBytes, 0);
289
+ const totalDifference = totalOriginal - totalGenerated;
290
+ const totalPercentChange = totalOriginal === 0 ? 0 : totalDifference / totalOriginal * 100;
291
+ return {
292
+ totalOriginal,
293
+ totalGenerated,
294
+ totalDifference,
295
+ totalPercentChange
296
+ };
297
+ };
298
+ var buildTable = (results) => {
299
+ if (results.length === 0) {
300
+ return "";
301
+ }
302
+ const lines = [];
303
+ const header = "File".padEnd(25) + "Original".padStart(10) + "Generated".padStart(12) + "Diff".padStart(8) + "Change".padStart(10);
304
+ const separator = "─".repeat(65);
305
+ lines.push(header);
306
+ lines.push(separator);
307
+ const sorted = [...results].sort((a, b) => Math.abs(b.difference) - Math.abs(a.difference));
308
+ for (const result of sorted) {
309
+ lines.push(formatRow(result));
310
+ }
311
+ const totals = summarize(results);
312
+ lines.push(separator);
313
+ const totalChangeStr = formatChange(totals.totalDifference, totals.totalPercentChange);
314
+ lines.push("TOTAL".padEnd(25) + totals.totalOriginal.toString().padStart(10) + totals.totalGenerated.toString().padStart(12) + totals.totalDifference.toString().padStart(8) + totalChangeStr.padStart(10));
315
+ lines.push("");
316
+ lines.push((totals.totalDifference > 0 ? "SAVED " : "INCREASED ") + Math.abs(totals.totalDifference) + " bytes (" + Math.abs(totals.totalPercentChange).toFixed(1) + "%)");
317
+ return lines.join(`
318
+ `);
319
+ };
320
+
321
+ // src/process.ts
322
+ var processFile = async (options) => {
323
+ const { file, prompt, mode = "balanced" } = options;
324
+ if (file.error) {
325
+ return {
326
+ status: "readError",
327
+ path: file.path,
328
+ error: file.error
329
+ };
330
+ }
331
+ const parseResult = await prompt(buildParsePrompt(file.content), ParseResponseSchema);
332
+ if (parseResult.error !== null) {
333
+ return {
334
+ status: "parseError",
335
+ path: file.path,
336
+ error: String(parseResult.error)
337
+ };
338
+ }
339
+ const formatResult = await prompt(buildFormatPrompt(JSON.stringify(parseResult.data), mode), FormatResponseSchema);
340
+ if (formatResult.error !== null) {
341
+ return {
342
+ status: "formatError",
343
+ path: file.path,
344
+ error: String(formatResult.error)
345
+ };
346
+ }
347
+ const formattedRules = formatResult.data.rules;
348
+ const joiner = mode === "concise" ? `
349
+ ` : `
350
+
351
+ `;
352
+ const content = formattedRules.join(joiner) + `
353
+ `;
354
+ const { error: writeError } = await safeAsync(() => writeFile(file.path, content, "utf-8"));
355
+ if (writeError) {
356
+ return {
357
+ status: "writeError",
358
+ path: file.path,
359
+ error: writeError.message
360
+ };
361
+ }
362
+ const comparison = compareBytes(basename(file.path), file.content, content);
363
+ return {
364
+ status: "success",
365
+ path: file.path,
366
+ rulesCount: formattedRules.length,
367
+ comparison
368
+ };
369
+ };
370
+
371
+ // src/utils/extractLlmError.ts
372
+ var extractLlmError = (info) => {
373
+ if (!info.error) {
374
+ return null;
375
+ }
376
+ const err = info.error;
377
+ if (err.data && err.data.message) {
378
+ return err.data.message;
379
+ }
380
+ return err.name || "Unknown LLM error";
381
+ };
382
+
383
+ // src/utils/stripCodeFences.ts
384
+ var stripCodeFences = (text) => {
385
+ return text.replace(/^.*```(?:json)?\s*\n?/m, "").replace(/\n?```\s*$/m, "").trim();
386
+ };
387
+
388
+ // src/utils/validate.ts
389
+ var validateJson = (json, schema) => {
390
+ const { data: parsed, error } = safe(() => JSON.parse(json));
391
+ if (error) {
392
+ return {
393
+ data: null,
394
+ error: "parse"
395
+ };
396
+ }
397
+ const result = schema.safeParse(parsed);
398
+ if (!result.success) {
399
+ return {
400
+ data: null,
401
+ error: "schema",
402
+ issues: result.error.issues
403
+ };
404
+ }
405
+ return {
406
+ data: result.data,
407
+ error: null
408
+ };
409
+ };
410
+ var formatValidationError = (result) => {
411
+ if (result.error === "parse") {
412
+ return "Invalid JSON. Return valid JSON.";
413
+ }
414
+ const issues = result.issues.map((i) => " - " + i.path.join(".") + ": " + i.message).join(`
415
+ `);
416
+ return `Schema validation failed:
417
+ ` + issues + `
418
+
419
+ Fix the issues and try again.`;
420
+ };
421
+
422
+ // src/session.ts
423
+ var MAX_RETRIES = 3;
424
+ var isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
425
+ var isPart = (v) => isRecord(v) && typeof v.type === "string";
426
+ var isPartArray = (v) => Array.isArray(v) && v.every(isPart);
427
+ var isMessageInfo = (v) => isRecord(v) && typeof v.role === "string";
428
+ var isMessageEntry = (v) => isRecord(v) && isMessageInfo(v.info) && isPartArray(v.parts);
429
+ var isMessageEntryArray = (v) => Array.isArray(v) && v.every(isMessageEntry);
430
+ var extractText = (parts) => {
431
+ return parts.filter((p) => p.type === "text" && p.text).map((p) => p.text || "").join("");
432
+ };
433
+ var detectModel = async (client, sessionId) => {
434
+ const messagesResult = await client.session.messages({
435
+ path: { id: sessionId }
436
+ });
437
+ if (!messagesResult.data) {
438
+ return null;
439
+ }
440
+ const messages = messagesResult.data;
441
+ if (!isMessageEntryArray(messages)) {
442
+ return null;
443
+ }
444
+ for (let i = messages.length - 1;i >= 0; i--) {
445
+ const info = messages[i].info;
446
+ if (info.role === "assistant" && info.providerID && info.modelID) {
447
+ return {
448
+ providerID: info.providerID,
449
+ modelID: info.modelID
450
+ };
451
+ }
452
+ }
453
+ return null;
454
+ };
455
+ var promptWithRetry = async (options) => {
456
+ const { client, sessionId, initialPrompt, schema, model } = options;
457
+ let prompt = initialPrompt;
458
+ let lastError = "";
459
+ for (let attempt = 0;attempt < MAX_RETRIES; attempt++) {
460
+ const response = await client.session.prompt({
461
+ path: { id: sessionId },
462
+ body: {
463
+ parts: [{ type: "text", text: prompt }],
464
+ tools: {},
465
+ model
466
+ }
467
+ });
468
+ if (!response.data) {
469
+ return {
470
+ data: null,
471
+ error: "No response from LLM (attempt " + (attempt + 1) + ")"
472
+ };
473
+ }
474
+ const info = response.data.info;
475
+ if (!isMessageInfo(info)) {
476
+ return {
477
+ data: null,
478
+ error: "Unexpected response shape: missing info (attempt " + (attempt + 1) + ")"
479
+ };
480
+ }
481
+ const llmError = extractLlmError(info);
482
+ if (llmError) {
483
+ return {
484
+ data: null,
485
+ error: llmError
486
+ };
487
+ }
488
+ const parts = response.data.parts;
489
+ if (!isPartArray(parts)) {
490
+ return {
491
+ data: null,
492
+ error: "Unexpected response shape: missing parts (attempt " + (attempt + 1) + ")"
493
+ };
494
+ }
495
+ const text = extractText(parts);
496
+ if (!text) {
497
+ lastError = "Empty response";
498
+ prompt = buildRetryPrompt("Empty response. Return valid JSON.");
499
+ continue;
500
+ }
501
+ const cleaned = stripCodeFences(text);
502
+ const validation = validateJson(cleaned, schema);
503
+ if (validation.error) {
504
+ const errorMsg = formatValidationError(validation);
505
+ lastError = errorMsg + " | raw: " + cleaned.slice(0, 200);
506
+ prompt = buildRetryPrompt(errorMsg);
507
+ continue;
508
+ }
509
+ return {
510
+ data: validation.data,
511
+ error: null
512
+ };
513
+ }
514
+ return {
515
+ data: null,
516
+ error: "Failed after " + MAX_RETRIES + " attempts. Last error: " + lastError
517
+ };
518
+ };
519
+
520
+ // src/index.ts
521
+ var resolveFiles2 = async (directory, filesArg) => {
522
+ if (filesArg) {
523
+ const paths = filesArg.split(",").map((p) => p.trim()).filter((p) => p.length > 0);
524
+ if (paths.length === 0) {
525
+ return { data: null, error: "No valid file paths provided" };
526
+ }
527
+ return { data: await readFilePaths(directory, paths), error: null };
528
+ }
529
+ return await discover(directory);
530
+ };
531
+ var ERROR_LABELS = {
532
+ readError: "Read failed",
533
+ parseError: "Parse failed",
534
+ formatError: "Format failed",
535
+ writeError: "Write failed"
536
+ };
537
+ var formatFileResult = (result) => {
538
+ if (result.status === "success") {
539
+ return "**" + result.path + "**: " + result.rulesCount + " rules written";
540
+ }
541
+ return "**" + result.path + "**: " + ERROR_LABELS[result.status] + " - " + result.error;
542
+ };
543
+ var appendComparisonTable = (lines, comparisons) => {
544
+ if (comparisons.length === 0) {
545
+ return;
546
+ }
547
+ lines.push("");
548
+ lines.push("## Comparison");
549
+ lines.push("```");
550
+ lines.push(buildTable(comparisons));
551
+ lines.push("```");
552
+ lines.push("");
553
+ lines.push("IMPORTANT: Show the comparison table above to the user exactly as-is.");
554
+ };
555
+ var IRFPlugin = async ({ directory, client }) => {
556
+ return {
557
+ tool: {
558
+ "irf-rewrite": tool({
559
+ description: [
560
+ "Discover instruction files from opencode.json, parse them into structured rules, format them into human-readable rules, and write the formatted rules back to the original files.",
561
+ "Accepts an optional mode: verbose (full Rule/Reason pairs), balanced (LLM decides which rules need reasons), or concise (bullet list, no reasons).",
562
+ "Defaults to balanced.",
563
+ "Accepts an optional files parameter to process specific files instead of running discovery."
564
+ ].join(" "),
565
+ args: {
566
+ mode: tool.schema.string().optional().describe("Output format: verbose, balanced, or concise (default: balanced)"),
567
+ files: tool.schema.string().optional().describe("Comma-separated file paths to process instead of discovering from opencode.json")
568
+ },
569
+ async execute(args, context) {
570
+ const mode = isFormatMode(args.mode) ? args.mode : "balanced";
571
+ try {
572
+ const resolved = await resolveFiles2(directory, args.files);
573
+ if (resolved.error !== null) {
574
+ return resolved.error;
575
+ }
576
+ const files = resolved.data;
577
+ const model = await detectModel(client, context.sessionID);
578
+ if (!model) {
579
+ return "Could not detect current model. Send a message first, then call irf-rewrite.";
580
+ }
581
+ const sessionResult = await client.session.create({
582
+ body: {
583
+ title: "IRF Parse"
584
+ }
585
+ });
586
+ if (!sessionResult.data) {
587
+ return "Failed to create internal session";
588
+ }
589
+ const sessionId = sessionResult.data.id;
590
+ const prompt = (text, schema) => promptWithRetry({
591
+ client,
592
+ sessionId,
593
+ initialPrompt: text,
594
+ schema,
595
+ model
596
+ });
597
+ const results = [];
598
+ const comparisons = [];
599
+ for (const file of files) {
600
+ if (context.abort.aborted) {
601
+ results.push("Cancelled");
602
+ break;
603
+ }
604
+ const fileResult = await processFile({
605
+ file,
606
+ prompt,
607
+ mode
608
+ });
609
+ if (fileResult.status === "success") {
610
+ comparisons.push(fileResult.comparison);
611
+ }
612
+ results.push(formatFileResult(fileResult));
613
+ }
614
+ await safeAsync(() => client.session.delete({
615
+ path: { id: sessionId }
616
+ }));
617
+ appendComparisonTable(results, comparisons);
618
+ return results.join(`
619
+ `);
620
+ } catch (err) {
621
+ const msg = err instanceof Error ? err.message : String(err);
622
+ return "irf-rewrite error: " + msg;
623
+ }
624
+ }
625
+ })
626
+ }
627
+ };
628
+ };
629
+ export {
630
+ IRFPlugin
631
+ };
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "opencode-irf",
3
+ "version": "0.0.1",
4
+ "description": "OpenCode plugin that converts unstructured instructions into structured rules using speech act theory and deontic logic.",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "types": "./dist/index.d.ts"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist"
16
+ ],
17
+ "scripts": {
18
+ "build": "bun build src/index.ts --outdir dist --target node --external @opencode-ai/plugin --external zod",
19
+ "prepublishOnly": "bun run build"
20
+ },
21
+ "keywords": [
22
+ "opencode",
23
+ "plugin",
24
+ "instructions",
25
+ "rules",
26
+ "formatting",
27
+ "speech-act-theory",
28
+ "deontic-logic"
29
+ ],
30
+ "author": "Dustin Whalen",
31
+ "license": "MIT",
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/whaaaley/irf.git"
35
+ },
36
+ "bugs": {
37
+ "url": "https://github.com/whaaaley/irf/issues"
38
+ },
39
+ "homepage": "https://github.com/whaaaley/irf#readme",
40
+ "peerDependencies": {
41
+ "@opencode-ai/plugin": ">=1.0.0"
42
+ },
43
+ "dependencies": {
44
+ "zod": "^4.1.8"
45
+ }
46
+ }