From 5069f7517175b8260f435d4e1a5686b1b0f39b12 Mon Sep 17 00:00:00 2001 From: Brent Larsen Date: Fri, 4 Sep 2026 22:54:50 -0500 Subject: [PATCH] prime --- README.md | 180 +++++++++++++++++++++++- bin/optimus.js | 52 +++++++ index.js | 191 ++++++++++++++++++++++++++ optimus.zip | Bin 0 -> 10112 bytes package-lock.json | 16 +++ package.json | 18 +++ test/fixture/overlays/experiment.json | 13 ++ test/fixture/overlays/remote.json | 13 ++ test/fixture/settings/artifacts.json | 7 + test/fixture/settings/input.json | 3 + test/fixture/settings/llm.json | 12 ++ test/fixture/settings/modules.json | 4 + test/fixture/tree/alpha/index.js | 8 ++ test/fixture/tree/beta/index.js | 9 ++ test/smoke.js | 84 +++++++++++ 15 files changed, 609 insertions(+), 1 deletion(-) create mode 100644 bin/optimus.js create mode 100644 index.js create mode 100644 optimus.zip create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 test/fixture/overlays/experiment.json create mode 100644 test/fixture/overlays/remote.json create mode 100644 test/fixture/settings/artifacts.json create mode 100644 test/fixture/settings/input.json create mode 100644 test/fixture/settings/llm.json create mode 100644 test/fixture/settings/modules.json create mode 100644 test/fixture/tree/alpha/index.js create mode 100644 test/fixture/tree/beta/index.js create mode 100644 test/smoke.js diff --git a/README.md b/README.md index d19332c..e608b1c 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,181 @@ # optimus -A pull based artifact pump that only rebuilds things that are stale \ No newline at end of file +A deliberately small project runtime for initialized modules, settings, named artifacts, and transformer-node helpers. + +## Project shape + +A host project provides JSON settings in `./settings/`: + +```text +settings/ + modules.json + artifacts.json + anything-else.json +``` + +`modules.json` maps semantic module names to initializer modules: + +```json +{ + "chatter": "./tree/chatter/index.js", + "markdown-builder": "./tree/markdown-builder/index.js" +} +``` + +Each module exports an initializer that receives the same shared `utils` object: + +```js +module.exports = utils => { + function doSomething() { + const chatter = utils.loadModule("chatter"); + const llm = utils.getSettings("llm"); + } + + return { doSomething }; +}; +``` + +## Utilities + +### `utils.loadModule(name)` + +Returns the initialized module registered under that semantic name. + +### `utils.getSettings(name)` + +Returns the parsed contents of `./settings/.json`. + +### `utils.hashPayload(value)` + +Returns a SHA-256 hex hash of `JSON.stringify(value)`. No key sorting or normalization is performed. + +### `utils.buildNode(payload, sources = {})` + +Builds a transformer-node packet from a payload and already-loaded source nodes: + +```js +const node = utils.buildNode(payload, { + hebrew: hebrewNode, + guidance: guidanceNode, +}); +``` + +Result: + +```json +{ + "sourceHash": { + "hebrew": "", + "guidance": "" + }, + "hash": "", + "payload": "" +} +``` + +Optimus does not persist or validate the node. The owning module decides where its artifacts live and when to rebuild them. + +### `utils.buildBinaryNode(filePath, sources = {})` + +Builds the binary equivalent of a transformer-node packet. The supplied path is kept as the usable binary payload locator, while `hash` is computed from the file bytes: + +```js +const node = utils.buildBinaryNode(resolvedCoverPath); +``` + +Result: + +```json +{ + "sourceHash": {}, + "hash": "", + "payloadBin": "" +} +``` + +Derived binary artifacts can name source nodes in the same way as `buildNode()`: + +```js +return utils.buildBinaryNode(pdfPath, { + volume: volumeNode, + style: styleNode, +}); +``` + +`buildBinaryNode()` does not copy the file, interpret the path, or know what kind of binary it represents. It hashes the bytes currently at `filePath` and returns that path unchanged as `payloadBin`. + +## Artifacts + +`settings/artifacts.json` explicitly names the module and public builder function for each top-level artifact: + +```json +{ + "job.en.md": { + "module": "unpointed-hebrew-markdown-builder", + "builder": "buildMarkdownOutput", + "params": ["job", "en"] + } +} +``` + +Build one artifact: + +```bash +npx optimus --artifact job.en.md +``` + +Build every declared artifact: + +```bash +npx optimus +``` + +Artifacts are logical requested outcomes. Their modules own filenames, directories, persistence, cache policy, and dependency behavior. + +A builder may call `utils.setArtifactOutcome("up to date")` (or another concise outcome) before returning. Optimus prints the requested artifact ID before invoking the builder and prints that outcome afterward; builders that do not report one receive the generic `completed — success` result. + +## Invocation setting overlays + +Optimus loads the project defaults from direct `settings/*.json` files. Each filename remains a free-form settings name available through `utils.getSettings(name)`. + +A process can layer one or more JSON overlays on top of those defaults: + +```bash +npx optimus --artifact psalms.en.md --setting settings/overlays/remote.json +``` + +Multiple overlays are applied left-to-right: + +```bash +npx optimus --artifact psalms.en.md \ + --setting settings/overlays/remote.json \ + --setting settings/overlays/experimental-model.json +``` + +An overlay is one object whose top-level keys are ordinary settings names: + +```json +{ + "llm": { + "host": "remote-box", + "model": "remote-model" + } +} +``` + +Overlay behavior is deliberately small: + +- object over object: recursively overlay properties; +- arrays: replace the previous array; +- scalars: replace the previous value; +- `null`: replace the previous value with `null`; +- omitted properties: inherit the value underneath; +- an overlay may introduce a new free-form settings name. + +`utils.getSettings("llm")` sees only the resulting effective settings. Modules do not know which values came from defaults versus invocation overlays. + +Overlay paths are resolved relative to the project root unless absolute. + +## Build-all memory behavior + +`buildAll()` awaits each declared artifact sequentially and does not retain prior artifact results. Artifact builders may still return either ordinary values or Promises; the runtime remains compatible with genuinely asynchronous work such as LLM requests. diff --git a/bin/optimus.js b/bin/optimus.js new file mode 100644 index 0000000..dfd7cd0 --- /dev/null +++ b/bin/optimus.js @@ -0,0 +1,52 @@ +#!/usr/bin/env node + +const { initialize } = require(".."); + +function getArtifactName(args) { + const inline = args.find(arg => arg.startsWith("--artifact=")); + if (inline) return inline.slice("--artifact=".length); + + const index = args.indexOf("--artifact"); + if (index === -1) return null; + if (!args[index + 1]) throw new Error("--artifact requires a name"); + return args[index + 1]; +} + +function getSettingOverlays(args) { + const overlays = []; + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg.startsWith("--setting=")) { + const value = arg.slice("--setting=".length); + if (!value) throw new Error("--setting requires a file path"); + overlays.push(value); + continue; + } + if (arg !== "--setting") continue; + if (!args[i + 1]) throw new Error("--setting requires a file path"); + overlays.push(args[++i]); + } + return overlays; +} + +async function main() { + const args = process.argv.slice(2); + const optimus = initialize(process.cwd(), { settingOverlays: getSettingOverlays(args) }); + const artifactName = getArtifactName(args); + + if (artifactName) { + await optimus.buildArtifact(artifactName); + console.log(`Artifact complete: ${artifactName}`); + return; + } + + const artifacts = optimus.getSettings("artifacts"); + const names = Object.keys(artifacts); + await optimus.buildAll(); + console.log(names.length ? `Artifacts complete: ${names.join(", ")}` : "No artifacts configured."); +} + +main().catch(error => { + console.error(error.message || error); + process.exitCode = 1; +}); diff --git a/index.js b/index.js new file mode 100644 index 0000000..efd3015 --- /dev/null +++ b/index.js @@ -0,0 +1,191 @@ +const crypto = require("crypto"); +const fs = require("fs"); +const path = require("path"); + +function isPlainObject(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function overlaySettingsValue(base, overlay) { + if (!isPlainObject(base) || !isPlainObject(overlay)) return overlay; + + const result = { ...base }; + for (const [key, value] of Object.entries(overlay)) { + result[key] = key in result ? overlaySettingsValue(result[key], value) : value; + } + return result; +} + +function applySettingOverlay(settings, projectRoot, overlayPath) { + const resolvedPath = path.isAbsolute(overlayPath) ? overlayPath : path.resolve(projectRoot, overlayPath); + const overlay = JSON.parse(fs.readFileSync(resolvedPath, "utf8")); + if (!isPlainObject(overlay)) throw new Error(`Setting overlay must contain an object: ${overlayPath}`); + + for (const [name, value] of Object.entries(overlay)) { + settings.set(name, settings.has(name) ? overlaySettingsValue(settings.get(name), value) : value); + } +} + +function loadSettings(projectRoot, settingOverlays = []) { + const settingsRoot = path.join(projectRoot, "settings"); + if (!fs.existsSync(settingsRoot)) throw new Error(`No settings folder found in ${projectRoot}`); + + const settings = new Map(); + for (const fileName of fs.readdirSync(settingsRoot)) { + if (path.extname(fileName).toLowerCase() !== ".json") continue; + const name = path.basename(fileName, path.extname(fileName)); + const filePath = path.join(settingsRoot, fileName); + settings.set(name, JSON.parse(fs.readFileSync(filePath, "utf8"))); + } + for (const overlayPath of settingOverlays) applySettingOverlay(settings, projectRoot, overlayPath); + return settings; +} + +function hashPayload(value) { + const json = JSON.stringify(value); + return crypto.createHash("sha256").update(json === undefined ? "undefined" : json).digest("hex"); +} + +function getSourceHash(sources) { + const sourceHash = {}; + for (const [name, source] of Object.entries(sources)) { + if (!source || typeof source !== "object" || !("hash" in source)) { + throw new Error(`Source "${name}" must be a node with a hash`); + } + sourceHash[name] = source.hash; + } + return sourceHash; +} + +function buildNode(payload, sources = {}) { + return { + sourceHash: getSourceHash(sources), + hash: hashPayload(payload), + payload, + }; +} + +function buildBinaryNode(filePath, sources = {}) { + const bytes = fs.readFileSync(filePath); + return { + sourceHash: getSourceHash(sources), + hash: crypto.createHash("sha256").update(bytes).digest("hex"), + payloadBin: filePath, + }; +} + +function initialize(projectRoot = process.cwd(), options = {}) { + const settingOverlays = options.settingOverlays || []; + if (!Array.isArray(settingOverlays)) throw new Error("options.settingOverlays must be an array"); + const settings = loadSettings(projectRoot, settingOverlays); + const registry = new Map(); + let artifactOutcome; + + const utils = { + loadModule(name) { + const entry = registry.get(name); + if (!entry) throw new Error(`Unknown module: ${name}`); + if (entry.module) return entry.module; + if (entry.initializing) throw new Error(`Circular module initialization involving: ${name}`); + + entry.initializing = true; + try { + const initialized = entry.init(utils); + if (!initialized || (typeof initialized !== "object" && typeof initialized !== "function")) { + throw new Error(`Module "${name}" initializer must return a module object or function`); + } + entry.module = initialized; + return entry.module; + } finally { + entry.initializing = false; + } + }, + + getSettings(name) { + if (!settings.has(name)) throw new Error(`Unknown settings: ${name}`); + return settings.get(name); + }, + + hashPayload, + buildNode, + buildBinaryNode, + + setArtifactOutcome(value) { + artifactOutcome = `${value ?? ""}`.trim() || undefined; + }, + }; + + const modules = utils.getSettings("modules"); + if (!modules || typeof modules !== "object" || Array.isArray(modules)) throw new Error("settings/modules.json must contain an object"); + + for (const [name, modulePath] of Object.entries(modules)) { + const resolvedPath = path.resolve(projectRoot, modulePath); + const init = require(resolvedPath); + if (typeof init !== "function") throw new Error(`Module "${name}" must export an initializer function: ${modulePath}`); + registry.set(name, { init, module: null, initializing: false }); + } + + for (const name of registry.keys()) utils.loadModule(name); + + function buildArtifact(name) { + const artifacts = utils.getSettings("artifacts"); + const artifact = artifacts[name]; + if (!artifact) throw new Error(`Unknown artifact: ${name}`); + if (!artifact.module) throw new Error(`Artifact "${name}" is missing module`); + if (!artifact.builder) throw new Error(`Artifact "${name}" is missing builder`); + + const module = utils.loadModule(artifact.module); + const builder = module[artifact.builder]; + if (typeof builder !== "function") { + throw new Error(`Artifact "${name}" builder not found: ${artifact.module}.${artifact.builder}`); + } + + const params = artifact.params === undefined ? [] : artifact.params; + if (!Array.isArray(params)) throw new Error(`Artifact "${name}" params must be an array`); + + artifactOutcome = undefined; + console.log(`Ensuring artifact: ${name}`); + try { + const result = builder(...params); + if (result && typeof result.then === "function") { + return result.then(value => { + console.log(` ${artifactOutcome ?? "completed — success"}`); + artifactOutcome = undefined; + return value; + }, error => { + console.error(" failed"); + artifactOutcome = undefined; + throw error; + }); + } + + console.log(` ${artifactOutcome ?? "completed — success"}`); + artifactOutcome = undefined; + return result; + } + catch (error) { + console.error(" failed"); + artifactOutcome = undefined; + throw error; + } + } + + async function buildAll() { + const artifacts = utils.getSettings("artifacts"); + for (const name of Object.keys(artifacts)) await buildArtifact(name); + } + + return { + utils, + loadModule: utils.loadModule, + getSettings: utils.getSettings, + hashPayload, + buildNode, + buildBinaryNode, + buildArtifact, + buildAll, + modules: [...registry.keys()], + }; +} + +module.exports = { initialize, hashPayload, buildNode, buildBinaryNode }; diff --git a/optimus.zip b/optimus.zip new file mode 100644 index 0000000000000000000000000000000000000000..e6e5d3bcaacf8987dfd3e62ae938e9de4f26769a GIT binary patch literal 10112 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 N@ { + function loadAlpha(value) { + const prefix = utils.getSettings("input").prefix; + return utils.buildNode(`${prefix}:${value}`); + } + + return { loadAlpha }; +}; diff --git a/test/fixture/tree/beta/index.js b/test/fixture/tree/beta/index.js new file mode 100644 index 0000000..b3c720e --- /dev/null +++ b/test/fixture/tree/beta/index.js @@ -0,0 +1,9 @@ +module.exports = utils => { + function buildBeta(value) { + const alpha = utils.loadModule("alpha"); + const source = alpha.loadAlpha(value); + return utils.buildNode(`beta:${source.payload}`, { alpha: source }); + } + + return { buildBeta }; +}; diff --git a/test/smoke.js b/test/smoke.js new file mode 100644 index 0000000..c7f2a10 --- /dev/null +++ b/test/smoke.js @@ -0,0 +1,84 @@ +const assert = require("assert"); +const crypto = require("crypto"); +const fs = require("fs"); +const os = require("os"); +const path = require("path"); +const { initialize, hashPayload, buildNode, buildBinaryNode } = require(".."); + +async function main() { + const projectRoot = path.join(__dirname, "fixture"); + const optimus = initialize(projectRoot); + + assert.deepStrictEqual(optimus.modules, ["alpha", "beta"]); + assert.strictEqual(optimus.getSettings("input").prefix, "alpha"); + assert.strictEqual(optimus.loadModule("alpha").loadAlpha("x").payload, "alpha:x"); + assert.throws(() => optimus.loadModule("missing"), /Unknown module/); + assert.throws(() => optimus.getSettings("missing"), /Unknown settings/); + + const overlaid = initialize(projectRoot, { + settingOverlays: ["overlays/remote.json", "overlays/experiment.json"], + }); + assert.deepStrictEqual(overlaid.getSettings("llm"), { + client: "lm-studio", + host: "remote-box", + port: 1234, + model: "experimental-model", + reasoning: null, + nested: { + keep: "default", + replace: "remote", + extra: true, + }, + list: ["remote"], + }); + assert.deepStrictEqual(overlaid.getSettings("run"), { label: "experiment", enabled: true }); + + const expectedHash = crypto.createHash("sha256").update(JSON.stringify({ a: 1 })).digest("hex"); + assert.strictEqual(hashPayload({ a: 1 }), expectedHash); + + const source = buildNode({ source: true }); + const node = buildNode({ result: true }, { source }); + assert.deepStrictEqual(node.sourceHash, { source: source.hash }); + assert.strictEqual(node.hash, hashPayload(node.payload)); + assert.deepStrictEqual(node.payload, { result: true }); + + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "optimus-binary-")); + const binaryPath = path.join(tempRoot, "sample.bin"); + const firstBytes = Buffer.from([0x00, 0x01, 0x02, 0xff]); + const secondBytes = Buffer.from([0x00, 0x01, 0x03, 0xff]); + + try { + fs.writeFileSync(binaryPath, firstBytes); + const binaryNode = buildBinaryNode(binaryPath, { source }); + const expectedBinaryHash = crypto.createHash("sha256").update(firstBytes).digest("hex"); + assert.deepStrictEqual(binaryNode.sourceHash, { source: source.hash }); + assert.strictEqual(binaryNode.hash, expectedBinaryHash); + assert.strictEqual(binaryNode.payloadBin, binaryPath); + assert.strictEqual("payload" in binaryNode, false); + + const binaryViaUtils = optimus.utils.buildBinaryNode(binaryPath); + assert.strictEqual(binaryViaUtils.hash, expectedBinaryHash); + + fs.writeFileSync(binaryPath, secondBytes); + const changedBinaryNode = buildBinaryNode(binaryPath); + assert.notStrictEqual(changedBinaryNode.hash, binaryNode.hash); + assert.strictEqual(changedBinaryNode.payloadBin, binaryPath); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + + const artifact = optimus.buildArtifact("beta-demo"); + assert.strictEqual(typeof artifact?.then, "undefined"); + assert.deepStrictEqual(artifact.sourceHash, { alpha: optimus.loadModule("alpha").loadAlpha("demo").hash }); + assert.strictEqual(artifact.payload, "beta:alpha:demo"); + + const all = await optimus.buildAll(); + assert.strictEqual(all, undefined); + + console.log("smoke test passed"); +} + +main().catch(error => { + console.error(error); + process.exitCode = 1; +});