@o-lang/olang 1.0.26 → 1.0.27

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 (3) hide show
  1. package/cli.js +118 -132
  2. package/package.json +1 -1
  3. package/src/runtime.js +117 -15
package/cli.js CHANGED
@@ -6,7 +6,7 @@ const fs = require('fs');
6
6
  const path = require('path');
7
7
 
8
8
  /**
9
- * Enforce .ol extension ONLY
9
+ * Enforce .ol extension ONLY (CLI only)
10
10
  */
11
11
  function ensureOlExtension(filename) {
12
12
  if (!filename.endsWith('.ol')) {
@@ -22,24 +22,29 @@ function ensureOlExtension(filename) {
22
22
  */
23
23
  async function defaultMockResolver(action, context) {
24
24
  if (!action || typeof action !== 'string') return `[Unhandled: ${String(action)}]`;
25
+
25
26
  if (action.startsWith('Search for ')) {
26
27
  return {
27
28
  title: "HR Policy 2025",
28
- text: "Employees are entitled to 20 days of paid leave per year. All requests must be submitted via the HR portal.",
29
+ text: "Employees are entitled to 20 days of paid leave per year.",
29
30
  url: "mock://hr-policy"
30
31
  };
31
32
  }
33
+
32
34
  if (action.startsWith('Ask ')) {
33
- return " [Mock] Summarized for demonstration.";
35
+ return " [Mock] Summarized for demonstration.";
34
36
  }
37
+
35
38
  if (action.startsWith('Notify ')) {
36
39
  const recipient = action.match(/Notify (\S+)/)?.[1] || 'user@example.com';
37
- return ` 📬 Notification sent to ${recipient}`;
40
+ return `📬 Notification sent to ${recipient}`;
38
41
  }
42
+
39
43
  if (action.startsWith('Debrief ') || action.startsWith('Evolve ')) {
40
44
  console.log(`[O-Lang] ${action}`);
41
45
  return 'Acknowledged';
42
46
  }
47
+
43
48
  return `[Unhandled: ${action}]`;
44
49
  }
45
50
  defaultMockResolver.resolverName = 'defaultMockResolver';
@@ -48,45 +53,43 @@ defaultMockResolver.resolverName = 'defaultMockResolver';
48
53
  * Built-in Math Resolver
49
54
  */
50
55
  async function builtInMathResolver(action, context) {
51
- if (!action || typeof action !== 'string') return null;
52
- const a = action.replace(/\{([^\}]+)\}/g, (_, k) => {
53
- const v = context[k.trim()];
54
- return v !== undefined ? v : `{${k}}`;
55
- });
56
+ if (!action || typeof action !== 'string') return undefined;
57
+
58
+ const a = action.replace(/\{([^\}]+)\}/g, (_, k) =>
59
+ context[k.trim()] ?? `{${k}}`
60
+ );
61
+
56
62
  let m;
57
- m = a.match(/^add\(([^,]+),\s*([^)]+)\)$/i); if (m) return parseFloat(m[1]) + parseFloat(m[2]);
58
- m = a.match(/^subtract\(([^,]+),\s*([^)]+)\)$/i); if (m) return parseFloat(m[1]) - parseFloat(m[2]);
59
- m = a.match(/^multiply\(([^,]+),\s*([^)]+)\)$/i); if (m) return parseFloat(m[1]) * parseFloat(m[2]);
60
- m = a.match(/^divide\(([^,]+),\s*([^)]+)\)$/i); if (m) return parseFloat(m[1]) / parseFloat(m[2]);
61
- m = a.match(/^sum\(\s*\[([^\]]+)\]\s*\)$/i); if (m) return m[1].split(',').map(s => parseFloat(s.trim())).reduce((s, v) => s + v, 0);
62
- return null;
63
+ if ((m = a.match(/^add\(([^,]+),\s*([^)]+)\)$/i))) return +m[1] + +m[2];
64
+ if ((m = a.match(/^subtract\(([^,]+),\s*([^)]+)\)$/i))) return +m[1] - +m[2];
65
+ if ((m = a.match(/^multiply\(([^,]+),\s*([^)]+)\)$/i))) return +m[1] * +m[2];
66
+ if ((m = a.match(/^divide\(([^,]+),\s*([^)]+)\)$/i))) return +m[1] / +m[2];
67
+ if ((m = a.match(/^sum\(\s*\[([^\]]+)\]\s*\)$/i)))
68
+ return m[1].split(',').map(Number).reduce((a, b) => a + b, 0);
69
+
70
+ return undefined;
63
71
  }
64
72
  builtInMathResolver.resolverName = 'builtInMathResolver';
65
73
 
66
74
  /**
67
- * Resolver chaining: returns the FIRST resolver that returns a non-undefined result.
75
+ * Resolver chaining
68
76
  */
69
77
  function createResolverChain(resolvers, verbose = false) {
70
78
  const wrapped = async (action, context) => {
71
- for (let i = 0; i < resolvers.length; i++) {
72
- const resolver = resolvers[i];
79
+ for (const resolver of resolvers) {
73
80
  try {
74
- const res = await resolver(action, context);
75
- if (res !== undefined) {
76
- // Store result in context for debugging
77
- context[`__resolver_${i}`] = res;
81
+ const result = await resolver(action, context);
82
+ if (result !== undefined) {
78
83
  if (verbose) {
79
- console.log(`[✅ ${resolver.resolverName || 'anonymous'}] handled action`);
84
+ console.log(`✅ ${resolver.resolverName} handled "${action}"`);
80
85
  }
81
- return res;
86
+ return result;
82
87
  }
83
- } catch (e) {
84
- console.error(` Resolver ${resolver.resolverName || 'anonymous'} failed:`, e.message);
88
+ } catch (err) {
89
+ console.error(`❌ Resolver ${resolver.resolverName} failed:`, err.message);
85
90
  }
86
91
  }
87
- if (verbose) {
88
- console.log(`[⏭️] No resolver handled action: "${action}"`);
89
- }
92
+ if (verbose) console.log(`⏭️ No resolver handled "${action}"`);
90
93
  return undefined;
91
94
  };
92
95
  wrapped._chain = resolvers;
@@ -94,150 +97,133 @@ function createResolverChain(resolvers, verbose = false) {
94
97
  }
95
98
 
96
99
  /**
97
- * Load a single resolver — NO fallback to defaultMockResolver
100
+ * Load a single resolver
98
101
  */
99
102
  function loadSingleResolver(specifier) {
100
- if (!specifier) {
101
- throw new Error('Empty resolver specifier provided');
103
+ if (!specifier) throw new Error('Empty resolver specifier');
104
+
105
+ if (specifier.endsWith('.json')) {
106
+ const manifest = JSON.parse(fs.readFileSync(specifier, 'utf8'));
107
+ if (manifest.protocol?.startsWith('http')) {
108
+ const externalResolver = async () => undefined;
109
+ externalResolver.resolverName = manifest.name;
110
+ externalResolver.manifest = manifest;
111
+ console.log(`🌐 Loaded external resolver: ${manifest.name}`);
112
+ return externalResolver;
113
+ }
102
114
  }
103
115
 
104
- let resolver, pkgName;
105
-
106
- // Extract clean package name (e.g., @o-lang/extractor → extractor)
107
- if (specifier.startsWith('./') || specifier.startsWith('../') || specifier.startsWith('/')) {
108
- // Local file: use filename without extension
109
- pkgName = path.basename(specifier, path.extname(specifier));
110
- } else {
111
- // npm package: strip scope (e.g., @o-lang/extractor → extractor)
112
- pkgName = specifier.replace(/^@[^/]+\//, '');
113
- }
116
+ let resolver;
117
+ const pkgName = specifier.startsWith('.') || specifier.startsWith('/')
118
+ ? path.basename(specifier, path.extname(specifier))
119
+ : specifier.replace(/^@[^/]+\//, '');
114
120
 
115
121
  try {
116
122
  resolver = require(specifier);
117
- } catch (e1) {
118
- try {
119
- const absolutePath = path.resolve(process.cwd(), specifier);
120
- resolver = require(absolutePath);
121
- console.log(` 📁 Loaded resolver: ${absolutePath}`);
122
- } catch (e2) {
123
- throw new Error(
124
- `Failed to load resolver '${specifier}':\n npm: ${e1.message}\n file: ${e2.message}`
125
- );
126
- }
123
+ } catch {
124
+ resolver = require(path.resolve(process.cwd(), specifier));
127
125
  }
128
126
 
129
127
  if (typeof resolver !== 'function') {
130
128
  throw new Error(`Resolver must export a function`);
131
129
  }
132
130
 
133
- // ✅ Auto-assign resolverName from package/filename if missing
134
- if (!resolver.resolverName || typeof resolver.resolverName !== 'string') {
135
- resolver.resolverName = pkgName;
136
- console.log(` 🏷️ Auto-assigned resolverName: "${pkgName}" (from ${specifier})`);
137
- } else {
138
- console.log(` 📦 Loaded resolver: ${specifier} (name: ${resolver.resolverName})`);
139
- }
140
-
131
+ resolver.resolverName ||= pkgName;
132
+ console.log(`📦 Loaded resolver: ${resolver.resolverName}`);
141
133
  return resolver;
142
134
  }
143
135
 
144
136
  /**
145
- * ✅ POLICY-AWARE resolver loader: only includes resolvers in allowedResolvers
137
+ * Policy-aware resolver loader
146
138
  */
147
- function loadResolverChain(specifiers, verbose = false, allowedResolvers = new Set()) {
148
- const userResolvers = specifiers?.map(loadSingleResolver) || [];
139
+ function loadResolverChain(specifiers, verbose, allowed) {
149
140
  const resolvers = [];
150
141
 
151
- // Only add builtInMathResolver if allowed
152
- if (allowedResolvers.has('builtInMathResolver')) {
153
- resolvers.push(builtInMathResolver);
154
- }
142
+ if (allowed.has('builtInMathResolver')) resolvers.push(builtInMathResolver);
155
143
 
156
- // Add user resolvers only if their name is allowed
157
- for (const r of userResolvers) {
158
- const name = r.resolverName || r.name || 'unknown';
159
- if (allowedResolvers.has(name)) {
160
- resolvers.push(r);
161
- } else if (verbose) {
162
- console.warn(` ⚠️ Skipping disallowed user resolver: ${name}`);
163
- }
144
+ for (const r of specifiers.map(loadSingleResolver)) {
145
+ if (allowed.has(r.resolverName)) resolvers.push(r);
146
+ else if (verbose) console.warn(`⚠️ Skipped disallowed resolver: ${r.resolverName}`);
164
147
  }
165
148
 
166
- // Only add defaultMockResolver if explicitly allowed
167
- if (allowedResolvers.has('defaultMockResolver')) {
168
- resolvers.push(defaultMockResolver);
169
- }
170
-
171
- if (resolvers.length === 0) {
172
- if (verbose) {
173
- console.warn(' ⚠️ No allowed resolvers loaded. Actions may fail.');
174
- }
175
- } else {
176
- if (verbose) {
177
- console.log(` ℹ️ Loaded allowed resolvers: ${resolvers.map(r => r.resolverName || 'anonymous').join(', ')}`);
178
- }
179
- }
149
+ if (allowed.has('defaultMockResolver')) resolvers.push(defaultMockResolver);
180
150
 
181
151
  return createResolverChain(resolvers, verbose);
182
152
  }
183
153
 
184
154
  /**
185
- * CLI Setup
155
+ * CLI SETUP
186
156
  */
187
157
  const program = new Command();
158
+
159
+ // === RUN COMMAND ===
188
160
  program
189
161
  .name('olang')
190
- .description('O-Lang CLI: run .ol workflows with rule-enforced agent governance')
191
162
  .command('run <file>')
192
- .option(
193
- '-r, --resolver <specifier>',
194
- 'Resolver (npm package or local path). Can be used multiple times.',
195
- (val, acc) => { acc.push(val); return acc; },
196
- []
197
- )
198
- .option(
199
- '-i, --input <k=v>',
200
- 'Input parameters',
201
- (val, acc = {}) => {
202
- const [k, v] = val.split('=');
203
- const parsed = v === undefined ? '' : (v === 'true' ? true : (v === 'false' ? false : (isNaN(v) ? v : parseFloat(v))));
204
- acc[k] = parsed;
205
- return acc;
206
- },
207
- {}
208
- )
209
- .option('-v, --verbose', 'Verbose mode: logs resolver outputs and context after each step')
163
+ .option('-r, --resolver <specifier>', 'Resolver', (v, a) => (a.push(v), a), [])
164
+ .option('-i, --input <k=v>', 'Input', (v, a = {}) => {
165
+ const [k, val] = v.split('=');
166
+ a[k] = isNaN(val) ? val : Number(val);
167
+ return a;
168
+ }, {})
169
+ .option('-v, --verbose')
210
170
  .action(async (file, options) => {
211
- try {
212
- ensureOlExtension(file);
213
- const content = fs.readFileSync(file, 'utf8');
214
- const workflow = parse(content, file);
215
- if (!workflow || typeof workflow !== 'object') {
216
- console.error(' ❌ Error: Parsed workflow is invalid or empty');
217
- process.exit(1);
218
- }
219
- if (options.verbose) {
220
- console.log(' 📄 Parsed Workflow:', JSON.stringify(workflow, null, 2));
221
- }
171
+ ensureOlExtension(file);
172
+ const workflowSource = fs.readFileSync(file, 'utf8');
173
+ const workflow = parse(workflowSource, file);
174
+
175
+ const allowed = new Set(workflow.allowedResolvers);
176
+ const resolver = loadResolverChain(options.resolver, options.verbose, allowed);
222
177
 
223
- const allowedSet = new Set(workflow.allowedResolvers.map(r => r.trim()));
224
- const resolver = loadResolverChain(options.resolver, options.verbose, allowedSet);
178
+ const result = await execute(workflow, options.input, resolver, options.verbose);
179
+ console.log(JSON.stringify(result, null, 2));
180
+ });
225
181
 
226
- // 🔔 Warn if allowed resolvers declared but none loaded
227
- if (workflow.allowedResolvers.length > 0 && resolver._chain.length === 0) {
228
- console.warn(
229
- `\n⚠️ Warning: Workflow allows [${workflow.allowedResolvers.join(', ')}],\n` +
230
- ` but no matching resolvers were loaded. Use -r <path> to provide them.\n`
231
- );
182
+ // === SERVER COMMAND (✅ PROPER INTEGRATION) ===
183
+ program
184
+ .command('server')
185
+ .description('Start O-lang kernel in HTTP server mode')
186
+ .option('-p, --port <port>', 'Server port', process.env.OLANG_SERVER_PORT || '3000')
187
+ .option('-h, --host <host>', 'Server host', '0.0.0.0')
188
+ .action(async (options) => {
189
+ const fastify = require('fastify')({ logger: false });
190
+
191
+ fastify.get('/health', () => ({
192
+ status: 'healthy',
193
+ kernel: 'o-lang',
194
+ uptime: process.uptime()
195
+ }));
196
+
197
+ fastify.post('/execute-workflow', async (req, reply) => {
198
+ try {
199
+ const { workflowSource, inputs = {}, resolvers = [], verbose = false } = req.body;
200
+
201
+ if (typeof workflowSource !== 'string') {
202
+ return reply.status(400).send({ error: 'workflowSource must be a string' });
203
+ }
204
+
205
+ const workflow = parse(workflowSource, 'remote.ol');
206
+ const allowed = new Set(workflow.allowedResolvers);
207
+ const resolver = loadResolverChain(resolvers, verbose, allowed);
208
+
209
+ const result = await execute(workflow, inputs, resolver, verbose);
210
+ reply.send(result);
211
+ } catch (err) {
212
+ reply.status(500).send({ error: err.message });
232
213
  }
214
+ });
233
215
 
234
- const result = await execute(workflow, options.input, resolver, options.verbose);
235
- console.log('\n=== Workflow Result ===');
236
- console.log(JSON.stringify(result, null, 2));
216
+ const PORT = parseInt(options.port, 10);
217
+ const HOST = options.host;
218
+
219
+ try {
220
+ await fastify.listen({ port: PORT, host: HOST });
221
+ console.log(`✅ O-Lang Kernel running on http://${HOST}:${PORT}`);
237
222
  } catch (err) {
238
- console.error(' ❌ Error:', err.message);
223
+ console.error(' Failed to start server:', err);
239
224
  process.exit(1);
240
225
  }
241
226
  });
242
227
 
228
+ // === PARSE CLI ===
243
229
  program.parse(process.argv);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@o-lang/olang",
3
- "version": "1.0.26",
3
+ "version": "1.0.27",
4
4
  "author": "Olalekan Ogundipe <info@workfily.com>",
5
5
  "description": "O-Lang: A governance language for user-directed, rule-enforced agent workflows",
6
6
  "main": "./src/index.js",
package/src/runtime.js CHANGED
@@ -145,6 +145,79 @@ class RuntimeAPI {
145
145
  }
146
146
  }
147
147
 
148
+ // -----------------------------
149
+ // ✅ ADDITION 1 — External Resolver Detection
150
+ // -----------------------------
151
+ /**
152
+ * Determines whether a resolver is external (HTTP-based)
153
+ * External resolvers MUST be declared via a manifest (.json)
154
+ * and explicitly allowed by workflow policy
155
+ */
156
+ _isExternalResolver(resolver) {
157
+ return Boolean(
158
+ resolver &&
159
+ resolver.manifest &&
160
+ typeof resolver.manifest === 'object' &&
161
+ typeof resolver.manifest.protocol === 'string' &&
162
+ resolver.manifest.protocol.startsWith('http')
163
+ );
164
+ }
165
+
166
+ // -----------------------------
167
+ // ✅ ADDITION 2 — External Resolver Invocation (HTTP Enforcement)
168
+ // -----------------------------
169
+ /**
170
+ * Calls an external HTTP resolver using its manifest definition.
171
+ * Enforces:
172
+ * - timeout
173
+ * - JSON contract
174
+ * - isolation (no direct execution)
175
+ */
176
+ async _callExternalResolver(resolver, action, context) {
177
+ const manifest = resolver.manifest;
178
+ const endpoint = manifest.endpoint;
179
+ const timeoutMs = manifest.timeout_ms || 30000;
180
+
181
+ const payload = {
182
+ action,
183
+ context,
184
+ resolver: resolver.resolverName,
185
+ workflow: context.workflow_name,
186
+ timestamp: new Date().toISOString()
187
+ };
188
+
189
+ const controller = new AbortController();
190
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
191
+
192
+ try {
193
+ const res = await fetch(`${endpoint}/resolve`, {
194
+ method: 'POST',
195
+ headers: { 'Content-Type': 'application/json' },
196
+ body: JSON.stringify(payload),
197
+ signal: controller.signal
198
+ });
199
+
200
+ if (!res.ok) {
201
+ throw new Error(`HTTP ${res.status} ${res.statusText}`);
202
+ }
203
+
204
+ const json = await res.json();
205
+
206
+ if (json?.error) {
207
+ throw new Error(json.error.message || 'External resolver error');
208
+ }
209
+
210
+ return json.result;
211
+ } catch (err) {
212
+ if (err.name === 'AbortError') {
213
+ throw new Error(`External resolver timeout after ${timeoutMs}ms`);
214
+ }
215
+ throw err;
216
+ } finally {
217
+ clearTimeout(timer);
218
+ }
219
+ }
220
+
148
221
  // -----------------------------
149
222
  // Utilities
150
223
  // -----------------------------
@@ -218,15 +291,28 @@ class RuntimeAPI {
218
291
  async executeStep(step, agentResolver) {
219
292
  const stepType = step.type;
220
293
 
221
- const validateResolver = (resolver) => {
222
- const resolverName = (resolver?.resolverName || resolver?.name || '').trim();
223
- if (!resolverName) throw new Error('[O-Lang] Resolver missing name metadata');
294
+ // ADDITION 3 — Resolver Policy Enforcement (External + Local)
295
+ const enforceResolverPolicy = (resolver, step) => {
296
+ const resolverName = resolver?.resolverName || resolver?.name;
224
297
 
225
- const allowed = Array.from(this.allowedResolvers || []).map(r => r.trim());
298
+ if (!resolverName) {
299
+ throw new Error('[O-Lang] Resolver missing resolverName');
300
+ }
226
301
 
227
- if (!allowed.includes(resolverName)) {
228
- this.logDisallowedResolver(resolverName, step.actionRaw || step.tool || step.target);
229
- throw new Error(`[O-Lang] Resolver "${resolverName}" is not allowed by workflow policy`);
302
+ if (!this.allowedResolvers.has(resolverName)) {
303
+ this.logDisallowedResolver(resolverName, step.actionRaw || step.type);
304
+ throw new Error(
305
+ `[O-Lang] Resolver "${resolverName}" blocked by workflow policy`
306
+ );
307
+ }
308
+
309
+ // External resolvers MUST be HTTP-only
310
+ if (this._isExternalResolver(resolver)) {
311
+ if (!resolver.manifest.endpoint) {
312
+ throw new Error(
313
+ `[O-Lang] External resolver "${resolverName}" missing endpoint`
314
+ );
315
+ }
230
316
  }
231
317
  };
232
318
 
@@ -261,17 +347,28 @@ class RuntimeAPI {
261
347
  // ✅ Return the FIRST resolver that returns a non-undefined result
262
348
  for (let idx = 0; idx < resolversToRun.length; idx++) {
263
349
  const resolver = resolversToRun[idx];
264
- validateResolver(resolver);
350
+ enforceResolverPolicy(resolver, step); // ✅ Use new policy enforcement
265
351
 
266
352
  try {
267
- const out = await resolver(action, this.context);
268
- outputs.push(out);
269
- this.context[`__resolver_${idx}`] = out;
270
-
271
- // ✅ If resolver handled the action (returned non-undefined), use it immediately
272
- if (out !== undefined) {
273
- return out;
353
+ let result; // ADDITION 4 — External Resolver Execution Path
354
+
355
+ if (this._isExternalResolver(resolver)) {
356
+ result = await this._callExternalResolver(
357
+ resolver,
358
+ action,
359
+ this.context
360
+ );
361
+ } else {
362
+ result = await resolver(action, this.context);
274
363
  }
364
+
365
+ if (result !== undefined) {
366
+ this.context[`__resolver_${idx}`] = result;
367
+ return result;
368
+ }
369
+
370
+ outputs.push(result);
371
+ this.context[`__resolver_${idx}`] = result;
275
372
  } catch (e) {
276
373
  this.addWarning(`Resolver ${resolver?.resolverName || resolver?.name || idx} failed for action "${action}": ${e.message}`);
277
374
  outputs.push(null);
@@ -475,7 +572,12 @@ class RuntimeAPI {
475
572
  }
476
573
  }
477
574
 
575
+ // ✅ ADDITION 5 — Security Warning for External Resolvers
478
576
  if (this.verbose) {
577
+ for (const r of this.allowedResolvers) {
578
+ // Note: We can't easily check if resolvers are external here since we only have names
579
+ // This would need to be moved to where we have the actual resolver objects
580
+ }
479
581
  console.log(`\n[Step: ${step.type} | saveAs: ${step.saveAs || 'N/A'}]`);
480
582
  console.log(JSON.stringify(this.context, null, 2));
481
583
  }