| name: CI | ||
| on: | ||
| push: | ||
| branches: [main] | ||
| pull_request: | ||
| jobs: | ||
| test: | ||
| runs-on: ubuntu-latest | ||
| strategy: | ||
| matrix: | ||
| node-version: [20, 22] | ||
| steps: | ||
| - uses: actions/checkout@v4 | ||
| - uses: actions/setup-node@v4 | ||
| with: | ||
| node-version: ${{ matrix.node-version }} | ||
| - run: npm ci | ||
| - run: npm test |
| # AgentLux QA Review Report | ||
| ## Scope | ||
| - Repository: AgentLux | ||
| - Review mode: full-repo QA audit (correctness, reliability, security, regression safety) | ||
| - Product decision locked: `delete_after=true` default is intentional and preserved | ||
| ## Design-Confirmed Items (Not Defects) | ||
| - Default source file deletion (`delete_after=true`) remains the expected plug-and-play behavior. | ||
| - Review treats this as policy, and evaluates only controllability/visibility around it. | ||
| ## Findings by Severity | ||
| ### Critical | ||
| 1. No automated regression gates | ||
| - Evidence: `package.json` test script was placeholder, no CI workflow. | ||
| - Impact: high risk of shipping breakages undetected. | ||
| - Resolution: added `node --test` suite and GitHub Actions CI matrix. | ||
| - Status: fixed. | ||
| ### High | ||
| 1. Unvalidated VLM payload used directly in crop math | ||
| - Evidence: direct JSON parse and field trust in runtime path. | ||
| - Impact: malformed model output could crash or create invalid crop. | ||
| - Resolution: added schema validation + typed parsing + defensive sanitization. | ||
| - Status: fixed. | ||
| 2. No timeout/retry for external vision call | ||
| - Evidence: raw `fetch` call without timeout/backoff. | ||
| - Impact: hanging requests and fragile transient failure behavior. | ||
| - Resolution: added timeout (`AbortController`), bounded retries, exponential backoff. | ||
| - Status: fixed. | ||
| 3. Weak error classification | ||
| - Evidence: generic `{ status: "error", message }` only. | ||
| - Impact: poor operability and ambiguous caller behavior. | ||
| - Resolution: introduced structured `error_code` and optional `details`. | ||
| - Status: fixed. | ||
| ### Medium | ||
| 1. Input/path boundary assumptions | ||
| - Evidence: no absolute-path check, no pre-stat validation, no size limits. | ||
| - Impact: reliability issues and potential misuse of arbitrary paths. | ||
| - Resolution: added absolute path check, file existence/type checks, max size guard. | ||
| - Status: fixed. | ||
| 2. Metadata assumptions on width/height | ||
| - Evidence: pipeline relied on width/height presence without validation. | ||
| - Impact: runtime failures on malformed images. | ||
| - Resolution: metadata positive-integer validation added. | ||
| - Status: fixed. | ||
| 3. Source deletion observability gap | ||
| - Evidence: unlink failure only logged to console, not surfaced in API result. | ||
| - Impact: callers cannot reliably reason about retention outcome. | ||
| - Resolution: result now exposes `source_file_deletion` and message on failure. | ||
| - Status: fixed. | ||
| ### Low | ||
| 1. Docs/implementation mismatch on provider support | ||
| - Evidence: docs implied multi-provider support while runtime is OpenAI-only. | ||
| - Impact: operator confusion and onboarding errors. | ||
| - Resolution: aligned docs to OpenAI-only runtime contract and env vars. | ||
| - Status: fixed. | ||
| ## Implemented Changes | ||
| - Runtime hardening in `index.js` | ||
| - `AgentLuxError` with stable error codes | ||
| - input/file/size/metadata validation | ||
| - VLM timeout/retry/backoff and structured HTTP parse | ||
| - crop schema parse + bound sanitization with min dimensions | ||
| - explicit source deletion status in success payload | ||
| - Regression gates | ||
| - `test/index.test.js` with 5 deterministic tests: | ||
| - default deletion behavior | ||
| - `delete_after=false` retention | ||
| - VLM schema failure path | ||
| - transient network retry success | ||
| - crop bounds clamping and min-size enforcement | ||
| - `package.json` test script to `node --test` | ||
| - `.github/workflows/ci.yml` for Node 20/22 | ||
| - Contract alignment | ||
| - README and SKILL updated to match real runtime behavior and env contract. | ||
| ## Second-Pass Review Findings (post-fix audit) | ||
| ### High | ||
| 1. Non-retryable errors were retried | ||
| - Evidence: `VLM_SCHEMA_ERROR`, `VLM_PARSE_ERROR`, and HTTP 4xx all entered the retry loop with backoff, wasting time and API calls. | ||
| - Impact: schema error test took 605ms instead of ~12ms; deterministic failures burned retry budget. | ||
| - Resolution: classified error codes into retryable (`VLM_TIMEOUT`, `VLM_NETWORK_ERROR`, `VLM_HTTP_TRANSIENT` for 5xx/429) vs non-retryable; break immediately on non-retryable. | ||
| - Status: fixed. | ||
| ### Medium | ||
| 1. Dead code in `applyLeicaM10Color` | ||
| - Evidence: `cx`, `cy`, `r` variables computed but never used (SVG uses percentage-based gradient). | ||
| - Resolution: removed the three unused variables. | ||
| - Status: fixed. | ||
| 2. Fragile environment variable parsing | ||
| - Evidence: `Number("abc")` returns `NaN`, causing silent config corruption at module load. | ||
| - Resolution: added `envInt()` helper with validation and fail-fast on invalid values. | ||
| - Status: fixed. | ||
| 3. No input validation test coverage | ||
| - Evidence: report claimed input validation was fixed, but zero tests exercised those paths. | ||
| - Resolution: added 4 tests: empty path, relative path, non-existent file, missing API key. | ||
| - Status: fixed. | ||
| ### Low | ||
| 1. Test environment leakage | ||
| - Evidence: `process.env.OPENAI_API_KEY` was set in each test but never restored. | ||
| - Resolution: added `setup()`/`teardown()` helpers that save and restore env + `global.fetch`. | ||
| - Status: fixed. | ||
| ## Test Evidence | ||
| - Command: `npm test` | ||
| - Result: 10 passed, 0 failed (5 original + 5 new) | ||
| ## Priority Roadmap | ||
| - P0 (done): runtime guardrails + error taxonomy + core tests | ||
| - P1 (done): docs/contract alignment + CI test gate | ||
| - P1.5 (done): retry correctness + env validation + input validation tests + test isolation | ||
| - P2 (recommended next): | ||
| - add lightweight lint/check gate | ||
| - add optional structured logs with request correlation | ||
| - add load tests for large-image throughput profile | ||
| ## Release Blockers | ||
| - None remaining for the scoped plan. | ||
| - Recommended pre-release checks: | ||
| - verify `OPENAI_API_KEY` present in deployment env | ||
| - verify `AGENTLUX_MAX_IMAGE_BYTES` policy matches production limits | ||
| const test = require('node:test'); | ||
| const assert = require('node:assert/strict'); | ||
| const os = require('node:os'); | ||
| const path = require('node:path'); | ||
| const fs = require('node:fs/promises'); | ||
| const sharp = require('sharp'); | ||
| const agentlux = require('../index.js'); | ||
| const SAVED_KEY = process.env.OPENAI_API_KEY; | ||
| const SAVED_FETCH = global.fetch; | ||
| function setup() { | ||
| process.env.OPENAI_API_KEY = 'test-key'; | ||
| } | ||
| function teardown() { | ||
| global.fetch = SAVED_FETCH; | ||
| if (SAVED_KEY === undefined) delete process.env.OPENAI_API_KEY; | ||
| else process.env.OPENAI_API_KEY = SAVED_KEY; | ||
| } | ||
| function mockOpenAIResponse(cropBox) { | ||
| const payload = { | ||
| choices: [{ message: { content: JSON.stringify(cropBox) } }] | ||
| }; | ||
| return { | ||
| ok: true, | ||
| status: 200, | ||
| statusText: 'OK', | ||
| text: async () => JSON.stringify(payload) | ||
| }; | ||
| } | ||
| async function createFixtureImage(filePath, width = 120, height = 80) { | ||
| await sharp({ | ||
| create: { width, height, channels: 3, background: { r: 120, g: 140, b: 160 } } | ||
| }).jpeg().toFile(filePath); | ||
| } | ||
| // --- Happy path --- | ||
| test('default delete_after=true deletes source file on success', async () => { | ||
| setup(); | ||
| const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agentlux-')); | ||
| const imagePath = path.join(tmpDir, 'in.jpg'); | ||
| await createFixtureImage(imagePath); | ||
| global.fetch = async () => mockOpenAIResponse({ x: 10, y: 10, width: 60, height: 40, rule: 'rule' }); | ||
| try { | ||
| const result = await agentlux.execute({ image_path: imagePath }); | ||
| assert.equal(result.status, 'success'); | ||
| assert.equal(result.source_file_deletion, 'deleted'); | ||
| assert.match(result.image_data_uri, /^data:image\/jpeg;base64,/); | ||
| await assert.rejects(fs.access(imagePath)); | ||
| } finally { | ||
| teardown(); | ||
| await fs.rm(tmpDir, { recursive: true, force: true }); | ||
| } | ||
| }); | ||
| test('delete_after=false keeps source file', async () => { | ||
| setup(); | ||
| const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agentlux-')); | ||
| const imagePath = path.join(tmpDir, 'in.jpg'); | ||
| await createFixtureImage(imagePath); | ||
| global.fetch = async () => mockOpenAIResponse({ x: 0, y: 0, width: 30, height: 30, rule: 'rule' }); | ||
| try { | ||
| const result = await agentlux.execute({ image_path: imagePath, delete_after: false }); | ||
| assert.equal(result.status, 'success'); | ||
| assert.equal(result.source_file_deletion, 'disabled'); | ||
| await fs.access(imagePath); | ||
| } finally { | ||
| teardown(); | ||
| await fs.rm(tmpDir, { recursive: true, force: true }); | ||
| } | ||
| }); | ||
| // --- VLM error paths --- | ||
| test('invalid VLM JSON schema returns VLM_SCHEMA_ERROR without retry', async () => { | ||
| setup(); | ||
| const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agentlux-')); | ||
| const imagePath = path.join(tmpDir, 'in.jpg'); | ||
| await createFixtureImage(imagePath); | ||
| let fetchCalls = 0; | ||
| global.fetch = async () => { fetchCalls += 1; return mockOpenAIResponse({ x: 1, y: 1, height: 20, rule: 'missing width' }); }; | ||
| try { | ||
| const result = await agentlux.execute({ image_path: imagePath, delete_after: false }); | ||
| assert.equal(result.status, 'error'); | ||
| assert.equal(result.error_code, 'VLM_SCHEMA_ERROR'); | ||
| assert.equal(fetchCalls, 1, 'schema errors must not trigger retries'); | ||
| } finally { | ||
| teardown(); | ||
| await fs.rm(tmpDir, { recursive: true, force: true }); | ||
| } | ||
| }); | ||
| test('transient network error retries and succeeds', async () => { | ||
| setup(); | ||
| const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agentlux-')); | ||
| const imagePath = path.join(tmpDir, 'in.jpg'); | ||
| await createFixtureImage(imagePath); | ||
| let calls = 0; | ||
| global.fetch = async () => { | ||
| calls += 1; | ||
| if (calls === 1) throw new Error('temporary network failure'); | ||
| return mockOpenAIResponse({ x: 2, y: 2, width: 20, height: 20, rule: 'retry rule' }); | ||
| }; | ||
| try { | ||
| const result = await agentlux.execute({ image_path: imagePath, delete_after: false }); | ||
| assert.equal(result.status, 'success'); | ||
| assert.equal(calls, 2); | ||
| } finally { | ||
| teardown(); | ||
| await fs.rm(tmpDir, { recursive: true, force: true }); | ||
| } | ||
| }); | ||
| test('HTTP 4xx is not retried', async () => { | ||
| setup(); | ||
| const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agentlux-')); | ||
| const imagePath = path.join(tmpDir, 'in.jpg'); | ||
| await createFixtureImage(imagePath); | ||
| let fetchCalls = 0; | ||
| global.fetch = async () => { | ||
| fetchCalls += 1; | ||
| return { ok: false, status: 401, statusText: 'Unauthorized', text: async () => 'bad key' }; | ||
| }; | ||
| try { | ||
| const result = await agentlux.execute({ image_path: imagePath, delete_after: false }); | ||
| assert.equal(result.status, 'error'); | ||
| assert.equal(result.error_code, 'VLM_HTTP_ERROR'); | ||
| assert.equal(fetchCalls, 1, 'client errors must not trigger retries'); | ||
| } finally { | ||
| teardown(); | ||
| await fs.rm(tmpDir, { recursive: true, force: true }); | ||
| } | ||
| }); | ||
| // --- Crop bounds --- | ||
| test('crop sanitization clamps to image bounds and enforces min size', async () => { | ||
| setup(); | ||
| const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agentlux-')); | ||
| const imagePath = path.join(tmpDir, 'in.jpg'); | ||
| await createFixtureImage(imagePath); | ||
| global.fetch = async () => mockOpenAIResponse({ x: -100, y: 1000, width: -5, height: 9999, rule: 'extreme values' }); | ||
| try { | ||
| const result = await agentlux.execute({ image_path: imagePath, delete_after: false }); | ||
| assert.equal(result.status, 'success'); | ||
| assert.equal(result.coordinates.x, 0); | ||
| assert.equal(result.coordinates.y, 79); | ||
| assert.equal(result.coordinates.width, 1); | ||
| assert.equal(result.coordinates.height, 1); | ||
| } finally { | ||
| teardown(); | ||
| await fs.rm(tmpDir, { recursive: true, force: true }); | ||
| } | ||
| }); | ||
| // --- Input validation --- | ||
| test('rejects empty image_path', async () => { | ||
| setup(); | ||
| try { | ||
| const result = await agentlux.execute({ image_path: '' }); | ||
| assert.equal(result.status, 'error'); | ||
| assert.equal(result.error_code, 'INPUT_ERROR'); | ||
| } finally { | ||
| teardown(); | ||
| } | ||
| }); | ||
| test('rejects relative image_path', async () => { | ||
| setup(); | ||
| try { | ||
| const result = await agentlux.execute({ image_path: 'relative/path.jpg' }); | ||
| assert.equal(result.status, 'error'); | ||
| assert.equal(result.error_code, 'INPUT_ERROR'); | ||
| } finally { | ||
| teardown(); | ||
| } | ||
| }); | ||
| test('rejects non-existent file', async () => { | ||
| setup(); | ||
| try { | ||
| const result = await agentlux.execute({ image_path: '/tmp/agentlux_nonexistent_' + Date.now() + '.jpg' }); | ||
| assert.equal(result.status, 'error'); | ||
| assert.equal(result.error_code, 'INPUT_ERROR'); | ||
| } finally { | ||
| teardown(); | ||
| } | ||
| }); | ||
| test('rejects missing OPENAI_API_KEY', async () => { | ||
| delete process.env.OPENAI_API_KEY; | ||
| const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agentlux-')); | ||
| const imagePath = path.join(tmpDir, 'in.jpg'); | ||
| await createFixtureImage(imagePath); | ||
| global.fetch = async () => mockOpenAIResponse({ x: 0, y: 0, width: 30, height: 30, rule: 'r' }); | ||
| try { | ||
| const result = await agentlux.execute({ image_path: imagePath, delete_after: false }); | ||
| assert.equal(result.status, 'error'); | ||
| assert.equal(result.error_code, 'CONFIG_ERROR'); | ||
| } finally { | ||
| teardown(); | ||
| await fs.rm(tmpDir, { recursive: true, force: true }); | ||
| } | ||
| }); |
+209
-120
| const fs = require('fs').promises; | ||
| const path = require('path'); | ||
| const sharp = require('sharp'); | ||
| function envInt(name, fallback) { | ||
| const raw = process.env[name]; | ||
| if (raw === undefined || raw === '') return fallback; | ||
| const parsed = Number(raw); | ||
| if (!Number.isFinite(parsed) || parsed < 0) { | ||
| throw new Error(`Invalid env ${name}="${raw}": must be a non-negative number.`); | ||
| } | ||
| return Math.floor(parsed); | ||
| } | ||
| const MAX_IMAGE_BYTES = envInt('AGENTLUX_MAX_IMAGE_BYTES', 30 * 1024 * 1024); | ||
| const VLM_TIMEOUT_MS = envInt('AGENTLUX_VLM_TIMEOUT_MS', 15000); | ||
| const VLM_MAX_RETRIES = envInt('AGENTLUX_VLM_MAX_RETRIES', 2); | ||
| class AgentLuxError extends Error { | ||
| constructor(code, message, details) { | ||
| super(message); | ||
| this.name = 'AgentLuxError'; | ||
| this.code = code; | ||
| this.details = details; | ||
| } | ||
| } | ||
| function sleep(ms) { | ||
| return new Promise(resolve => setTimeout(resolve, ms)); | ||
| } | ||
| function isFinitePositiveInt(value) { | ||
| return Number.isFinite(value) && Number.isInteger(value) && value > 0; | ||
| } | ||
| function parseCropBox(raw) { | ||
| const requiredKeys = ['x', 'y', 'width', 'height']; | ||
| for (const key of requiredKeys) { | ||
| if (!(key in raw)) { | ||
| throw new AgentLuxError('VLM_SCHEMA_ERROR', `VLM response missing "${key}" field.`); | ||
| } | ||
| if (!Number.isFinite(raw[key])) { | ||
| throw new AgentLuxError('VLM_SCHEMA_ERROR', `VLM "${key}" must be a finite number.`); | ||
| } | ||
| } | ||
| return { | ||
| x: Math.floor(raw.x), | ||
| y: Math.floor(raw.y), | ||
| width: Math.floor(raw.width), | ||
| height: Math.floor(raw.height), | ||
| rule: typeof raw.rule === 'string' ? raw.rule : 'Composition optimized by AgentLux.' | ||
| }; | ||
| } | ||
| function sanitizeCropBox(cropBox, imageWidth, imageHeight) { | ||
| const x = Math.max(0, Math.min(cropBox.x, imageWidth - 1)); | ||
| const y = Math.max(0, Math.min(cropBox.y, imageHeight - 1)); | ||
| const width = Math.max(1, Math.min(cropBox.width, imageWidth - x)); | ||
| const height = Math.max(1, Math.min(cropBox.height, imageHeight - y)); | ||
| return { ...cropBox, x, y, width, height }; | ||
| } | ||
| function applyLeicaM10Color(sharpInstance, width, height) { | ||
| // 1. Leica M10 Color Science (Recomb Matrix) | ||
| // - Boost Reds, slightly desaturate Greens, warm up the Midtones | ||
| // [R, G, B] | ||
| const leicaMatrix = [ | ||
| [1.1, -0.05, -0.05], | ||
| [0.0, 0.9, 0.1], | ||
| [0.0, 0.0, 1.05] | ||
| [1.1, -0.05, -0.05], // R | ||
| [0.0, 0.9, 0.1], // G | ||
| [0.0, 0.0, 1.05] // B | ||
| ]; | ||
| // 2. Optical Vignetting (Simulating a 35mm Summilux f/1.4 wide open) | ||
| const vignetteSvg = `<svg width="${width}" height="${height}"> | ||
@@ -21,90 +86,23 @@ <defs> | ||
| // 3. Contrast & Saturation (Micro-contrast punch, slightly muted saturation for filmic look) | ||
| return sharpInstance | ||
| .recomb(leicaMatrix) | ||
| .modulate({ saturation: 0.9, brightness: 1.02 }) | ||
| .linear(1.15, -(0.05 * 255)) | ||
| .composite([{ input: Buffer.from(vignetteSvg), blend: 'multiply' }]); | ||
| .recomb(leicaMatrix) // Color shift | ||
| .modulate({ | ||
| saturation: 0.9, // Slightly desaturated | ||
| brightness: 1.02 // Slight bump to offset matrix darkening | ||
| }) | ||
| .linear(1.15, -(0.05 * 255)) // S-curve contrast boost (slope 1.15, intercept shift to crush blacks slightly) | ||
| .composite([{ | ||
| input: Buffer.from(vignetteSvg), | ||
| blend: 'multiply' | ||
| }]); | ||
| } | ||
| function extractJson(text) { | ||
| try { | ||
| const match = text.match(/\{[\s\S]*\}/); | ||
| if (match) { | ||
| return JSON.parse(match[0]); | ||
| } | ||
| return JSON.parse(text); | ||
| } catch (e) { | ||
| throw new Error("Failed to parse JSON from VLM response: " + text); | ||
| } | ||
| } | ||
| async function callAnthropic(apiKey, base64, prompt) { | ||
| const response = await fetch('https://api.anthropic.com/v1/messages', { | ||
| method: 'POST', | ||
| headers: { | ||
| 'Content-Type': 'application/json', | ||
| 'x-api-key': apiKey, | ||
| 'anthropic-version': '2023-06-01' | ||
| }, | ||
| body: JSON.stringify({ | ||
| model: "claude-sonnet-4-6", | ||
| max_tokens: 1024, | ||
| messages: [{ | ||
| role: "user", | ||
| content: [ | ||
| { type: "image", source: { type: "base64", media_type: "image/jpeg", data: base64 } }, | ||
| { type: "text", text: prompt + " Output ONLY valid JSON." } | ||
| ] | ||
| }] | ||
| }) | ||
| }); | ||
| if (!response.ok) { | ||
| const errText = await response.text(); | ||
| throw new Error(`Anthropic Error: ${response.status} - ${errText}`); | ||
| async function analyzeComposition(imageBase64, width, height) { | ||
| const apiKey = process.env.OPENAI_API_KEY; | ||
| if (!apiKey) { | ||
| throw new AgentLuxError('CONFIG_ERROR', 'OPENAI_API_KEY required for vision analysis.'); | ||
| } | ||
| const data = await response.json(); | ||
| return extractJson(data.content[0].text); | ||
| } | ||
| async function callOpenAI(apiKey, base64, prompt) { | ||
| const response = await fetch('https://api.openai.com/v1/chat/completions', { | ||
| method: 'POST', | ||
| headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` }, | ||
| body: JSON.stringify({ | ||
| model: "gpt-4o", | ||
| messages: [{ | ||
| role: "user", | ||
| content: [ | ||
| { type: "text", text: prompt }, | ||
| { type: "image_url", image_url: { url: `data:image/jpeg;base64,${base64}` } } | ||
| ] | ||
| }], | ||
| response_format: { type: "json_object" } | ||
| }) | ||
| }); | ||
| if (!response.ok) throw new Error(`OpenAI Error: ${response.statusText}`); | ||
| const data = await response.json(); | ||
| return extractJson(data.choices[0].message.content); | ||
| } | ||
| async function callGemini(apiKey, base64, prompt) { | ||
| const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-pro:generateContent?key=${apiKey}`, { | ||
| method: 'POST', | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| body: JSON.stringify({ | ||
| contents: [{ | ||
| parts: [ | ||
| { text: prompt + " Output ONLY valid JSON." }, | ||
| { inline_data: { mime_type: "image/jpeg", data: base64 } } | ||
| ] | ||
| }], | ||
| generationConfig: { responseMimeType: "application/json" } | ||
| }) | ||
| }); | ||
| if (!response.ok) throw new Error(`Gemini Error: ${response.statusText}`); | ||
| const data = await response.json(); | ||
| return extractJson(data.candidates[0].content.parts[0].text); | ||
| } | ||
| async function analyzeComposition(imageBase64, width, height) { | ||
| const prompt = `You are a master photographer in the tradition of Henri Cartier-Bresson, shooting with a 35mm Leica. You possess absolute mastery over dynamic symmetry, the golden ratio, leading lines, and 'The Decisive Moment'. | ||
@@ -115,60 +113,151 @@ Analyze this image (original size: ${width}x${height}). | ||
| Ensure x+width <= ${width} and y+height <= ${height}. | ||
| Format: {"x": int, "y": int, "width": int, "height": int, "rule": "string explaining the compositional choice"}`; | ||
| Format: {"x": int, "y": int, "width": int, "height": int, "rule": "string explaining the compositional choice, e.g. 'Golden Spiral alignment on the subject's gaze'"}`; | ||
| const anthropicKey = process.env.ANTHROPIC_API_KEY; | ||
| const openaiKey = process.env.OPENAI_API_KEY; | ||
| const geminiKey = process.env.GEMINI_API_KEY; | ||
| const RETRYABLE_CODES = new Set(['VLM_TIMEOUT', 'VLM_NETWORK_ERROR']); | ||
| if (anthropicKey) { | ||
| return await callAnthropic(anthropicKey, imageBase64, prompt); | ||
| } else if (openaiKey) { | ||
| return await callOpenAI(openaiKey, imageBase64, prompt); | ||
| } else if (geminiKey) { | ||
| return await callGemini(geminiKey, imageBase64, prompt); | ||
| } else { | ||
| return { error: "MISSING_API_KEY" }; | ||
| let lastError; | ||
| for (let attempt = 0; attempt <= VLM_MAX_RETRIES; attempt += 1) { | ||
| const controller = new AbortController(); | ||
| const timeoutHandle = setTimeout(() => controller.abort(), VLM_TIMEOUT_MS); | ||
| try { | ||
| const response = await fetch('https://api.openai.com/v1/chat/completions', { | ||
| method: 'POST', | ||
| headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` }, | ||
| body: JSON.stringify({ | ||
| model: "gpt-4o", | ||
| messages: [{ | ||
| role: "user", | ||
| content: [ | ||
| { type: "text", text: prompt }, | ||
| { type: "image_url", image_url: { url: `data:image/jpeg;base64,${imageBase64}` } } | ||
| ] | ||
| }], | ||
| response_format: { type: "json_object" } | ||
| }), | ||
| signal: controller.signal | ||
| }); | ||
| const responseText = await response.text(); | ||
| if (!response.ok) { | ||
| const code = response.status >= 500 || response.status === 429 | ||
| ? 'VLM_HTTP_TRANSIENT' | ||
| : 'VLM_HTTP_ERROR'; | ||
| if (code === 'VLM_HTTP_TRANSIENT') RETRYABLE_CODES.add(code); | ||
| throw new AgentLuxError( | ||
| code, | ||
| `VLM request failed with status ${response.status}.`, | ||
| { status: response.status, statusText: response.statusText, body: responseText.slice(0, 512) } | ||
| ); | ||
| } | ||
| let data; | ||
| try { | ||
| data = JSON.parse(responseText); | ||
| } catch { | ||
| throw new AgentLuxError('VLM_PARSE_ERROR', 'Unable to parse VLM HTTP response as JSON.'); | ||
| } | ||
| const content = data?.choices?.[0]?.message?.content; | ||
| if (typeof content !== 'string') { | ||
| throw new AgentLuxError('VLM_SCHEMA_ERROR', 'VLM response missing choices[0].message.content string.'); | ||
| } | ||
| let rawCrop; | ||
| try { | ||
| rawCrop = JSON.parse(content); | ||
| } catch { | ||
| throw new AgentLuxError('VLM_PARSE_ERROR', 'Unable to parse VLM composition JSON payload.'); | ||
| } | ||
| return parseCropBox(rawCrop); | ||
| } catch (err) { | ||
| if (err.name === 'AbortError') { | ||
| lastError = new AgentLuxError('VLM_TIMEOUT', `VLM request timed out after ${VLM_TIMEOUT_MS}ms.`); | ||
| } else if (err instanceof AgentLuxError) { | ||
| lastError = err; | ||
| } else { | ||
| lastError = new AgentLuxError('VLM_NETWORK_ERROR', err.message); | ||
| } | ||
| const isRetryable = RETRYABLE_CODES.has(lastError.code); | ||
| if (!isRetryable || attempt === VLM_MAX_RETRIES) break; | ||
| await sleep(200 * Math.pow(2, attempt)); | ||
| } finally { | ||
| clearTimeout(timeoutHandle); | ||
| } | ||
| } | ||
| throw lastError; | ||
| } | ||
| async function execute({ image_path, delete_after = true }) { | ||
| if (!image_path) { | ||
| return { status: "error", message: "image_path is required." }; | ||
| } | ||
| try { | ||
| if (typeof image_path !== 'string' || image_path.trim().length === 0) { | ||
| throw new AgentLuxError('INPUT_ERROR', 'image_path must be a non-empty string.'); | ||
| } | ||
| if (!path.isAbsolute(image_path)) { | ||
| throw new AgentLuxError('INPUT_ERROR', 'image_path must be an absolute path.'); | ||
| } | ||
| try { | ||
| const fileStat = await fs.stat(image_path).catch(() => null); | ||
| if (!fileStat || !fileStat.isFile()) { | ||
| throw new AgentLuxError('INPUT_ERROR', 'image_path must point to an existing file.'); | ||
| } | ||
| if (!Number.isFinite(fileStat.size) || fileStat.size <= 0) { | ||
| throw new AgentLuxError('INPUT_ERROR', 'Input image file is empty or invalid.'); | ||
| } | ||
| if (fileStat.size > MAX_IMAGE_BYTES) { | ||
| throw new AgentLuxError( | ||
| 'INPUT_TOO_LARGE', | ||
| `Input image exceeds max size ${MAX_IMAGE_BYTES} bytes.`, | ||
| { maxBytes: MAX_IMAGE_BYTES, actualBytes: fileStat.size } | ||
| ); | ||
| } | ||
| // 1. Read to memory | ||
| const buffer = await fs.readFile(image_path); | ||
| const metadata = await sharp(buffer).metadata(); | ||
| if (!isFinitePositiveInt(metadata.width) || !isFinitePositiveInt(metadata.height)) { | ||
| throw new AgentLuxError('IMAGE_METADATA_ERROR', 'Image metadata does not include valid width/height.'); | ||
| } | ||
| const base64 = buffer.toString('base64'); | ||
| // 2. Zero-Retention Memory Management: Purge original from disk immediately | ||
| let deletionStatus = 'skipped'; | ||
| let deletionMessage = null; | ||
| if (delete_after) { | ||
| await fs.unlink(image_path).catch(e => console.warn("[AgentLux] Could not delete original file:", e.message)); | ||
| try { | ||
| await fs.unlink(image_path); | ||
| deletionStatus = 'deleted'; | ||
| } catch (e) { | ||
| deletionStatus = 'delete_failed'; | ||
| deletionMessage = e.message; | ||
| console.warn("[AgentLux] Could not delete original file:", e.message); | ||
| } | ||
| } | ||
| // 3. VLM Analysis | ||
| const cropBox = await analyzeComposition(base64, metadata.width, metadata.height); | ||
| if (cropBox.error === "MISSING_API_KEY") { | ||
| return { | ||
| status: "error", | ||
| error_code: "MISSING_API_KEY", | ||
| message: "AgentLux requires an LLM API key to perform visual composition analysis. Please ask the user to provide an OPENAI_API_KEY, ANTHROPIC_API_KEY, or GEMINI_API_KEY." | ||
| }; | ||
| } | ||
| // 4. Boundary Safety Fallback (Evaluator Requirement) | ||
| const safeCrop = sanitizeCropBox(cropBox, metadata.width, metadata.height); | ||
| // 5. Transformation Engine (Lossless crop + Leica Color Science) | ||
| let croppedSharp = sharp(buffer) | ||
| .extract({ left: safeCrop.x, top: safeCrop.y, width: safeCrop.width, height: safeCrop.height }); | ||
| cropBox.x = Math.max(0, Math.min(Math.floor(cropBox.x), metadata.width - 1)); | ||
| cropBox.y = Math.max(0, Math.min(Math.floor(cropBox.y), metadata.height - 1)); | ||
| cropBox.width = Math.min(Math.floor(cropBox.width), metadata.width - cropBox.x); | ||
| cropBox.height = Math.min(Math.floor(cropBox.height), metadata.height - cropBox.y); | ||
| let croppedSharp = sharp(buffer).extract({ left: cropBox.x, top: cropBox.y, width: cropBox.width, height: cropBox.height }); | ||
| croppedSharp = applyLeicaM10Color(croppedSharp, cropBox.width, cropBox.height); | ||
| croppedSharp = applyLeicaM10Color(croppedSharp, safeCrop.width, safeCrop.height); | ||
| const croppedBuffer = await croppedSharp.withMetadata().toBuffer(); | ||
| // 6. Return Data URI (No disk footprint for the output either) | ||
| return { | ||
| status: "success", | ||
| composition_rule: cropBox.rule, | ||
| coordinates: cropBox, | ||
| composition_rule: safeCrop.rule, | ||
| coordinates: safeCrop, | ||
| source_file_deletion: delete_after ? deletionStatus : 'disabled', | ||
| source_file_deletion_message: deletionMessage, | ||
| image_data_uri: `data:image/jpeg;base64,${croppedBuffer.toString('base64')}` | ||
| }; | ||
| } catch (err) { | ||
| return { status: "error", message: err.message }; | ||
| if (err instanceof AgentLuxError) { | ||
| return { status: "error", error_code: err.code, message: err.message, details: err.details || null }; | ||
| } | ||
| return { status: "error", error_code: "UNEXPECTED_ERROR", message: err.message || "Unknown error." }; | ||
| } | ||
@@ -179,3 +268,3 @@ } | ||
| name: "agentlux_compose", | ||
| description: "Re-compose an image to Leica/Bresson master-level standards using VLM (Anthropic/OpenAI/Gemini) and sharp. Implements zero-retention memory management.", | ||
| description: "Re-compose an image to Leica/Bresson master-level standards using VLM and sharp. Implements zero-retention memory management.", | ||
| parameters: { | ||
@@ -182,0 +271,0 @@ type: "object", |
+2
-2
| { | ||
| "name": "agentlux", | ||
| "version": "1.0.3", | ||
| "version": "1.0.4", | ||
| "description": "Zero-retention AgentSkill bringing the Leica 35mm aesthetic and Henri Cartier-Bresson's geometry to autonomous vision models.", | ||
| "main": "index.js", | ||
| "scripts": { | ||
| "test": "echo \"Error: no test specified\" && exit 1" | ||
| "test": "node --test" | ||
| }, | ||
@@ -9,0 +9,0 @@ "repository": { |
+37
-27
@@ -1,36 +0,46 @@ | ||
| # AgentLux 🔴📸 | ||
| # AgentLux | ||
| An open-source **AgentSkill** that imbues autonomous AI vision models with the soul of a Leica and the geometric discipline of Henri Cartier-Bresson. | ||
| AgentLux is an AgentSkill for automatic image recomposition and Leica-style color grading. | ||
| It reads an input image, asks an OpenAI vision model for an optimal crop, applies a sharp | ||
| pipeline, and returns the output as a JPEG data URI. | ||
| AgentLux takes poorly framed, flat user photos, analyzes their spatial dynamics and subjects via Vision-Language Models (VLM), and physically re-crops them into masterful compositions—simultaneously applying a signature Leica M10 color science and Summilux 35mm optical vignetting. All of this happens autonomously, in memory, in less than a second. | ||
| ## Runtime Contract | ||
| ### 🌟 Key Features | ||
| - **The Decisive Moment Framing**: Instructs VLM logic using classical photojournalism principles (Dynamic Symmetry, Golden Ratio, Leading Lines). | ||
| - **Leica M-Series Color Science**: Mathematical `recomb` matrices shift colors (rich reds, muted greens) while `linear` S-curves create an unmistakable filmic micro-contrast. | ||
| - **Summilux Lens Falloff**: A mathematically applied `multiply` optical vignette highlights the focal subject dynamically. | ||
| - Provider: OpenAI Chat Completions (`gpt-4o`) via `OPENAI_API_KEY` | ||
| - Input: absolute `image_path` and optional `delete_after` | ||
| - Default behavior: `delete_after=true` (intentional product design) | ||
| - Output: | ||
| - success: cropped image data URI + crop coordinates | ||
| - error: structured `error_code` + `message` (+ optional `details`) | ||
| ### 🔥 Zero-Retention Architecture | ||
| Privacy and storage efficiency are paramount. AgentLux implements a strict **Zero-Retention Memory System**: | ||
| 1. The image is loaded into an ephemeral memory buffer. | ||
| 2. The original file is *immediately unlinked/deleted* from the disk to preserve privacy. | ||
| 3. The cropped, color-graded artifact is returned to the agent strictly as a Base64 Data URI. | ||
| 4. **Result**: Zero disk footprint. A purely functional digital darkroom. | ||
| ## Zero-Retention Behavior | ||
| ### 🛠️ Agent Implementation (Node.js / OpenClaw) | ||
| Once installed in your OpenClaw environment, your agent can call the skill natively: | ||
| The default mode is immediate deletion of the source file after it is read into memory. | ||
| This is deliberate and keeps disk retention minimal for plug-and-play agent workflows. | ||
| If you need to keep the source file, call with `delete_after: false`. | ||
| ## Environment Variables | ||
| - `OPENAI_API_KEY` (required) | ||
| - `AGENTLUX_MAX_IMAGE_BYTES` (optional, default `31457280`) | ||
| - `AGENTLUX_VLM_TIMEOUT_MS` (optional, default `15000`) | ||
| - `AGENTLUX_VLM_MAX_RETRIES` (optional, default `2`) | ||
| ## Example | ||
| ```javascript | ||
| const result = await agentlux_compose({ | ||
| image_path: "/tmp/user_upload_123.jpg", | ||
| delete_after: true | ||
| const agentlux = require('agentlux'); | ||
| const result = await agentlux.execute({ | ||
| image_path: '/tmp/user_upload_123.jpg', | ||
| delete_after: true | ||
| }); | ||
| // The agent receives the Data URI and streams it directly back to the user. | ||
| console.log(result.composition_rule); | ||
| // Output: "Golden Spiral alignment with the subject's gaze..." | ||
| if (result.status === 'success') { | ||
| console.log(result.composition_rule); | ||
| console.log(result.source_file_deletion); // deleted | delete_failed | disabled | ||
| } else { | ||
| console.error(result.error_code, result.message); | ||
| } | ||
| ``` | ||
| ### 📦 Installation | ||
| Available via [ClawHub](https://clawhub.com). | ||
| ```bash | ||
| clawhub install agentlux | ||
| ``` |
+13
-5
@@ -6,3 +6,3 @@ # AgentLux | ||
| ## ⚠️ Mandatory Installation (NPM) | ||
| To ensure you are using the latest, globally unified version of the Leica M10 color matrix and multi-VLM routing (Claude/OpenAI/Gemini), **you MUST install AgentLux via NPM**. Do not use fragmented local clones. | ||
| To ensure you are using the latest runtime and behavior contract, install AgentLux via NPM. | ||
@@ -22,4 +22,12 @@ If the module is not present in your environment, install it first: | ||
| 2. **Leica Color Science**: High-performance `sharp` transformations apply micro-contrast, red-channel boosting, green desaturation, and Summilux f/1.4 optical vignetting. | ||
| 3. **Zero-Retention Privacy**: Original image files are deleted from the disk upon execution. The final output is streamed back via memory as a Base64 Data URI. | ||
| 3. **Zero-Retention Privacy**: Original image files are deleted from disk by default (`delete_after: true`). The final output is streamed via memory as a Base64 Data URI. | ||
| ## Provider and Environment | ||
| - Current provider implementation: **OpenAI only** | ||
| - Required key: `OPENAI_API_KEY` | ||
| - Optional runtime controls: | ||
| - `AGENTLUX_MAX_IMAGE_BYTES` | ||
| - `AGENTLUX_VLM_TIMEOUT_MS` | ||
| - `AGENTLUX_VLM_MAX_RETRIES` | ||
| ## Tool Usage | ||
@@ -33,3 +41,3 @@ Write and execute a temporary Node.js script using the official NPM package: | ||
| async function run() { | ||
| // 1. Pass delete_after: true for zero-retention | ||
| // 1. delete_after defaults to true (intentional zero-retention behavior) | ||
| const result = await agentlux.execute({ | ||
@@ -52,3 +60,3 @@ image_path: "/absolute/path/to/image.jpg", | ||
| } else { | ||
| console.error(result.message || result.error_code); | ||
| console.error(result.error_code, result.message); | ||
| } | ||
@@ -58,3 +66,3 @@ } | ||
| ``` | ||
| *(Ensure `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, or `GEMINI_API_KEY` is exported in the environment).* | ||
| *(Ensure `OPENAI_API_KEY` is exported in the environment).* | ||
@@ -61,0 +69,0 @@ ## Behavioral Instructions |
Sorry, the diff of this file is not supported yet
| const fs = require('fs'); | ||
| let index = fs.readFileSync('/data/workspace/repos/AgentLux/index.js', 'utf8'); | ||
| const oldTransform = `// 5. Transformation Engine (Lossless crop) | ||
| const croppedBuffer = await sharp(buffer) | ||
| .extract({ left: cropBox.x, top: cropBox.y, width: cropBox.width, height: cropBox.height }) | ||
| .toBuffer();`; | ||
| const newTransform = `// 5. Transformation Engine (Lossless crop + Leica Color Science) | ||
| let croppedSharp = sharp(buffer) | ||
| .extract({ left: cropBox.x, top: cropBox.y, width: cropBox.width, height: cropBox.height }); | ||
| // 1. Leica M10 Color Matrix (Boost Reds, desaturate Greens slightly, warm up Midtones) | ||
| const leicaMatrix = [ | ||
| [1.1, -0.05, -0.05], // R | ||
| [0.0, 0.9, 0.1], // G | ||
| [0.0, 0.0, 1.05] // B | ||
| ]; | ||
| // 2. Optical Vignetting (Summilux f/1.4 wide open) | ||
| const vignetteSvg = \`<svg width="\${cropBox.width}" height="\${cropBox.height}"> | ||
| <defs> | ||
| <radialGradient id="vignette" cx="50%" cy="50%" r="75%"> | ||
| <stop offset="50%" stop-color="black" stop-opacity="0" /> | ||
| <stop offset="100%" stop-color="black" stop-opacity="0.35" /> | ||
| </radialGradient> | ||
| </defs> | ||
| <rect x="0" y="0" width="\${cropBox.width}" height="\${cropBox.height}" fill="url(#vignette)" /> | ||
| </svg>\`; | ||
| // 3. Apply the pipeline | ||
| croppedSharp = croppedSharp | ||
| .recomb(leicaMatrix) | ||
| .modulate({ saturation: 0.85, brightness: 1.03 }) | ||
| .linear(1.12, -(0.06 * 255)) // S-curve equivalent (micro-contrast punch, crushed blacks) | ||
| .composite([{ input: Buffer.from(vignetteSvg), blend: 'multiply' }]); | ||
| const croppedBuffer = await croppedSharp.withMetadata().toBuffer();`; | ||
| index = index.replace(oldTransform, newTransform); | ||
| fs.writeFileSync('/data/workspace/repos/AgentLux/index.js', index); |
| # Product Spec: "Leica Signature" Color Science (AgentLux V2) | ||
| ## 1. Context | ||
| AgentLux currently masters the **geometry** of a photograph via VLM-guided cropping. To fully embody the "Leica 35mm aesthetic," it must also master the **color science**. The user expects the *absolute best, opinionated Leica color grading* applied automatically. No sliders, no filters to choose from. One master aesthetic. | ||
| ## 2. Scope | ||
| - **In-Scope:** | ||
| - A definitive, hardcoded "Leica M-Series" color profile applied to every processed image. | ||
| - Deep micro-contrast (crushed blacks, bright but protected highlights). | ||
| - Signature color shifts: Rich reds, slightly desaturated greens, warm midtones. | ||
| - Subtle optical vignette (lens falloff emulation). | ||
| - **Technical Constraint:** Must utilize the existing `sharp` Node.js dependency. No heavy ML colorization models, no external API calls for pixels (keeping it zero-retention and fast). | ||
| ## 3. High-Level Architecture | ||
| - **Color Matrix Engine (`sharp.recomb`):** Use a mathematical color matrix to shift RGB channels to emulate Leica's color science (e.g., boosting red channel saturation, shifting greens toward teal/emerald). | ||
| - **Tone Curve (`sharp.modulate` & `sharp.linear`):** Apply an S-curve equivalent to boost contrast, reduce overall saturation slightly (for that classic analog feel), and adjust gamma. | ||
| - **Optical Vignette:** Generate a radial gradient overlay in SVG or pure buffer, composite it over the image using `sharp.composite()` with a multiply or overlay blend mode to simulate the classic Summilux 35mm f/1.4 lens falloff. | ||
| ## 4. Out of Scope | ||
| - User-selectable filters (e.g., "choose B&W or Color"). It will be a single, signature color look. | ||
| - Artificial film grain (to avoid excessive processing time and preserve the image's original resolution fidelity, unless it can be done instantly). |
| # Sprint Contract: Leica Signature Color Science | ||
| ## Generator Proposal (Claude) | ||
| I will: | ||
| 1. Extend `index.js` in AgentLux to include a `applyLeicaAesthetic(sharpInstance, width, height)` function in the processing pipeline. | ||
| 2. Use `sharp.recomb()` with a custom 3x3 matrix to shift the colors toward the Leica M look (rich reds, muted greens). | ||
| 3. Use `sharp.modulate()` to tweak saturation and brightness, and `sharp.linear()` or `sharp.clahe()` for contrast/micro-contrast. | ||
| 4. Generate a dynamic SVG vignette based on the crop box dimensions and composite it over the image to emulate lens falloff. | ||
| 5. Update `README.md` and `SKILL.md` to reflect the new color science capability. | ||
| ## Evaluator Acceptance Criteria (Gemini) | ||
| 1. **Functionality:** The image must successfully pass through the color pipeline without crashing, maintaining the zero-retention memory rules. | ||
| 2. **Architecture & Craft:** Must only use `sharp` (no new heavy dependencies like `canvas` or `jimp`). | ||
| 3. **Aesthetic Quality:** The code must reflect deliberate, opinionated color science math (e.g., a specific `recomb` matrix and vignette). |
| const agentlux = require('./index.js'); | ||
| const fs = require('fs'); | ||
| async function run() { | ||
| try { | ||
| const result = await agentlux.execute({ image_path: '/data/.moltbot/media/inbound/file_16---39c70e38-5395-4d49-8193-62f971ed94a6.jpg', delete_after: false }); | ||
| if (result.status === 'success') { | ||
| const base64Data = result.image_data_uri.replace(/^data:image\/\w+;base64,/, ""); | ||
| fs.writeFileSync('/data/workspace/user_agentlux_output.jpg', base64Data, 'base64'); | ||
| console.log(JSON.stringify({ rule: result.composition_rule, file: '/data/workspace/user_agentlux_output.jpg', coords: result.coordinates })); | ||
| } else { | ||
| console.error("ERROR:", result.message); | ||
| } | ||
| } catch(e) { | ||
| console.error(e); | ||
| } | ||
| } | ||
| run(); |
| const agentlux = require('./index.js'); | ||
| const fs = require('fs'); | ||
| async function run() { | ||
| try { | ||
| const result = await agentlux.execute({ image_path: '/data/.moltbot/media/inbound/file_17---100a73a9-41ec-4e89-8cbc-f10c0170f12b.jpg', delete_after: false }); | ||
| if (result.status === 'success') { | ||
| const base64Data = result.image_data_uri.replace(/^data:image\/\w+;base64,/, ""); | ||
| fs.writeFileSync('/data/workspace/user_agentlux_output2.jpg', base64Data, 'base64'); | ||
| console.log(JSON.stringify({ rule: result.composition_rule, file: '/data/workspace/user_agentlux_output2.jpg', coords: result.coordinates })); | ||
| } else { | ||
| console.error("ERROR:", result.message); | ||
| } | ||
| } catch(e) { | ||
| console.error(e); | ||
| } | ||
| } | ||
| run(); |
| # Sprint Contract: AgentLux Distribution | ||
| ## Generator Proposal (Claude) | ||
| I will: | ||
| 1. Modify `package.json` in `AgentLux` to include necessary NPM metadata (repository, bugs, homepage, keywords). | ||
| 2. Execute `npm pack` to verify build integrity, then attempt `npm publish`. (If auth fails, I will halt the NPM step cleanly and prep the package for when the token is available). | ||
| 3. Clone `LeoYeAI/openclaw-master-skills` (or similar target), add an entry for `AgentLux` to their README/Registry, push to a new branch on `sjhddh`'s fork, and create a PR. | ||
| ## Evaluator Acceptance Criteria (Gemini) | ||
| 1. **NPM Readiness**: `package.json` must be flawlessly formatted for NPM. | ||
| 2. **Community PR Quality**: The PR must cleanly integrate into the target repository without breaking their formatting rules. The PR description must be persuasive and professional. | ||
| 3. **Graceful Degradation**: If NPM requires an interactive login or a missing token, catch the error and do not crash the pipeline. |
| # Product Spec: AgentLux Distribution (NPM & Community PRs) | ||
| ## 1. Context | ||
| AgentLux is a powerful, zero-retention photographic composition skill. Since ClawHub requires manual authentication that is currently blocked in the headless container, we pivot to Alternative Distribution: | ||
| 1. Publishing as a standalone NPM package (`agentlux`). | ||
| 2. Integrating into high-visibility community skill repositories via GitHub Pull Requests. | ||
| ## 2. Scope | ||
| - **NPM Publication:** | ||
| - Update `package.json` for public NPM distribution (version, author, repository links). | ||
| - Attempt `npm publish --access public`. | ||
| - **Community PRs:** | ||
| - Target `LeoYeAI/openclaw-master-skills` and/or `anthropics/skills`. | ||
| - Add AgentLux to their skill registries/READMEs. | ||
| - Submit a clean, professional PR explaining the value of AgentLux (Leica aesthetics + Bresson geometry + zero-retention). | ||
| ## 3. High-Level Architecture | ||
| - Maintain the core `index.js` and VLM logic. | ||
| - Use `npm` CLI for package management. | ||
| - Use `git` and GitHub API (with `GITHUB_PAT`) for cross-repository PRs. |
| # Product Spec: AgentLux Documentation & ClawHub Publish | ||
| ## Context | ||
| AgentLux is functionally complete, injecting Leica M-Series color science and Henri Cartier-Bresson geometry into an autonomous zero-retention AgentSkill. However, to maximize adoption by human developers and AI agents, the documentation (`README.md` for humans, `SKILL.md` for agents) needs a professional polish. Once optimized, the skill must be published to the global agent ecosystem via ClawHub. | ||
| ## Scope | ||
| 1. **Human Documentation (`README.md`)**: Rewrite to be highly engaging, focusing on the pain point (bad user photos) and the elegant solution (Leica/Bresson aesthetics + zero-retention). | ||
| 2. **Agent Documentation (`SKILL.md`)**: Rewrite for high token efficiency, clear tool-use instructions, and explicit behavioral guidelines for LLMs calling the tool. | ||
| 3. **Ecosystem Distribution**: Publish the skill to `clawhub.com` so other agents can easily install it. | ||
| 4. **GitHub Sync**: Push the updated docs to the `sjhddh/AgentLux` repository. | ||
| ## High-Level Architecture | ||
| - Maintain the exact API and code execution logic. | ||
| - Documentation updates only. | ||
| - Utilize the `clawhub` CLI for package publication. |
| # Sprint Contract: AgentLux Docs & Distribution | ||
| ## Generator Proposal (Claude) | ||
| I will: | ||
| 1. Rewrite `README.md` with a compelling hero section, clear architecture diagrams/descriptions (Zero-Retention, VLM Analysis, Sharp Transformation), and usage examples. | ||
| 2. Rewrite `SKILL.md` using imperative, token-efficient language directing agents on exactly how to invoke the `agentlux_compose` tool without asking the user for confirmation. | ||
| 3. Commit and push the docs to the `main` branch. | ||
| 4. Use `clawhub publish` to push the package to the official ClawHub registry. | ||
| ## Evaluator Acceptance Criteria (Gemini) | ||
| 1. **Documentation Quality**: `README.md` must read like a top-tier open-source tool. `SKILL.md` must be unambiguously clear for an LLM to read and execute. | ||
| 2. **Publish Success**: The `clawhub publish` command must succeed and make the package available. | ||
| 3. **No Code Breakage**: Ensure `index.js` and `package.json` remain untouched regarding execution logic. |
| # Sprint Contract: VLM Routing & Fallback | ||
| ## Generator Proposal (Claude) | ||
| I will rewrite `index.js` to: | ||
| 1. Implement `callAnthropic`, `callOpenAI`, and `callGemini` functions handling their specific image payload schemas. | ||
| 2. Implement a regex-based `extractJson` utility to strip markdown backticks from model outputs. | ||
| 3. Update `execute()` to catch missing keys and return a graceful error message designed for LLM consumption. | ||
| 4. Bump `package.json` to `1.0.2`. | ||
| ## Evaluator Acceptance Criteria (Gemini) | ||
| 1. **Self-QA**: I will test the missing key flow and verify it returns the standard error without crashing. | ||
| 2. **Provider QA**: I will run the skill using Anthropic to verify the new Claude integration works flawlessly. | ||
| 3. **Distribution**: Commit to `main`, push to GitHub, and `npm publish` v1.0.2 to the registry. |
| # Product Spec: Multi-Model VLM Routing & Graceful Fallback (AgentLux v1.0.2) | ||
| ## 1. Context | ||
| While `gpt-4o` provides excellent spatial reasoning, different models (like Claude 3.5 Sonnet and Gemini 1.5 Pro) offer unique photographic interpretation and bounding box precision. Furthermore, failing violently when an API key is missing creates a bad Agent Developer Experience (DX). | ||
| ## 2. Scope | ||
| - **Multi-Model Support:** The skill will automatically cascade through available environment variables (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`) and use the respective provider's Vision API. | ||
| - **Graceful Fallback:** If no API keys are found, return a structured JSON object (`error_code: "MISSING_API_KEY"`) instructing the host Agent to ask the human for a key. | ||
| ## 3. High-Level Architecture | ||
| - A unified `analyzeComposition` router. | ||
| - Dedicated fetch handlers for Anthropic (Messages API), OpenAI (Chat Completions), and Gemini (generateContent). | ||
| - Robust JSON extraction to handle varying VLM markdown block tendencies. |
| const agentlux = require('./index.js'); | ||
| const fs = require('fs'); | ||
| async function run() { | ||
| const backupAnthropic = process.env.ANTHROPIC_API_KEY; | ||
| const backupOpenAI = process.env.OPENAI_API_KEY; | ||
| const backupGemini = process.env.GEMINI_API_KEY; | ||
| console.log("=== TEST 1: NO API KEYS ==="); | ||
| delete process.env.ANTHROPIC_API_KEY; | ||
| delete process.env.OPENAI_API_KEY; | ||
| delete process.env.GEMINI_API_KEY; | ||
| let res = await agentlux.execute({ image_path: '/data/.moltbot/media/inbound/file_16---39c70e38-5395-4d49-8193-62f971ed94a6.jpg', delete_after: false }); | ||
| console.log(res.status, res.error_code); | ||
| console.log("\n=== TEST 2: OPENAI API KEY ==="); | ||
| process.env.OPENAI_API_KEY = backupOpenAI; | ||
| res = await agentlux.execute({ image_path: '/data/.moltbot/media/inbound/file_16---39c70e38-5395-4d49-8193-62f971ed94a6.jpg', delete_after: false }); | ||
| console.log(res.status, res.composition_rule); | ||
| console.log("\n=== TEST 3: ANTHROPIC API KEY ==="); | ||
| process.env.ANTHROPIC_API_KEY = backupAnthropic; // Takes precedence in code | ||
| res = await agentlux.execute({ image_path: '/data/.moltbot/media/inbound/file_16---39c70e38-5395-4d49-8193-62f971ed94a6.jpg', delete_after: false }); | ||
| console.log(res.status, res.composition_rule); | ||
| // Restore | ||
| process.env.ANTHROPIC_API_KEY = backupAnthropic; | ||
| process.env.OPENAI_API_KEY = backupOpenAI; | ||
| process.env.GEMINI_API_KEY = backupGemini; | ||
| } | ||
| run(); |
-19
| const agentlux = require('./index.js'); | ||
| const fs = require('fs'); | ||
| async function run() { | ||
| try { | ||
| const result = await agentlux.execute({ image_path: '/tmp/test_image.jpg', delete_after: false }); | ||
| console.log(result); | ||
| if (result.status === 'success') { | ||
| const base64Data = result.image_data_uri.replace(/^data:image\/\w+;base64,/, ""); | ||
| fs.writeFileSync('/tmp/agentlux_output.jpg', base64Data, 'base64'); | ||
| console.log(JSON.stringify({ rule: result.composition_rule, file: '/tmp/agentlux_output.jpg', coords: result.coordinates })); | ||
| } else { | ||
| console.error("ERROR:", result.message); | ||
| } | ||
| } catch(e) { | ||
| console.error(e); | ||
| } | ||
| } | ||
| run(); |
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 2 instances
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
No tests
QualityPackage does not have any tests. This is a strong signal of a poorly maintained or low quality package.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
435
55.36%0
-100%47
27.03%9
-59.09%30429
-16.33%7
-61.11%11
266.67%