Designing this as a strictly modular system with a hard 150-200 line limit per
file is the perfect approach. It enforces clean separation of concerns and
makes debugging the deterministic pipeline incredibly straightforward.
We can achieve this by splitting the architecture into four distinct, highly
focused modules:
lexer.js: Purely responsible for unwrapping the Markdown envelope and
tokenizing strings.
vm.js: The core execution loop, program counter (PC), and memory state.
opcodes.js: The standard library of instructions injected into the VM.
io.js: Handles file system reads, writes, and the rolling backup logic.
Here is the blueprint for the engine, keeping each file well under your size
limits.
1. The Parser: lexer.js
This file only cares about text processing. It extracts the blocks from the
envelope and splits the code into executable token arrays.
/ lexer.js export class Lexer { static extractEnvelope(markdownText) { const
stateMatch = markdownText.match(/```fso-state
([\s\S]*?)
```/); const codeMatch = markdownText.match(/```fso-code
([\s\S]*?)
```/); if (!codeMatch) throw new Error("Lexer Error: Could not locate fso-code
block."); return { stateRaw: stateMatch ? stateMatch[1].trim() : "", codeRaw:
codeMatch[1].trim() }; } static tokenize(rawText) { const lines =
rawText.split('
'); const program = []; const labels = {}; for (let i = 0; i < lines.length;
i++) { let line = lines[i].trim(); if (!line || line.startsWith('//'))
continue; // Split by space, preserve quoted strings, remove quotes let tokens
= line.match(/(?:[^\s"]+|"[^"]*")+/g) .map(t => t.replace(/(^"|"$)/g, '')); if
(tokens[0] === 'LBL') { labels[tokens[1]] = program.length; } else {
program.push(tokens); } } return { program, labels }; } }
2. The Core Engine: vm.js
This is the state machine. It manages memory and advances the execution
thread. It has no hardcoded operations; it just routes commands to whatever is
registered.
/ vm.js export class SemanticVM { constructor() { this.memory = {};
this.program = []; this.labels = {}; this.pc = 0; this.state = 'IDLE'; //
IDLE, RUNNING, YIELDED, HALTED this.opcodes = {}; // Hooks for the host
environment this.onYield = null; this.onOutput = console.log; }
register(opcode, handler) { this.opcodes[opcode.toUpperCase()] = handler; }
load(program, labels, initialState = {}) { this.program = program; this.labels
= labels; this.memory = { ...initialState }; this.pc = 0; } run() { this.state
= 'RUNNING'; while (this.pc < this.program.length && this.state === 'RUNNING')
{ const tokens = this.program[this.pc]; const opcode = tokens[0]; const args =
tokens.slice(1); if (!this.opcodes[opcode]) { throw new Error(`VM Fault:
Unknown Opcode '${opcode}' at PC ${this.pc}`); } // Execute instruction
this.opcodes[opcode](this, args); // Advance if the instruction didn't trigger
a jump or yield if (this.state === 'RUNNING' && tokens === this.program[this.pc
]) { this.pc++; } } if (this.pc >= this.program.length && this.state !==
'YIELDED') { this.state = 'HALTED'; } } resume(injectedMemory = {}) {
this.memory = { ...this.memory, ...injectedMemory }; this.state = 'RUNNING';
this.pc++; this.run(); } }
3. The Standard Library: opcodes.js
This file exports a function that mounts our specific instruction set onto any
VM instance. Isolating this means you can easily create custom dialects of the
language later just by mounting different opcode files.
/ opcodes.js export function mountStandardLibrary(vm) { // -- MEMORY --
vm.register('VAR', (ctx, args) => { const [name, type, val] = args; if (type
=== 'NUM') ctx.memory[name] = parseFloat(val); else if (type === 'BOOL')
ctx.memory[name] = (val === 'true'); else ctx.memory[name] = val; // Default
to string }); vm.register('SET', (ctx, args) => { ctx.memory[args[0]] =
args[1]; }); // -- CONTROL FLOW -- vm.register('JMP', (ctx, args) => { ctx.pc
= ctx.labels[args[0]]; }); vm.register('JMP_IF', (ctx, args) => { const
[varName, condition, val, targetLabel] = args; const memVal =
ctx.memory[varName]; let isTrue = false; if (condition === 'IS') isTrue =
(String(memVal) === String(val)); if (condition === 'NOT') isTrue =
(String(memVal) !== String(val)); if (isTrue) ctx.pc = ctx.labels[targetLabel];
}); // -- I/O -- vm.register('OUT', (ctx, args) => { // Basic interpolation:
replace {VAR} with memory value const output = args[0].replace(/{(\w+)}/g, (_,
v) => ctx.memory[v] || ''); ctx.onOutput(output); }); vm.register('INP', (ctx,
args) => { ctx.state = 'YIELDED'; if (ctx.onYield) ctx.onYield(args[0]); }); }
4. The Host & Persistence: runtime.js
This glues everything together, handling the manual rolling backups and file
I/O so the core VM doesn't need to know about the file system.
/ runtime.js import fs from 'fs'; import { Lexer } from './lexer.js'; import {
SemanticVM } from './vm.js'; import { mountStandardLibrary } from
'./opcodes.js'; export class RuntimeEnvironment { constructor(filePath,
maxBackups = 3) { this.filePath = filePath; this.maxBackups = maxBackups;
this.vm = new SemanticVM(); mountStandardLibrary(this.vm); this.vm.onYield =
(targetVar) => { this.saveState(); console.log(`
[SYSTEM YIELD] Waiting for input: ${targetVar}`); // In a real CLI, you'd hook
readline here and call this.vm.resume() }; } start() { const fileContent =
fs.readFileSync(this.filePath, 'utf-8'); const { stateRaw, codeRaw } =
Lexer.extractEnvelope(fileContent); // Tokenize state to hydrate initial
memory const stateTokens = Lexer.tokenize(stateRaw).program; const
initialState = {}; stateTokens.forEach(t => { if(t[0] === 'VAR')
initialState[t[1]] = t[3]; }); // Tokenize main code and run const compiled =
Lexer.tokenize(codeRaw); this.vm.load(compiled.program, compiled.labels,
initialState); this.vm.run(); } saveState() { const mem = this.vm.memory; //
Reconstruct the fso-state block from memory const newStateVars =
Object.keys(mem).map(key => { const val = mem[key]; const type = typeof val
=== 'number' ? 'NUM' : typeof val === 'boolean' ? 'BOOL' : 'STR'; return `VAR
${key} ${type} "${val}"`; }).join('
'); const originalFile = fs.readFileSync(this.filePath, 'utf-8'); const
updatedFile = originalFile.replace( /```fso-state
[\s\S]*?
```/, `\`\`\`fso-state
${newStateVars}
\`\`\`` ); if (this.maxBackups > 0) this.rotateBackups(); fs.writeFileSync(this
.filePath, updatedFile); } rotateBackups() { for (let i = this.maxBackups; i >
0; i--) { const oldFile = `${this.filePath}.${i - 1}`; const newFile =
`${this.filePath}.${i}`; const target = i === 1 ? this.filePath : oldFile; if
(fs.existsSync(target)) { fs.copyFileSync(target, newFile); } } } }
Next Steps
This gives us a highly robust, isolated compilation pipeline that respects
your size and modularity requirements.
To bridge this back to the AI context: our local Gemma model never sees these
internal JavaScript files. It only looks at the user's raw text and outputs
the Markdown .md file. Then, you point this Node.js runtime.js at that .md
file, and it takes over completely.
How would you like to handle mathematical operations or array manipulation
within the opcodes? Should we add operators like ADD, SUB, and PUSH, or keep
it strictly to state flags for now?