tova 0.1.1 → 0.2.2
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/LICENSE +1 -1
- package/README.md +2 -0
- package/bin/tova.js +811 -154
- package/package.json +8 -2
- package/src/analyzer/analyzer.js +297 -58
- package/src/analyzer/scope.js +38 -1
- package/src/analyzer/type-registry.js +72 -0
- package/src/analyzer/types.js +478 -0
- package/src/codegen/base-codegen.js +371 -0
- package/src/codegen/client-codegen.js +62 -10
- package/src/codegen/codegen.js +111 -2
- package/src/codegen/server-codegen.js +175 -3
- package/src/config/edit-toml.js +100 -0
- package/src/config/package-json.js +52 -0
- package/src/config/resolve.js +100 -0
- package/src/config/toml.js +209 -0
- package/src/lexer/lexer.js +2 -2
- package/src/lsp/server.js +284 -30
- package/src/parser/ast.js +105 -0
- package/src/parser/parser.js +202 -2
- package/src/runtime/ai.js +305 -0
- package/src/runtime/devtools.js +228 -0
- package/src/runtime/embedded.js +3 -1
- package/src/runtime/io.js +240 -0
- package/src/runtime/reactivity.js +264 -19
- package/src/runtime/ssr.js +196 -24
- package/src/runtime/table.js +522 -0
- package/src/stdlib/collections.js +245 -0
- package/src/stdlib/core.js +87 -0
- package/src/stdlib/datetime.js +88 -0
- package/src/stdlib/encoding.js +35 -0
- package/src/stdlib/functional.js +82 -0
- package/src/stdlib/inline.js +334 -67
- package/src/stdlib/math.js +93 -0
- package/src/stdlib/string.js +95 -0
- package/src/stdlib/url.js +33 -0
- package/src/stdlib/validation.js +29 -0
package/src/codegen/codegen.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { SharedCodegen } from './shared-codegen.js';
|
|
6
6
|
import { ServerCodegen } from './server-codegen.js';
|
|
7
7
|
import { ClientCodegen } from './client-codegen.js';
|
|
8
|
+
import { BUILTIN_NAMES } from '../stdlib/inline.js';
|
|
8
9
|
|
|
9
10
|
export class CodeGenerator {
|
|
10
11
|
constructor(ast, filename = '<stdin>') {
|
|
@@ -30,6 +31,7 @@ export class CodeGenerator {
|
|
|
30
31
|
const topLevel = [];
|
|
31
32
|
|
|
32
33
|
const testBlocks = [];
|
|
34
|
+
const dataBlocks = [];
|
|
33
35
|
|
|
34
36
|
for (const node of this.ast.body) {
|
|
35
37
|
switch (node.type) {
|
|
@@ -37,6 +39,7 @@ export class CodeGenerator {
|
|
|
37
39
|
case 'ServerBlock': serverBlocks.push(node); break;
|
|
38
40
|
case 'ClientBlock': clientBlocks.push(node); break;
|
|
39
41
|
case 'TestBlock': testBlocks.push(node); break;
|
|
42
|
+
case 'DataBlock': dataBlocks.push(node); break;
|
|
40
43
|
default: topLevel.push(node); break;
|
|
41
44
|
}
|
|
42
45
|
}
|
|
@@ -46,8 +49,16 @@ export class CodeGenerator {
|
|
|
46
49
|
// All shared blocks (regardless of name) are merged into one shared output
|
|
47
50
|
const sharedCode = sharedBlocks.map(b => sharedGen.generate(b)).join('\n');
|
|
48
51
|
const topLevelCode = topLevel.map(s => sharedGen.generateStatement(s)).join('\n');
|
|
52
|
+
|
|
53
|
+
// Pre-scan server/client blocks for builtin usage so shared stdlib includes them
|
|
54
|
+
this._scanBlocksForBuiltins([...serverBlocks, ...clientBlocks], sharedGen._usedBuiltins);
|
|
55
|
+
|
|
49
56
|
const helpers = sharedGen.generateHelpers();
|
|
50
|
-
|
|
57
|
+
|
|
58
|
+
// Generate data block code (sources, pipelines, validators, refresh)
|
|
59
|
+
const dataCode = dataBlocks.map(b => this._genDataBlock(b, sharedGen)).join('\n');
|
|
60
|
+
|
|
61
|
+
const combinedShared = [helpers, sharedCode, topLevelCode, dataCode].filter(s => s.trim()).join('\n').trim();
|
|
51
62
|
|
|
52
63
|
// Group server and client blocks by name
|
|
53
64
|
const serverGroups = this._groupByName(serverBlocks);
|
|
@@ -99,7 +110,7 @@ export class CodeGenerator {
|
|
|
99
110
|
for (const [name, blocks] of clientGroups) {
|
|
100
111
|
const gen = new ClientCodegen();
|
|
101
112
|
const key = name || 'default';
|
|
102
|
-
clients[key] = gen.generate(blocks, combinedShared);
|
|
113
|
+
clients[key] = gen.generate(blocks, combinedShared, sharedGen._usedBuiltins);
|
|
103
114
|
}
|
|
104
115
|
|
|
105
116
|
// Generate tests if test blocks exist
|
|
@@ -145,4 +156,102 @@ export class CodeGenerator {
|
|
|
145
156
|
if (testCode) result.test = testCode;
|
|
146
157
|
return result;
|
|
147
158
|
}
|
|
159
|
+
|
|
160
|
+
// Walk AST nodes to find builtin function calls/identifiers
|
|
161
|
+
_scanBlocksForBuiltins(blocks, targetSet) {
|
|
162
|
+
const walk = (node) => {
|
|
163
|
+
if (!node || typeof node !== 'object') return;
|
|
164
|
+
if (node.type === 'Identifier' && BUILTIN_NAMES.has(node.name)) {
|
|
165
|
+
targetSet.add(node.name);
|
|
166
|
+
}
|
|
167
|
+
if (node.type === 'CallExpression' && node.callee && node.callee.type === 'Identifier' && BUILTIN_NAMES.has(node.callee.name)) {
|
|
168
|
+
targetSet.add(node.callee.name);
|
|
169
|
+
}
|
|
170
|
+
for (const key of Object.keys(node)) {
|
|
171
|
+
if (key === 'loc' || key === 'type') continue;
|
|
172
|
+
const val = node[key];
|
|
173
|
+
if (Array.isArray(val)) {
|
|
174
|
+
for (const item of val) {
|
|
175
|
+
if (item && typeof item === 'object') walk(item);
|
|
176
|
+
}
|
|
177
|
+
} else if (val && typeof val === 'object' && val.type) {
|
|
178
|
+
walk(val);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
for (const block of blocks) walk(block);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
_genDataBlock(node, gen) {
|
|
186
|
+
const lines = [];
|
|
187
|
+
lines.push('// ── Data Block ──');
|
|
188
|
+
|
|
189
|
+
for (const stmt of node.body) {
|
|
190
|
+
switch (stmt.type) {
|
|
191
|
+
case 'SourceDeclaration': {
|
|
192
|
+
// Source: lazy cached getter
|
|
193
|
+
const expr = gen.genExpression(stmt.expression);
|
|
194
|
+
lines.push(`let __data_${stmt.name}_cache = null;`);
|
|
195
|
+
lines.push(`async function __data_${stmt.name}_load() {`);
|
|
196
|
+
lines.push(` if (__data_${stmt.name}_cache === null) {`);
|
|
197
|
+
lines.push(` __data_${stmt.name}_cache = await ${expr};`);
|
|
198
|
+
lines.push(` }`);
|
|
199
|
+
lines.push(` return __data_${stmt.name}_cache;`);
|
|
200
|
+
lines.push(`}`);
|
|
201
|
+
// Also expose as a simple getter variable via lazy init
|
|
202
|
+
lines.push(`let ${stmt.name} = null;`);
|
|
203
|
+
lines.push(`Object.defineProperty(globalThis, ${JSON.stringify(stmt.name)}, {`);
|
|
204
|
+
lines.push(` get() { if (${stmt.name} === null) { ${stmt.name} = __data_${stmt.name}_load(); } return ${stmt.name}; },`);
|
|
205
|
+
lines.push(` configurable: true,`);
|
|
206
|
+
lines.push(`});`);
|
|
207
|
+
break;
|
|
208
|
+
}
|
|
209
|
+
case 'PipelineDeclaration': {
|
|
210
|
+
// Pipeline: function that chains transforms
|
|
211
|
+
const expr = gen.genExpression(stmt.expression);
|
|
212
|
+
lines.push(`async function __pipeline_${stmt.name}() {`);
|
|
213
|
+
lines.push(` return ${expr};`);
|
|
214
|
+
lines.push(`}`);
|
|
215
|
+
lines.push(`let ${stmt.name} = null;`);
|
|
216
|
+
lines.push(`Object.defineProperty(globalThis, ${JSON.stringify(stmt.name)}, {`);
|
|
217
|
+
lines.push(` get() { if (${stmt.name} === null) { ${stmt.name} = __pipeline_${stmt.name}(); } return ${stmt.name}; },`);
|
|
218
|
+
lines.push(` configurable: true,`);
|
|
219
|
+
lines.push(`});`);
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
case 'ValidateBlock': {
|
|
223
|
+
// Validate: validator function
|
|
224
|
+
const rules = stmt.rules.map(r => gen.genExpression(r));
|
|
225
|
+
lines.push(`function __validate_${stmt.typeName}(it) {`);
|
|
226
|
+
lines.push(` const errors = [];`);
|
|
227
|
+
for (let i = 0; i < rules.length; i++) {
|
|
228
|
+
lines.push(` if (!(${rules[i]})) errors.push("Validation rule ${i + 1} failed for ${stmt.typeName}");`);
|
|
229
|
+
}
|
|
230
|
+
lines.push(` return errors.length === 0 ? { valid: true, errors: [] } : { valid: false, errors };`);
|
|
231
|
+
lines.push(`}`);
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
case 'RefreshPolicy': {
|
|
235
|
+
// Refresh: interval cache invalidation
|
|
236
|
+
if (stmt.interval === 'on_demand') {
|
|
237
|
+
lines.push(`function refresh_${stmt.sourceName}() { __data_${stmt.sourceName}_cache = null; ${stmt.sourceName} = null; }`);
|
|
238
|
+
} else {
|
|
239
|
+
const { value, unit } = stmt.interval;
|
|
240
|
+
let ms;
|
|
241
|
+
switch (unit) {
|
|
242
|
+
case 'seconds': case 'second': ms = value * 1000; break;
|
|
243
|
+
case 'minutes': case 'minute': ms = value * 60 * 1000; break;
|
|
244
|
+
case 'hours': case 'hour': ms = value * 60 * 60 * 1000; break;
|
|
245
|
+
case 'days': case 'day': ms = value * 24 * 60 * 60 * 1000; break;
|
|
246
|
+
default: ms = value * 60 * 1000; // default to minutes
|
|
247
|
+
}
|
|
248
|
+
lines.push(`setInterval(() => { __data_${stmt.sourceName}_cache = null; ${stmt.sourceName} = null; }, ${ms});`);
|
|
249
|
+
}
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return lines.join('\n');
|
|
256
|
+
}
|
|
148
257
|
}
|
|
@@ -154,6 +154,7 @@ export class ServerCodegen extends BaseCodegen {
|
|
|
154
154
|
let cacheConfig = null;
|
|
155
155
|
const sseDecls = [];
|
|
156
156
|
const modelDecls = [];
|
|
157
|
+
const aiConfigs = []; // { name: string|null, config: object }
|
|
157
158
|
|
|
158
159
|
const collectFromBody = (stmts, groupPrefix = null, groupMiddlewares = []) => {
|
|
159
160
|
for (const stmt of stmts) {
|
|
@@ -225,6 +226,8 @@ export class ServerCodegen extends BaseCodegen {
|
|
|
225
226
|
sseDecls.push(stmt);
|
|
226
227
|
} else if (stmt.type === 'ModelDeclaration') {
|
|
227
228
|
modelDecls.push(stmt);
|
|
229
|
+
} else if (stmt.type === 'AiConfigDeclaration') {
|
|
230
|
+
aiConfigs.push(stmt);
|
|
228
231
|
} else {
|
|
229
232
|
otherStatements.push(stmt);
|
|
230
233
|
}
|
|
@@ -463,6 +466,32 @@ export class ServerCodegen extends BaseCodegen {
|
|
|
463
466
|
lines.push('');
|
|
464
467
|
}
|
|
465
468
|
|
|
469
|
+
// ════════════════════════════════════════════════════════════
|
|
470
|
+
// 3b. AI Client Initialization
|
|
471
|
+
// ════════════════════════════════════════════════════════════
|
|
472
|
+
if (aiConfigs.length > 0) {
|
|
473
|
+
lines.push('// ── AI Clients ──');
|
|
474
|
+
lines.push(this._getAiRuntime());
|
|
475
|
+
lines.push('');
|
|
476
|
+
|
|
477
|
+
for (const aiConf of aiConfigs) {
|
|
478
|
+
const configParts = [];
|
|
479
|
+
for (const [key, valueNode] of Object.entries(aiConf.config)) {
|
|
480
|
+
configParts.push(` ${key}: ${this.genExpression(valueNode)}`);
|
|
481
|
+
}
|
|
482
|
+
const configStr = `{\n${configParts.join(',\n')}\n}`;
|
|
483
|
+
|
|
484
|
+
if (aiConf.name) {
|
|
485
|
+
// Named provider: ai "claude" { ... } → const claude = __createAI({...})
|
|
486
|
+
lines.push(`const ${aiConf.name} = __createAI(${configStr});`);
|
|
487
|
+
} else {
|
|
488
|
+
// Default provider: ai { ... } → const ai = __createAI({...})
|
|
489
|
+
lines.push(`const ai = __createAI(${configStr});`);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
lines.push('');
|
|
493
|
+
}
|
|
494
|
+
|
|
466
495
|
// ════════════════════════════════════════════════════════════
|
|
467
496
|
// 4. Peer Server RPC Proxies (with circuit breaker + retry)
|
|
468
497
|
// ════════════════════════════════════════════════════════════
|
|
@@ -1179,7 +1208,17 @@ export class ServerCodegen extends BaseCodegen {
|
|
|
1179
1208
|
const aw = isAsync ? 'await ' : '';
|
|
1180
1209
|
for (const modelDecl of modelDecls) {
|
|
1181
1210
|
const typeName = modelDecl.name;
|
|
1182
|
-
|
|
1211
|
+
let typeInfo = sharedTypes.get(typeName);
|
|
1212
|
+
// Fall back to model declaration fields if shared type not in this file (multi-file)
|
|
1213
|
+
if (!typeInfo && modelDecl.config) {
|
|
1214
|
+
const fields = [{ name: 'id', type: 'Int' }];
|
|
1215
|
+
for (const [key, value] of Object.entries(modelDecl.config)) {
|
|
1216
|
+
if (key === 'table' || key === 'timestamps' || key === 'belongs_to' || key === 'has_many') continue;
|
|
1217
|
+
const fieldType = value.name || (value.type === 'ArrayTypeAnnotation' ? 'Array' : 'Any');
|
|
1218
|
+
fields.push({ name: key, type: fieldType });
|
|
1219
|
+
}
|
|
1220
|
+
if (fields.length > 0) typeInfo = { fields };
|
|
1221
|
+
}
|
|
1183
1222
|
if (!typeInfo) continue;
|
|
1184
1223
|
const tableName = modelDecl.config && modelDecl.config.table
|
|
1185
1224
|
? this.genExpression(modelDecl.config.table).replace(/"/g, '')
|
|
@@ -1378,6 +1417,10 @@ export class ServerCodegen extends BaseCodegen {
|
|
|
1378
1417
|
}
|
|
1379
1418
|
|
|
1380
1419
|
lines.push('};');
|
|
1420
|
+
// Alias so server functions can reference the model by its original type name
|
|
1421
|
+
// Use var so it works both when Task is already declared (single-file shared block)
|
|
1422
|
+
// and when it isn't (multi-file: server in separate file from shared types)
|
|
1423
|
+
lines.push(`var ${typeName} = ${typeName}Model;`);
|
|
1381
1424
|
lines.push('');
|
|
1382
1425
|
}
|
|
1383
1426
|
}
|
|
@@ -1937,8 +1980,8 @@ export class ServerCodegen extends BaseCodegen {
|
|
|
1937
1980
|
lines.push(' const html = `<!DOCTYPE html><html><head><title>API Docs</title>');
|
|
1938
1981
|
lines.push(' <link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css"></head>');
|
|
1939
1982
|
lines.push(' <body><div id="swagger-ui"></div>');
|
|
1940
|
-
lines.push(' <script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"
|
|
1941
|
-
lines.push(' <script>SwaggerUIBundle({ url: "/openapi.json", dom_id: "#swagger-ui" })
|
|
1983
|
+
lines.push(' <script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"><\\/script>');
|
|
1984
|
+
lines.push(' <script>SwaggerUIBundle({ url: "/openapi.json", dom_id: "#swagger-ui" });<\\/script>');
|
|
1942
1985
|
lines.push(' </body></html>`;');
|
|
1943
1986
|
lines.push(' return new Response(html, { headers: { "Content-Type": "text/html" } });');
|
|
1944
1987
|
lines.push('});');
|
|
@@ -2503,4 +2546,133 @@ export class ServerCodegen extends BaseCodegen {
|
|
|
2503
2546
|
|
|
2504
2547
|
return lines.join('\n');
|
|
2505
2548
|
}
|
|
2549
|
+
|
|
2550
|
+
_getAiRuntime() {
|
|
2551
|
+
return `// AI Client Runtime
|
|
2552
|
+
function __createAI(config) {
|
|
2553
|
+
const providerName = config.provider || 'custom';
|
|
2554
|
+
async function __aiRequest(method, args, callOpts = {}) {
|
|
2555
|
+
const cfg = { ...config, ...callOpts };
|
|
2556
|
+
const baseUrl = cfg.base_url || (providerName === 'anthropic' ? 'https://api.anthropic.com' : providerName === 'ollama' ? 'http://localhost:11434' : 'https://api.openai.com');
|
|
2557
|
+
const headers = { 'Content-Type': 'application/json', ...(cfg.headers || {}) };
|
|
2558
|
+
if (providerName === 'anthropic') {
|
|
2559
|
+
headers['x-api-key'] = cfg.api_key;
|
|
2560
|
+
headers['anthropic-version'] = '2023-06-01';
|
|
2561
|
+
} else if (cfg.api_key) {
|
|
2562
|
+
headers['Authorization'] = 'Bearer ' + cfg.api_key;
|
|
2563
|
+
}
|
|
2564
|
+
const timeout = cfg.timeout || 60000;
|
|
2565
|
+
|
|
2566
|
+
if (method === 'ask') {
|
|
2567
|
+
const [prompt, opts] = args;
|
|
2568
|
+
let body, url;
|
|
2569
|
+
if (providerName === 'anthropic') {
|
|
2570
|
+
body = { model: cfg.model, max_tokens: opts?.max_tokens || cfg.max_tokens || 4096, messages: [{ role: 'user', content: prompt }] };
|
|
2571
|
+
if (opts?.temperature ?? cfg.temperature) body.temperature = opts?.temperature ?? cfg.temperature;
|
|
2572
|
+
if (opts?.tools) body.tools = opts.tools.map(t => ({ name: t.name, description: t.description, input_schema: { type: 'object', properties: t.params ? Object.fromEntries(Object.entries(t.params).map(([k, v]) => [k, { type: typeof v === 'string' ? v.toLowerCase() : 'string' }])) : {} } }));
|
|
2573
|
+
url = baseUrl + '/v1/messages';
|
|
2574
|
+
} else if (providerName === 'ollama') {
|
|
2575
|
+
body = { model: cfg.model, messages: [{ role: 'user', content: prompt }], stream: false };
|
|
2576
|
+
url = baseUrl + '/api/chat';
|
|
2577
|
+
} else {
|
|
2578
|
+
body = { model: cfg.model, messages: [{ role: 'user', content: prompt }] };
|
|
2579
|
+
if (opts?.max_tokens || cfg.max_tokens) body.max_tokens = opts?.max_tokens || cfg.max_tokens;
|
|
2580
|
+
if (opts?.temperature ?? cfg.temperature) body.temperature = opts?.temperature ?? cfg.temperature;
|
|
2581
|
+
if (opts?.tools) body.tools = opts.tools.map(t => ({ type: 'function', function: { name: t.name, description: t.description, parameters: { type: 'object', properties: t.params ? Object.fromEntries(Object.entries(t.params).map(([k, v]) => [k, { type: typeof v === 'string' ? v.toLowerCase() : 'string' }])) : {} } } }));
|
|
2582
|
+
url = baseUrl + '/v1/chat/completions';
|
|
2583
|
+
}
|
|
2584
|
+
const res = await fetch(url, { method: 'POST', headers, body: JSON.stringify(body), signal: AbortSignal.timeout(timeout) });
|
|
2585
|
+
if (!res.ok) throw new Error(providerName + ' API error ' + res.status + ': ' + (await res.text()));
|
|
2586
|
+
const data = await res.json();
|
|
2587
|
+
if (providerName === 'anthropic') {
|
|
2588
|
+
if (opts?.tools && data.content?.some(c => c.type === 'tool_use')) return { text: data.content.filter(c => c.type === 'text').map(c => c.text).join(''), tool_calls: data.content.filter(c => c.type === 'tool_use') };
|
|
2589
|
+
return data.content.map(c => c.text).join('');
|
|
2590
|
+
}
|
|
2591
|
+
if (providerName === 'ollama') return data.message.content;
|
|
2592
|
+
const choice = data.choices[0];
|
|
2593
|
+
if (opts?.tools && choice.message.tool_calls) return { text: choice.message.content || '', tool_calls: choice.message.tool_calls };
|
|
2594
|
+
return choice.message.content;
|
|
2595
|
+
}
|
|
2596
|
+
|
|
2597
|
+
if (method === 'chat') {
|
|
2598
|
+
const [messages, opts] = args;
|
|
2599
|
+
let body, url;
|
|
2600
|
+
if (providerName === 'anthropic') {
|
|
2601
|
+
const sys = messages.filter(m => m.role === 'system');
|
|
2602
|
+
const msgs = messages.filter(m => m.role !== 'system');
|
|
2603
|
+
body = { model: cfg.model, max_tokens: opts?.max_tokens || cfg.max_tokens || 4096, messages: msgs };
|
|
2604
|
+
if (sys.length > 0) body.system = sys.map(m => m.content).join('\\n');
|
|
2605
|
+
url = baseUrl + '/v1/messages';
|
|
2606
|
+
} else if (providerName === 'ollama') {
|
|
2607
|
+
body = { model: cfg.model, messages, stream: false };
|
|
2608
|
+
url = baseUrl + '/api/chat';
|
|
2609
|
+
} else {
|
|
2610
|
+
body = { model: cfg.model, messages };
|
|
2611
|
+
if (opts?.max_tokens || cfg.max_tokens) body.max_tokens = opts?.max_tokens || cfg.max_tokens;
|
|
2612
|
+
url = baseUrl + '/v1/chat/completions';
|
|
2613
|
+
}
|
|
2614
|
+
if (opts?.temperature ?? cfg.temperature) body.temperature = opts?.temperature ?? cfg.temperature;
|
|
2615
|
+
const res = await fetch(url, { method: 'POST', headers, body: JSON.stringify(body), signal: AbortSignal.timeout(timeout) });
|
|
2616
|
+
if (!res.ok) throw new Error(providerName + ' API error ' + res.status + ': ' + (await res.text()));
|
|
2617
|
+
const data = await res.json();
|
|
2618
|
+
if (providerName === 'anthropic') return data.content.map(c => c.text).join('');
|
|
2619
|
+
if (providerName === 'ollama') return data.message.content;
|
|
2620
|
+
return data.choices[0].message.content;
|
|
2621
|
+
}
|
|
2622
|
+
|
|
2623
|
+
if (method === 'embed') {
|
|
2624
|
+
const [input, opts] = args;
|
|
2625
|
+
let body, url;
|
|
2626
|
+
if (providerName === 'ollama') {
|
|
2627
|
+
url = baseUrl + '/api/embeddings';
|
|
2628
|
+
if (Array.isArray(input)) {
|
|
2629
|
+
const results = [];
|
|
2630
|
+
for (const text of input) {
|
|
2631
|
+
const r = await fetch(url, { method: 'POST', headers, body: JSON.stringify({ model: cfg.model, prompt: text }) });
|
|
2632
|
+
results.push((await r.json()).embedding);
|
|
2633
|
+
}
|
|
2634
|
+
return results;
|
|
2635
|
+
}
|
|
2636
|
+
body = { model: cfg.model, prompt: input };
|
|
2637
|
+
} else {
|
|
2638
|
+
body = { model: cfg.model || 'text-embedding-3-small', input };
|
|
2639
|
+
url = baseUrl + '/v1/embeddings';
|
|
2640
|
+
}
|
|
2641
|
+
const res = await fetch(url, { method: 'POST', headers, body: JSON.stringify(body), signal: AbortSignal.timeout(timeout) });
|
|
2642
|
+
if (!res.ok) throw new Error(providerName + ' API error ' + res.status + ': ' + (await res.text()));
|
|
2643
|
+
const data = await res.json();
|
|
2644
|
+
if (providerName === 'ollama') return data.embedding;
|
|
2645
|
+
if (Array.isArray(input)) return data.data.map(d => d.embedding);
|
|
2646
|
+
return data.data[0].embedding;
|
|
2647
|
+
}
|
|
2648
|
+
|
|
2649
|
+
if (method === 'extract') {
|
|
2650
|
+
const [prompt, schema, opts] = args;
|
|
2651
|
+
const extractPrompt = prompt + '\\n\\nRespond with a JSON object matching this schema: ' + JSON.stringify(schema);
|
|
2652
|
+
const text = await __aiRequest('ask', [extractPrompt, opts]);
|
|
2653
|
+
try { return JSON.parse(text); } catch { return JSON.parse(text.match(/\\{[\\s\\S]*\\}/)?.[0] || '{}'); }
|
|
2654
|
+
}
|
|
2655
|
+
|
|
2656
|
+
if (method === 'classify') {
|
|
2657
|
+
const [text, categories, opts] = args;
|
|
2658
|
+
const catList = Array.isArray(categories) ? categories : Object.keys(categories);
|
|
2659
|
+
const classifyPrompt = 'Classify into one of: ' + catList.join(', ') + '\\n\\nText: "' + text + '"\\n\\nRespond with only the category name.';
|
|
2660
|
+
const result = (await __aiRequest('ask', [classifyPrompt, { ...opts, max_tokens: 100 }])).trim();
|
|
2661
|
+
return catList.find(c => c.toLowerCase() === result.toLowerCase()) || result;
|
|
2662
|
+
}
|
|
2663
|
+
|
|
2664
|
+
throw new Error('Unknown AI method: ' + method);
|
|
2665
|
+
}
|
|
2666
|
+
|
|
2667
|
+
return {
|
|
2668
|
+
ask(prompt, opts) { return __aiRequest('ask', [prompt, opts || {}], opts); },
|
|
2669
|
+
chat(messages, opts) { return __aiRequest('chat', [messages, opts || {}], opts); },
|
|
2670
|
+
embed(input, opts) { return __aiRequest('embed', [input, opts || {}], opts); },
|
|
2671
|
+
extract(prompt, schema, opts) { return __aiRequest('extract', [prompt, schema, opts || {}], opts); },
|
|
2672
|
+
classify(text, categories, opts) { return __aiRequest('classify', [text, categories, opts || {}], opts); },
|
|
2673
|
+
};
|
|
2674
|
+
}
|
|
2675
|
+
// Default AI object for one-off calls (no config block required)
|
|
2676
|
+
const ai = typeof ai === 'undefined' ? __createAI({}) : ai;`;
|
|
2677
|
+
}
|
|
2506
2678
|
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Line-level TOML editing for tova add / tova remove.
|
|
2
|
+
// Operates on raw text to preserve formatting, comments, and whitespace.
|
|
3
|
+
|
|
4
|
+
import { readFileSync, writeFileSync } from 'fs';
|
|
5
|
+
|
|
6
|
+
export function addToSection(filePath, section, key, value) {
|
|
7
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
8
|
+
const lines = content.split('\n');
|
|
9
|
+
const entry = `${key} = "${value}"`;
|
|
10
|
+
|
|
11
|
+
// Find the section header
|
|
12
|
+
const sectionIdx = findSectionIndex(lines, section);
|
|
13
|
+
|
|
14
|
+
if (sectionIdx === -1) {
|
|
15
|
+
// Section doesn't exist — append it at end of file
|
|
16
|
+
const newLines = [...lines];
|
|
17
|
+
// Ensure blank line before new section
|
|
18
|
+
if (newLines.length > 0 && newLines[newLines.length - 1].trim() !== '') {
|
|
19
|
+
newLines.push('');
|
|
20
|
+
}
|
|
21
|
+
newLines.push(`[${section}]`);
|
|
22
|
+
newLines.push(entry);
|
|
23
|
+
writeFileSync(filePath, newLines.join('\n'));
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Find the end of this section (next section header or EOF)
|
|
28
|
+
const endIdx = findSectionEnd(lines, sectionIdx);
|
|
29
|
+
|
|
30
|
+
// Check if key already exists in this section — update it
|
|
31
|
+
for (let i = sectionIdx + 1; i < endIdx; i++) {
|
|
32
|
+
const line = lines[i].trim();
|
|
33
|
+
if (line === '' || line.startsWith('#')) continue;
|
|
34
|
+
const eqIdx = line.indexOf('=');
|
|
35
|
+
if (eqIdx !== -1) {
|
|
36
|
+
const existingKey = line.slice(0, eqIdx).trim();
|
|
37
|
+
if (existingKey === key) {
|
|
38
|
+
lines[i] = entry;
|
|
39
|
+
writeFileSync(filePath, lines.join('\n'));
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Key doesn't exist — insert after last non-blank line in section
|
|
46
|
+
let insertIdx = sectionIdx + 1;
|
|
47
|
+
for (let i = endIdx - 1; i > sectionIdx; i--) {
|
|
48
|
+
if (lines[i].trim() !== '') {
|
|
49
|
+
insertIdx = i + 1;
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
lines.splice(insertIdx, 0, entry);
|
|
54
|
+
writeFileSync(filePath, lines.join('\n'));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function removeFromSection(filePath, section, key) {
|
|
58
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
59
|
+
const lines = content.split('\n');
|
|
60
|
+
|
|
61
|
+
const sectionIdx = findSectionIndex(lines, section);
|
|
62
|
+
if (sectionIdx === -1) return false;
|
|
63
|
+
|
|
64
|
+
const endIdx = findSectionEnd(lines, sectionIdx);
|
|
65
|
+
|
|
66
|
+
for (let i = sectionIdx + 1; i < endIdx; i++) {
|
|
67
|
+
const line = lines[i].trim();
|
|
68
|
+
if (line === '' || line.startsWith('#')) continue;
|
|
69
|
+
const eqIdx = line.indexOf('=');
|
|
70
|
+
if (eqIdx !== -1) {
|
|
71
|
+
const existingKey = line.slice(0, eqIdx).trim();
|
|
72
|
+
if (existingKey === key) {
|
|
73
|
+
lines.splice(i, 1);
|
|
74
|
+
writeFileSync(filePath, lines.join('\n'));
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function findSectionIndex(lines, section) {
|
|
84
|
+
const pattern = `[${section}]`;
|
|
85
|
+
for (let i = 0; i < lines.length; i++) {
|
|
86
|
+
const line = lines[i].trim();
|
|
87
|
+
if (line === pattern) return i;
|
|
88
|
+
}
|
|
89
|
+
return -1;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function findSectionEnd(lines, sectionIdx) {
|
|
93
|
+
for (let i = sectionIdx + 1; i < lines.length; i++) {
|
|
94
|
+
const line = lines[i].trim();
|
|
95
|
+
if (line.startsWith('[') && !line.startsWith('[[')) {
|
|
96
|
+
return i;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return lines.length;
|
|
100
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Generate a shadow package.json from tova.toml config.
|
|
2
|
+
|
|
3
|
+
import { writeFileSync, existsSync, readFileSync } from 'fs';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
|
|
6
|
+
const MARKER = '// Auto-generated from tova.toml. Do not edit.';
|
|
7
|
+
|
|
8
|
+
export function generatePackageJson(config, cwd) {
|
|
9
|
+
const npmProd = config.npm?.prod || {};
|
|
10
|
+
const npmDev = config.npm?.dev || {};
|
|
11
|
+
|
|
12
|
+
const hasNpmDeps = Object.keys(npmProd).length > 0 || Object.keys(npmDev).length > 0;
|
|
13
|
+
if (!hasNpmDeps) return null;
|
|
14
|
+
|
|
15
|
+
const pkg = {
|
|
16
|
+
'//': MARKER,
|
|
17
|
+
name: config.project.name,
|
|
18
|
+
version: config.project.version,
|
|
19
|
+
private: true,
|
|
20
|
+
type: 'module',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
if (Object.keys(npmProd).length > 0) {
|
|
24
|
+
pkg.dependencies = npmProd;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (Object.keys(npmDev).length > 0) {
|
|
28
|
+
pkg.devDependencies = npmDev;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return pkg;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function writePackageJson(config, cwd) {
|
|
35
|
+
const pkg = generatePackageJson(config, cwd);
|
|
36
|
+
if (!pkg) return false;
|
|
37
|
+
|
|
38
|
+
const pkgPath = join(cwd, 'package.json');
|
|
39
|
+
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function isGeneratedPackageJson(cwd) {
|
|
44
|
+
const pkgPath = join(cwd, 'package.json');
|
|
45
|
+
if (!existsSync(pkgPath)) return false;
|
|
46
|
+
try {
|
|
47
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
48
|
+
return pkg['//'] === MARKER;
|
|
49
|
+
} catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Config resolution: reads tova.toml → falls back to package.json → defaults.
|
|
2
|
+
|
|
3
|
+
import { readFileSync, existsSync } from 'fs';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
import { parseTOML } from './toml.js';
|
|
6
|
+
|
|
7
|
+
const DEFAULTS = {
|
|
8
|
+
project: {
|
|
9
|
+
name: 'tova-app',
|
|
10
|
+
version: '0.1.0',
|
|
11
|
+
description: '',
|
|
12
|
+
entry: 'src',
|
|
13
|
+
},
|
|
14
|
+
build: {
|
|
15
|
+
output: '.tova-out',
|
|
16
|
+
},
|
|
17
|
+
dev: {
|
|
18
|
+
port: 3000,
|
|
19
|
+
},
|
|
20
|
+
dependencies: {},
|
|
21
|
+
npm: {},
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function resolveConfig(cwd) {
|
|
25
|
+
const tomlPath = join(cwd, 'tova.toml');
|
|
26
|
+
const pkgPath = join(cwd, 'package.json');
|
|
27
|
+
|
|
28
|
+
// Try tova.toml first
|
|
29
|
+
if (existsSync(tomlPath)) {
|
|
30
|
+
const raw = readFileSync(tomlPath, 'utf-8');
|
|
31
|
+
const parsed = parseTOML(raw);
|
|
32
|
+
return normalizeConfig(parsed, 'tova.toml');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Fall back to package.json
|
|
36
|
+
if (existsSync(pkgPath)) {
|
|
37
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
|
38
|
+
return configFromPackageJson(pkg);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Return defaults
|
|
42
|
+
return { ...DEFAULTS, _source: 'defaults' };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function normalizeConfig(parsed, source) {
|
|
46
|
+
const config = {
|
|
47
|
+
project: {
|
|
48
|
+
name: parsed.project?.name || DEFAULTS.project.name,
|
|
49
|
+
version: parsed.project?.version || DEFAULTS.project.version,
|
|
50
|
+
description: parsed.project?.description || DEFAULTS.project.description,
|
|
51
|
+
entry: parsed.project?.entry || DEFAULTS.project.entry,
|
|
52
|
+
},
|
|
53
|
+
build: {
|
|
54
|
+
output: parsed.build?.output || DEFAULTS.build.output,
|
|
55
|
+
},
|
|
56
|
+
dev: {
|
|
57
|
+
port: parsed.dev?.port ?? DEFAULTS.dev.port,
|
|
58
|
+
},
|
|
59
|
+
dependencies: parsed.dependencies || {},
|
|
60
|
+
npm: {},
|
|
61
|
+
_source: source,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// Collect npm deps: top-level from [npm], dev deps from [npm.dev]
|
|
65
|
+
if (parsed.npm) {
|
|
66
|
+
for (const [key, value] of Object.entries(parsed.npm)) {
|
|
67
|
+
if (key === 'dev' && typeof value === 'object' && !Array.isArray(value)) {
|
|
68
|
+
config.npm.dev = value;
|
|
69
|
+
} else if (typeof value === 'string') {
|
|
70
|
+
if (!config.npm.prod) config.npm.prod = {};
|
|
71
|
+
config.npm.prod[key] = value;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return config;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function configFromPackageJson(pkg) {
|
|
80
|
+
return {
|
|
81
|
+
project: {
|
|
82
|
+
name: pkg.name || DEFAULTS.project.name,
|
|
83
|
+
version: pkg.version || DEFAULTS.project.version,
|
|
84
|
+
description: pkg.description || DEFAULTS.project.description,
|
|
85
|
+
entry: DEFAULTS.project.entry,
|
|
86
|
+
},
|
|
87
|
+
build: {
|
|
88
|
+
output: DEFAULTS.build.output,
|
|
89
|
+
},
|
|
90
|
+
dev: {
|
|
91
|
+
port: DEFAULTS.dev.port,
|
|
92
|
+
},
|
|
93
|
+
dependencies: {},
|
|
94
|
+
npm: {
|
|
95
|
+
prod: pkg.dependencies || {},
|
|
96
|
+
dev: pkg.devDependencies || {},
|
|
97
|
+
},
|
|
98
|
+
_source: 'package.json',
|
|
99
|
+
};
|
|
100
|
+
}
|