future-lang 0.4.0 → 0.4.1
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.
- package/FUTURE_FOR_LLMS.md +35 -2
- package/package.json +1 -1
- package/runtime/ai.js +20 -9
- package/runtime/assert.js +27 -0
- package/runtime/http.js +54 -8
- package/runtime/index.js +12 -3
- package/runtime/providers/anthropic.js +38 -10
- package/runtime/providers/openai-compat.js +35 -19
- package/src/cli.js +111 -12
- package/src/generator.js +8 -1
- package/src/parser.js +1 -1
- package/src/sourcemap.js +69 -0
package/FUTURE_FOR_LLMS.md
CHANGED
|
@@ -14,7 +14,7 @@ agent use as
|
|
|
14
14
|
|
|
15
15
|
Reserved namespaces (cannot be reassigned or used as function names):
|
|
16
16
|
```
|
|
17
|
-
ai http mqtt tts rag vision home memory schedule system device math
|
|
17
|
+
ai http mqtt tts rag vision home memory schedule system device math assert
|
|
18
18
|
```
|
|
19
19
|
|
|
20
20
|
---
|
|
@@ -176,6 +176,10 @@ embed = ai.embed("text to embed")
|
|
|
176
176
|
ai.configure("openai", "sk-...")
|
|
177
177
|
ai.configure("ollama")
|
|
178
178
|
|
|
179
|
+
# With options: temperature and max_tokens
|
|
180
|
+
answer = ai.ask("Explain quantum physics", { temperature: 0.2 max_tokens: 200 })
|
|
181
|
+
reply = ai.chat(messages, { model: "gpt-4o" temperature: 0.7 })
|
|
182
|
+
|
|
179
183
|
stream ai.ask("Tell me a story")
|
|
180
184
|
print chunk
|
|
181
185
|
end
|
|
@@ -224,7 +228,36 @@ end
|
|
|
224
228
|
# schedule.once and schedule.cron also available
|
|
225
229
|
```
|
|
226
230
|
|
|
227
|
-
### `http
|
|
231
|
+
### `http`
|
|
232
|
+
```
|
|
233
|
+
data = http.get("https://api.example.com/todos/1")
|
|
234
|
+
print data.title
|
|
235
|
+
|
|
236
|
+
res = http.post("https://api.example.com/items", { name: "Widget" price: 9.99 })
|
|
237
|
+
print res.id
|
|
238
|
+
|
|
239
|
+
# Global config (call once at the top of your program)
|
|
240
|
+
http.configure({ headers: { Authorization: "Bearer {token}" } timeout: 5000 })
|
|
241
|
+
|
|
242
|
+
# Errors have .status, .code, .url, .body properties
|
|
243
|
+
try
|
|
244
|
+
data = http.get("https://api.example.com/private")
|
|
245
|
+
catch err
|
|
246
|
+
print "Status: {err.status}"
|
|
247
|
+
print "Code: {err.code}"
|
|
248
|
+
end
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
### `assert` (use in *.test.future files)
|
|
252
|
+
```
|
|
253
|
+
assert.ok(value)
|
|
254
|
+
assert.equal(actual, expected)
|
|
255
|
+
assert.notEqual(a, b)
|
|
256
|
+
assert.deepEqual(obj1, obj2)
|
|
257
|
+
assert.fail("custom message")
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
### `system`, `rag`, `vision`, `home`, `device`
|
|
228
261
|
See [README.md](README.md) for full API tables.
|
|
229
262
|
|
|
230
263
|
### `math`
|
package/package.json
CHANGED
package/runtime/ai.js
CHANGED
|
@@ -34,31 +34,42 @@ export function configure(baseUrlOrProvider, apiKey, model) {
|
|
|
34
34
|
});
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
/**
|
|
38
|
-
|
|
39
|
-
|
|
37
|
+
/**
|
|
38
|
+
* Ask a single question.
|
|
39
|
+
* @param {string} prompt
|
|
40
|
+
* @param {{ temperature?: number, max_tokens?: number, model?: string, system?: string }} [opts]
|
|
41
|
+
* @returns {Promise<string>}
|
|
42
|
+
*/
|
|
43
|
+
export async function ask(prompt, opts = {}) {
|
|
44
|
+
return chat([{ role: 'user', content: String(prompt) }], opts);
|
|
40
45
|
}
|
|
41
46
|
|
|
42
|
-
/**
|
|
43
|
-
|
|
47
|
+
/**
|
|
48
|
+
* Multi-turn chat.
|
|
49
|
+
* @param {Array<{role,content}>} messages
|
|
50
|
+
* @param {{ temperature?: number, max_tokens?: number, model?: string, system?: string }} [opts]
|
|
51
|
+
* @returns {Promise<string>}
|
|
52
|
+
*/
|
|
53
|
+
export async function chat(messages, opts = {}) {
|
|
44
54
|
const provider = resolveProvider();
|
|
45
55
|
if (!provider) return offlineStub(messages);
|
|
46
|
-
return provider.chat(messages);
|
|
56
|
+
return provider.chat(messages, opts);
|
|
47
57
|
}
|
|
48
58
|
|
|
49
59
|
/**
|
|
50
60
|
* Stream a response chunk-by-chunk.
|
|
51
61
|
* @param {string|Array} promptOrMessages
|
|
52
|
-
* @param {(chunk: string) => void} onChunk
|
|
62
|
+
* @param {(chunk: string) => void} onChunk
|
|
63
|
+
* @param {{ temperature?: number, max_tokens?: number, model?: string }} [opts]
|
|
53
64
|
* @returns {Promise<void>}
|
|
54
65
|
*/
|
|
55
|
-
export async function stream(promptOrMessages, onChunk) {
|
|
66
|
+
export async function stream(promptOrMessages, onChunk, opts = {}) {
|
|
56
67
|
const messages = Array.isArray(promptOrMessages)
|
|
57
68
|
? promptOrMessages
|
|
58
69
|
: [{ role: 'user', content: String(promptOrMessages) }];
|
|
59
70
|
const provider = resolveProvider();
|
|
60
71
|
if (!provider) { onChunk(offlineStub(messages)); return; }
|
|
61
|
-
return provider.stream(messages, onChunk);
|
|
72
|
+
return provider.stream(messages, onChunk, opts);
|
|
62
73
|
}
|
|
63
74
|
|
|
64
75
|
/**
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// runtime/assert.js — Test assertions for `future test`.
|
|
2
|
+
// Wraps node:assert/strict with Future-friendly error messages.
|
|
3
|
+
|
|
4
|
+
import nodeAssert from 'node:assert/strict';
|
|
5
|
+
|
|
6
|
+
function wrap(fn, name) {
|
|
7
|
+
return (...args) => {
|
|
8
|
+
try {
|
|
9
|
+
fn(...args);
|
|
10
|
+
} catch (err) {
|
|
11
|
+
// Re-throw with the assert namespace tag so the test runner can identify it.
|
|
12
|
+
const e = new Error(err.message);
|
|
13
|
+
e.name = 'AssertionError';
|
|
14
|
+
e.namespace = 'assert';
|
|
15
|
+
e.operator = err.operator ?? name;
|
|
16
|
+
e.actual = err.actual;
|
|
17
|
+
e.expected = err.expected;
|
|
18
|
+
throw e;
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const ok = wrap((val, msg) => nodeAssert.ok(val, msg), 'ok');
|
|
24
|
+
export const equal = wrap((a, b, msg) => nodeAssert.equal(a, b, msg), 'equal');
|
|
25
|
+
export const notEqual = wrap((a, b, msg) => nodeAssert.notEqual(a, b, msg), 'notEqual');
|
|
26
|
+
export const deepEqual = wrap((a, b, msg) => nodeAssert.deepEqual(a, b, msg), 'deepEqual');
|
|
27
|
+
export const fail = (msg = 'assertion failed') => { throw Object.assign(new Error(msg), { name: 'AssertionError', namespace: 'assert' }); };
|
package/runtime/http.js
CHANGED
|
@@ -1,32 +1,78 @@
|
|
|
1
1
|
// runtime/http.js — consume REST APIs.
|
|
2
2
|
// Uses the global fetch (stable in Node 22). Returns parsed JSON or text.
|
|
3
3
|
|
|
4
|
+
export class HttpError extends Error {
|
|
5
|
+
constructor(status, statusText, url, body) {
|
|
6
|
+
super(`HTTP ${status} ${statusText} — ${url}`);
|
|
7
|
+
this.name = 'HttpError';
|
|
8
|
+
this.status = status;
|
|
9
|
+
this.statusText = statusText;
|
|
10
|
+
this.url = url;
|
|
11
|
+
this.body = body;
|
|
12
|
+
this.namespace = 'http';
|
|
13
|
+
this.code = `HTTP_${status}`;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Global config state — mutated by configure().
|
|
18
|
+
let _config = {
|
|
19
|
+
headers: {},
|
|
20
|
+
timeout: 0,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Set global defaults for all HTTP requests.
|
|
25
|
+
* Useful for Authorization headers, base timeouts, etc.
|
|
26
|
+
* @param {{ headers?: Record<string,string>, timeout?: number }} opts
|
|
27
|
+
*/
|
|
28
|
+
export function configure(opts = {}) {
|
|
29
|
+
if (opts.headers) _config.headers = { ..._config.headers, ...opts.headers };
|
|
30
|
+
if (opts.timeout != null) _config.timeout = opts.timeout;
|
|
31
|
+
}
|
|
32
|
+
|
|
4
33
|
// Default headers — many public APIs (e.g. GitHub) reject requests without a
|
|
5
34
|
// User-Agent. Callers can override any of these.
|
|
6
35
|
const DEFAULT_HEADERS = {
|
|
7
|
-
'user-agent': 'future-lang/0.
|
|
36
|
+
'user-agent': 'future-lang/0.4 (+https://github.com/humolot/future-lang)',
|
|
8
37
|
accept: 'application/json, text/*;q=0.9, */*;q=0.8',
|
|
9
38
|
};
|
|
10
39
|
|
|
11
|
-
async function
|
|
40
|
+
async function parseBody(res) {
|
|
12
41
|
const ct = res.headers.get('content-type') || '';
|
|
13
42
|
return ct.includes('application/json') ? res.json() : res.text();
|
|
14
43
|
}
|
|
15
44
|
|
|
45
|
+
function buildSignal() {
|
|
46
|
+
if (!_config.timeout) return undefined;
|
|
47
|
+
return AbortSignal.timeout(_config.timeout);
|
|
48
|
+
}
|
|
49
|
+
|
|
16
50
|
/** GET a URL. @returns parsed JSON object/array, or text. */
|
|
17
51
|
export async function get(url, headers = {}) {
|
|
18
|
-
const res = await fetch(url, {
|
|
19
|
-
|
|
20
|
-
|
|
52
|
+
const res = await fetch(url, {
|
|
53
|
+
headers: { ...DEFAULT_HEADERS, ..._config.headers, ...headers },
|
|
54
|
+
signal: buildSignal(),
|
|
55
|
+
});
|
|
56
|
+
if (!res.ok) {
|
|
57
|
+
let body;
|
|
58
|
+
try { body = await parseBody(res); } catch { body = null; }
|
|
59
|
+
throw new HttpError(res.status, res.statusText, url, body);
|
|
60
|
+
}
|
|
61
|
+
return parseBody(res);
|
|
21
62
|
}
|
|
22
63
|
|
|
23
64
|
/** POST a JSON body to a URL. @returns parsed JSON or text. */
|
|
24
65
|
export async function post(url, body, headers = {}) {
|
|
25
66
|
const res = await fetch(url, {
|
|
26
67
|
method: 'POST',
|
|
27
|
-
headers: { ...DEFAULT_HEADERS, 'content-type': 'application/json', ...headers },
|
|
68
|
+
headers: { ...DEFAULT_HEADERS, 'content-type': 'application/json', ..._config.headers, ...headers },
|
|
28
69
|
body: typeof body === 'string' ? body : JSON.stringify(body),
|
|
70
|
+
signal: buildSignal(),
|
|
29
71
|
});
|
|
30
|
-
if (!res.ok)
|
|
31
|
-
|
|
72
|
+
if (!res.ok) {
|
|
73
|
+
let errBody;
|
|
74
|
+
try { errBody = await parseBody(res); } catch { errBody = null; }
|
|
75
|
+
throw new HttpError(res.status, res.statusText, url, errBody);
|
|
76
|
+
}
|
|
77
|
+
return parseBody(res);
|
|
32
78
|
}
|
package/runtime/index.js
CHANGED
|
@@ -16,15 +16,16 @@ import * as schedule from './schedule.js';
|
|
|
16
16
|
import * as system from './system.js';
|
|
17
17
|
import * as device from './device.js';
|
|
18
18
|
import * as math from './math.js';
|
|
19
|
+
import * as assert from './assert.js';
|
|
19
20
|
import readline from 'node:readline';
|
|
20
21
|
|
|
21
22
|
// Canonical ordered list of capability module names.
|
|
22
23
|
const MODULE_NAMES = [
|
|
23
24
|
'ai', 'http', 'mqtt', 'tts', 'rag', 'vision', 'home',
|
|
24
|
-
'memory', 'schedule', 'system', 'device', 'math',
|
|
25
|
+
'memory', 'schedule', 'system', 'device', 'math', 'assert',
|
|
25
26
|
];
|
|
26
27
|
|
|
27
|
-
const _base = { ai, http, mqtt, tts, rag, vision, home, memory, schedule, system, device, math };
|
|
28
|
+
const _base = { ai, http, mqtt, tts, rag, vision, home, memory, schedule, system, device, math, assert };
|
|
28
29
|
|
|
29
30
|
/**
|
|
30
31
|
* When FUTURE_DEBUG=1, wrap every namespace method with timing/logging.
|
|
@@ -406,6 +407,14 @@ export const manifest = {
|
|
|
406
407
|
pi: { description: 'The mathematical constant π', params: [], returns: 'number', async: false },
|
|
407
408
|
e: { description: "Euler's number", params: [], returns: 'number', async: false },
|
|
408
409
|
},
|
|
410
|
+
|
|
411
|
+
assert: {
|
|
412
|
+
ok: { description: 'Assert that value is truthy', params: [{ name: 'value', type: 'any' }, { name: 'msg', type: 'string', optional: true }], returns: 'void', async: false },
|
|
413
|
+
equal: { description: 'Assert that actual === expected', params: [{ name: 'actual', type: 'any' }, { name: 'expected', type: 'any' }, { name: 'msg', type: 'string', optional: true }], returns: 'void', async: false },
|
|
414
|
+
notEqual: { description: 'Assert that actual !== expected', params: [{ name: 'actual', type: 'any' }, { name: 'expected', type: 'any' }, { name: 'msg', type: 'string', optional: true }], returns: 'void', async: false },
|
|
415
|
+
deepEqual: { description: 'Assert deep structural equality', params: [{ name: 'actual', type: 'any' }, { name: 'expected', type: 'any' }, { name: 'msg', type: 'string', optional: true }], returns: 'void', async: false },
|
|
416
|
+
fail: { description: 'Unconditionally fail the test with a message', params: [{ name: 'msg', type: 'string', optional: true }], returns: 'void', async: false },
|
|
417
|
+
},
|
|
409
418
|
};
|
|
410
419
|
|
|
411
420
|
// --- Introspection API ---
|
|
@@ -425,7 +434,7 @@ runtime.listFunctions = (mod) => {
|
|
|
425
434
|
* Suitable for AI agent discovery or documentation generation.
|
|
426
435
|
*/
|
|
427
436
|
runtime.describe = () => ({
|
|
428
|
-
version: '0.4.
|
|
437
|
+
version: '0.4.1',
|
|
429
438
|
modules: [...MODULE_NAMES],
|
|
430
439
|
manifest,
|
|
431
440
|
});
|
|
@@ -6,13 +6,26 @@ import { parseSSE, keywordVector } from './util.js';
|
|
|
6
6
|
|
|
7
7
|
const BASE = 'https://api.anthropic.com/v1';
|
|
8
8
|
|
|
9
|
+
export class AiError extends Error {
|
|
10
|
+
constructor(status, provider, body) {
|
|
11
|
+
const msg = body?.error?.message ?? body ?? `HTTP ${status}`;
|
|
12
|
+
super(`[ai:${provider}] ${msg}`);
|
|
13
|
+
this.name = 'AiError';
|
|
14
|
+
this.status = status;
|
|
15
|
+
this.code = `AI_HTTP_${status}`;
|
|
16
|
+
this.namespace = 'ai';
|
|
17
|
+
this.provider = provider;
|
|
18
|
+
this.body = body;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
9
22
|
/**
|
|
10
23
|
* Create an Anthropic provider instance.
|
|
11
24
|
* @param {{ apiKey: string, model?: string }} config
|
|
12
25
|
*/
|
|
13
26
|
export function create(config) {
|
|
14
|
-
const key
|
|
15
|
-
const
|
|
27
|
+
const key = config.apiKey;
|
|
28
|
+
const defaultModel = config.model ?? 'claude-sonnet-4-6';
|
|
16
29
|
|
|
17
30
|
const headers = {
|
|
18
31
|
'content-type': 'application/json',
|
|
@@ -20,28 +33,43 @@ export function create(config) {
|
|
|
20
33
|
'anthropic-version': '2023-06-01',
|
|
21
34
|
};
|
|
22
35
|
|
|
23
|
-
async function chat(messages) {
|
|
36
|
+
async function chat(messages, opts = {}) {
|
|
37
|
+
const model = opts.model ?? defaultModel;
|
|
38
|
+
const max_tokens = opts.max_tokens ?? 1024;
|
|
39
|
+
const body = { model, max_tokens, messages };
|
|
40
|
+
if (opts.temperature != null) body.temperature = opts.temperature;
|
|
41
|
+
if (opts.system) body.system = opts.system;
|
|
42
|
+
|
|
24
43
|
const res = await fetch(`${BASE}/messages`, {
|
|
25
44
|
method: 'POST',
|
|
26
45
|
headers,
|
|
27
|
-
body: JSON.stringify(
|
|
46
|
+
body: JSON.stringify(body),
|
|
28
47
|
});
|
|
29
|
-
if (!res.ok)
|
|
48
|
+
if (!res.ok) {
|
|
49
|
+
let errBody;
|
|
50
|
+
try { errBody = await res.json(); } catch { errBody = await res.text(); }
|
|
51
|
+
throw new AiError(res.status, 'anthropic', errBody);
|
|
52
|
+
}
|
|
30
53
|
const data = await res.json();
|
|
31
54
|
return (data.content ?? []).map((b) => b.text ?? '').join('').trim();
|
|
32
55
|
}
|
|
33
56
|
|
|
34
|
-
async function ask(prompt) {
|
|
35
|
-
return chat([{ role: 'user', content: String(prompt) }]);
|
|
57
|
+
async function ask(prompt, opts = {}) {
|
|
58
|
+
return chat([{ role: 'user', content: String(prompt) }], opts);
|
|
36
59
|
}
|
|
37
60
|
|
|
38
|
-
async function stream(messages, onChunk) {
|
|
61
|
+
async function stream(messages, onChunk, opts = {}) {
|
|
62
|
+
const model = opts.model ?? defaultModel;
|
|
63
|
+
const max_tokens = opts.max_tokens ?? 1024;
|
|
64
|
+
const body = { model, max_tokens, messages, stream: true };
|
|
65
|
+
if (opts.temperature != null) body.temperature = opts.temperature;
|
|
66
|
+
|
|
39
67
|
const res = await fetch(`${BASE}/messages`, {
|
|
40
68
|
method: 'POST',
|
|
41
69
|
headers,
|
|
42
|
-
body: JSON.stringify(
|
|
70
|
+
body: JSON.stringify(body),
|
|
43
71
|
});
|
|
44
|
-
if (!res.ok) throw new
|
|
72
|
+
if (!res.ok) throw new AiError(res.status, 'anthropic', `stream HTTP ${res.status}`);
|
|
45
73
|
for await (const { event, data } of parseSSE(res.body)) {
|
|
46
74
|
if (event === 'content_block_delta' || data?.type === 'content_block_delta') {
|
|
47
75
|
onChunk(data.delta?.text ?? '');
|
|
@@ -7,19 +7,20 @@
|
|
|
7
7
|
// No separate SDK or special casing needed.
|
|
8
8
|
|
|
9
9
|
import { parseSSE, keywordVector } from './util.js';
|
|
10
|
+
import { AiError } from './anthropic.js';
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Well-known provider presets. Used when FUTURE_AI_PROVIDER is set without FUTURE_AI_BASE_URL.
|
|
13
14
|
* Users can always override by setting FUTURE_AI_BASE_URL directly.
|
|
14
15
|
*/
|
|
15
16
|
export const PRESETS = {
|
|
16
|
-
openai: { baseUrl: 'https://api.openai.com/v1',
|
|
17
|
-
ollama: { baseUrl: 'http://localhost:11434/v1',
|
|
18
|
-
openrouter: { baseUrl: 'https://openrouter.ai/api/v1',
|
|
17
|
+
openai: { baseUrl: 'https://api.openai.com/v1', embedModel: 'text-embedding-3-small' },
|
|
18
|
+
ollama: { baseUrl: 'http://localhost:11434/v1', embedModel: 'nomic-embed-text' },
|
|
19
|
+
openrouter: { baseUrl: 'https://openrouter.ai/api/v1', embedModel: null },
|
|
19
20
|
gemini: { baseUrl: 'https://generativelanguage.googleapis.com/v1beta/openai', embedModel: 'text-embedding-004' },
|
|
20
|
-
venice: { baseUrl: 'https://api.venice.ai/api/v1',
|
|
21
|
-
groq: { baseUrl: 'https://api.groq.com/openai/v1',
|
|
22
|
-
together: { baseUrl: 'https://api.together.xyz/v1',
|
|
21
|
+
venice: { baseUrl: 'https://api.venice.ai/api/v1', embedModel: null },
|
|
22
|
+
groq: { baseUrl: 'https://api.groq.com/openai/v1', embedModel: null },
|
|
23
|
+
together: { baseUrl: 'https://api.together.xyz/v1', embedModel: 'togethercomputer/m2-bert-80M-8k-retrieval' },
|
|
23
24
|
};
|
|
24
25
|
|
|
25
26
|
/**
|
|
@@ -27,38 +28,53 @@ export const PRESETS = {
|
|
|
27
28
|
* @param {{ baseUrl: string, apiKey: string, model?: string, embedModel?: string }} config
|
|
28
29
|
*/
|
|
29
30
|
export function create(config) {
|
|
30
|
-
const baseUrl
|
|
31
|
-
const apiKey
|
|
32
|
-
const
|
|
33
|
-
const embedModel
|
|
31
|
+
const baseUrl = config.baseUrl.replace(/\/$/, '');
|
|
32
|
+
const apiKey = config.apiKey;
|
|
33
|
+
const defaultModel = config.model ?? 'gpt-4o-mini';
|
|
34
|
+
const embedModel = config.embedModel ?? null;
|
|
35
|
+
const providerTag = `openai-compat(${baseUrl})`;
|
|
34
36
|
|
|
35
37
|
const headers = {
|
|
36
38
|
'content-type': 'application/json',
|
|
37
39
|
'authorization': `Bearer ${apiKey}`,
|
|
38
40
|
};
|
|
39
41
|
|
|
40
|
-
async function chat(messages) {
|
|
42
|
+
async function chat(messages, opts = {}) {
|
|
43
|
+
const model = opts.model ?? defaultModel;
|
|
44
|
+
const max_tokens = opts.max_tokens ?? 1024;
|
|
45
|
+
const body = { model, messages, max_tokens };
|
|
46
|
+
if (opts.temperature != null) body.temperature = opts.temperature;
|
|
47
|
+
|
|
41
48
|
const res = await fetch(`${baseUrl}/chat/completions`, {
|
|
42
49
|
method: 'POST',
|
|
43
50
|
headers,
|
|
44
|
-
body: JSON.stringify(
|
|
51
|
+
body: JSON.stringify(body),
|
|
45
52
|
});
|
|
46
|
-
if (!res.ok)
|
|
53
|
+
if (!res.ok) {
|
|
54
|
+
let errBody;
|
|
55
|
+
try { errBody = await res.json(); } catch { errBody = await res.text(); }
|
|
56
|
+
throw new AiError(res.status, providerTag, errBody);
|
|
57
|
+
}
|
|
47
58
|
const data = await res.json();
|
|
48
59
|
return data.choices?.[0]?.message?.content?.trim() ?? '';
|
|
49
60
|
}
|
|
50
61
|
|
|
51
|
-
async function ask(prompt) {
|
|
52
|
-
return chat([{ role: 'user', content: String(prompt) }]);
|
|
62
|
+
async function ask(prompt, opts = {}) {
|
|
63
|
+
return chat([{ role: 'user', content: String(prompt) }], opts);
|
|
53
64
|
}
|
|
54
65
|
|
|
55
|
-
async function stream(messages, onChunk) {
|
|
66
|
+
async function stream(messages, onChunk, opts = {}) {
|
|
67
|
+
const model = opts.model ?? defaultModel;
|
|
68
|
+
const max_tokens = opts.max_tokens ?? 1024;
|
|
69
|
+
const body = { model, messages, max_tokens, stream: true };
|
|
70
|
+
if (opts.temperature != null) body.temperature = opts.temperature;
|
|
71
|
+
|
|
56
72
|
const res = await fetch(`${baseUrl}/chat/completions`, {
|
|
57
73
|
method: 'POST',
|
|
58
74
|
headers,
|
|
59
|
-
body: JSON.stringify(
|
|
75
|
+
body: JSON.stringify(body),
|
|
60
76
|
});
|
|
61
|
-
if (!res.ok) throw new
|
|
77
|
+
if (!res.ok) throw new AiError(res.status, providerTag, `stream HTTP ${res.status}`);
|
|
62
78
|
for await (const { data } of parseSSE(res.body)) {
|
|
63
79
|
const chunk = data.choices?.[0]?.delta?.content;
|
|
64
80
|
if (chunk) onChunk(chunk);
|
|
@@ -81,5 +97,5 @@ export function create(config) {
|
|
|
81
97
|
}
|
|
82
98
|
}
|
|
83
99
|
|
|
84
|
-
return { name:
|
|
100
|
+
return { name: providerTag, ask, chat, stream, embed };
|
|
85
101
|
}
|
package/src/cli.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// future help | --help
|
|
12
12
|
// future version | --version
|
|
13
13
|
|
|
14
|
-
import { readFileSync, writeFileSync, unlinkSync, mkdirSync, existsSync } from 'node:fs';
|
|
14
|
+
import { readFileSync, writeFileSync, unlinkSync, mkdirSync, existsSync, readdirSync, statSync } from 'node:fs';
|
|
15
15
|
import { basename, dirname, extname, join, relative, resolve } from 'node:path';
|
|
16
16
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
17
17
|
import { tmpdir } from 'node:os';
|
|
@@ -20,8 +20,9 @@ import process from 'node:process';
|
|
|
20
20
|
import { compile, tokenize, parse } from './index.js';
|
|
21
21
|
import { format } from './formatter.js';
|
|
22
22
|
import { FutureError } from './errors.js';
|
|
23
|
+
import { buildSourceMap } from './sourcemap.js';
|
|
23
24
|
|
|
24
|
-
const VERSION = '0.4.
|
|
25
|
+
const VERSION = '0.4.1';
|
|
25
26
|
const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
26
27
|
const RUNTIME_INDEX = join(PROJECT_ROOT, 'runtime', 'index.js');
|
|
27
28
|
|
|
@@ -30,6 +31,7 @@ const USAGE = `Future ${VERSION} — a tiny language that compiles to JavaScript
|
|
|
30
31
|
Usage:
|
|
31
32
|
future run <file.future> Compile and run a program
|
|
32
33
|
future compile <file.future> Compile to JavaScript (<file>.js)
|
|
34
|
+
future test [pattern] Run *.test.future files
|
|
33
35
|
future new <name> Create a new project
|
|
34
36
|
future check <file.future> Check for syntax errors
|
|
35
37
|
future fmt <file.future> Format source code in-place
|
|
@@ -45,16 +47,19 @@ Import system:
|
|
|
45
47
|
|
|
46
48
|
Flags:
|
|
47
49
|
future run --debug <file> Show timing for every namespace call
|
|
50
|
+
future compile --sourcemap <file> Also emit a .js.map source map
|
|
48
51
|
`;
|
|
49
52
|
|
|
50
53
|
async function main(argv) {
|
|
51
|
-
const debug
|
|
54
|
+
const debug = argv.includes('--debug');
|
|
55
|
+
const sourcemap = argv.includes('--sourcemap');
|
|
52
56
|
if (debug) process.env.FUTURE_DEBUG = '1';
|
|
53
|
-
const rest = argv.filter((a) => a !== '--debug');
|
|
57
|
+
const rest = argv.filter((a) => a !== '--debug' && a !== '--sourcemap');
|
|
54
58
|
const [command, arg] = rest;
|
|
55
59
|
switch (command) {
|
|
56
60
|
case 'run': return cmdRun(arg);
|
|
57
|
-
case 'compile': return cmdCompile(arg);
|
|
61
|
+
case 'compile': return cmdCompile(arg, { sourcemap });
|
|
62
|
+
case 'test': return cmdTest(arg);
|
|
58
63
|
case 'new': return cmdNew(arg);
|
|
59
64
|
case 'check': return cmdCheck(arg);
|
|
60
65
|
case 'fmt': return cmdFmt(arg);
|
|
@@ -198,7 +203,7 @@ function compileDepModule(source, sourcePath, tempDir, pathMap) {
|
|
|
198
203
|
// Commands
|
|
199
204
|
// ---------------------------------------------------------------------------
|
|
200
205
|
|
|
201
|
-
function cmdCompile(file) {
|
|
206
|
+
function cmdCompile(file, { sourcemap = false } = {}) {
|
|
202
207
|
let path, source;
|
|
203
208
|
try { ({ path, source } = readSource(file)); }
|
|
204
209
|
catch (err) { return fail(err, file); }
|
|
@@ -230,18 +235,30 @@ function cmdCompile(file) {
|
|
|
230
235
|
console.log(`Compiled ${relPath} -> ${depOut}`);
|
|
231
236
|
}
|
|
232
237
|
|
|
233
|
-
const
|
|
238
|
+
const rawJs = compileOrReport(source, file, {
|
|
234
239
|
runtimeSpecifier: relativeRuntimeSpecifier(outDir),
|
|
240
|
+
sourceMaps: sourcemap,
|
|
235
241
|
resolveSource: (relPath) => {
|
|
236
242
|
const abs = resolve(outDir, relPath);
|
|
237
243
|
return existsSync(abs) ? readFileSync(abs, 'utf8') : null;
|
|
238
244
|
},
|
|
239
245
|
});
|
|
240
|
-
if (
|
|
241
|
-
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
|
|
246
|
+
if (rawJs === null) return 1;
|
|
247
|
+
|
|
248
|
+
const outBase = join(outDir, basename(path, extname(path)));
|
|
249
|
+
const outPath = `${outBase}.js`;
|
|
250
|
+
|
|
251
|
+
if (sourcemap) {
|
|
252
|
+
const mapFile = `${outBase}.js.map`;
|
|
253
|
+
const { code, map } = buildSourceMap(rawJs, basename(file), source);
|
|
254
|
+
writeFileSync(outPath, `${code}//# sourceMappingURL=${basename(mapFile)}\n`, 'utf8');
|
|
255
|
+
writeFileSync(mapFile, JSON.stringify(map), 'utf8');
|
|
256
|
+
console.log(`Compiled ${file} -> ${outPath}`);
|
|
257
|
+
console.log(`Source map -> ${mapFile}`);
|
|
258
|
+
} else {
|
|
259
|
+
writeFileSync(outPath, rawJs, 'utf8');
|
|
260
|
+
console.log(`Compiled ${file} -> ${outPath}`);
|
|
261
|
+
}
|
|
245
262
|
return 0;
|
|
246
263
|
}
|
|
247
264
|
|
|
@@ -328,6 +345,88 @@ function cmdFmt(file) {
|
|
|
328
345
|
return 0;
|
|
329
346
|
}
|
|
330
347
|
|
|
348
|
+
/** Recursively collect .future files matching a pattern or default test globs. */
|
|
349
|
+
function findTestFiles(pattern) {
|
|
350
|
+
const cwd = process.cwd();
|
|
351
|
+
const files = [];
|
|
352
|
+
|
|
353
|
+
function walk(dir) {
|
|
354
|
+
let entries;
|
|
355
|
+
try { entries = readdirSync(dir); } catch { return; }
|
|
356
|
+
for (const entry of entries) {
|
|
357
|
+
if (entry === 'node_modules') continue;
|
|
358
|
+
const full = join(dir, entry);
|
|
359
|
+
const st = statSync(full);
|
|
360
|
+
if (st.isDirectory()) { walk(full); continue; }
|
|
361
|
+
if (!entry.endsWith('.future')) continue;
|
|
362
|
+
const rel = relative(cwd, full).split('\\').join('/');
|
|
363
|
+
if (pattern) {
|
|
364
|
+
if (rel.includes(pattern) || entry.includes(pattern)) files.push(full);
|
|
365
|
+
} else {
|
|
366
|
+
if (entry.endsWith('.test.future') || rel.startsWith('test/')) files.push(full);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
walk(cwd);
|
|
372
|
+
return files;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Run *.test.future files and report results. */
|
|
376
|
+
async function cmdTest(pattern) {
|
|
377
|
+
const testFiles = findTestFiles(pattern);
|
|
378
|
+
if (testFiles.length === 0) {
|
|
379
|
+
process.stderr.write('No test files found.\n');
|
|
380
|
+
process.stderr.write(' Naming: *.test.future or test/**/*.future\n');
|
|
381
|
+
return 1;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const tempDir = tmpdir();
|
|
385
|
+
let passed = 0;
|
|
386
|
+
let failed = 0;
|
|
387
|
+
|
|
388
|
+
for (const testFile of testFiles) {
|
|
389
|
+
const rel = relative(process.cwd(), testFile).split('\\').join('/');
|
|
390
|
+
let source;
|
|
391
|
+
try { source = readFileSync(testFile, 'utf8'); } catch (err) { process.stderr.write(`error reading ${rel}: ${err.message}\n`); failed++; continue; }
|
|
392
|
+
|
|
393
|
+
// Compile dependencies.
|
|
394
|
+
const pathMap = compileDepsToTemp(testFile, source, tempDir);
|
|
395
|
+
if (pathMap === null) { failed++; continue; }
|
|
396
|
+
|
|
397
|
+
const js = compileOrReport(source, rel, {
|
|
398
|
+
runtimeSpecifier: pathToFileURL(RUNTIME_INDEX).href,
|
|
399
|
+
pathMap,
|
|
400
|
+
resolveSource: (p) => {
|
|
401
|
+
const abs = resolve(dirname(testFile), p);
|
|
402
|
+
return existsSync(abs) ? readFileSync(abs, 'utf8') : null;
|
|
403
|
+
},
|
|
404
|
+
});
|
|
405
|
+
if (js === null) { failed++; continue; }
|
|
406
|
+
|
|
407
|
+
const tmp = join(tempDir, `future-test-${process.pid}-${Date.now()}.mjs`);
|
|
408
|
+
writeFileSync(tmp, js, 'utf8');
|
|
409
|
+
const depTmps = [...pathMap.values()].map((u) => fileURLToPath(u));
|
|
410
|
+
try {
|
|
411
|
+
await import(pathToFileURL(tmp).href);
|
|
412
|
+
console.log(` ✓ ${rel}`);
|
|
413
|
+
passed++;
|
|
414
|
+
} catch (err) {
|
|
415
|
+
const isAssert = err.name === 'AssertionError' || err.namespace === 'assert';
|
|
416
|
+
process.stderr.write(` ✗ ${rel}\n`);
|
|
417
|
+
process.stderr.write(` ${isAssert ? 'AssertionError' : err.name ?? 'Error'}: ${err.message}\n`);
|
|
418
|
+
failed++;
|
|
419
|
+
} finally {
|
|
420
|
+
try { unlinkSync(tmp); } catch { /* ignore */ }
|
|
421
|
+
for (const p of depTmps) { try { unlinkSync(p); } catch { /* ignore */ } }
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
const total = passed + failed;
|
|
426
|
+
console.log(`\n${passed}/${total} tests passed${failed > 0 ? `, ${failed} failed` : ''}`);
|
|
427
|
+
return failed > 0 ? 1 : 0;
|
|
428
|
+
}
|
|
429
|
+
|
|
331
430
|
/** Create a new project scaffold. */
|
|
332
431
|
function cmdNew(name) {
|
|
333
432
|
if (!name) {
|
package/src/generator.js
CHANGED
|
@@ -24,6 +24,7 @@ export const NAMESPACES = new Set([
|
|
|
24
24
|
'rag', 'vision', 'home', // AI / automation extension points
|
|
25
25
|
'memory', 'schedule', 'system', 'device', // optional new modules
|
|
26
26
|
'math', // general-purpose math
|
|
27
|
+
'assert', // test assertions
|
|
27
28
|
]);
|
|
28
29
|
|
|
29
30
|
export class Generator {
|
|
@@ -36,6 +37,7 @@ export class Generator {
|
|
|
36
37
|
this.runtimeSpecifier = options.runtimeSpecifier ?? 'future-lang/runtime';
|
|
37
38
|
this.browserMode = options.browserMode ?? false;
|
|
38
39
|
this.isModule = options.isModule ?? false;
|
|
40
|
+
this.sourceMaps = options.sourceMaps ?? false;
|
|
39
41
|
// Map<importedFuturePath, string[]> — exported names for non-aliased use statements.
|
|
40
42
|
this.importedNames = options.importedNames ?? new Map();
|
|
41
43
|
// Map<importedFuturePath, resolvedJsPath> — path override for `future run` temp files.
|
|
@@ -79,7 +81,12 @@ export class Generator {
|
|
|
79
81
|
}
|
|
80
82
|
for (const stmt of program.body) {
|
|
81
83
|
if (stmt.type === NodeType.UseStatement) continue; // already emitted above
|
|
82
|
-
|
|
84
|
+
const code = this.genStatement(stmt, 0, /* topLevel= */ true);
|
|
85
|
+
if (this.sourceMaps && stmt.line != null) {
|
|
86
|
+
lines.push(`/*@FL:${stmt.line}*/${code}`);
|
|
87
|
+
} else {
|
|
88
|
+
lines.push(code);
|
|
89
|
+
}
|
|
83
90
|
}
|
|
84
91
|
return lines.join('\n') + '\n';
|
|
85
92
|
}
|
package/src/parser.js
CHANGED
|
@@ -19,7 +19,7 @@ const EXPR_TERMINATORS = new Set(['END', 'ELSE', 'CATCH', 'EOF']);
|
|
|
19
19
|
/** Built-in namespace names that cannot be redefined by user code. */
|
|
20
20
|
const RESERVED_NAMESPACES = new Set([
|
|
21
21
|
'ai', 'http', 'mqtt', 'tts', 'rag', 'vision', 'home',
|
|
22
|
-
'memory', 'schedule', 'system', 'device', 'math',
|
|
22
|
+
'memory', 'schedule', 'system', 'device', 'math', 'assert',
|
|
23
23
|
]);
|
|
24
24
|
|
|
25
25
|
export class Parser {
|
package/src/sourcemap.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// src/sourcemap.js — Source map generation (Source Map v3).
|
|
2
|
+
// The generator embeds @FL:N markers (inside block comments) at statement lines.
|
|
3
|
+
// This module strips them and produces a v3 source map + clean JS.
|
|
4
|
+
|
|
5
|
+
const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
6
|
+
|
|
7
|
+
function encodeVlq(value) {
|
|
8
|
+
let vlq = value < 0 ? ((-value) << 1) | 1 : (value << 1);
|
|
9
|
+
let out = '';
|
|
10
|
+
do {
|
|
11
|
+
let digit = vlq & 0x1F;
|
|
12
|
+
vlq >>>= 5;
|
|
13
|
+
if (vlq > 0) digit |= 0x20;
|
|
14
|
+
out += B64[digit];
|
|
15
|
+
} while (vlq > 0);
|
|
16
|
+
return out;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const MARKER_RE = /^\/\*@FL:(\d+)\*\//;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Strip @FL:N markers from generated JS and build a v3 source map.
|
|
23
|
+
*
|
|
24
|
+
* @param {string} js Generated JS (possibly with @FL markers)
|
|
25
|
+
* @param {string} sourceFile Original .future filename (for `sources` field)
|
|
26
|
+
* @param {string} futureSource Original .future source text (for `sourcesContent`)
|
|
27
|
+
* @returns {{ code: string, map: object }}
|
|
28
|
+
*/
|
|
29
|
+
export function buildSourceMap(js, sourceFile, futureSource) {
|
|
30
|
+
const jsLines = js.split('\n');
|
|
31
|
+
const cleanLines = [];
|
|
32
|
+
const mappings = [];
|
|
33
|
+
|
|
34
|
+
// Delta state for VLQ.
|
|
35
|
+
let prevSrcLine = 0;
|
|
36
|
+
let prevSrcCol = 0;
|
|
37
|
+
|
|
38
|
+
for (const line of jsLines) {
|
|
39
|
+
const m = MARKER_RE.exec(line);
|
|
40
|
+
if (m) {
|
|
41
|
+
const srcLine = parseInt(m[1], 10) - 1; // 0-indexed
|
|
42
|
+
const srcCol = 0;
|
|
43
|
+
// Segment: [genCol=0, sourceIdx=0, srcLine delta, srcCol delta]
|
|
44
|
+
const seg = encodeVlq(0)
|
|
45
|
+
+ encodeVlq(0)
|
|
46
|
+
+ encodeVlq(srcLine - prevSrcLine)
|
|
47
|
+
+ encodeVlq(srcCol - prevSrcCol);
|
|
48
|
+
mappings.push(seg);
|
|
49
|
+
prevSrcLine = srcLine;
|
|
50
|
+
prevSrcCol = srcCol;
|
|
51
|
+
cleanLines.push(line.slice(m[0].length));
|
|
52
|
+
} else {
|
|
53
|
+
// No marker — emit an empty mapping for this line.
|
|
54
|
+
mappings.push('');
|
|
55
|
+
cleanLines.push(line);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const map = {
|
|
60
|
+
version: 3,
|
|
61
|
+
file: sourceFile.replace(/\.future$/, '.js'),
|
|
62
|
+
sources: [sourceFile],
|
|
63
|
+
sourcesContent: [futureSource],
|
|
64
|
+
names: [],
|
|
65
|
+
mappings: mappings.join(';'),
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
return { code: cleanLines.join('\n'), map };
|
|
69
|
+
}
|