@basaltkit/ai 0.10.0 → 1.0.0
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/README.md +4 -3
- package/dist/analyze/run.d.ts +19 -0
- package/dist/analyze/run.js +58 -0
- package/dist/commands.d.ts +19 -0
- package/dist/commands.js +272 -0
- package/dist/context/project.d.ts +78 -0
- package/dist/context/project.js +202 -0
- package/dist/doctor/fixes.d.ts +31 -0
- package/dist/doctor/fixes.js +82 -0
- package/dist/doctor/rules.d.ts +7 -0
- package/dist/doctor/rules.js +230 -0
- package/dist/doctor/run.d.ts +9 -0
- package/dist/doctor/run.js +16 -0
- package/dist/doctor/types.d.ts +27 -0
- package/dist/doctor/types.js +3 -0
- package/dist/index.d.ts +35 -791
- package/dist/index.js +40 -2355
- package/dist/make/fields.d.ts +35 -0
- package/dist/make/fields.js +139 -0
- package/dist/make/make.d.ts +14 -0
- package/dist/make/make.js +316 -0
- package/dist/make/openapi.d.ts +9 -0
- package/dist/make/openapi.js +36 -0
- package/dist/make/permissions.d.ts +13 -0
- package/dist/make/permissions.js +38 -0
- package/dist/make/relations.d.ts +23 -0
- package/dist/make/relations.js +62 -0
- package/dist/make/render.d.ts +4 -0
- package/dist/make/render.js +66 -0
- package/dist/make/repository.d.ts +16 -0
- package/dist/make/repository.js +176 -0
- package/dist/make/schema.d.ts +22 -0
- package/dist/make/schema.js +54 -0
- package/dist/make/types.d.ts +77 -0
- package/dist/make/types.js +1 -0
- package/dist/make/verify.d.ts +12 -0
- package/dist/make/verify.js +19 -0
- package/dist/make/wire.d.ts +23 -0
- package/dist/make/wire.js +76 -0
- package/dist/plan/context.d.ts +8 -0
- package/dist/plan/context.js +39 -0
- package/dist/plan/knowledge.d.ts +9 -0
- package/dist/plan/knowledge.js +50 -0
- package/dist/plan/plan.d.ts +15 -0
- package/dist/plan/plan.js +146 -0
- package/dist/plan/render.d.ts +4 -0
- package/dist/plan/render.js +55 -0
- package/dist/plan/types.d.ts +45 -0
- package/dist/plan/types.js +1 -0
- package/dist/provider/anthropic.d.ts +26 -0
- package/dist/provider/anthropic.js +72 -0
- package/dist/provider/factory.d.ts +27 -0
- package/dist/provider/factory.js +58 -0
- package/dist/provider/http.d.ts +24 -0
- package/dist/provider/http.js +36 -0
- package/dist/provider/ollama.d.ts +22 -0
- package/dist/provider/ollama.js +42 -0
- package/dist/provider/openai.d.ts +48 -0
- package/dist/provider/openai.js +94 -0
- package/dist/provider/sse.d.ts +28 -0
- package/dist/provider/sse.js +57 -0
- package/dist/provider/types.d.ts +49 -0
- package/dist/provider/types.js +9 -0
- package/dist/render.d.ts +10 -0
- package/dist/render.js +70 -0
- package/dist/review/knowledge.d.ts +6 -0
- package/dist/review/knowledge.js +25 -0
- package/dist/review/render.d.ts +4 -0
- package/dist/review/render.js +12 -0
- package/dist/review/review.d.ts +17 -0
- package/dist/review/review.js +80 -0
- package/dist/review/types.d.ts +12 -0
- package/dist/review/types.js +1 -0
- package/package.json +10 -11
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Architect agent's system prompt — Basalt's conventions and official APIs,
|
|
3
|
+
* encoded so the model plans *with* the framework instead of inventing its own
|
|
4
|
+
* architecture (spec §8 Framework Knowledge, §22 "prefer official APIs").
|
|
5
|
+
*
|
|
6
|
+
* This is deliberately prescriptive: the plan must reuse the generator and the
|
|
7
|
+
* platform plugins, never hand-roll parallel abstractions.
|
|
8
|
+
*/
|
|
9
|
+
export declare const BASALT_KNOWLEDGE = "You are the Architect agent for Basalt \u2014 a batteries-included SaaS framework for Node.js/TypeScript (Fastify + Prisma + Zod). You turn a developer's natural-language request into a precise, convention-following implementation PLAN. You PLAN only; you never write code here.\n\nABSOLUTE RULES \u2014 always use the framework's official APIs and generators, never invent new ones:\n\n1. SCAFFOLDING. Create a resource with the generator, not by hand:\n `basalt make:resource <Name>` \u2014 generates a full vertical: Zod schema, repository, service, DI plugin, typed routes and a test, all on-convention and auto-wired into src/app.ts.\n Flags: --prisma (Prisma-backed repository + a schema.prisma model with id/createdAt/updatedAt), --soft-delete (adds deletedAt, a restore() method + route), --no-register (skip wiring), --dir=<path>, --force.\n\n2. PERSISTENCE. Prisma via prismaPlugin. Models live in schema.prisma. After editing the schema, run `basalt prisma:sync` to generate the client + migration. Use --prisma so the generator emits the model for you.\n\n3. MULTI-TENANCY. When tenancy is enabled, every tenant-owned model MUST carry a `tenantId` column, and the tenancy layer scopes queries by the current tenant. NEVER plan a query that ignores tenant isolation. Platform-global tables (users, tenants, plans) are the only exceptions.\n\n4. RBAC / PERMISSIONS. For each resource, register `<resource>.view`, `<resource>.create`, `<resource>.update`, `<resource>.delete` and guard the matching routes with the permission check. Use the resource name in plural, lowercase (e.g. `patients.create`).\n\n5. AUDIT. Emit an audit event for each state change: `<resource>.created`, `<resource>.updated`, `<resource>.deleted` (singular resource).\n\n6. VALIDATION + DOCS. Every route carries a Zod schema; OpenAPI is generated from it automatically \u2014 no separate doc wiring.\n\n7. TESTS. Every resource ships a test (the generator creates one; extend it for domain rules).\n\nGROUNDING: Reuse what already exists in the project. Avoid name collisions with existing models. Only add what is missing. Prefer editing the schema + running the generator over hand-writing files.\n\nOUTPUT: Return ONLY a single JSON object (no prose, no markdown fences) matching exactly:\n{\n \"summary\": string, // 1-2 sentences on the approach\n \"entities\": [ // domain entities to create\n { \"name\": string, \"fields\": [{ \"name\": string, \"type\": string, \"enum\"?: string[] }], \"tenantScoped\": boolean,\n \"relations\": [{ \"name\": string, \"model\": string }] } // belongs-to: this model gets a <name>Id FK \u2192 <model>\n ],\n \"steps\": [ // ordered implementation steps\n { \"order\": number, \"title\": string, \"kind\": \"generator\"|\"schema\"|\"migration\"|\"service\"|\"routes\"|\"permissions\"|\"audit\"|\"test\"|\"docs\"|\"other\", \"detail\": string, \"command\": string, \"files\": string[] }\n ],\n \"permissions\": string[], // e.g. [\"patients.view\",\"patients.create\",...]\n \"auditEvents\": string[], // e.g. [\"patient.created\",...]\n \"tenantScoped\": boolean, // does this feature involve tenant-owned data\n \"warnings\": string[] // risks, decisions to confirm, missing info\n}\nFor generator steps, put the exact command in \"command\" (e.g. \"basalt make:resource Patient --prisma --soft-delete\"). Omit \"command\"/\"files\" when not applicable.\n\nENUMS: a field with a fixed set of values (a status/state) MUST set \"enum\" to those values \u2014 e.g. \"estado (pago/pendente)\" \u2192 { \"name\": \"estado\", \"type\": \"String\", \"enum\": [\"pago\", \"pendente\"] }. It becomes a validated z.enum([...]) (stored as a String column), never a free z.string().\n\nRELATIONS: express a belongs-to as an entry in \"relations\" (e.g. an Appointment belongs to a Patient \u2192 { \"name\": \"patient\", \"model\": \"Patient\" }). Do NOT also add the \"<name>Id\" field to \"fields\" \u2014 the FK column, the @relation and the inverse field are generated. Prefer generating both sides of a relation in the same plan.";
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Architect agent's system prompt — Basalt's conventions and official APIs,
|
|
3
|
+
* encoded so the model plans *with* the framework instead of inventing its own
|
|
4
|
+
* architecture (spec §8 Framework Knowledge, §22 "prefer official APIs").
|
|
5
|
+
*
|
|
6
|
+
* This is deliberately prescriptive: the plan must reuse the generator and the
|
|
7
|
+
* platform plugins, never hand-roll parallel abstractions.
|
|
8
|
+
*/
|
|
9
|
+
export const BASALT_KNOWLEDGE = `You are the Architect agent for Basalt — a batteries-included SaaS framework for Node.js/TypeScript (Fastify + Prisma + Zod). You turn a developer's natural-language request into a precise, convention-following implementation PLAN. You PLAN only; you never write code here.
|
|
10
|
+
|
|
11
|
+
ABSOLUTE RULES — always use the framework's official APIs and generators, never invent new ones:
|
|
12
|
+
|
|
13
|
+
1. SCAFFOLDING. Create a resource with the generator, not by hand:
|
|
14
|
+
\`basalt make:resource <Name>\` — generates a full vertical: Zod schema, repository, service, DI plugin, typed routes and a test, all on-convention and auto-wired into src/app.ts.
|
|
15
|
+
Flags: --prisma (Prisma-backed repository + a schema.prisma model with id/createdAt/updatedAt), --soft-delete (adds deletedAt, a restore() method + route), --no-register (skip wiring), --dir=<path>, --force.
|
|
16
|
+
|
|
17
|
+
2. PERSISTENCE. Prisma via prismaPlugin. Models live in schema.prisma. After editing the schema, run \`basalt prisma:sync\` to generate the client + migration. Use --prisma so the generator emits the model for you.
|
|
18
|
+
|
|
19
|
+
3. MULTI-TENANCY. When tenancy is enabled, every tenant-owned model MUST carry a \`tenantId\` column, and the tenancy layer scopes queries by the current tenant. NEVER plan a query that ignores tenant isolation. Platform-global tables (users, tenants, plans) are the only exceptions.
|
|
20
|
+
|
|
21
|
+
4. RBAC / PERMISSIONS. For each resource, register \`<resource>.view\`, \`<resource>.create\`, \`<resource>.update\`, \`<resource>.delete\` and guard the matching routes with the permission check. Use the resource name in plural, lowercase (e.g. \`patients.create\`).
|
|
22
|
+
|
|
23
|
+
5. AUDIT. Emit an audit event for each state change: \`<resource>.created\`, \`<resource>.updated\`, \`<resource>.deleted\` (singular resource).
|
|
24
|
+
|
|
25
|
+
6. VALIDATION + DOCS. Every route carries a Zod schema; OpenAPI is generated from it automatically — no separate doc wiring.
|
|
26
|
+
|
|
27
|
+
7. TESTS. Every resource ships a test (the generator creates one; extend it for domain rules).
|
|
28
|
+
|
|
29
|
+
GROUNDING: Reuse what already exists in the project. Avoid name collisions with existing models. Only add what is missing. Prefer editing the schema + running the generator over hand-writing files.
|
|
30
|
+
|
|
31
|
+
OUTPUT: Return ONLY a single JSON object (no prose, no markdown fences) matching exactly:
|
|
32
|
+
{
|
|
33
|
+
"summary": string, // 1-2 sentences on the approach
|
|
34
|
+
"entities": [ // domain entities to create
|
|
35
|
+
{ "name": string, "fields": [{ "name": string, "type": string, "enum"?: string[] }], "tenantScoped": boolean,
|
|
36
|
+
"relations": [{ "name": string, "model": string }] } // belongs-to: this model gets a <name>Id FK → <model>
|
|
37
|
+
],
|
|
38
|
+
"steps": [ // ordered implementation steps
|
|
39
|
+
{ "order": number, "title": string, "kind": "generator"|"schema"|"migration"|"service"|"routes"|"permissions"|"audit"|"test"|"docs"|"other", "detail": string, "command": string, "files": string[] }
|
|
40
|
+
],
|
|
41
|
+
"permissions": string[], // e.g. ["patients.view","patients.create",...]
|
|
42
|
+
"auditEvents": string[], // e.g. ["patient.created",...]
|
|
43
|
+
"tenantScoped": boolean, // does this feature involve tenant-owned data
|
|
44
|
+
"warnings": string[] // risks, decisions to confirm, missing info
|
|
45
|
+
}
|
|
46
|
+
For generator steps, put the exact command in "command" (e.g. "basalt make:resource Patient --prisma --soft-delete"). Omit "command"/"files" when not applicable.
|
|
47
|
+
|
|
48
|
+
ENUMS: a field with a fixed set of values (a status/state) MUST set "enum" to those values — e.g. "estado (pago/pendente)" → { "name": "estado", "type": "String", "enum": ["pago", "pendente"] }. It becomes a validated z.enum([...]) (stored as a String column), never a free z.string().
|
|
49
|
+
|
|
50
|
+
RELATIONS: express a belongs-to as an entry in "relations" (e.g. an Appointment belongs to a Patient → { "name": "patient", "model": "Patient" }). Do NOT also add the "<name>Id" field to "fields" — the FK column, the @relation and the inverse field are generated. Prefer generating both sides of a relation in the same plan.`;
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ProjectContext } from '../context/project.js';
|
|
2
|
+
import type { AIProvider } from '../provider/types.js';
|
|
3
|
+
import type { ArchitecturePlan } from './types.js';
|
|
4
|
+
export interface CreatePlanOptions {
|
|
5
|
+
temperature?: number;
|
|
6
|
+
maxTokens?: number;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Turn a natural-language request into a grounded {@link ArchitecturePlan}. The
|
|
10
|
+
* project context keeps the plan on-convention and collision-free. Read-only —
|
|
11
|
+
* it produces a plan, it changes nothing.
|
|
12
|
+
*/
|
|
13
|
+
export declare function createPlan(provider: AIProvider, ctx: ProjectContext, request: string, options?: CreatePlanOptions): Promise<ArchitecturePlan>;
|
|
14
|
+
/** Parse + normalize the model's JSON into a validated plan. Tolerant of fences and missing arrays. */
|
|
15
|
+
export declare function parsePlan(raw: string, request: string): ArchitecturePlan;
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { buildPlanContext } from './context.js';
|
|
2
|
+
import { BASALT_KNOWLEDGE } from './knowledge.js';
|
|
3
|
+
const STEP_KINDS = [
|
|
4
|
+
'generator',
|
|
5
|
+
'schema',
|
|
6
|
+
'migration',
|
|
7
|
+
'service',
|
|
8
|
+
'routes',
|
|
9
|
+
'permissions',
|
|
10
|
+
'audit',
|
|
11
|
+
'test',
|
|
12
|
+
'docs',
|
|
13
|
+
'other',
|
|
14
|
+
];
|
|
15
|
+
/**
|
|
16
|
+
* Turn a natural-language request into a grounded {@link ArchitecturePlan}. The
|
|
17
|
+
* project context keeps the plan on-convention and collision-free. Read-only —
|
|
18
|
+
* it produces a plan, it changes nothing.
|
|
19
|
+
*/
|
|
20
|
+
export async function createPlan(provider, ctx, request, options = {}) {
|
|
21
|
+
const raw = await provider.generate({
|
|
22
|
+
messages: [
|
|
23
|
+
{ role: 'system', content: BASALT_KNOWLEDGE },
|
|
24
|
+
{
|
|
25
|
+
role: 'user',
|
|
26
|
+
content: `REQUEST:\n${request}\n\n${buildPlanContext(ctx)}\n\nReturn the plan as a single JSON object only.`,
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
temperature: options.temperature ?? 0,
|
|
30
|
+
maxTokens: options.maxTokens ?? 4096,
|
|
31
|
+
});
|
|
32
|
+
return parsePlan(raw, request);
|
|
33
|
+
}
|
|
34
|
+
/** Parse + normalize the model's JSON into a validated plan. Tolerant of fences and missing arrays. */
|
|
35
|
+
export function parsePlan(raw, request) {
|
|
36
|
+
const json = extractJson(raw);
|
|
37
|
+
let obj;
|
|
38
|
+
try {
|
|
39
|
+
obj = JSON.parse(json);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
throw new Error(`ai:plan — the model did not return valid JSON. Got: ${raw.slice(0, 160)}…`);
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
request,
|
|
46
|
+
summary: typeof obj['summary'] === 'string' ? obj['summary'] : '',
|
|
47
|
+
entities: normalizeEntities(obj['entities']),
|
|
48
|
+
steps: normalizeSteps(obj['steps']),
|
|
49
|
+
permissions: stringArray(obj['permissions']),
|
|
50
|
+
auditEvents: stringArray(obj['auditEvents']),
|
|
51
|
+
tenantScoped: obj['tenantScoped'] === true,
|
|
52
|
+
warnings: stringArray(obj['warnings']),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/** Strip markdown fences and isolate the outermost JSON object. */
|
|
56
|
+
function extractJson(raw) {
|
|
57
|
+
const withoutFence = raw.replace(/```(?:json)?/gi, '').trim();
|
|
58
|
+
const start = withoutFence.indexOf('{');
|
|
59
|
+
const end = withoutFence.lastIndexOf('}');
|
|
60
|
+
if (start === -1 || end === -1 || end < start)
|
|
61
|
+
return withoutFence;
|
|
62
|
+
return withoutFence.slice(start, end + 1);
|
|
63
|
+
}
|
|
64
|
+
function stringArray(value) {
|
|
65
|
+
return Array.isArray(value) ? value.filter((v) => typeof v === 'string') : [];
|
|
66
|
+
}
|
|
67
|
+
function normalizeEntities(value) {
|
|
68
|
+
if (!Array.isArray(value))
|
|
69
|
+
return [];
|
|
70
|
+
return value
|
|
71
|
+
.filter((e) => typeof e === 'object' && e !== null)
|
|
72
|
+
.map((e) => {
|
|
73
|
+
const relations = normalizeRelations(e['relations']);
|
|
74
|
+
const entity = {
|
|
75
|
+
name: typeof e['name'] === 'string' ? e['name'] : 'Unnamed',
|
|
76
|
+
fields: normalizeFields(e['fields']),
|
|
77
|
+
tenantScoped: e['tenantScoped'] === true,
|
|
78
|
+
...(relations.length > 0 ? { relations } : {}),
|
|
79
|
+
};
|
|
80
|
+
return entity;
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
const lowerFirst = (s) => s.charAt(0).toLowerCase() + s.slice(1);
|
|
84
|
+
const upperFirst = (s) => s.charAt(0).toUpperCase() + s.slice(1);
|
|
85
|
+
/** Accept relations as bare model names (`"Paciente"`) or objects (`{ name, model }`). */
|
|
86
|
+
function normalizeRelations(value) {
|
|
87
|
+
if (!Array.isArray(value))
|
|
88
|
+
return [];
|
|
89
|
+
const out = [];
|
|
90
|
+
for (const r of value) {
|
|
91
|
+
if (typeof r === 'string' && r.trim() !== '') {
|
|
92
|
+
const model = r.trim();
|
|
93
|
+
out.push({ name: lowerFirst(model), model });
|
|
94
|
+
}
|
|
95
|
+
else if (typeof r === 'object' && r !== null) {
|
|
96
|
+
const obj = r;
|
|
97
|
+
const rawModel = typeof obj['model'] === 'string' ? obj['model'] : '';
|
|
98
|
+
const rawName = typeof obj['name'] === 'string' ? obj['name'] : '';
|
|
99
|
+
const model = rawModel !== '' ? rawModel : rawName !== '' ? upperFirst(rawName) : '';
|
|
100
|
+
if (model === '')
|
|
101
|
+
continue;
|
|
102
|
+
out.push({ name: rawName !== '' ? rawName : lowerFirst(model), model });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
function normalizeFields(value) {
|
|
108
|
+
if (!Array.isArray(value))
|
|
109
|
+
return [];
|
|
110
|
+
return value
|
|
111
|
+
.filter((f) => typeof f === 'object' && f !== null)
|
|
112
|
+
.map((f) => {
|
|
113
|
+
const values = Array.isArray(f['enum']) ? f['enum'].filter((v) => typeof v === 'string') : [];
|
|
114
|
+
return {
|
|
115
|
+
name: typeof f['name'] === 'string' ? f['name'] : '',
|
|
116
|
+
type: typeof f['type'] === 'string' ? f['type'] : 'String',
|
|
117
|
+
...(values.length > 0 ? { enum: values } : {}),
|
|
118
|
+
};
|
|
119
|
+
})
|
|
120
|
+
.filter((f) => f.name !== '');
|
|
121
|
+
}
|
|
122
|
+
function normalizeSteps(value) {
|
|
123
|
+
if (!Array.isArray(value))
|
|
124
|
+
return [];
|
|
125
|
+
return value
|
|
126
|
+
.filter((s) => typeof s === 'object' && s !== null)
|
|
127
|
+
.map((s, index) => {
|
|
128
|
+
const command = typeof s['command'] === 'string' ? s['command'] : undefined;
|
|
129
|
+
const files = stringArray(s['files']);
|
|
130
|
+
const step = {
|
|
131
|
+
order: typeof s['order'] === 'number' ? s['order'] : index + 1,
|
|
132
|
+
title: typeof s['title'] === 'string' ? s['title'] : `Step ${index + 1}`,
|
|
133
|
+
kind: normalizeKind(s['kind']),
|
|
134
|
+
detail: typeof s['detail'] === 'string' ? s['detail'] : '',
|
|
135
|
+
...(command ? { command } : {}),
|
|
136
|
+
...(files.length > 0 ? { files } : {}),
|
|
137
|
+
};
|
|
138
|
+
return step;
|
|
139
|
+
})
|
|
140
|
+
.sort((a, b) => a.order - b.order);
|
|
141
|
+
}
|
|
142
|
+
function normalizeKind(value) {
|
|
143
|
+
return typeof value === 'string' && STEP_KINDS.includes(value)
|
|
144
|
+
? value
|
|
145
|
+
: 'other';
|
|
146
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/** Render an {@link ArchitecturePlan} in the spec's `ARCHITECTURE PLAN` style. */
|
|
2
|
+
export function renderPlan(plan, io) {
|
|
3
|
+
io.log('ARCHITECTURE PLAN');
|
|
4
|
+
io.log('');
|
|
5
|
+
io.log(`Request: "${plan.request}"`);
|
|
6
|
+
if (plan.summary) {
|
|
7
|
+
io.log('');
|
|
8
|
+
io.log(plan.summary);
|
|
9
|
+
}
|
|
10
|
+
if (plan.entities.length > 0) {
|
|
11
|
+
io.log('');
|
|
12
|
+
io.log('Entities:');
|
|
13
|
+
for (const entity of plan.entities) {
|
|
14
|
+
const scope = entity.tenantScoped ? ' (tenant-scoped)' : '';
|
|
15
|
+
const fields = entity.fields.map((f) => `${f.name}: ${f.type}`).join(', ');
|
|
16
|
+
io.log(` • ${entity.name}${scope}${fields ? ` — ${fields}` : ''}`);
|
|
17
|
+
if (entity.relations && entity.relations.length > 0) {
|
|
18
|
+
io.log(` relations: ${entity.relations.map((r) => `${r.name} → ${r.model}`).join(', ')}`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
io.log('');
|
|
23
|
+
io.log('Steps:');
|
|
24
|
+
if (plan.steps.length === 0) {
|
|
25
|
+
io.log(' (none proposed)');
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
for (const step of plan.steps) {
|
|
29
|
+
const n = String(step.order).padStart(2, ' ');
|
|
30
|
+
io.log(` ${n}. [${step.kind}] ${step.title}`);
|
|
31
|
+
if (step.detail)
|
|
32
|
+
io.log(` ${step.detail}`);
|
|
33
|
+
if (step.command)
|
|
34
|
+
io.log(` $ ${step.command}`);
|
|
35
|
+
if (step.files && step.files.length > 0)
|
|
36
|
+
io.log(` files: ${step.files.join(', ')}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (plan.permissions.length > 0) {
|
|
40
|
+
io.log('');
|
|
41
|
+
io.log(`Permissions: ${plan.permissions.join(', ')}`);
|
|
42
|
+
}
|
|
43
|
+
if (plan.auditEvents.length > 0) {
|
|
44
|
+
io.log(`Audit events: ${plan.auditEvents.join(', ')}`);
|
|
45
|
+
}
|
|
46
|
+
if (plan.warnings.length > 0) {
|
|
47
|
+
io.log('');
|
|
48
|
+
io.log('⚠ Warnings:');
|
|
49
|
+
for (const warning of plan.warnings)
|
|
50
|
+
io.log(` - ${warning}`);
|
|
51
|
+
}
|
|
52
|
+
io.log('');
|
|
53
|
+
io.log('This is a plan only — nothing was changed.');
|
|
54
|
+
io.log('Next: `basalt ai:make` will execute it (arrives in the next phase).');
|
|
55
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export type PlanStepKind = 'generator' | 'schema' | 'migration' | 'service' | 'routes' | 'permissions' | 'audit' | 'test' | 'docs' | 'other';
|
|
2
|
+
export interface PlanField {
|
|
3
|
+
name: string;
|
|
4
|
+
type: string;
|
|
5
|
+
/**
|
|
6
|
+
* Allowed values — makes this a `z.enum([...])` field (validated on input,
|
|
7
|
+
* stored as a String column). E.g. a status: `['pago', 'pendente']`.
|
|
8
|
+
*/
|
|
9
|
+
enum?: string[];
|
|
10
|
+
}
|
|
11
|
+
/** A belongs-to relation: this entity holds a `<name>Id` FK referencing `model`. */
|
|
12
|
+
export interface PlanRelation {
|
|
13
|
+
/** Relation field name on this model, e.g. `paciente`. */
|
|
14
|
+
name: string;
|
|
15
|
+
/** Related model, e.g. `Paciente`. */
|
|
16
|
+
model: string;
|
|
17
|
+
}
|
|
18
|
+
export interface PlanEntity {
|
|
19
|
+
name: string;
|
|
20
|
+
fields: PlanField[];
|
|
21
|
+
tenantScoped: boolean;
|
|
22
|
+
relations?: PlanRelation[];
|
|
23
|
+
}
|
|
24
|
+
export interface PlanStep {
|
|
25
|
+
order: number;
|
|
26
|
+
title: string;
|
|
27
|
+
kind: PlanStepKind;
|
|
28
|
+
detail: string;
|
|
29
|
+
/** Exact CLI command to run (mainly for `kind: 'generator'`). */
|
|
30
|
+
command?: string;
|
|
31
|
+
/** Files the step creates or edits. */
|
|
32
|
+
files?: string[];
|
|
33
|
+
}
|
|
34
|
+
export interface ArchitecturePlan {
|
|
35
|
+
request: string;
|
|
36
|
+
summary: string;
|
|
37
|
+
entities: PlanEntity[];
|
|
38
|
+
steps: PlanStep[];
|
|
39
|
+
/** RBAC permissions to register, e.g. `patients.create`. */
|
|
40
|
+
permissions: string[];
|
|
41
|
+
/** Audit events to emit, e.g. `patient.created`. */
|
|
42
|
+
auditEvents: string[];
|
|
43
|
+
tenantScoped: boolean;
|
|
44
|
+
warnings: string[];
|
|
45
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type AIProvider, type FetchLike, type GenerateOptions } from './types.js';
|
|
2
|
+
export interface AnthropicProviderOptions {
|
|
3
|
+
/** API key → `x-api-key` header. Required. */
|
|
4
|
+
apiKey: string;
|
|
5
|
+
/** Model id. Defaults to a current Claude model. */
|
|
6
|
+
model?: string;
|
|
7
|
+
/** API base URL. Defaults to the public Anthropic API. */
|
|
8
|
+
baseUrl?: string;
|
|
9
|
+
/** Injected fetch. Defaults to the global `fetch`. */
|
|
10
|
+
fetch?: FetchLike;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Anthropic Messages API provider — REST directly, no SDK (same approach as the
|
|
14
|
+
* Elasticsearch driver). System messages are hoisted into the top-level `system`
|
|
15
|
+
* field, as the API requires.
|
|
16
|
+
*/
|
|
17
|
+
export declare class AnthropicProvider implements AIProvider {
|
|
18
|
+
readonly name = "anthropic";
|
|
19
|
+
readonly model: string;
|
|
20
|
+
private readonly apiKey;
|
|
21
|
+
private readonly baseUrl;
|
|
22
|
+
private readonly fetchImpl;
|
|
23
|
+
constructor(options: AnthropicProviderOptions);
|
|
24
|
+
generate(options: GenerateOptions): Promise<string>;
|
|
25
|
+
stream(options: GenerateOptions): AsyncIterable<string>;
|
|
26
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { fetchWithRetry } from './http.js';
|
|
2
|
+
import { singleChunkStream, } from './types.js';
|
|
3
|
+
const DEFAULT_MODEL = 'claude-sonnet-5';
|
|
4
|
+
const API_VERSION = '2023-06-01';
|
|
5
|
+
/**
|
|
6
|
+
* Anthropic Messages API provider — REST directly, no SDK (same approach as the
|
|
7
|
+
* Elasticsearch driver). System messages are hoisted into the top-level `system`
|
|
8
|
+
* field, as the API requires.
|
|
9
|
+
*/
|
|
10
|
+
export class AnthropicProvider {
|
|
11
|
+
name = 'anthropic';
|
|
12
|
+
model;
|
|
13
|
+
apiKey;
|
|
14
|
+
baseUrl;
|
|
15
|
+
fetchImpl;
|
|
16
|
+
constructor(options) {
|
|
17
|
+
if (!options.apiKey) {
|
|
18
|
+
throw new Error('AnthropicProvider: apiKey is required (set AI_API_KEY)');
|
|
19
|
+
}
|
|
20
|
+
this.apiKey = options.apiKey;
|
|
21
|
+
this.model = options.model ?? DEFAULT_MODEL;
|
|
22
|
+
this.baseUrl = (options.baseUrl ?? 'https://api.anthropic.com').replace(/\/$/, '');
|
|
23
|
+
const f = options.fetch ?? globalThis.fetch;
|
|
24
|
+
if (!f)
|
|
25
|
+
throw new Error('AnthropicProvider: no fetch available — pass options.fetch');
|
|
26
|
+
this.fetchImpl = f;
|
|
27
|
+
}
|
|
28
|
+
async generate(options) {
|
|
29
|
+
const system = options.messages
|
|
30
|
+
.filter((m) => m.role === 'system')
|
|
31
|
+
.map((m) => m.content)
|
|
32
|
+
.join('\n\n');
|
|
33
|
+
const messages = options.messages
|
|
34
|
+
.filter((m) => m.role !== 'system')
|
|
35
|
+
.map((m) => ({ role: m.role, content: m.content }));
|
|
36
|
+
const { ok, status, body } = await fetchWithRetry(this.fetchImpl, this.baseUrl + '/v1/messages', {
|
|
37
|
+
method: 'POST',
|
|
38
|
+
headers: {
|
|
39
|
+
'content-type': 'application/json',
|
|
40
|
+
'x-api-key': this.apiKey,
|
|
41
|
+
'anthropic-version': API_VERSION,
|
|
42
|
+
},
|
|
43
|
+
body: JSON.stringify({
|
|
44
|
+
model: this.model,
|
|
45
|
+
max_tokens: options.maxTokens ?? 4096,
|
|
46
|
+
...(options.temperature !== undefined ? { temperature: options.temperature } : {}),
|
|
47
|
+
...(system ? { system } : {}),
|
|
48
|
+
messages,
|
|
49
|
+
}),
|
|
50
|
+
});
|
|
51
|
+
if (!ok) {
|
|
52
|
+
const detail = safeError(body);
|
|
53
|
+
throw new Error(`AnthropicProvider: ${status}${detail ? ` — ${detail}` : ''}`);
|
|
54
|
+
}
|
|
55
|
+
const json = JSON.parse(body);
|
|
56
|
+
return (json.content ?? [])
|
|
57
|
+
.filter((block) => block.type === 'text')
|
|
58
|
+
.map((block) => block.text ?? '')
|
|
59
|
+
.join('');
|
|
60
|
+
}
|
|
61
|
+
stream(options) {
|
|
62
|
+
return singleChunkStream(() => this.generate(options));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function safeError(text) {
|
|
66
|
+
try {
|
|
67
|
+
return JSON.parse(text).error?.message ?? text.slice(0, 200);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return text.slice(0, 200);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { AIProvider, FetchLike } from './types.js';
|
|
2
|
+
/** Providers wired in 0.1. `openai` covers any OpenAI-compatible gateway; `google` is stubbed. */
|
|
3
|
+
export type ProviderName = 'anthropic' | 'ollama' | 'openai' | 'openai-compatible' | 'google';
|
|
4
|
+
/** The subset of environment the provider layer reads. */
|
|
5
|
+
export interface ProviderEnv {
|
|
6
|
+
AI_PROVIDER?: string;
|
|
7
|
+
AI_MODEL?: string;
|
|
8
|
+
AI_API_KEY?: string;
|
|
9
|
+
AI_BASE_URL?: string;
|
|
10
|
+
/** `'false'` disables SSE streaming for the OpenAI-compatible provider. */
|
|
11
|
+
AI_STREAM?: string;
|
|
12
|
+
}
|
|
13
|
+
export interface CreateProviderOptions {
|
|
14
|
+
/** Override the injected fetch (tests). */
|
|
15
|
+
fetch?: FetchLike;
|
|
16
|
+
}
|
|
17
|
+
/** Read the provider-relevant vars off `process.env` into a typed {@link ProviderEnv}. */
|
|
18
|
+
export declare function providerEnvFromProcess(): ProviderEnv;
|
|
19
|
+
/**
|
|
20
|
+
* Build an {@link AIProvider} from environment. `AI_PROVIDER` selects the vendor
|
|
21
|
+
* (default `anthropic`); `AI_MODEL`, `AI_API_KEY`, `AI_BASE_URL` tune it.
|
|
22
|
+
*
|
|
23
|
+
* The read-only `ai:analyze` / `ai:doctor` commands never call this — their rules
|
|
24
|
+
* are deterministic and run offline. A provider is only needed for the optional
|
|
25
|
+
* `--explain` narrative and, later, for `ai:plan` / `ai:make`.
|
|
26
|
+
*/
|
|
27
|
+
export declare function createProvider(env?: ProviderEnv, options?: CreateProviderOptions): AIProvider;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { AnthropicProvider } from './anthropic.js';
|
|
2
|
+
import { OllamaProvider } from './ollama.js';
|
|
3
|
+
import { OpenAICompatibleProvider } from './openai.js';
|
|
4
|
+
/** Read the provider-relevant vars off `process.env` into a typed {@link ProviderEnv}. */
|
|
5
|
+
export function providerEnvFromProcess() {
|
|
6
|
+
const e = process.env;
|
|
7
|
+
return {
|
|
8
|
+
...(e.AI_PROVIDER !== undefined ? { AI_PROVIDER: e.AI_PROVIDER } : {}),
|
|
9
|
+
...(e.AI_MODEL !== undefined ? { AI_MODEL: e.AI_MODEL } : {}),
|
|
10
|
+
...(e.AI_API_KEY !== undefined ? { AI_API_KEY: e.AI_API_KEY } : {}),
|
|
11
|
+
...(e.AI_BASE_URL !== undefined ? { AI_BASE_URL: e.AI_BASE_URL } : {}),
|
|
12
|
+
...(e.AI_STREAM !== undefined ? { AI_STREAM: e.AI_STREAM } : {}),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Build an {@link AIProvider} from environment. `AI_PROVIDER` selects the vendor
|
|
17
|
+
* (default `anthropic`); `AI_MODEL`, `AI_API_KEY`, `AI_BASE_URL` tune it.
|
|
18
|
+
*
|
|
19
|
+
* The read-only `ai:analyze` / `ai:doctor` commands never call this — their rules
|
|
20
|
+
* are deterministic and run offline. A provider is only needed for the optional
|
|
21
|
+
* `--explain` narrative and, later, for `ai:plan` / `ai:make`.
|
|
22
|
+
*/
|
|
23
|
+
export function createProvider(env = {}, options = {}) {
|
|
24
|
+
const name = (env.AI_PROVIDER ?? 'anthropic').toLowerCase();
|
|
25
|
+
const fetchOpt = options.fetch ? { fetch: options.fetch } : {};
|
|
26
|
+
switch (name) {
|
|
27
|
+
case 'anthropic':
|
|
28
|
+
return new AnthropicProvider({
|
|
29
|
+
apiKey: env.AI_API_KEY ?? '',
|
|
30
|
+
...(env.AI_MODEL ? { model: env.AI_MODEL } : {}),
|
|
31
|
+
...(env.AI_BASE_URL ? { baseUrl: env.AI_BASE_URL } : {}),
|
|
32
|
+
...fetchOpt,
|
|
33
|
+
});
|
|
34
|
+
case 'ollama':
|
|
35
|
+
return new OllamaProvider({
|
|
36
|
+
...(env.AI_MODEL ? { model: env.AI_MODEL } : {}),
|
|
37
|
+
...(env.AI_BASE_URL ? { baseUrl: env.AI_BASE_URL } : {}),
|
|
38
|
+
...fetchOpt,
|
|
39
|
+
});
|
|
40
|
+
case 'openai':
|
|
41
|
+
case 'openai-compatible':
|
|
42
|
+
// Any OpenAI Chat Completions endpoint — OpenAI, LiteLLM, OpenRouter,
|
|
43
|
+
// go4ai, etc. Point AI_BASE_URL at the gateway's `/v1`. Streams by default
|
|
44
|
+
// (AI_STREAM=false to disable).
|
|
45
|
+
return new OpenAICompatibleProvider({
|
|
46
|
+
apiKey: env.AI_API_KEY ?? '',
|
|
47
|
+
...(env.AI_MODEL ? { model: env.AI_MODEL } : {}),
|
|
48
|
+
...(env.AI_BASE_URL ? { baseUrl: env.AI_BASE_URL } : {}),
|
|
49
|
+
...(env.AI_STREAM === 'false' ? { stream: false } : {}),
|
|
50
|
+
...fetchOpt,
|
|
51
|
+
});
|
|
52
|
+
case 'google':
|
|
53
|
+
throw new Error(`createProvider: 'google' is not implemented in @basaltkit/ai@0.1. ` +
|
|
54
|
+
`Available now: 'anthropic', 'ollama', 'openai' (OpenAI-compatible).`);
|
|
55
|
+
default:
|
|
56
|
+
throw new Error(`createProvider: unknown AI_PROVIDER='${name}'. Use 'anthropic', 'ollama' or 'openai'.`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { FetchLike } from './types.js';
|
|
2
|
+
export interface RetryOptions {
|
|
3
|
+
/** Extra attempts after the first (so `2` = up to 3 tries). */
|
|
4
|
+
retries?: number;
|
|
5
|
+
/** Base backoff in ms; doubles each attempt. */
|
|
6
|
+
baseDelayMs?: number;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Fetch with retry on transient 5xx/429 responses and network errors, using
|
|
10
|
+
* exponential backoff. Providers hit remote gateways (go4ai, OpenAI, Anthropic)
|
|
11
|
+
* that occasionally return a spurious 500 — one retry usually clears it.
|
|
12
|
+
*
|
|
13
|
+
* Reads the body only on the terminal attempt, so retried responses are simply
|
|
14
|
+
* discarded.
|
|
15
|
+
*/
|
|
16
|
+
export declare function fetchWithRetry(fetchImpl: FetchLike, url: string, init: {
|
|
17
|
+
method?: string;
|
|
18
|
+
headers?: Record<string, string>;
|
|
19
|
+
body?: string;
|
|
20
|
+
}, options?: RetryOptions): Promise<{
|
|
21
|
+
ok: boolean;
|
|
22
|
+
status: number;
|
|
23
|
+
body: string;
|
|
24
|
+
}>;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
2
|
+
/** Transient failures worth retrying: gateway/server errors and rate limits. */
|
|
3
|
+
function isRetryableStatus(status) {
|
|
4
|
+
return status >= 500 || status === 429;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Fetch with retry on transient 5xx/429 responses and network errors, using
|
|
8
|
+
* exponential backoff. Providers hit remote gateways (go4ai, OpenAI, Anthropic)
|
|
9
|
+
* that occasionally return a spurious 500 — one retry usually clears it.
|
|
10
|
+
*
|
|
11
|
+
* Reads the body only on the terminal attempt, so retried responses are simply
|
|
12
|
+
* discarded.
|
|
13
|
+
*/
|
|
14
|
+
export async function fetchWithRetry(fetchImpl, url, init, options = {}) {
|
|
15
|
+
const retries = options.retries ?? 2;
|
|
16
|
+
const baseDelayMs = options.baseDelayMs ?? 300;
|
|
17
|
+
let lastError;
|
|
18
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
19
|
+
try {
|
|
20
|
+
const res = await fetchImpl(url, init);
|
|
21
|
+
if (isRetryableStatus(res.status) && attempt < retries) {
|
|
22
|
+
await sleep(baseDelayMs * 2 ** attempt);
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
return { ok: res.ok, status: res.status, body: await res.text() };
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
lastError = error;
|
|
29
|
+
if (attempt >= retries)
|
|
30
|
+
throw error;
|
|
31
|
+
await sleep(baseDelayMs * 2 ** attempt);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
// Unreachable: the loop either returns or throws.
|
|
35
|
+
throw lastError instanceof Error ? lastError : new Error('fetchWithRetry: exhausted retries');
|
|
36
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type AIProvider, type FetchLike, type GenerateOptions } from './types.js';
|
|
2
|
+
export interface OllamaProviderOptions {
|
|
3
|
+
/** Model id, e.g. `'llama3.1'`. */
|
|
4
|
+
model?: string;
|
|
5
|
+
/** Ollama base URL. Defaults to the local daemon. */
|
|
6
|
+
baseUrl?: string;
|
|
7
|
+
/** Injected fetch. Defaults to the global `fetch`. */
|
|
8
|
+
fetch?: FetchLike;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Local Ollama provider (`/api/chat`, `stream: false`). Needs no API key, so it's
|
|
12
|
+
* the zero-cost default for offline development and CI.
|
|
13
|
+
*/
|
|
14
|
+
export declare class OllamaProvider implements AIProvider {
|
|
15
|
+
readonly name = "ollama";
|
|
16
|
+
readonly model: string;
|
|
17
|
+
private readonly baseUrl;
|
|
18
|
+
private readonly fetchImpl;
|
|
19
|
+
constructor(options?: OllamaProviderOptions);
|
|
20
|
+
generate(options: GenerateOptions): Promise<string>;
|
|
21
|
+
stream(options: GenerateOptions): AsyncIterable<string>;
|
|
22
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { fetchWithRetry } from './http.js';
|
|
2
|
+
import { singleChunkStream, } from './types.js';
|
|
3
|
+
const DEFAULT_MODEL = 'llama3.1';
|
|
4
|
+
/**
|
|
5
|
+
* Local Ollama provider (`/api/chat`, `stream: false`). Needs no API key, so it's
|
|
6
|
+
* the zero-cost default for offline development and CI.
|
|
7
|
+
*/
|
|
8
|
+
export class OllamaProvider {
|
|
9
|
+
name = 'ollama';
|
|
10
|
+
model;
|
|
11
|
+
baseUrl;
|
|
12
|
+
fetchImpl;
|
|
13
|
+
constructor(options = {}) {
|
|
14
|
+
this.model = options.model ?? DEFAULT_MODEL;
|
|
15
|
+
this.baseUrl = (options.baseUrl ?? 'http://localhost:11434').replace(/\/$/, '');
|
|
16
|
+
const f = options.fetch ?? globalThis.fetch;
|
|
17
|
+
if (!f)
|
|
18
|
+
throw new Error('OllamaProvider: no fetch available — pass options.fetch');
|
|
19
|
+
this.fetchImpl = f;
|
|
20
|
+
}
|
|
21
|
+
async generate(options) {
|
|
22
|
+
const { ok, status, body } = await fetchWithRetry(this.fetchImpl, this.baseUrl + '/api/chat', {
|
|
23
|
+
method: 'POST',
|
|
24
|
+
headers: { 'content-type': 'application/json' },
|
|
25
|
+
body: JSON.stringify({
|
|
26
|
+
model: this.model,
|
|
27
|
+
stream: false,
|
|
28
|
+
messages: options.messages.map((m) => ({ role: m.role, content: m.content })),
|
|
29
|
+
...(options.temperature !== undefined ? { options: { temperature: options.temperature } } : {}),
|
|
30
|
+
}),
|
|
31
|
+
});
|
|
32
|
+
if (!ok)
|
|
33
|
+
throw new Error(`OllamaProvider: ${status} — ${body.slice(0, 200)}`);
|
|
34
|
+
const json = JSON.parse(body);
|
|
35
|
+
if (json.error)
|
|
36
|
+
throw new Error(`OllamaProvider: ${json.error}`);
|
|
37
|
+
return json.message?.content ?? '';
|
|
38
|
+
}
|
|
39
|
+
stream(options) {
|
|
40
|
+
return singleChunkStream(() => this.generate(options));
|
|
41
|
+
}
|
|
42
|
+
}
|