@mirrowel/opencode-souk 0.1.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/config.js ADDED
@@ -0,0 +1,496 @@
1
+ import { createHash } from "node:crypto";
2
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { homedir } from "node:os";
5
+ import { parse, stringify } from "comment-json";
6
+ import { z } from "zod";
7
+ export const ITEM_KINDS = ["plugin", "mcp", "agent", "command", "theme", "skill", "tool", "app", "project", "resource", "unknown"];
8
+ export const CONFIDENCE = ["verified", "partial", "unmapped", "unvetted", "blocked"];
9
+ export const SOURCES = ["opencode-cafe", "awesome-opencode", "opencode-docs", "installed"];
10
+ export const PERSONALITY_PRESETS = [
11
+ "curio-shelf",
12
+ "expedition-cataloguer",
13
+ "the-fence",
14
+ "wayfinder",
15
+ "quartermaster",
16
+ "appraiser",
17
+ "the-souk",
18
+ "wary-antiquarian",
19
+ "friendly-stallkeeper",
20
+ "contract-scribe",
21
+ "pragmatic-artisan",
22
+ "sober-host",
23
+ ];
24
+ export const InstallHint = z.object({
25
+ type: z.enum(["plugin", "mcp", "agent", "command", "theme", "skill"]).optional(),
26
+ spec: z.string().optional(),
27
+ specs: z.array(z.string()).optional(),
28
+ name: z.string().optional(),
29
+ config: z.record(z.string(), z.unknown()).optional(),
30
+ reason: z.string().optional(),
31
+ verified: z.boolean().optional(),
32
+ });
33
+ export const SourceRef = z.object({
34
+ source: z.enum(SOURCES),
35
+ id: z.string().optional(),
36
+ sourceType: z.string().optional(),
37
+ name: z.string().optional(),
38
+ description: z.string().optional(),
39
+ repoUrl: z.string().optional(),
40
+ homepageUrl: z.string().optional(),
41
+ kind: z.enum(ITEM_KINDS).optional(),
42
+ confidence: z.enum(CONFIDENCE).optional(),
43
+ install: InstallHint.optional(),
44
+ installationMarkdown: z.string().optional(),
45
+ updatedAt: z.string().optional(),
46
+ });
47
+ export const RegistryItem = z.object({
48
+ id: z.string().min(1),
49
+ source: z.enum(SOURCES),
50
+ sourceType: z.string().optional(),
51
+ kind: z.enum(ITEM_KINDS),
52
+ confidence: z.enum(CONFIDENCE),
53
+ name: z.string().min(1),
54
+ description: z.string().default(""),
55
+ repoUrl: z.string().optional(),
56
+ homepageUrl: z.string().optional(),
57
+ tags: z.array(z.string()).default([]),
58
+ aliases: z.array(z.string()).default([]),
59
+ alternateKinds: z.array(z.enum(ITEM_KINDS)).default([]),
60
+ sources: z.array(SourceRef).default([]),
61
+ primarySource: z.enum(SOURCES).optional(),
62
+ installationMarkdown: z.string().optional(),
63
+ install: InstallHint.optional(),
64
+ warnings: z.array(z.string()).default([]),
65
+ updatedAt: z.string().optional(),
66
+ });
67
+ const SourceSettings = z.object({
68
+ enabled: z.boolean().default(true),
69
+ url: z.string().url().optional(),
70
+ });
71
+ const UiSettings = z.object({
72
+ width: z.enum(["medium", "large", "xlarge"]).default("large"),
73
+ height: z.enum(["compact", "normal", "tall", "max"]).default("tall"),
74
+ height_percent: z.number().int().min(25).max(100).default(68),
75
+ browser: z.object({
76
+ width: z.enum(["medium", "large", "xlarge"]).default("xlarge"),
77
+ }).default({ width: "xlarge" }),
78
+ }).default({ width: "large", height: "tall", height_percent: 68, browser: { width: "xlarge" } });
79
+ const CacheSettings = z.object({
80
+ fetch_on_empty: z.boolean().default(true),
81
+ refresh_manually: z.boolean().default(true),
82
+ stale_after_hours: z.number().int().min(1).default(168),
83
+ }).default({ fetch_on_empty: true, refresh_manually: true, stale_after_hours: 168 });
84
+ const InstallSettings = z.object({
85
+ default_scope: z.enum(["ask", "global", "project"]).default("ask"),
86
+ conflict_policy: z.enum(["refuse"]).default("refuse"),
87
+ allow_claude_mcp_conversion: z.enum(["ask", "never"]).default("ask"),
88
+ runtime_mcp_connect: z.boolean().default(true),
89
+ }).default({ default_scope: "ask", conflict_policy: "refuse", allow_claude_mcp_conversion: "ask", runtime_mcp_connect: true });
90
+ const ForgeAgentSettings = z.object({
91
+ model: z.string().min(1).default("opencode/big-pickle"),
92
+ variant: z.string().optional(),
93
+ temperature: z.number().finite().optional(),
94
+ top_p: z.number().finite().optional(),
95
+ options: z.record(z.string(), z.unknown()).optional(),
96
+ personality: z.enum(PERSONALITY_PRESETS).default("curio-shelf"),
97
+ });
98
+ const ForgeSettings = z.object({
99
+ enabled: z.boolean().default(false),
100
+ bulk: z.boolean().default(true),
101
+ require_typed_high_risk: z.boolean().default(true),
102
+ agent: ForgeAgentSettings.default({ model: "opencode/big-pickle", personality: "curio-shelf" }),
103
+ }).default({ enabled: false, bulk: true, require_typed_high_risk: true, agent: { model: "opencode/big-pickle", personality: "curio-shelf" } });
104
+ export const SidecarConfig = z.object({
105
+ debug: z.boolean().default(false),
106
+ ui: UiSettings,
107
+ cache: CacheSettings,
108
+ install: InstallSettings,
109
+ forge: ForgeSettings,
110
+ sources: z.object({
111
+ opencode_cafe: SourceSettings.default({ enabled: true, url: "https://curious-quail-727.convex.cloud/api/query" }),
112
+ awesome_opencode: SourceSettings.default({ enabled: true, url: "https://raw.githubusercontent.com/awesome-opencode/awesome-opencode/main/dist/registry.json" }),
113
+ opencode_docs: SourceSettings.default({ enabled: true, url: "https://raw.githubusercontent.com/anomalyco/opencode/dev/packages/web/src/content/docs/ecosystem.mdx" }),
114
+ }).default({
115
+ opencode_cafe: { enabled: true, url: "https://curious-quail-727.convex.cloud/api/query" },
116
+ awesome_opencode: { enabled: true, url: "https://raw.githubusercontent.com/awesome-opencode/awesome-opencode/main/dist/registry.json" },
117
+ opencode_docs: { enabled: true, url: "https://raw.githubusercontent.com/anomalyco/opencode/dev/packages/web/src/content/docs/ecosystem.mdx" },
118
+ }),
119
+ hidden: z.record(z.string(), z.boolean()).default({}),
120
+ });
121
+ const BackupOperation = z.object({
122
+ op: z.enum(["add", "remove", "replace"]),
123
+ path: z.array(z.union([z.string(), z.number()])),
124
+ value: z.unknown().optional(),
125
+ });
126
+ const PatchBackupEntry = z.object({
127
+ type: z.literal("patch"),
128
+ id: z.string(),
129
+ timestamp: z.string(),
130
+ before_hash: z.string(),
131
+ after_hash: z.string(),
132
+ changed_paths: z.array(z.string()),
133
+ reverse_patch: z.array(BackupOperation),
134
+ });
135
+ const FullBackupEntry = z.object({
136
+ type: z.literal("full"),
137
+ id: z.string(),
138
+ timestamp: z.string(),
139
+ hash: z.string(),
140
+ label: z.string().optional(),
141
+ config: SidecarConfig,
142
+ });
143
+ export const BackupJournal = z.object({
144
+ version: z.literal(1).default(1),
145
+ patch_limit: z.number().int().min(1).default(50),
146
+ patches: z.array(PatchBackupEntry).default([]),
147
+ full: z.array(FullBackupEntry).default([]),
148
+ });
149
+ export const CacheFile = z.object({
150
+ version: z.literal(1).default(1),
151
+ fetchedAt: z.string().optional(),
152
+ items: z.array(RegistryItem).default([]),
153
+ errors: z.record(z.string(), z.string()).default({}),
154
+ sourceStatus: z.record(z.string(), z.object({
155
+ enabled: z.boolean().default(true),
156
+ status: z.enum(["fetched", "skipped", "failed"]).default("fetched"),
157
+ rawCount: z.number().int().min(0).default(0),
158
+ normalizedCount: z.number().int().min(0).default(0),
159
+ warnings: z.array(z.string()).default([]),
160
+ error: z.string().optional(),
161
+ })).default({}),
162
+ });
163
+ export function defaultConfigDir() {
164
+ if (process.env.OPENCODE_CONFIG_DIR)
165
+ return process.env.OPENCODE_CONFIG_DIR;
166
+ if (process.env.XDG_CONFIG_HOME)
167
+ return join(process.env.XDG_CONFIG_HOME, "opencode");
168
+ if (process.platform === "win32" && process.env.APPDATA)
169
+ return join(process.env.APPDATA, "opencode");
170
+ return join(homedir(), ".config", "opencode");
171
+ }
172
+ export function defaultSidecarPath() {
173
+ return join(defaultConfigDir(), "souk.jsonc");
174
+ }
175
+ export function defaultCachePath() {
176
+ return join(defaultConfigDir(), "souk-cache.json");
177
+ }
178
+ export function heightPresetPercent(height) {
179
+ if (height === "compact")
180
+ return 32;
181
+ if (height === "normal")
182
+ return 50;
183
+ if (height === "max")
184
+ return 100;
185
+ return 68;
186
+ }
187
+ export function effectiveUiHeightPercent(ui) {
188
+ return Math.max(25, Math.min(100, Math.round(ui.height_percent ?? heightPresetPercent(ui.height))));
189
+ }
190
+ export function debugLogPath(configDir = defaultConfigDir()) {
191
+ return join(configDir, "souk.debug.log");
192
+ }
193
+ export function backupJournalPath(configDir = defaultConfigDir()) {
194
+ return join(configDir, "souk.backup.json");
195
+ }
196
+ export function emptyConfig() {
197
+ return SidecarConfig.parse({});
198
+ }
199
+ export function emptyCache() {
200
+ return CacheFile.parse({ version: 1, items: [], errors: {} });
201
+ }
202
+ export function emptyBackupJournal() {
203
+ return BackupJournal.parse({ version: 1, patch_limit: 50, patches: [], full: [] });
204
+ }
205
+ export function loadSidecar(file = defaultSidecarPath()) {
206
+ if (!existsSync(file))
207
+ return emptyConfig();
208
+ return SidecarConfig.parse(parse(readFileSync(file, "utf8")));
209
+ }
210
+ export function loadSidecarSafe(file = defaultSidecarPath()) {
211
+ try {
212
+ return { config: loadSidecar(file) };
213
+ }
214
+ catch (error) {
215
+ return { config: emptyConfig(), error: error instanceof Error ? error.message : String(error) };
216
+ }
217
+ }
218
+ export function saveSidecar(config, file = defaultSidecarPath(), options = {}) {
219
+ const parsed = SidecarConfig.parse(config);
220
+ let previous;
221
+ if (existsSync(file)) {
222
+ try {
223
+ previous = loadSidecar(file);
224
+ }
225
+ catch (error) {
226
+ if (!options.allowInvalidPrevious)
227
+ throw error;
228
+ }
229
+ }
230
+ if (previous && stableStringify(previous) === stableStringify(parsed))
231
+ return { changed: false };
232
+ mkdirSync(dirname(file), { recursive: true });
233
+ if (previous && options.backup !== false)
234
+ writePatchBackup(previous, parsed, backupJournalPath(dirname(file)));
235
+ const temp = `${file}.tmp`;
236
+ writeFileSync(temp, `${stringify(parsed, null, 2)}\n`, "utf8");
237
+ renameSync(temp, file);
238
+ return { changed: true };
239
+ }
240
+ export function loadBackupJournal(file = backupJournalPath()) {
241
+ if (!existsSync(file))
242
+ return emptyBackupJournal();
243
+ return BackupJournal.parse(JSON.parse(readFileSync(file, "utf8")));
244
+ }
245
+ export function saveBackupJournal(journal, file = backupJournalPath()) {
246
+ const parsed = BackupJournal.parse(journal);
247
+ mkdirSync(dirname(file), { recursive: true });
248
+ const temp = `${file}.tmp`;
249
+ writeFileSync(temp, `${JSON.stringify(parsed, null, 2)}\n`, "utf8");
250
+ renameSync(temp, file);
251
+ }
252
+ export function hashConfig(config) {
253
+ return createHash("sha256").update(stableStringify(backupHashConfig(config))).digest("hex");
254
+ }
255
+ function backupHashConfig(config) {
256
+ const parsed = SidecarConfig.parse(config);
257
+ const { debug: _debug, ui: _ui, ...rest } = parsed;
258
+ return rest;
259
+ }
260
+ export function createFullBackup(config, input) {
261
+ const journal = loadBackupJournal(input?.file);
262
+ const now = new Date().toISOString();
263
+ journal.full.unshift({ type: "full", id: now, timestamp: now, hash: hashConfig(config), label: input?.label, config: SidecarConfig.parse(config) });
264
+ saveBackupJournal(journal, input?.file);
265
+ return journal;
266
+ }
267
+ export function deleteFullBackup(id, file = backupJournalPath()) {
268
+ const journal = loadBackupJournal(file);
269
+ journal.full = journal.full.filter((entry) => entry.id !== id);
270
+ saveBackupJournal(journal, file);
271
+ return journal;
272
+ }
273
+ export function deleteAllFullBackups(file = backupJournalPath()) {
274
+ const journal = loadBackupJournal(file);
275
+ journal.full = [];
276
+ saveBackupJournal(journal, file);
277
+ return journal;
278
+ }
279
+ export function reconstructPatchBackup(index, current, journal = loadBackupJournal()) {
280
+ let config = SidecarConfig.parse(current);
281
+ for (const [entryIndex, entry] of journal.patches.entries()) {
282
+ const currentHash = hashConfig(config);
283
+ if (currentHash !== entry.after_hash)
284
+ return { ok: false, message: `Hash mismatch at patch ${entryIndex + 1}. Current config is ${currentHash.slice(0, 8)}, expected ${entry.after_hash.slice(0, 8)}.` };
285
+ config = applyBackupOperations(config, entry.reverse_patch);
286
+ const restoredHash = hashConfig(config);
287
+ if (restoredHash !== entry.before_hash)
288
+ return { ok: false, message: `Patch ${entryIndex + 1} restored ${restoredHash.slice(0, 8)}, expected ${entry.before_hash.slice(0, 8)}.` };
289
+ if (entryIndex === index)
290
+ return { ok: true, config, consumed: entryIndex + 1 };
291
+ }
292
+ return { ok: false, message: "Patch backup was not found." };
293
+ }
294
+ function writePatchBackup(previous, next, file) {
295
+ const reverse = diffReverse(previous, next);
296
+ if (reverse.operations.length === 0)
297
+ return;
298
+ const journal = loadBackupJournal(file);
299
+ const now = new Date().toISOString();
300
+ journal.patches.unshift({ type: "patch", id: now, timestamp: now, before_hash: hashConfig(previous), after_hash: hashConfig(next), changed_paths: reverse.changed_paths, reverse_patch: reverse.operations });
301
+ journal.patches = journal.patches.slice(0, journal.patch_limit);
302
+ saveBackupJournal(journal, file);
303
+ }
304
+ function diffReverse(previous, next, path = []) {
305
+ if (stableStringify(previous) === stableStringify(next))
306
+ return { operations: [], changed_paths: [] };
307
+ if (!isPlainObject(previous) || !isPlainObject(next))
308
+ return { operations: [{ op: previous === undefined ? "remove" : next === undefined ? "add" : "replace", path, value: previous }], changed_paths: [formatPath(path)] };
309
+ return [...new Set([...Object.keys(previous), ...Object.keys(next)])]
310
+ .map((key) => diffReverse(previous[key], next[key], [...path, key]))
311
+ .reduce((acc, item) => ({ operations: [...acc.operations, ...item.operations], changed_paths: [...acc.changed_paths, ...item.changed_paths] }), { operations: [], changed_paths: [] });
312
+ }
313
+ function applyBackupOperations(config, operations) {
314
+ const result = structuredClone(config);
315
+ for (const operation of operations)
316
+ applyBackupOperation(result, operation);
317
+ return SidecarConfig.parse(result);
318
+ }
319
+ function applyBackupOperation(target, operation) {
320
+ const parent = operation.path.slice(0, -1).reduce((cursor, segment) => {
321
+ if (!isPlainObject(cursor[segment]))
322
+ cursor[segment] = {};
323
+ return cursor[segment];
324
+ }, target);
325
+ const key = operation.path.at(-1);
326
+ if (key === undefined)
327
+ return;
328
+ if (operation.op === "remove")
329
+ delete parent[key];
330
+ else
331
+ parent[key] = operation.value;
332
+ }
333
+ export function stableStringify(value) {
334
+ if (Array.isArray(value))
335
+ return `[${value.map(stableStringify).join(",")}]`;
336
+ if (isPlainObject(value))
337
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(",")}}`;
338
+ return JSON.stringify(value);
339
+ }
340
+ function isPlainObject(value) {
341
+ return value !== null && typeof value === "object" && !Array.isArray(value);
342
+ }
343
+ function formatPath(path) {
344
+ return path.length === 0 ? "<root>" : path.join(".");
345
+ }
346
+ export function debugEnabled() {
347
+ try {
348
+ return loadSidecar(defaultSidecarPath()).debug;
349
+ }
350
+ catch {
351
+ return false;
352
+ }
353
+ }
354
+ export function debugLog(title, message, enabled = debugEnabled()) {
355
+ if (!enabled)
356
+ return;
357
+ try {
358
+ mkdirSync(defaultConfigDir(), { recursive: true });
359
+ appendFileSync(debugLogPath(defaultConfigDir()), `${new Date().toISOString()} ${title}: ${message}\n`, "utf8");
360
+ }
361
+ catch {
362
+ // Debug logging must never affect Souk behavior.
363
+ }
364
+ }
365
+ export function loadCache(file = defaultCachePath()) {
366
+ if (!existsSync(file))
367
+ return emptyCache();
368
+ try {
369
+ return CacheFile.parse(JSON.parse(readFileSync(file, "utf8")));
370
+ }
371
+ catch {
372
+ return emptyCache();
373
+ }
374
+ }
375
+ export function saveCache(cache, file = defaultCachePath()) {
376
+ mkdirSync(dirname(file), { recursive: true });
377
+ const temp = `${file}.tmp`;
378
+ writeFileSync(temp, JSON.stringify(cache, null, 2), "utf8");
379
+ renameSync(temp, file);
380
+ }
381
+ export function modelCatalogFromProviders(providers) {
382
+ const catalog = { providers: new Set(), providersWithModelList: new Set(), refs: new Set(), variants: new Map() };
383
+ const list = Array.isArray(providers) ? providers : Object.entries((providers ?? {})).map(([id, value]) => ({ id, ...value }));
384
+ for (const provider of list) {
385
+ const providerID = typeof provider.id === "string" ? provider.id : undefined;
386
+ if (!providerID)
387
+ continue;
388
+ catalog.providers.add(providerID);
389
+ const models = provider.models;
390
+ if (!models || Object.keys(models).length === 0)
391
+ continue;
392
+ catalog.providersWithModelList.add(providerID);
393
+ for (const [key, value] of Object.entries(models)) {
394
+ const refs = [`${providerID}/${key}`, typeof value.id === "string" ? `${providerID}/${value.id}` : undefined].filter((ref) => !!ref);
395
+ for (const ref of refs) {
396
+ catalog.refs.add(ref);
397
+ if (value.variants && Object.keys(value.variants).length > 0)
398
+ catalog.variants.set(ref, new Set(Object.keys(value.variants)));
399
+ }
400
+ }
401
+ }
402
+ return catalog;
403
+ }
404
+ export function validateModel(model, catalog) {
405
+ if (!model)
406
+ return;
407
+ const split = splitModelRef(model);
408
+ if (!split)
409
+ return `Model "${model}" must use provider/model format.`;
410
+ if (!catalog.providers.has(split.providerID))
411
+ return `Provider "${split.providerID}" is not configured for model "${model}".`;
412
+ if (catalog.providersWithModelList.has(split.providerID) && !catalog.refs.has(model))
413
+ return `Model "${model}" was not found in provider "${split.providerID}".`;
414
+ }
415
+ export function validateModelVariant(model, variant, catalog) {
416
+ if (!variant || !model)
417
+ return;
418
+ const variants = catalog.variants.get(model);
419
+ if (variants && variants.size > 0 && !variants.has(variant))
420
+ return `Variant "${variant}" was not found for model "${model}".`;
421
+ }
422
+ export function diagnoseConfig(config, input = {}) {
423
+ const diagnostics = [];
424
+ const catalog = modelCatalogFromProviders(input.providers);
425
+ diagnostics.push({ level: "info", message: `Debug mode is ${config.debug ? "enabled" : "disabled"}.` });
426
+ if (input.configParseError)
427
+ diagnostics.push({ level: "error", message: `Sidecar config parse failed; defaults are active: ${input.configParseError}` });
428
+ const modelIssue = validateModel(config.forge.agent.model, catalog);
429
+ if (modelIssue)
430
+ diagnostics.push({ level: "warning", message: `Kāf model: ${modelIssue}` });
431
+ const variantIssue = validateModelVariant(config.forge.agent.model, config.forge.agent.variant, catalog);
432
+ if (variantIssue)
433
+ diagnostics.push({ level: "warning", message: `Kāf model variant: ${variantIssue}` });
434
+ for (const [name, source] of Object.entries(config.sources)) {
435
+ if (!source.enabled)
436
+ diagnostics.push({ level: "info", message: `Source ${name} is disabled.` });
437
+ if (source.enabled && !source.url)
438
+ diagnostics.push({ level: "warning", message: `Source ${name} is enabled but has no URL.` });
439
+ }
440
+ const cache = input.cache ?? loadCache();
441
+ diagnostics.push({ level: "info", message: `Cache has ${cache.items.length} item(s); fetched ${cache.fetchedAt ?? "never"}.` });
442
+ for (const [source, status] of Object.entries(cache.sourceStatus)) {
443
+ diagnostics.push({ level: status.error ? "warning" : "info", message: `Source ${source}: ${status.status}, raw ${status.rawCount}, normalized ${status.normalizedCount}${status.error ? `, error: ${status.error}` : ""}.` });
444
+ for (const warning of status.warnings)
445
+ diagnostics.push({ level: "warning", message: `Source ${source}: ${warning}` });
446
+ }
447
+ if (Object.keys(cache.errors).length > 0)
448
+ diagnostics.push({ level: "warning", message: `Cache has source errors: ${Object.entries(cache.errors).map(([key, value]) => `${key}: ${value}`).join("; ")}` });
449
+ if (cache.fetchedAt) {
450
+ const ageHours = (Date.now() - Date.parse(cache.fetchedAt)) / 3_600_000;
451
+ if (Number.isFinite(ageHours) && ageHours > config.cache.stale_after_hours)
452
+ diagnostics.push({ level: "warning", message: `Cache is stale (${Math.round(ageHours)}h old).` });
453
+ }
454
+ if (input.pluginIDs && !input.pluginIDs.includes("opencode-souk"))
455
+ diagnostics.push({ level: "warning", message: "Souk TUI plugin ID was not found in loaded plugin list." });
456
+ try {
457
+ const journal = loadBackupJournal();
458
+ diagnostics.push({ level: "info", message: `Backup journal has ${journal.patches.length} patch restore point(s) and ${journal.full.length} full backup(s).` });
459
+ if (journal.patches.length > 0) {
460
+ const restored = reconstructPatchBackup(0, config, journal);
461
+ if (!restored.ok)
462
+ diagnostics.push({ level: "warning", message: `Backup journal hash check failed: ${restored.message}` });
463
+ }
464
+ }
465
+ catch (error) {
466
+ diagnostics.push({ level: "warning", message: `Backup journal could not be read: ${error instanceof Error ? error.message : String(error)}` });
467
+ }
468
+ return diagnostics;
469
+ }
470
+ export function splitModelRef(ref) {
471
+ const [providerID, ...rest] = ref.split("/");
472
+ const modelID = rest.join("/");
473
+ if (!providerID || !modelID)
474
+ return undefined;
475
+ return { providerID, modelID };
476
+ }
477
+ export function confidenceRank(confidence) {
478
+ return CONFIDENCE.indexOf(confidence);
479
+ }
480
+ export function personalityDescription(preset) {
481
+ const map = {
482
+ "curio-shelf": "Quiet cabinet-of-curiosities voice; default. Atmosphere is light, warnings are plain.",
483
+ "expedition-cataloguer": "Meticulous artifact catalogue: provenance, condition, handling notes.",
484
+ "the-fence": "World-weary procurer. Blunt about sketchy goods.",
485
+ wayfinder: "Routes, maps, known terrain, and unmapped territory.",
486
+ quartermaster: "Procedural deployment/logistics voice.",
487
+ appraiser: "Authenticity, provenance, condition, and risk appraisal.",
488
+ "the-souk": "The marketplace itself as the character and institution.",
489
+ "wary-antiquarian": "Rare-artifact dealer who has seen cursed things.",
490
+ "friendly-stallkeeper": "Warm and protective market guide.",
491
+ "contract-scribe": "Formal, dry, exact, and safety-forward.",
492
+ "pragmatic-artisan": "Skilled craftsperson: practical, concise, honest.",
493
+ "sober-host": "Minimal personality, maximum clarity.",
494
+ };
495
+ return map[preset];
496
+ }
@@ -0,0 +1,3 @@
1
+ import type { TuiPluginApi } from "@opencode-ai/plugin/tui";
2
+ import type { RegistryItem, ScopeChoice, SidecarConfig } from "./config.js";
3
+ export declare function openForge(api: TuiPluginApi, config: SidecarConfig, items: RegistryItem[], scope: ScopeChoice): Promise<string>;
package/dist/forge.js ADDED
@@ -0,0 +1,78 @@
1
+ import { debugLog, personalityDescription, splitModelRef } from "./config.js";
2
+ import { scopeLabel } from "./install.js";
3
+ export async function openForge(api, config, items, scope) {
4
+ debugLog("Forge open start", `${items.length} item(s) -> ${scopeLabel(scope)}; model=${config.forge.agent.model}; variant=${config.forge.agent.variant ?? "default"}`);
5
+ const model = splitModelRef(config.forge.agent.model);
6
+ const title = items.length === 1 ? `Souk Forge: ${items[0]?.name ?? "item"}` : `Souk Forge: ${items.length} items`;
7
+ const client = api.client;
8
+ if (!client.session?.create || (!client.session.promptAsync && !client.session.prompt)) {
9
+ throw new Error("This OpenCode build does not expose session create/prompt APIs to TUI plugins.");
10
+ }
11
+ const created = unwrap(await client.session.create({
12
+ title,
13
+ agent: "kāf-plan",
14
+ ...(model ? { model: { providerID: model.providerID, id: model.modelID, variant: config.forge.agent.variant } } : {}),
15
+ permission: planPermission(),
16
+ }));
17
+ if (!created?.id)
18
+ throw new Error("Failed to create Souk Forge session.");
19
+ const prompt = forgePrompt(config, items, scope);
20
+ const payload = {
21
+ sessionID: created.id,
22
+ agent: "kāf-plan",
23
+ ...(model ? { model: { providerID: model.providerID, modelID: model.modelID }, variant: config.forge.agent.variant } : {}),
24
+ parts: [{ type: "text", text: prompt }],
25
+ };
26
+ if (client.session.promptAsync)
27
+ await client.session.promptAsync(payload);
28
+ else
29
+ await client.session.prompt?.(payload);
30
+ api.route.navigate("session", { sessionID: created.id });
31
+ debugLog("Forge open complete", created.id);
32
+ return created.id;
33
+ }
34
+ function forgePrompt(config, items, scope) {
35
+ return [
36
+ "You are Kāf, the dedicated OpenCode Souk Forge agent.",
37
+ "",
38
+ "Before doing anything else, load these skills with the skill tool:",
39
+ "- customize-opencode",
40
+ "- souk-installer",
41
+ "",
42
+ `Personality preset: ${config.forge.agent.personality}`,
43
+ personalityDescription(config.forge.agent.personality),
44
+ "",
45
+ "You are in PLAN MODE. You must not edit files, write config, run install commands, or mutate the system yet.",
46
+ "You may use MCP tools for research, inspection, and appraisal in plan mode when available. Do not use MCP tools to mutate files, config, accounts, remote services, or project state before action approval.",
47
+ "Analyze security first. Inspect provenance, install scripts, requested permissions, config writes, network/token handling, and stale docs risk.",
48
+ "When and only when you have a clear plan, call souk_request_action to ask the user yes/no before action mode.",
49
+ "If the user says no, continue planning and propose switching again only when appropriate.",
50
+ "",
51
+ `Requested scope: ${scopeLabel(scope)}`,
52
+ "Selected items:",
53
+ JSON.stringify(items, null, 2),
54
+ ].join("\n");
55
+ }
56
+ function planPermission() {
57
+ return [
58
+ { permission: "edit", pattern: "*", action: "deny" },
59
+ { permission: "write", pattern: "*", action: "deny" },
60
+ { permission: "patch", pattern: "*", action: "deny" },
61
+ { permission: "bash", pattern: "*", action: "deny" },
62
+ { permission: "skill", pattern: "*", action: "allow" },
63
+ { permission: "read", pattern: "*", action: "allow" },
64
+ { permission: "grep", pattern: "*", action: "allow" },
65
+ { permission: "glob", pattern: "*", action: "allow" },
66
+ { permission: "webfetch", pattern: "*", action: "allow" },
67
+ { permission: "websearch", pattern: "*", action: "allow" },
68
+ { permission: "souk_request_action", pattern: "*", action: "allow" },
69
+ { permission: "souk_registry_search", pattern: "*", action: "allow" },
70
+ { permission: "souk_native_preview", pattern: "*", action: "allow" },
71
+ { permission: "souk_native_install", pattern: "*", action: "deny" },
72
+ ];
73
+ }
74
+ function unwrap(result) {
75
+ if (result && typeof result === "object" && "data" in result)
76
+ return result.data;
77
+ return result;
78
+ }
@@ -0,0 +1,3 @@
1
+ import type { Plugin } from "@opencode-ai/plugin";
2
+ declare const plugin: Plugin;
3
+ export default plugin;