@maskweaver/plugin 0.1.11 → 0.3.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.
- package/dist/index.js +431 -12
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,21 +1,440 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
import { tool } from "@opencode-ai/plugin";
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
function parseSimpleYaml(content) {
|
|
6
|
+
const lines = content.split(`
|
|
7
|
+
`);
|
|
8
|
+
const result = {};
|
|
9
|
+
const stack = [
|
|
10
|
+
{ indent: -2, obj: result }
|
|
11
|
+
];
|
|
12
|
+
let currentArrayKey = null;
|
|
13
|
+
let currentArray = [];
|
|
14
|
+
let multilineKey = null;
|
|
15
|
+
let multilineValue = [];
|
|
16
|
+
let multilineIndent = 0;
|
|
17
|
+
for (let i = 0;i < lines.length; i++) {
|
|
18
|
+
const line = lines[i];
|
|
19
|
+
const trimmed = line.trimStart();
|
|
20
|
+
if (!trimmed || trimmed.startsWith("#")) {
|
|
21
|
+
if (multilineKey)
|
|
22
|
+
multilineValue.push("");
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
const indent = line.length - trimmed.length;
|
|
26
|
+
if (multilineKey) {
|
|
27
|
+
if (indent > multilineIndent || indent === multilineIndent && !trimmed.includes(":")) {
|
|
28
|
+
multilineValue.push(trimmed);
|
|
29
|
+
continue;
|
|
30
|
+
} else {
|
|
31
|
+
const parent = stack[stack.length - 1];
|
|
32
|
+
parent.obj[multilineKey] = multilineValue.join(`
|
|
33
|
+
`).trim();
|
|
34
|
+
multilineKey = null;
|
|
35
|
+
multilineValue = [];
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (trimmed.startsWith("- ")) {
|
|
39
|
+
const value = trimmed.slice(2).trim();
|
|
40
|
+
while (stack.length > 1 && stack[stack.length - 1].indent >= indent) {
|
|
41
|
+
const popped = stack.pop();
|
|
42
|
+
if (popped.key && currentArrayKey === popped.key) {
|
|
43
|
+
const parent = stack[stack.length - 1];
|
|
44
|
+
parent.obj[popped.key] = currentArray;
|
|
45
|
+
currentArrayKey = null;
|
|
46
|
+
currentArray = [];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (value.includes(":")) {
|
|
50
|
+
const colonIdx = value.indexOf(":");
|
|
51
|
+
const objKey = value.slice(0, colonIdx).trim();
|
|
52
|
+
const objVal = value.slice(colonIdx + 1).trim();
|
|
53
|
+
const arrayItem = {};
|
|
54
|
+
if (objVal)
|
|
55
|
+
arrayItem[objKey] = parseValue(objVal);
|
|
56
|
+
let j = i + 1;
|
|
57
|
+
const itemIndent = indent + 2;
|
|
58
|
+
while (j < lines.length) {
|
|
59
|
+
const nextLine = lines[j];
|
|
60
|
+
const nextTrimmed = nextLine.trimStart();
|
|
61
|
+
const nextIndent = nextLine.length - nextTrimmed.length;
|
|
62
|
+
if (!nextTrimmed || nextTrimmed.startsWith("#")) {
|
|
63
|
+
j++;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (nextIndent < itemIndent || nextTrimmed.startsWith("- "))
|
|
67
|
+
break;
|
|
68
|
+
if (nextTrimmed.includes(":")) {
|
|
69
|
+
const nColonIdx = nextTrimmed.indexOf(":");
|
|
70
|
+
const nKey = nextTrimmed.slice(0, nColonIdx).trim();
|
|
71
|
+
const nVal = nextTrimmed.slice(nColonIdx + 1).trim();
|
|
72
|
+
if (nVal)
|
|
73
|
+
arrayItem[nKey] = parseValue(nVal);
|
|
74
|
+
}
|
|
75
|
+
j++;
|
|
76
|
+
}
|
|
77
|
+
i = j - 1;
|
|
78
|
+
currentArray.push(arrayItem);
|
|
79
|
+
} else {
|
|
80
|
+
currentArray.push(parseValue(value));
|
|
81
|
+
}
|
|
82
|
+
if (!currentArrayKey) {
|
|
83
|
+
for (let s = stack.length - 1;s >= 0; s--) {
|
|
84
|
+
if (stack[s].key) {
|
|
85
|
+
currentArrayKey = stack[s].key;
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (trimmed.includes(":")) {
|
|
93
|
+
while (stack.length > 1 && stack[stack.length - 1].indent >= indent) {
|
|
94
|
+
const popped = stack.pop();
|
|
95
|
+
if (popped.key && currentArrayKey === popped.key) {
|
|
96
|
+
const parent2 = stack[stack.length - 1];
|
|
97
|
+
parent2.obj[popped.key] = currentArray;
|
|
98
|
+
currentArrayKey = null;
|
|
99
|
+
currentArray = [];
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (currentArrayKey) {
|
|
103
|
+
const parent2 = stack[stack.length - 1];
|
|
104
|
+
parent2.obj[currentArrayKey] = currentArray;
|
|
105
|
+
currentArrayKey = null;
|
|
106
|
+
currentArray = [];
|
|
107
|
+
}
|
|
108
|
+
const colonIdx = trimmed.indexOf(":");
|
|
109
|
+
const key = trimmed.slice(0, colonIdx).trim();
|
|
110
|
+
const value = trimmed.slice(colonIdx + 1).trim();
|
|
111
|
+
const parent = stack[stack.length - 1];
|
|
112
|
+
if (!value) {
|
|
113
|
+
const nextLine = lines[i + 1];
|
|
114
|
+
if (nextLine && nextLine.trimStart().startsWith("|")) {
|
|
115
|
+
multilineKey = key;
|
|
116
|
+
multilineIndent = indent;
|
|
117
|
+
i++;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const newObj = {};
|
|
121
|
+
parent.obj[key] = newObj;
|
|
122
|
+
stack.push({ indent, obj: newObj, key });
|
|
123
|
+
} else if (value === "|" || value === ">") {
|
|
124
|
+
multilineKey = key;
|
|
125
|
+
multilineIndent = indent;
|
|
126
|
+
} else {
|
|
127
|
+
parent.obj[key] = parseValue(value);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (multilineKey) {
|
|
132
|
+
const parent = stack[stack.length - 1];
|
|
133
|
+
parent.obj[multilineKey] = multilineValue.join(`
|
|
134
|
+
`).trim();
|
|
135
|
+
}
|
|
136
|
+
if (currentArrayKey) {
|
|
137
|
+
const parent = stack[stack.length - 1];
|
|
138
|
+
parent.obj[currentArrayKey] = currentArray;
|
|
139
|
+
}
|
|
140
|
+
return result;
|
|
141
|
+
}
|
|
142
|
+
function parseValue(value) {
|
|
143
|
+
if (!value)
|
|
144
|
+
return "";
|
|
145
|
+
if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
|
|
146
|
+
return value.slice(1, -1);
|
|
147
|
+
}
|
|
148
|
+
if (value === "true")
|
|
149
|
+
return true;
|
|
150
|
+
if (value === "false")
|
|
151
|
+
return false;
|
|
152
|
+
if (value === "null" || value === "~")
|
|
153
|
+
return null;
|
|
154
|
+
const num = Number(value);
|
|
155
|
+
if (!isNaN(num) && value !== "")
|
|
156
|
+
return num;
|
|
157
|
+
return value;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
class MaskLoader {
|
|
161
|
+
masksDir;
|
|
162
|
+
catalog = null;
|
|
163
|
+
cache = new Map;
|
|
164
|
+
constructor(masksDir) {
|
|
165
|
+
this.masksDir = masksDir;
|
|
166
|
+
}
|
|
167
|
+
async loadCatalog() {
|
|
168
|
+
if (this.catalog)
|
|
169
|
+
return this.catalog;
|
|
170
|
+
const indexPath = path.join(this.masksDir, "index.json");
|
|
171
|
+
if (!fs.existsSync(indexPath))
|
|
172
|
+
throw new Error(`Mask catalog not found: ${indexPath}`);
|
|
173
|
+
const content = fs.readFileSync(indexPath, "utf-8");
|
|
174
|
+
this.catalog = JSON.parse(content);
|
|
175
|
+
return this.catalog;
|
|
176
|
+
}
|
|
177
|
+
async load(maskId) {
|
|
178
|
+
if (this.cache.has(maskId))
|
|
179
|
+
return this.cache.get(maskId);
|
|
180
|
+
const catalog = await this.loadCatalog();
|
|
181
|
+
let entry = null;
|
|
182
|
+
let categoryId = null;
|
|
183
|
+
for (const [catId, category] of Object.entries(catalog.categories)) {
|
|
184
|
+
const found = category.masks.find((m) => m.id === maskId);
|
|
185
|
+
if (found) {
|
|
186
|
+
entry = found;
|
|
187
|
+
categoryId = catId;
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (!entry || !categoryId)
|
|
192
|
+
return null;
|
|
193
|
+
const filePath = path.join(this.masksDir, entry.file);
|
|
194
|
+
if (!fs.existsSync(filePath))
|
|
195
|
+
throw new Error(`Mask file not found: ${filePath}`);
|
|
196
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
197
|
+
const parsed = filePath.endsWith(".yaml") || filePath.endsWith(".yml") ? parseSimpleYaml(content) : JSON.parse(content);
|
|
198
|
+
const loadedMask = { ...parsed, category: categoryId, filePath };
|
|
199
|
+
this.cache.set(maskId, loadedMask);
|
|
200
|
+
return loadedMask;
|
|
201
|
+
}
|
|
202
|
+
async listAll() {
|
|
203
|
+
const catalog = await this.loadCatalog();
|
|
204
|
+
const result = [];
|
|
205
|
+
for (const [categoryId, category] of Object.entries(catalog.categories)) {
|
|
206
|
+
for (const mask of category.masks) {
|
|
207
|
+
result.push({ ...mask, category: categoryId });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return result;
|
|
211
|
+
}
|
|
212
|
+
async listCategories() {
|
|
213
|
+
const catalog = await this.loadCatalog();
|
|
214
|
+
return Object.entries(catalog.categories).map(([id, cat]) => ({
|
|
215
|
+
id,
|
|
216
|
+
name: cat.name,
|
|
217
|
+
description: cat.description,
|
|
218
|
+
count: cat.masks.length
|
|
219
|
+
}));
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
function buildRichPrompt(mask) {
|
|
223
|
+
const parts = [];
|
|
224
|
+
parts.push(`You are ${mask.profile.name}.`);
|
|
225
|
+
parts.push(`${mask.profile.tagline}`);
|
|
226
|
+
parts.push("");
|
|
227
|
+
parts.push("BACKGROUND:");
|
|
228
|
+
parts.push(mask.profile.background.trim());
|
|
229
|
+
parts.push("");
|
|
230
|
+
parts.push("YOUR EXPERTISE:");
|
|
231
|
+
for (const exp of mask.profile.expertise)
|
|
232
|
+
parts.push(`- ${exp}`);
|
|
233
|
+
parts.push("");
|
|
234
|
+
parts.push("YOUR THINKING STYLE:");
|
|
235
|
+
parts.push(mask.profile.thinkingStyle.trim());
|
|
236
|
+
parts.push("");
|
|
237
|
+
parts.push("INSTRUCTIONS:");
|
|
238
|
+
parts.push(mask.behavior.systemPrompt.trim());
|
|
239
|
+
parts.push("");
|
|
240
|
+
const style = mask.behavior.communicationStyle;
|
|
241
|
+
parts.push("COMMUNICATION STYLE:");
|
|
242
|
+
parts.push(`- Tone: ${style.tone}`);
|
|
243
|
+
parts.push(`- Verbosity: ${style.verbosity}`);
|
|
244
|
+
parts.push(`- Technical depth: ${style.technicalDepth}`);
|
|
245
|
+
parts.push("");
|
|
246
|
+
parts.push("YOUR STRENGTHS:");
|
|
247
|
+
for (const strength of mask.profile.strengths)
|
|
248
|
+
parts.push(`- ${strength}`);
|
|
249
|
+
if (mask.profile.limitations?.length) {
|
|
250
|
+
parts.push("");
|
|
251
|
+
parts.push("ACKNOWLEDGE YOUR LIMITATIONS:");
|
|
252
|
+
for (const limitation of mask.profile.limitations)
|
|
253
|
+
parts.push(`- ${limitation}`);
|
|
254
|
+
}
|
|
255
|
+
if (mask.behavior.signaturePhrases?.length) {
|
|
256
|
+
parts.push("");
|
|
257
|
+
parts.push("PHRASES YOU MIGHT USE:");
|
|
258
|
+
for (const phrase of mask.behavior.signaturePhrases)
|
|
259
|
+
parts.push(`- "${phrase}"`);
|
|
260
|
+
}
|
|
261
|
+
return parts.join(`
|
|
262
|
+
`);
|
|
263
|
+
}
|
|
264
|
+
var state = null;
|
|
265
|
+
var MaskweaverPlugin = async ({ client, directory }) => {
|
|
266
|
+
const masksDir = path.join(directory, ".opencode", "masks");
|
|
267
|
+
state = {
|
|
268
|
+
maskLoader: null,
|
|
269
|
+
activeMask: null,
|
|
270
|
+
masksDir
|
|
271
|
+
};
|
|
272
|
+
client.app.log({
|
|
4
273
|
service: "maskweaver",
|
|
5
274
|
level: "info",
|
|
6
|
-
message: "Maskweaver plugin loaded v0.
|
|
275
|
+
message: "Maskweaver plugin loaded v0.3.0"
|
|
7
276
|
});
|
|
277
|
+
if (fs.existsSync(masksDir)) {
|
|
278
|
+
state.maskLoader = new MaskLoader(masksDir);
|
|
279
|
+
try {
|
|
280
|
+
await state.maskLoader.loadCatalog();
|
|
281
|
+
client.app.log({
|
|
282
|
+
service: "maskweaver",
|
|
283
|
+
level: "info",
|
|
284
|
+
message: `Masks found at: ${masksDir}`
|
|
285
|
+
});
|
|
286
|
+
} catch (e) {
|
|
287
|
+
client.app.log({
|
|
288
|
+
service: "maskweaver",
|
|
289
|
+
level: "warn",
|
|
290
|
+
message: `Failed to load masks: ${e}`
|
|
291
|
+
});
|
|
292
|
+
state.maskLoader = null;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
8
295
|
return {
|
|
9
|
-
|
|
10
|
-
{
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
296
|
+
"experimental.chat.system.transform": async (_input, output) => {
|
|
297
|
+
if (state?.activeMask) {
|
|
298
|
+
const maskPrompt = `<ACTIVE_PERSONA>
|
|
299
|
+
You are currently embodying the "${state.activeMask.profile.name}" persona.
|
|
300
|
+
|
|
301
|
+
${buildRichPrompt(state.activeMask)}
|
|
302
|
+
</ACTIVE_PERSONA>`;
|
|
303
|
+
(output.system ||= []).push(maskPrompt);
|
|
17
304
|
}
|
|
18
|
-
|
|
305
|
+
},
|
|
306
|
+
tool: {
|
|
307
|
+
list_masks: tool({
|
|
308
|
+
description: "List all available expert persona masks. Shows mask IDs, names, categories, and tags.",
|
|
309
|
+
args: {
|
|
310
|
+
category: tool.schema.string().optional().describe("Filter by category (software-engineering, ai-ml, architecture)")
|
|
311
|
+
},
|
|
312
|
+
async execute(args, _context) {
|
|
313
|
+
if (!state?.maskLoader) {
|
|
314
|
+
return "Error: No masks directory found. Create .opencode/masks/index.json";
|
|
315
|
+
}
|
|
316
|
+
try {
|
|
317
|
+
const masks = await state.maskLoader.listAll();
|
|
318
|
+
const categories = await state.maskLoader.listCategories();
|
|
319
|
+
let filtered = masks;
|
|
320
|
+
if (args.category) {
|
|
321
|
+
filtered = masks.filter((m) => m.category === args.category);
|
|
322
|
+
}
|
|
323
|
+
const lines = [];
|
|
324
|
+
lines.push(`Maskweaver v0.3.0 - ${filtered.length} masks available`);
|
|
325
|
+
lines.push(`Active mask: ${state.activeMask?.metadata.id || "none"}`);
|
|
326
|
+
lines.push("");
|
|
327
|
+
lines.push("Categories:");
|
|
328
|
+
for (const cat of categories) {
|
|
329
|
+
lines.push(` - ${cat.id}: ${cat.name} (${cat.count} masks)`);
|
|
330
|
+
}
|
|
331
|
+
lines.push("");
|
|
332
|
+
lines.push("Masks:");
|
|
333
|
+
for (const mask of filtered) {
|
|
334
|
+
lines.push(` - ${mask.id}: ${mask.name} [${mask.category}]`);
|
|
335
|
+
lines.push(` Tags: ${mask.tags.join(", ")}`);
|
|
336
|
+
}
|
|
337
|
+
return lines.join(`
|
|
338
|
+
`);
|
|
339
|
+
} catch (e) {
|
|
340
|
+
return `Error: ${e}`;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}),
|
|
344
|
+
select_mask: tool({
|
|
345
|
+
description: "Select and apply an expert persona mask. The AI will embody this expert personality.",
|
|
346
|
+
args: {
|
|
347
|
+
maskId: tool.schema.string().describe('Mask ID (e.g., "kent-beck", "linus-torvalds", "martin-fowler")')
|
|
348
|
+
},
|
|
349
|
+
async execute(args, _context) {
|
|
350
|
+
if (!state?.maskLoader) {
|
|
351
|
+
return "Error: No masks directory found.";
|
|
352
|
+
}
|
|
353
|
+
try {
|
|
354
|
+
const mask = await state.maskLoader.load(args.maskId);
|
|
355
|
+
if (!mask) {
|
|
356
|
+
const available = await state.maskLoader.listAll();
|
|
357
|
+
return `Error: Mask "${args.maskId}" not found.
|
|
358
|
+
Available: ${available.map((m) => m.id).join(", ")}`;
|
|
359
|
+
}
|
|
360
|
+
state.activeMask = mask;
|
|
361
|
+
return `✓ Mask activated: ${mask.profile.name}
|
|
362
|
+
|
|
363
|
+
"${mask.profile.tagline}"
|
|
364
|
+
|
|
365
|
+
Expertise: ${mask.profile.expertise.join(", ")}
|
|
366
|
+
|
|
367
|
+
The mask prompt will be injected into all future messages in this session.`;
|
|
368
|
+
} catch (e) {
|
|
369
|
+
return `Error: ${e}`;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}),
|
|
373
|
+
deselect_mask: tool({
|
|
374
|
+
description: "Remove the current mask and return to default AI behavior.",
|
|
375
|
+
args: {},
|
|
376
|
+
async execute(_args, _context) {
|
|
377
|
+
const prev = state?.activeMask;
|
|
378
|
+
if (state)
|
|
379
|
+
state.activeMask = null;
|
|
380
|
+
if (prev) {
|
|
381
|
+
return `✓ Mask removed: ${prev.profile.name}
|
|
382
|
+
Returned to default behavior.`;
|
|
383
|
+
}
|
|
384
|
+
return "No mask was active.";
|
|
385
|
+
}
|
|
386
|
+
}),
|
|
387
|
+
get_mask_prompt: tool({
|
|
388
|
+
description: "View the full system prompt for a mask.",
|
|
389
|
+
args: {
|
|
390
|
+
maskId: tool.schema.string().optional().describe("Mask ID. Uses active mask if not specified.")
|
|
391
|
+
},
|
|
392
|
+
async execute(args, _context) {
|
|
393
|
+
if (!state?.maskLoader)
|
|
394
|
+
return "Error: No masks directory.";
|
|
395
|
+
const maskId = args.maskId || state.activeMask?.metadata.id;
|
|
396
|
+
if (!maskId)
|
|
397
|
+
return "Error: No mask specified and no active mask.";
|
|
398
|
+
try {
|
|
399
|
+
const mask = await state.maskLoader.load(maskId);
|
|
400
|
+
if (!mask)
|
|
401
|
+
return `Error: Mask "${maskId}" not found.`;
|
|
402
|
+
return `# ${mask.profile.name}
|
|
403
|
+
|
|
404
|
+
${buildRichPrompt(mask)}`;
|
|
405
|
+
} catch (e) {
|
|
406
|
+
return `Error: ${e}`;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}),
|
|
410
|
+
maskweaver_status: tool({
|
|
411
|
+
description: "Check Maskweaver status and active mask.",
|
|
412
|
+
args: {},
|
|
413
|
+
async execute(_args, _context) {
|
|
414
|
+
let masksCount = 0;
|
|
415
|
+
let categoriesCount = 0;
|
|
416
|
+
if (state?.maskLoader) {
|
|
417
|
+
try {
|
|
418
|
+
const masks = await state.maskLoader.listAll();
|
|
419
|
+
const categories = await state.maskLoader.listCategories();
|
|
420
|
+
masksCount = masks.length;
|
|
421
|
+
categoriesCount = categories.length;
|
|
422
|
+
} catch (_e) {}
|
|
423
|
+
}
|
|
424
|
+
const lines = [
|
|
425
|
+
"Maskweaver v0.3.0",
|
|
426
|
+
`Masks directory: ${state?.masksDir}`,
|
|
427
|
+
`Available: ${state?.maskLoader ? "yes" : "no"}`,
|
|
428
|
+
`Total masks: ${masksCount}`,
|
|
429
|
+
`Categories: ${categoriesCount}`,
|
|
430
|
+
"",
|
|
431
|
+
`Active mask: ${state?.activeMask ? `${state.activeMask.profile.name} (${state.activeMask.metadata.id})` : "none"}`
|
|
432
|
+
];
|
|
433
|
+
return lines.join(`
|
|
434
|
+
`);
|
|
435
|
+
}
|
|
436
|
+
})
|
|
437
|
+
}
|
|
19
438
|
};
|
|
20
439
|
};
|
|
21
440
|
var src_default = MaskweaverPlugin;
|