@causyne/agent 1.10.19 → 1.10.21

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 (48) hide show
  1. package/docs/ARCHITECTURE.md +20 -0
  2. package/docs/THIRD_PARTY_NOTICES.md +44 -0
  3. package/native-skills/media/creative-studio-pipeline/SKILL.md +52 -0
  4. package/native-skills/media/photo-studio-automation/SKILL.md +53 -0
  5. package/native-skills/media/video-timeline-assembly/SKILL.md +52 -0
  6. package/package.json +1 -1
  7. package/src/agentLoop.js +4 -0
  8. package/src/appChannel.js +7 -3
  9. package/src/cliMode.js +33 -67
  10. package/src/commands.js +56 -8
  11. package/src/compiledSkills.js +15 -0
  12. package/src/computerUse/visionGrounding.js +249 -0
  13. package/src/computerUse/visionGrounding.ts +331 -0
  14. package/src/config.js +80 -11
  15. package/src/creative/apps.js +69 -0
  16. package/src/creative/apps.ts +58 -0
  17. package/src/creative/blenderBatch.js +233 -0
  18. package/src/creative/blenderBatch.ts +217 -0
  19. package/src/creative/pipeline.js +56 -0
  20. package/src/creative/pipeline.ts +82 -0
  21. package/src/creative/studioScripts.js +374 -0
  22. package/src/creative/studioScripts.ts +378 -0
  23. package/src/decisionPolicy.js +2 -0
  24. package/src/decisionPolicy.ts +1 -0
  25. package/src/dynamicCatalogue.js +270 -0
  26. package/src/dynamicCatalogue.ts +283 -0
  27. package/src/entrypoint.js +2 -1
  28. package/src/modelCatalogue.d.ts +5 -0
  29. package/src/modelCatalogue.js +49 -18
  30. package/src/providers/anthropic.js +4 -0
  31. package/src/sandbox.js +8 -1
  32. package/src/toolPacks.js +6 -3
  33. package/src/tools/computerUse.js +139 -0
  34. package/src/tools/computerUse.ts +147 -0
  35. package/src/tools/creativeApps.js +106 -0
  36. package/src/tools/creativeApps.ts +99 -0
  37. package/src/tools/desktopAct.d.ts +10 -0
  38. package/src/tools/registry.js +5 -1
  39. package/src/tools/uiForge.js +132 -0
  40. package/src/tools/uiForge.ts +145 -0
  41. package/src/tui/banner.js +3 -1
  42. package/src/tui/banner.ts +3 -1
  43. package/src/tui/dialogs.js +10 -0
  44. package/src/tui/home.js +29 -72
  45. package/src/tui/setup.js +104 -0
  46. package/src/tui/setup.ts +98 -0
  47. package/src/uiEngine/uiForge.js +211 -0
  48. package/src/uiEngine/uiForge.ts +258 -0
@@ -0,0 +1,378 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import { randomUUID } from 'node:crypto';
5
+ import { execFile } from 'node:child_process';
6
+ import { promisify } from 'node:util';
7
+
8
+ const exec = promisify(execFile);
9
+
10
+ export type StudioHost =
11
+ | 'photoshop'
12
+ | 'premiere'
13
+ | 'after-effects'
14
+ | 'photo-studio'
15
+ | 'video-studio'
16
+ | 'motion-studio';
17
+
18
+ type CanonicalHost = 'photoshop' | 'premiere' | 'after-effects';
19
+
20
+ export function canonicalHost(host: unknown): CanonicalHost {
21
+ if (host === 'photo-studio' || host === 'photoshop') return 'photoshop';
22
+ if (host === 'video-studio' || host === 'premiere') return 'premiere';
23
+ if (host === 'motion-studio' || host === 'after-effects') return 'after-effects';
24
+ throw new Error('Supported studio hosts: photo-studio (or photoshop), video-studio (or premiere), motion-studio (or after-effects)');
25
+ }
26
+
27
+ type Dict = Record<string, unknown>;
28
+
29
+ function object(value: unknown): Dict {
30
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Expected an object');
31
+ return value as Dict;
32
+ }
33
+
34
+ function text(value: unknown, name: string, max = 256): string {
35
+ if (typeof value !== 'string' || !value.trim() || value.length > max || /[\x00-\x1f]/.test(value)) {
36
+ throw new Error(`Invalid ${name}`);
37
+ }
38
+ return value;
39
+ }
40
+
41
+ function number(value: unknown, name: string, min: number, max: number): number {
42
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < min || value > max) {
43
+ throw new Error(`Invalid ${name}: expected ${min}..${max}`);
44
+ }
45
+ return value;
46
+ }
47
+
48
+ export interface StudioJob {
49
+ host: StudioHost;
50
+ canonicalHost: CanonicalHost;
51
+ input?: string | undefined;
52
+ output: string;
53
+ actions: Dict[];
54
+ }
55
+
56
+ export function validateStudioJob(value: unknown): StudioJob {
57
+ const job = object(value);
58
+ const cHost = canonicalHost(job.host);
59
+ const output = text(job.output, 'output', 4096);
60
+
61
+ if (cHost === 'photoshop') {
62
+ if (!/\.(psd|png|jpg|jpeg|webp)$/i.test(output)) {
63
+ throw new Error('Output must be PSD, PNG, JPG, or WEBP for Photo Studio');
64
+ }
65
+ } else if (cHost === 'premiere') {
66
+ if (!/\.(prproj|xml)$/i.test(output)) {
67
+ throw new Error('Output must be PRPROJ or XML for Video Studio');
68
+ }
69
+ } else {
70
+ if (!/\.aep$/i.test(output)) {
71
+ throw new Error('Output must be AEP for Motion Studio');
72
+ }
73
+ }
74
+
75
+ const input = (cHost === 'photoshop' || cHost === 'premiere') && job.input !== undefined
76
+ ? text(job.input, 'input', 4096)
77
+ : undefined;
78
+
79
+ if (!Array.isArray(job.actions) || job.actions.length < 1 || job.actions.length > 64) {
80
+ throw new Error('Provide 1..64 editing actions');
81
+ }
82
+
83
+ const actions = job.actions.map(raw => {
84
+ const a = object(raw);
85
+ const type = text(a.type, 'action type');
86
+
87
+ if (cHost === 'photoshop') {
88
+ if (type === 'resize') return { type, width: number(a.width, 'width', 1, 16384), height: number(a.height, 'height', 1, 16384) };
89
+ if (type === 'rotate') return { type, angle: number(a.angle, 'angle', -360, 360) };
90
+ if (type === 'crop') return { type, left: number(a.left ?? 0, 'left', 0, 16384), top: number(a.top ?? 0, 'top', 0, 16384), width: number(a.width, 'width', 1, 16384), height: number(a.height, 'height', 1, 16384) };
91
+ if (type === 'layer') {
92
+ const result: Dict = { type, index: number(a.index, 'layer index', 0, 9999) };
93
+ if (!Number.isInteger(result.index)) throw new Error('Layer index must be an integer');
94
+ if (a.name !== undefined) result.name = text(a.name, 'layer name');
95
+ if (a.opacity !== undefined) result.opacity = number(a.opacity, 'opacity', 0, 100);
96
+ if (a.visible !== undefined) {
97
+ if (typeof a.visible !== 'boolean') throw new Error('visible must be boolean');
98
+ result.visible = a.visible;
99
+ }
100
+ if (a.blendMode !== undefined) result.blendMode = text(a.blendMode, 'blend mode', 64);
101
+ if (Object.keys(result).length === 2) throw new Error('Specify a layer change');
102
+ return result;
103
+ }
104
+ if (type === 'new_layer') return { type, name: text(a.name, 'layer name'), opacity: typeof a.opacity === 'number' ? number(a.opacity, 'opacity', 0, 100) : 100 };
105
+ if (type === 'text') return { type, text: text(a.text, 'text', 4000), x: number(a.x ?? 50, 'x', 0, 16384), y: number(a.y ?? 50, 'y', 0, 16384), size: typeof a.size === 'number' ? number(a.size, 'size', 1, 1000) : 32, font: typeof a.font === 'string' ? text(a.font, 'font') : 'Helvetica' };
106
+ if (type === 'adjust_color') return { type, brightness: typeof a.brightness === 'number' ? number(a.brightness, 'brightness', -100, 100) : 0, contrast: typeof a.contrast === 'number' ? number(a.contrast, 'contrast', -100, 100) : 0 };
107
+ if (type === 'flatten') return { type };
108
+ } else if (cHost === 'premiere') {
109
+ if (type === 'sequence') return { type, name: text(a.name, 'sequence name'), width: typeof a.width === 'number' ? number(a.width, 'width', 16, 8192) : 1920, height: typeof a.height === 'number' ? number(a.height, 'height', 16, 8192) : 1080, fps: typeof a.fps === 'number' ? number(a.fps, 'fps', 1, 120) : 30 };
110
+ if (type === 'import') return { type, path: text(a.path, 'media path', 4096), start: number(a.start ?? 0, 'start', 0, 36000), ...(a.duration === undefined ? {} : { duration: number(a.duration, 'duration', 0.04, 36000) }), track: typeof a.track === 'number' ? number(a.track, 'track', 0, 99) : 0, mediaType: typeof a.mediaType === 'string' ? text(a.mediaType, 'mediaType') : 'video' };
111
+ if (type === 'marker') return { type, start: number(a.start, 'marker time', 0, 36000), ...(a.name !== undefined ? { name: text(a.name, 'marker name') } : {}), ...(a.comment !== undefined ? { comment: text(a.comment, 'marker comment') } : {}) };
112
+ if (type === 'cut') return { type, time: number(a.time, 'cut time', 0, 36000), track: number(a.track ?? 0, 'track index', 0, 99) };
113
+ } else {
114
+ if (type === 'composition') return { type, name: text(a.name, 'composition name'), width: number(a.width, 'width', 4, 16384), height: number(a.height, 'height', 4, 16384), duration: number(a.duration, 'duration', 0.04, 36000), fps: number(a.fps, 'fps', 1, 120) };
115
+ if (type === 'import') return { type, path: text(a.path, 'media path', 4096), start: number(a.start ?? 0, 'start', 0, 36000), ...(a.duration === undefined ? {} : { duration: number(a.duration, 'duration', 0.04, 36000) }) };
116
+ if (type === 'text') return { type, text: text(a.text, 'text', 2000), start: number(a.start ?? 0, 'start', 0, 36000) };
117
+ if (type === 'solid') return { type, name: text(a.name, 'solid name'), width: number(a.width ?? 1920, 'width', 4, 16384), height: number(a.height ?? 1080, 'height', 4, 16384), duration: number(a.duration ?? 10, 'duration', 0.04, 36000) };
118
+ }
119
+ throw new Error('Unsupported editing action for this host');
120
+ });
121
+
122
+ if (cHost === 'after-effects' && (actions[0]?.type !== 'composition' || actions.filter(a => a.type === 'composition').length !== 1)) {
123
+ throw new Error('Motion Studio jobs must begin with exactly one composition');
124
+ }
125
+
126
+ return { host: job.host as StudioHost, canonicalHost: cHost, input, output, actions };
127
+ }
128
+
129
+ /** Fixed native operations; no model-provided script, eval, network, or service key. */
130
+ export function studioScript(value: unknown, resultFile: string): string {
131
+ const job = validateStudioJob(value);
132
+ const cHost = job.canonicalHost;
133
+ const data = JSON.stringify({ ...job, resultFile }).replace(/\u2028/g, '\\u2028').replace(/\u2029/g, '\\u2029');
134
+ const common = `var job = ${data};\nvar output = new File(job.output);\nif (output.exists) throw new Error('Output already exists');\n`;
135
+
136
+ const photoshop = `var source = null, edited = null;
137
+ var previousDialogs = app.displayDialogs;
138
+ try {
139
+ app.displayDialogs = DialogModes.NO;
140
+ if (job.input) {
141
+ source = app.open(new File(job.input));
142
+ edited = source.duplicate('Causyne Studio Edit', false);
143
+ } else {
144
+ edited = app.documents.add(1920, 1080, 72, 'Causyne Studio Project', NewDocumentMode.RGB);
145
+ }
146
+ for (var i=0; i<job.actions.length; i++) {
147
+ var a=job.actions[i];
148
+ if (a.type==='resize') edited.resizeImage(UnitValue(a.width,'px'),UnitValue(a.height,'px'),null,ResampleMethod.BICUBIC);
149
+ else if (a.type==='rotate') edited.rotateCanvas(a.angle);
150
+ else if (a.type==='crop') edited.crop([UnitValue(a.left,'px'),UnitValue(a.top,'px'),UnitValue(a.left+a.width,'px'),UnitValue(a.top+a.height,'px')]);
151
+ else if (a.type==='flatten') edited.flatten();
152
+ else if (a.type==='new_layer') {
153
+ var nl = edited.artLayers.add();
154
+ nl.name = a.name;
155
+ if (a.opacity !== undefined) nl.opacity = a.opacity;
156
+ } else if (a.type==='text') {
157
+ var tl = edited.artLayers.add();
158
+ tl.kind = LayerKind.TEXT;
159
+ tl.textItem.contents = a.text;
160
+ tl.textItem.position = [UnitValue(a.x,'px'), UnitValue(a.y,'px')];
161
+ if (a.size) tl.textItem.size = UnitValue(a.size,'pt');
162
+ } else if (a.type==='adjust_color') {
163
+ if (edited.activeLayer) {
164
+ if (a.brightness !== 0 || a.contrast !== 0) {
165
+ try { edited.activeLayer.adjustBrightnessContrast(a.brightness, a.contrast); } catch(e){}
166
+ }
167
+ }
168
+ } else {
169
+ if (a.index>=edited.layers.length) throw new Error('Layer index does not exist');
170
+ var layer=edited.layers[a.index];
171
+ if (a.name!==undefined) layer.name=a.name;
172
+ if (a.opacity!==undefined) layer.opacity=a.opacity;
173
+ if (a.visible!==undefined) layer.visible=a.visible;
174
+ }
175
+ }
176
+ if (output.exists) throw new Error('Output appeared while editing; refusing overwrite');
177
+ var options = /\\.png$/i.test(job.output) ? new PNGSaveOptions()
178
+ : /\\.(jpe?g)$/i.test(job.output) ? new JPEGSaveOptions()
179
+ : new PhotoshopSaveOptions();
180
+ edited.saveAs(output, options, true, Extension.LOWERCASE);
181
+ } finally {
182
+ if (edited) edited.close(SaveOptions.DONOTSAVECHANGES);
183
+ if (source) source.close(SaveOptions.DONOTSAVECHANGES);
184
+ app.displayDialogs=previousDialogs;
185
+ }
186
+ `;
187
+
188
+ const premiere = `// Video Studio batch automation
189
+ var proj = null;
190
+ if (job.input) {
191
+ app.openDocument(new File(job.input).fsName, false, false, false);
192
+ }
193
+ if (!app.project) {
194
+ app.newProject(new File(job.output).fsName);
195
+ }
196
+ proj = app.project;
197
+ if (!proj) throw new Error('Could not access Video Studio project');
198
+ for (var i = 0; i < job.actions.length; i++) {
199
+ var a = job.actions[i];
200
+ if (a.type === 'sequence') {
201
+ proj.createNewSequence(a.name, '');
202
+ } else if (a.type === 'import') {
203
+ var fileObj = new File(a.path);
204
+ if (!fileObj.exists) throw new Error('Import file does not exist: ' + a.path);
205
+ proj.importFiles([fileObj.fsName], true, proj.rootItem, false);
206
+ if (proj.activeSequence && proj.rootItem.children.numItems > 0) {
207
+ var item = proj.rootItem.children[proj.rootItem.children.numItems - 1];
208
+ if (item && proj.activeSequence.videoTracks && proj.activeSequence.videoTracks.numTracks > 0) {
209
+ proj.activeSequence.videoTracks[a.track || 0].insertClip(item, a.start || 0);
210
+ }
211
+ }
212
+ } else if (a.type === 'marker') {
213
+ if (proj.activeSequence && proj.activeSequence.markers) {
214
+ var m = proj.activeSequence.markers.createMarker(a.start);
215
+ if (a.name) m.name = a.name;
216
+ if (a.comment) m.comments = a.comment;
217
+ }
218
+ }
219
+ }
220
+ if (output.exists) throw new Error('Output appeared while editing; refusing overwrite');
221
+ proj.saveAs(output.fsName);
222
+ `;
223
+
224
+ const afterEffects = `// Motion Studio batch automation
225
+ if (app.project && (app.project.file || app.project.numItems>0)) throw new Error('Open an empty project before running this batch');
226
+ if (!app.project) app.newProject();
227
+ app.beginUndoGroup('Causyne creative batch');
228
+ try {
229
+ var c=job.actions[0];
230
+ var comp=app.project.items.addComp(c.name,c.width,c.height,1,c.duration,c.fps);
231
+ for (var i=1; i<job.actions.length; i++) {
232
+ var a=job.actions[i], layer;
233
+ if (a.type==='import') {
234
+ var media=app.project.importFile(new ImportOptions(new File(a.path)));
235
+ layer=comp.layers.add(media); layer.startTime=a.start;
236
+ if (a.duration!==undefined) layer.outPoint=Math.min(a.start+a.duration,comp.duration);
237
+ } else if (a.type==='solid') {
238
+ layer=comp.layers.addSolid([0.1,0.1,0.1],a.name,a.width||comp.width,a.height||comp.height,1,a.duration||comp.duration);
239
+ } else {
240
+ layer=comp.layers.addText(a.text); layer.startTime=a.start;
241
+ }
242
+ }
243
+ if (output.exists) throw new Error('Output appeared while editing; refusing overwrite');
244
+ app.project.save(output);
245
+ } finally { app.endUndoGroup(); }
246
+ `;
247
+
248
+ const body = cHost === 'photoshop' ? photoshop : cHost === 'premiere' ? premiere : afterEffects;
249
+ return `// Causyne Agent: reviewed local creative editing batch.\n(function(){\n${common}${body}
250
+ if (!output.exists || output.length===0) throw new Error('The host did not save the requested output');
251
+ var result = new File(job.resultFile);
252
+ result.encoding='UTF-8';
253
+ if (!result.open('w')) throw new Error('Cannot write completion receipt');
254
+ result.write('CAUSYNE_EDIT_COMPLETE'); result.close();
255
+ })();\n`;
256
+ }
257
+
258
+ function isInside(root: string, file: string): boolean {
259
+ const relative = path.relative(root, file);
260
+ return relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
261
+ }
262
+
263
+ async function scoped(root: string, value: string, exists: boolean): Promise<string> {
264
+ const file = path.resolve(root, value);
265
+ const actual = exists
266
+ ? await fs.realpath(file)
267
+ : path.join(await fs.realpath(path.dirname(file)), path.basename(file));
268
+ if (!isInside(root, actual)) throw new Error('Editing files must remain in the selected workspace');
269
+ if (!exists) {
270
+ try {
271
+ await fs.lstat(file);
272
+ throw new Error('Output already exists');
273
+ } catch (e) {
274
+ if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e;
275
+ }
276
+ }
277
+ return actual;
278
+ }
279
+
280
+ export async function findHostApplication(host: CanonicalHost): Promise<string | null> {
281
+ if (process.platform === 'darwin') {
282
+ const pattern = host === 'photoshop'
283
+ ? /(?:^|\s)Photoshop(?:\s|\.app)/i
284
+ : host === 'premiere'
285
+ ? /(?:^|\s)Premiere(?:\s+Pro)?(?:\s|\.app)/i
286
+ : /(?:^|\s)After\s+Effects(?:\s|\.app)/i;
287
+
288
+ for (const root of ['/Applications', path.join(os.homedir(), 'Applications')]) {
289
+ let entries: string[];
290
+ try { entries = await fs.readdir(root); } catch { continue; }
291
+ for (const name of entries.filter(n => pattern.test(n)).sort().reverse()) {
292
+ if (name.endsWith('.app')) return path.join(root, name);
293
+ const parent = path.join(root, name);
294
+ try {
295
+ const bundle = (await fs.readdir(parent)).find(n => pattern.test(n) && n.endsWith('.app'));
296
+ if (bundle) return path.join(parent, bundle);
297
+ } catch { /* Not an application folder. */ }
298
+ }
299
+ }
300
+ return null;
301
+ }
302
+
303
+ if (process.platform === 'win32') {
304
+ const programFiles = process.env.ProgramFiles || 'C:\\Program Files';
305
+ const folderPattern = host === 'photoshop' ? /Photoshop/i : host === 'premiere' ? /Premiere/i : /After Effects/i;
306
+ const exePattern = host === 'photoshop' ? /Photoshop\.exe$/i : host === 'premiere' ? /Premiere\.exe$/i : /AfterFX\.exe$/i;
307
+ try {
308
+ const topDirs = await fs.readdir(programFiles);
309
+ for (const dir of topDirs) {
310
+ const fullDir = path.join(programFiles, dir);
311
+ if (folderPattern.test(dir)) {
312
+ const files = await fs.readdir(fullDir);
313
+ const foundExe = files.find(f => exePattern.test(f));
314
+ if (foundExe) return path.join(fullDir, foundExe);
315
+ }
316
+ }
317
+ } catch { /* Ignore filesystem search failures */ }
318
+ }
319
+
320
+ return null;
321
+ }
322
+
323
+ export async function runStudioEdit(value: unknown, workspace = process.cwd(), signal?: AbortSignal) {
324
+ signal?.throwIfAborted();
325
+ const job = validateStudioJob(value);
326
+ const root = await fs.realpath(workspace);
327
+ job.output = await scoped(root, job.output, false);
328
+ if (job.input) job.input = await scoped(root, job.input, true);
329
+ for (const action of job.actions) {
330
+ if (action.type === 'import') action.path = await scoped(root, String(action.path), true);
331
+ }
332
+
333
+ const application = await findHostApplication(job.canonicalHost);
334
+ if (!application) {
335
+ return {
336
+ success: false,
337
+ error: `Creative host (${job.host}) is not installed at a supported location. No editing was attempted.`
338
+ };
339
+ }
340
+
341
+ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'causyne-studio-'));
342
+ try {
343
+ const scriptFile = path.join(dir, 'edit.jsx');
344
+ const receipt = path.join(dir, `${randomUUID()}.receipt`);
345
+ await fs.writeFile(scriptFile, studioScript(job, receipt), { mode: 0o600 });
346
+
347
+ if (process.platform === 'darwin') {
348
+ const quote = (s: string) => '"' + s.replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
349
+ const invocation = job.canonicalHost === 'photoshop'
350
+ ? `do javascript file (POSIX file ${quote(scriptFile)})`
351
+ : job.canonicalHost === 'premiere'
352
+ ? `try\n do javascript file (POSIX file ${quote(scriptFile)})\non error\n try\n DoScriptFile (POSIX file ${quote(scriptFile)})\n on error\n open (POSIX file ${quote(scriptFile)})\n end try\nend try`
353
+ : `DoScriptFile (POSIX file ${quote(scriptFile)})`;
354
+
355
+ await exec('/usr/bin/osascript', ['-e', `with timeout of 120 seconds\ntell application ${quote(application)}\n${invocation}\nend tell\nend timeout`], {
356
+ cwd: root, timeout: 125000, maxBuffer: 128 * 1024, ...(signal ? { signal } : {}), env: { PATH: '/usr/bin:/bin', HOME: os.homedir() }
357
+ });
358
+ } else {
359
+ // Windows COM / Command invocation
360
+ await exec(application, ['-r', scriptFile], {
361
+ cwd: root, timeout: 125000, maxBuffer: 128 * 1024, ...(signal ? { signal } : {})
362
+ });
363
+ }
364
+
365
+ signal?.throwIfAborted();
366
+ if ((await fs.readFile(receipt, 'utf8')) !== 'CAUSYNE_EDIT_COMPLETE') {
367
+ throw new Error('No completion receipt from the creative host');
368
+ }
369
+ const output = await scoped(root, job.output, true);
370
+ const stat = await fs.stat(output);
371
+ if (!stat.isFile() || !stat.size) {
372
+ throw new Error('The creative host did not produce a nonempty output file');
373
+ }
374
+ return { success: true, output, bytes: stat.size, host: job.host };
375
+ } finally {
376
+ await fs.rm(dir, { recursive: true, force: true });
377
+ }
378
+ }
@@ -51,6 +51,8 @@ export function requiresImplementationPlan(prompt, archetype = '', confidence =
51
51
  return containsAny(text, ARCHITECTURE_TERMS) && containsAny(text, CHANGE_TERMS);
52
52
  }
53
53
  export function isMutatingToolCall(toolName, args = {}) {
54
+ if (toolName === 'creative_apps')
55
+ return args.action !== 'status';
54
56
  if (MUTATING_TOOLS.has(toolName)) {
55
57
  if (toolName === 'sysRun' || toolName === 'sysRunBg') {
56
58
  const command = String(args.command ?? '');
@@ -90,6 +90,7 @@ export function requiresImplementationPlan(prompt: string, archetype = '', confi
90
90
  }
91
91
 
92
92
  export function isMutatingToolCall(toolName: string, args: Record<string, unknown> = {}): boolean {
93
+ if (toolName === 'creative_apps') return args.action !== 'status';
93
94
  if (MUTATING_TOOLS.has(toolName)) {
94
95
  if (toolName === 'sysRun' || toolName === 'sysRunBg') {
95
96
  const command = String(args.command ?? '');
@@ -0,0 +1,270 @@
1
+ // Sovereign Dynamic Model Architecture & Live Discovery
2
+ //
3
+ // WHY THIS EXISTS:
4
+ // AI providers release and update models continuously. Hardcoded static
5
+ // catalogues freeze models at build time. This module gives Causyne a dual-layer
6
+ // model architecture:
7
+ // 1. Curated baseline: Instant offline availability with zero latency and verified pricing/capability tiers.
8
+ // 2. Dynamic discovery: Live discovery via provider APIs (Ollama, OpenAI, Gemini, Anthropic, DeepSeek, etc.)
9
+ // with resilient local caching in ~/.causyne/models_cache.json.
10
+ //
11
+ // ZERO-MALWARE: Pure TypeScript/ESM. Strictly respects timeouts, no secret leakage, no unprompted background calls.
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+ import { causyneHome } from './utils/paths.js';
15
+ import { MODEL_CATALOGUE, DEFAULT_MODEL_BY_PROVIDER } from './modelCatalogue.js';
16
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours
17
+ const REQUEST_TIMEOUT_MS = 3500; // Fast timeout to never block user interaction
18
+ export function cacheFilePath() {
19
+ return path.join(causyneHome(), 'models_cache.json');
20
+ }
21
+ export function normalizeProviderKey(provider) {
22
+ const p = (provider || '').toLowerCase().trim();
23
+ if (p === 'dashscope' || p === 'dashscope-byok' || p === 'alibaba')
24
+ return 'dashscope-byok';
25
+ if (p === 'ollama')
26
+ return 'ollama';
27
+ if (p === 'openai')
28
+ return 'openai';
29
+ if (p === 'anthropic' || p === 'claude')
30
+ return 'anthropic';
31
+ if (p === 'gemini' || p === 'google')
32
+ return 'gemini';
33
+ if (p === 'deepseek')
34
+ return 'deepseek';
35
+ if (p === 'xai' || p === 'grok')
36
+ return 'xai';
37
+ if (p === 'moonshot' || p === 'kimi')
38
+ return 'moonshot';
39
+ if (p === 'muse' || p === 'meta')
40
+ return 'muse';
41
+ if (p === 'openai-compatible' || p === 'custom')
42
+ return 'openai-compatible';
43
+ return p;
44
+ }
45
+ /** Get the static curated models for a provider id. */
46
+ export function getCuratedModels(provider) {
47
+ const norm = normalizeProviderKey(provider);
48
+ const mapping = {
49
+ 'deepseek': 'DeepSeek',
50
+ 'qwen': 'Qwen',
51
+ 'dashscope-byok': 'Alibaba (DashScope)',
52
+ 'dashscope': 'Alibaba (DashScope)',
53
+ 'anthropic': 'Anthropic',
54
+ 'openai': 'OpenAI',
55
+ 'gemini': 'Gemini',
56
+ 'muse': 'Meta (Muse)',
57
+ 'ollama': 'Ollama (Local)',
58
+ 'moonshot': 'Moonshot (Kimi)',
59
+ 'xai': 'xAI (Grok)'
60
+ };
61
+ const key = mapping[norm];
62
+ if (key && MODEL_CATALOGUE[key]) {
63
+ return [...MODEL_CATALOGUE[key]];
64
+ }
65
+ return [];
66
+ }
67
+ /** Read cache safely from ~/.causyne/models_cache.json */
68
+ export function readModelCache() {
69
+ try {
70
+ const file = cacheFilePath();
71
+ if (!fs.existsSync(file))
72
+ return null;
73
+ const content = fs.readFileSync(file, 'utf8');
74
+ const parsed = JSON.parse(content);
75
+ if (parsed && parsed.version === 1 && typeof parsed.providers === 'object') {
76
+ return parsed;
77
+ }
78
+ }
79
+ catch {
80
+ // Graceful fallback on corrupt or inaccessible cache
81
+ }
82
+ return null;
83
+ }
84
+ /** Save cache safely to ~/.causyne/models_cache.json */
85
+ export function writeModelCache(cache) {
86
+ try {
87
+ const dir = causyneHome();
88
+ if (!fs.existsSync(dir)) {
89
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
90
+ }
91
+ const file = cacheFilePath();
92
+ fs.writeFileSync(file, JSON.stringify(cache, null, 2), { mode: 0o600 });
93
+ }
94
+ catch {
95
+ // Cache failures must never crash the CLI
96
+ }
97
+ }
98
+ /** Get cached models for a provider if not expired */
99
+ export function getCachedModels(provider) {
100
+ const cache = readModelCache();
101
+ if (!cache)
102
+ return null;
103
+ const norm = normalizeProviderKey(provider);
104
+ const entry = cache.providers[norm];
105
+ if (!entry)
106
+ return null;
107
+ if (Date.now() - entry.timestamp > CACHE_TTL_MS)
108
+ return null;
109
+ return entry.models;
110
+ }
111
+ /** Save models for a provider into the local cache */
112
+ export function saveCachedModels(provider, models) {
113
+ const norm = normalizeProviderKey(provider);
114
+ const cache = readModelCache() || {
115
+ version: 1,
116
+ updatedAt: new Date().toISOString(),
117
+ providers: {}
118
+ };
119
+ cache.providers[norm] = {
120
+ timestamp: Date.now(),
121
+ models: [...new Set(models)]
122
+ };
123
+ cache.updatedAt = new Date().toISOString();
124
+ writeModelCache(cache);
125
+ }
126
+ /** Live query provider API to discover available models */
127
+ export async function fetchLiveModels(provider, config = {}) {
128
+ const norm = normalizeProviderKey(provider);
129
+ const { apiKey, baseUrl } = config;
130
+ try {
131
+ if (norm === 'ollama') {
132
+ const host = (baseUrl || 'http://127.0.0.1:11434').replace(/\/+$/, '');
133
+ const res = await fetch(`${host}/api/tags`, {
134
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
135
+ });
136
+ if (!res.ok)
137
+ return [];
138
+ const data = await res.json();
139
+ if (Array.isArray(data.models)) {
140
+ return data.models
141
+ .map((m) => (m.name || '').replace(/:latest$/, ''))
142
+ .filter(Boolean);
143
+ }
144
+ }
145
+ if (norm === 'openai') {
146
+ if (!apiKey)
147
+ return [];
148
+ const host = (baseUrl || 'https://api.openai.com/v1').replace(/\/+$/, '');
149
+ const res = await fetch(`${host}/models`, {
150
+ headers: { Authorization: `Bearer ${apiKey}` },
151
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
152
+ });
153
+ if (!res.ok)
154
+ return [];
155
+ const data = await res.json();
156
+ if (Array.isArray(data.data)) {
157
+ return data.data
158
+ .map((m) => m.id || '')
159
+ .filter((id) => /^(gpt|o1|o3|chatgpt)/i.test(id))
160
+ .sort();
161
+ }
162
+ }
163
+ if (norm === 'gemini') {
164
+ if (!apiKey)
165
+ return [];
166
+ const res = await fetch(`https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(apiKey)}`, {
167
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
168
+ });
169
+ if (!res.ok)
170
+ return [];
171
+ const data = await res.json();
172
+ if (Array.isArray(data.models)) {
173
+ return data.models
174
+ .filter((m) => m.supportedGenerationMethods?.includes('generateContent'))
175
+ .map((m) => (m.name || '').replace(/^models\//, ''))
176
+ .filter(Boolean)
177
+ .sort();
178
+ }
179
+ }
180
+ if (norm === 'anthropic') {
181
+ if (!apiKey)
182
+ return [];
183
+ const res = await fetch('https://api.anthropic.com/v1/models', {
184
+ headers: {
185
+ 'x-api-key': apiKey,
186
+ 'anthropic-version': '2023-06-01'
187
+ },
188
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
189
+ });
190
+ if (!res.ok)
191
+ return [];
192
+ const data = await res.json();
193
+ if (Array.isArray(data.data)) {
194
+ return data.data
195
+ .map((m) => m.id || '')
196
+ .filter(Boolean)
197
+ .sort();
198
+ }
199
+ }
200
+ if (norm === 'deepseek') {
201
+ if (!apiKey)
202
+ return [];
203
+ const res = await fetch('https://api.deepseek.com/models', {
204
+ headers: { Authorization: `Bearer ${apiKey}` },
205
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
206
+ });
207
+ if (!res.ok)
208
+ return [];
209
+ const data = await res.json();
210
+ if (Array.isArray(data.data)) {
211
+ return data.data.map((m) => m.id || '').filter(Boolean).sort();
212
+ }
213
+ }
214
+ if (norm === 'openai-compatible') {
215
+ if (!baseUrl)
216
+ return [];
217
+ const host = baseUrl.replace(/\/+$/, '');
218
+ const headers = {};
219
+ if (apiKey)
220
+ headers['Authorization'] = `Bearer ${apiKey}`;
221
+ const res = await fetch(`${host}/models`, {
222
+ headers,
223
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
224
+ });
225
+ if (!res.ok)
226
+ return [];
227
+ const data = await res.json();
228
+ if (Array.isArray(data.data)) {
229
+ return data.data.map((m) => m.id || '').filter(Boolean).sort();
230
+ }
231
+ }
232
+ }
233
+ catch {
234
+ // Network errors or timeouts silently fall back
235
+ }
236
+ return [];
237
+ }
238
+ /**
239
+ * Resolve available models using dual-layer architecture:
240
+ * 1. Checks live query if forceRefresh is true.
241
+ * 2. Checks local cache if available and fresh.
242
+ * 3. Falls back to static curated catalogue merged with any cached items.
243
+ */
244
+ export async function resolveAvailableModels(provider, config = {}, forceRefresh = false) {
245
+ const norm = normalizeProviderKey(provider);
246
+ const curated = getCuratedModels(norm);
247
+ if (forceRefresh) {
248
+ const live = await fetchLiveModels(norm, config);
249
+ if (live.length > 0) {
250
+ const combined = [...new Set([...live, ...curated])];
251
+ saveCachedModels(norm, combined);
252
+ return { models: combined, source: 'live' };
253
+ }
254
+ }
255
+ const cached = getCachedModels(norm);
256
+ if (cached && cached.length > 0) {
257
+ const combined = [...new Set([...cached, ...curated])];
258
+ return { models: combined, source: 'cache' };
259
+ }
260
+ // Attempt lazy background discovery if credentials exist
261
+ if (config.apiKey || norm === 'ollama') {
262
+ const live = await fetchLiveModels(norm, config);
263
+ if (live.length > 0) {
264
+ const combined = [...new Set([...live, ...curated])];
265
+ saveCachedModels(norm, combined);
266
+ return { models: combined, source: 'live' };
267
+ }
268
+ }
269
+ return { models: curated, source: 'catalogue' };
270
+ }