@stacksjs/ai 0.70.161 → 0.70.163

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 CHANGED
@@ -35,6 +35,36 @@ const client = useAI()
35
35
  // client...
36
36
  ```
37
37
 
38
+ ## Compact project context
39
+
40
+ Stacks conventions reduce the amount of application-owned code a coding model
41
+ must generate. A model can describe domain shape once, use known Model-View-Action
42
+ locations, and rely on framework traits and generators for migrations, validation,
43
+ routes, API artifacts, and types where those paths are supported. Installed
44
+ dependencies still exist, but their `node_modules` implementation is package-manager
45
+ state, not application context an LLM should ingest or reproduce.
46
+
47
+ Generate a deterministic context payload for any coding model or agent:
48
+
49
+ ```bash
50
+ buddy ai:context
51
+ buddy ai:context --json
52
+ buddy ai:context --json --output .stacks/ai-context.json
53
+ buddy ai:context --max-chars 4000 --model claude-sonnet-4
54
+ ```
55
+
56
+ The versioned JSON contract identifies canonical source roles, instruction files,
57
+ available application surfaces, safe package metadata, and representative source
58
+ paths. It excludes dependency, build, cache, lock, environment, credential, key,
59
+ and secret paths by default. Existing Buddy AI calls consume the same compact text
60
+ representation.
61
+
62
+ Output metrics include the enforced character budget and a heuristic token estimate.
63
+ They also compare the payload with the previous unstructured context shape. This is
64
+ evidence about prompt size only. It is not evidence that a particular model will
65
+ write correct code, and exact billing tokens require the selected provider's
66
+ tokenizer.
67
+
38
68
  Learn more in the docs.
39
69
 
40
70
  ## 🧪 Testing
package/dist/buddy.js CHANGED
@@ -1,8 +1,9 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
4
  import { claudeAgent } from "./agents";
5
5
  import { createAnthropicDriver, createClaudeAgentSDKDriver, createOllamaDriver, createOpenAIDriver } from "./drivers";
6
+ import { buildProjectContext } from "./context";
6
7
  export const CONFIG = {
7
8
  workDir: join(homedir(), "Code", ".buddy-repos"),
8
9
  commitMessage: "chore: wip",
@@ -168,26 +169,7 @@ export function getAvailableDrivers() {
168
169
  return ["claude-cli-local", "claude-cli-ec2", "claude", "claude-sdk", "openai", "ollama", "mock"];
169
170
  }
170
171
  export async function getRepoContext(repoPath) {
171
- const { $: _$ } = await import("bun"), files = (await _$`cd ${repoPath} && find . -type f -not -path "*/node_modules/*" -not -path "*/.git/*" -not -name "*.lock" | head -50`.quiet()).text().trim();
172
- let readme = "";
173
- const readmePath = join(repoPath, "README.md");
174
- if (existsSync(readmePath))
175
- readme = readFileSync(readmePath, "utf-8").slice(0, 2000);
176
- let packageJson = "";
177
- const packagePath = join(repoPath, "package.json");
178
- if (existsSync(packagePath))
179
- packageJson = readFileSync(packagePath, "utf-8");
180
- return `
181
- Repository Structure:
182
- ${files}
183
-
184
- ${readme ? `README.md (excerpt):
185
- ${readme}
186
- ` : ""}
187
- ${packageJson ? `package.json:
188
- ${packageJson}
189
- ` : ""}
190
- `.trim();
172
+ return buildProjectContext(repoPath).text;
191
173
  }
192
174
  export async function openRepository(input) {
193
175
  const { $: _$ } = await import("bun");
@@ -0,0 +1,9 @@
1
+ import type { AIProvider, ConfiguredAIClient, ConfiguredAIOptions } from './types';
2
+ declare function parseObject(content: string): unknown;
3
+ declare function validateSchema(value: unknown, schema: Record<string, any>, path?: string): string[];
4
+ /**
5
+ * Create a provider-neutral AI client from an application's `config/ai.ts`.
6
+ * Secrets remain server-side and may be supplied by config or provider env vars.
7
+ */
8
+ export declare function createAIClient(config: ConfiguredAIOptions, override?: AIProvider): ConfiguredAIClient;
9
+ export { parseObject as parseAIObject, validateSchema as validateAISchema };
package/dist/client.js ADDED
@@ -0,0 +1,172 @@
1
+ import * as anthropic from "./drivers/anthropic";
2
+ import * as ollama from "./drivers/ollama";
3
+ import * as openai from "./drivers/openai";
4
+ function textContent(content) {
5
+ if (typeof content === "string")
6
+ return content;
7
+ return content.filter((block) => block.type === "text").map((block) => block.text ?? "").join(`
8
+ `);
9
+ }
10
+ function providerFromConfig(config, override) {
11
+ if (override)
12
+ return override;
13
+ const configured = String(config.default || "").toLowerCase();
14
+ if (configured === "anthropic" || configured.startsWith("anthropic."))
15
+ return "anthropic";
16
+ if (configured === "openai" || configured.startsWith("gpt-") || configured.startsWith("o1") || configured.startsWith("o3"))
17
+ return "openai";
18
+ if (configured === "ollama")
19
+ return "ollama";
20
+ throw Error(`Unsupported configured AI driver: ${config.default || "(empty)"}. Expected anthropic, openai, or ollama.`);
21
+ }
22
+ function parseObject(content) {
23
+ const unfenced = content.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/, "");
24
+ try {
25
+ return JSON.parse(unfenced);
26
+ } catch {
27
+ const start = unfenced.indexOf("{"), end = unfenced.lastIndexOf("}");
28
+ if (start >= 0 && end > start)
29
+ return JSON.parse(unfenced.slice(start, end + 1));
30
+ throw Error("AI response did not contain a valid JSON object");
31
+ }
32
+ }
33
+ function typeMatches(value, type) {
34
+ if (type === "array")
35
+ return Array.isArray(value);
36
+ if (type === "integer")
37
+ return typeof value === "number" && Number.isInteger(value);
38
+ if (type === "object")
39
+ return typeof value === "object" && value !== null && !Array.isArray(value);
40
+ if (type === "null")
41
+ return value === null;
42
+ return typeof value === type;
43
+ }
44
+ function validateSchema(value, schema, path = "$") {
45
+ const errors = [];
46
+ if (schema.type) {
47
+ const allowed = Array.isArray(schema.type) ? schema.type : [schema.type];
48
+ if (!allowed.some((type) => typeMatches(value, type)))
49
+ return [`${path} must be ${allowed.join(" or ")}`];
50
+ }
51
+ if (schema.enum && !schema.enum.some((candidate) => Object.is(candidate, value)))
52
+ errors.push(`${path} must be one of ${schema.enum.join(", ")}`);
53
+ if (typeof value === "string") {
54
+ if (schema.minLength !== void 0 && value.length < schema.minLength)
55
+ errors.push(`${path} must contain at least ${schema.minLength} characters`);
56
+ if (schema.maxLength !== void 0 && value.length > schema.maxLength)
57
+ errors.push(`${path} must contain at most ${schema.maxLength} characters`);
58
+ }
59
+ if (typeof value === "number") {
60
+ if (schema.minimum !== void 0 && value < schema.minimum)
61
+ errors.push(`${path} must be at least ${schema.minimum}`);
62
+ if (schema.maximum !== void 0 && value > schema.maximum)
63
+ errors.push(`${path} must be at most ${schema.maximum}`);
64
+ }
65
+ if (Array.isArray(value)) {
66
+ if (schema.minItems !== void 0 && value.length < schema.minItems)
67
+ errors.push(`${path} must contain at least ${schema.minItems} items`);
68
+ if (schema.maxItems !== void 0 && value.length > schema.maxItems)
69
+ errors.push(`${path} must contain at most ${schema.maxItems} items`);
70
+ if (schema.items)
71
+ value.forEach((item, index) => errors.push(...validateSchema(item, schema.items, `${path}[${index}]`)));
72
+ }
73
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
74
+ const record = value;
75
+ for (const key of schema.required ?? [])
76
+ if (!(key in record))
77
+ errors.push(`${path}.${key} is required`);
78
+ for (const [key, child] of Object.entries(schema.properties ?? {}))
79
+ if (key in record)
80
+ errors.push(...validateSchema(record[key], child, `${path}.${key}`));
81
+ if (schema.additionalProperties === !1) {
82
+ const known = new Set(Object.keys(schema.properties ?? {}));
83
+ for (const key of Object.keys(record))
84
+ if (!known.has(key))
85
+ errors.push(`${path}.${key} is not allowed`);
86
+ }
87
+ }
88
+ return errors;
89
+ }
90
+ function createGenerate(config, provider) {
91
+ if (provider === "anthropic") {
92
+ const driver = config.drivers?.anthropic ?? {};
93
+ anthropic.configure({
94
+ apiKey: driver.apiKey || process.env.ANTHROPIC_API_KEY || "",
95
+ model: driver.model,
96
+ maxTokens: driver.maxTokens,
97
+ anthropicVersion: driver.anthropicVersion
98
+ });
99
+ return async (messages, options = {}) => {
100
+ const systemMessages = messages.filter((message) => message.role === "system").map((message) => textContent(message.content)), nonSystem = messages.filter((message) => message.role !== "system");
101
+ return anthropic.chat(nonSystem, {
102
+ ...options,
103
+ system: [options.system, ...systemMessages].filter(Boolean).join(`
104
+
105
+ `) || void 0
106
+ });
107
+ };
108
+ }
109
+ if (provider === "openai") {
110
+ const driver = config.drivers?.openai ?? {};
111
+ openai.configure({
112
+ apiKey: driver.apiKey || process.env.OPENAI_API_KEY || "",
113
+ model: driver.model,
114
+ maxTokens: driver.maxTokens,
115
+ baseUrl: driver.baseUrl,
116
+ embeddingModel: driver.embeddingModel
117
+ });
118
+ return (messages, options = {}) => {
119
+ const finalMessages = options.system ? [{ role: "system", content: options.system }, ...messages] : messages;
120
+ return openai.chat(finalMessages, options);
121
+ };
122
+ }
123
+ const driver = config.drivers?.ollama ?? {};
124
+ ollama.configure({
125
+ host: driver.host || driver.baseUrl,
126
+ model: driver.model,
127
+ embeddingModel: driver.embeddingModel
128
+ });
129
+ return (messages, options = {}) => {
130
+ const finalMessages = options.system ? [{ role: "system", content: options.system }, ...messages] : messages;
131
+ return ollama.chat(finalMessages, options);
132
+ };
133
+ }
134
+ export function createAIClient(config, override) {
135
+ const provider = providerFromConfig(config, override), generate = createGenerate(config, provider);
136
+ return {
137
+ provider,
138
+ generate,
139
+ async generateObject(messages, schema, options = {}) {
140
+ const { attempts = 2, system, ...completionOptions } = options, history = [...messages];
141
+ let lastError;
142
+ for (let attempt = 1;attempt <= Math.max(1, attempts); attempt++) {
143
+ const result = await generate(history, {
144
+ ...completionOptions,
145
+ system,
146
+ responseFormat: {
147
+ type: "json_schema",
148
+ json_schema: { name: "structured_output", schema, strict: !0 }
149
+ }
150
+ });
151
+ try {
152
+ const data = parseObject(result.content), errors = validateSchema(data, schema);
153
+ if (errors.length)
154
+ throw Error(`AI response failed schema validation: ${errors.slice(0, 8).join("; ")}`);
155
+ return { data, result };
156
+ } catch (error) {
157
+ lastError = error instanceof Error ? error : Error(String(error));
158
+ if (attempt < attempts) {
159
+ history.push({ role: "assistant", content: result.content });
160
+ history.push({
161
+ role: "user",
162
+ content: `Return only a corrected JSON object. Validation error: ${lastError.message}`
163
+ });
164
+ }
165
+ }
166
+ }
167
+ throw lastError ?? Error("AI object generation failed");
168
+ }
169
+ };
170
+ }
171
+
172
+ export { parseObject as parseAIObject, validateSchema as validateAISchema };
@@ -0,0 +1,53 @@
1
+ declare function representativeFiles(files: string[], maxFiles: number): string[];
2
+ export declare function buildProjectContext(repoPath: string, options?: ProjectContextOptions): ProjectContextResult;
3
+ export declare const STACKS_PROJECT_CONTEXT_SCHEMA: 'https://stacksjs.org/schemas/ai-project-context/v1';
4
+ export declare interface ProjectContextOptions {
5
+ maxChars?: number
6
+ maxFiles?: number
7
+ model?: string
8
+ }
9
+ export declare interface ProjectContextSurface {
10
+ id: string
11
+ purpose: string
12
+ paths: string[]
13
+ }
14
+ export declare interface StacksProjectContext {
15
+ schema: typeof STACKS_PROJECT_CONTEXT_SCHEMA
16
+ schemaVersion: '1.0.0'
17
+ framework: 'stacks'
18
+ architecture: {
19
+ pattern: 'Model-View-Action'
20
+ overrideRule: string
21
+ authoringOrder: string[]
22
+ principles: string[]
23
+ }
24
+ project: {
25
+ name: string | null
26
+ version: string | null
27
+ scripts: string[]
28
+ dependencies: string[]
29
+ }
30
+ instructionFiles: string[]
31
+ surfaces: ProjectContextSurface[]
32
+ representativeFiles: string[]
33
+ exclusions: string[]
34
+ }
35
+ export declare interface ProjectContextMetrics {
36
+ candidateFiles: number
37
+ includedFiles: number
38
+ maxCharacters: number
39
+ outputCharacters: number
40
+ estimatedTokens: number
41
+ model: string
42
+ truncated: boolean
43
+ baselineMethod: 'sorted-first-50-paths+readme-2000+package-json'
44
+ baselineCharacters: number
45
+ baselineEstimatedTokens: number
46
+ estimatedTokenReductionPercent: number | null
47
+ tokenEstimateIsHeuristic: true
48
+ }
49
+ export declare interface ProjectContextResult {
50
+ context: StacksProjectContext
51
+ text: string
52
+ metrics: ProjectContextMetrics
53
+ }
@@ -0,0 +1,200 @@
1
+ import { existsSync, lstatSync, readFileSync, readdirSync } from "node:fs";
2
+ import { basename, relative, resolve, sep } from "node:path";
3
+ import { estimateTokens } from "./utils/tokens";
4
+ export const STACKS_PROJECT_CONTEXT_SCHEMA = "https://stacksjs.org/schemas/ai-project-context/v1";
5
+ const DEFAULT_MAX_CHARS = 4000, DEFAULT_MAX_FILES = 30, excludedSegments = new Set([
6
+ ".git",
7
+ ".bun",
8
+ ".cache",
9
+ ".next",
10
+ ".output",
11
+ ".turbo",
12
+ ".vite",
13
+ "build",
14
+ "coverage",
15
+ "dist",
16
+ "node_modules",
17
+ "tmp",
18
+ "vendor"
19
+ ]), sensitiveNames = /^(?:\.env(?:\..*)?|\.npmrc|\.pypirc|credentials(?:\..*)?|id_(?:rsa|ed25519)(?:\.pub)?|secrets?(?:\..*)?)$/i, lockNames = /(?:^|\.)(?:bun|package-lock|pnpm-lock|yarn|pantry)\.?(?:lock|yaml)?$/i, surfaceDefinitions = [
20
+ { id: "models", purpose: "Domain schema, validation, relationships, factories, and traits.", prefixes: ["app/Models/", "storage/framework/defaults/app/Models/"] },
21
+ { id: "actions", purpose: "Transport-independent application behavior.", prefixes: ["app/Actions/", "storage/framework/defaults/app/Actions/"] },
22
+ { id: "routes", purpose: "HTTP and API route registration.", prefixes: ["routes/", "app/Routes.ts"] },
23
+ { id: "jobs", purpose: "Background and inline work.", prefixes: ["app/Jobs/", "storage/framework/defaults/app/Jobs/"] },
24
+ { id: "configuration", purpose: "Typed application and provider configuration.", prefixes: ["config/"] },
25
+ { id: "views", purpose: "STX views, layouts, components, and client functions.", prefixes: ["resources/"] },
26
+ { id: "database", purpose: "Reviewable migrations and seed data.", prefixes: ["database/"] },
27
+ { id: "tests", purpose: "Unit, feature, contract, and end-to-end evidence.", prefixes: ["tests/", "storage/framework/core/"] }
28
+ ];
29
+ function normalizePath(path) {
30
+ return path.split(sep).join("/");
31
+ }
32
+ function isExcluded(path) {
33
+ const segments = path.split("/"), name = basename(path);
34
+ return segments.some((segment) => excludedSegments.has(segment)) || sensitiveNames.test(name) || lockNames.test(name) || name.endsWith(".lock");
35
+ }
36
+ function collectFiles(root) {
37
+ const files = [];
38
+ function visit(directory) {
39
+ const entries = readdirSync(directory, { withFileTypes: !0 }).sort((a, b) => a.name.localeCompare(b.name));
40
+ for (const entry of entries) {
41
+ const fullPath = resolve(directory, entry.name), projectPath = normalizePath(relative(root, fullPath));
42
+ if (isExcluded(projectPath))
43
+ continue;
44
+ if (entry.isDirectory())
45
+ visit(fullPath);
46
+ else if (entry.isFile() && !lstatSync(fullPath).isSymbolicLink())
47
+ files.push(projectPath);
48
+ }
49
+ }
50
+ visit(root);
51
+ return files.sort((a, b) => a.localeCompare(b));
52
+ }
53
+ function readPackageSummary(root) {
54
+ const packagePath = resolve(root, "package.json");
55
+ if (!existsSync(packagePath))
56
+ return { name: null, version: null, scripts: [], dependencies: [] };
57
+ try {
58
+ const manifest = JSON.parse(readFileSync(packagePath, "utf8")), dependencies = [
59
+ ...Object.keys(manifest.dependencies || {}),
60
+ ...Object.keys(manifest.devDependencies || {}),
61
+ ...Object.keys(manifest.peerDependencies || {})
62
+ ];
63
+ return {
64
+ name: typeof manifest.name === "string" ? manifest.name : null,
65
+ version: typeof manifest.version === "string" ? manifest.version : null,
66
+ scripts: Object.keys(manifest.scripts || {}).sort().slice(0, 25),
67
+ dependencies: [...new Set(dependencies)].sort().slice(0, 30)
68
+ };
69
+ } catch {
70
+ return { name: null, version: null, scripts: [], dependencies: [] };
71
+ }
72
+ }
73
+ function filePriority(path) {
74
+ if (/^(?:AGENTS|CLAUDE)\.md$|^\.github\/copilot-instructions\.md$/.test(path))
75
+ return 0;
76
+ if (path === "package.json" || path === "app/Routes.ts" || path === "app/Commands.ts")
77
+ return 1;
78
+ if (/^(?:app|routes|config)\//.test(path))
79
+ return 2;
80
+ if (/^(?:resources|database|tests)\//.test(path))
81
+ return 3;
82
+ if (/^docs\//.test(path))
83
+ return 4;
84
+ if (/^storage\/framework\/defaults\//.test(path))
85
+ return 5;
86
+ return 6;
87
+ }
88
+ function representativeFiles(files, maxFiles) {
89
+ return [...files].sort((a, b) => filePriority(a) - filePriority(b) || a.localeCompare(b)).slice(0, maxFiles).sort((a, b) => a.localeCompare(b));
90
+ }
91
+ function surfacePaths(files, prefixes) {
92
+ return files.filter((file) => prefixes.some((prefix) => prefix.endsWith("/") ? file.startsWith(prefix) : file === prefix)).slice(0, 4);
93
+ }
94
+ function renderContext(context) {
95
+ return [
96
+ "Stacks AI Project Context v1",
97
+ "",
98
+ `Project: ${context.project.name || "unnamed"}${context.project.version ? `@${context.project.version}` : ""}`,
99
+ "Architecture: Model-View-Action",
100
+ `Override rule: ${context.architecture.overrideRule}`,
101
+ `Authoring order: ${context.architecture.authoringOrder.join(" -> ")}`,
102
+ "",
103
+ "Authoring principles:",
104
+ ...context.architecture.principles.map((principle) => `- ${principle}`),
105
+ "",
106
+ "Instruction files:",
107
+ ...context.instructionFiles.length ? context.instructionFiles.map((file) => `- ${file}`) : ["- none detected"],
108
+ "",
109
+ "Available surfaces:",
110
+ ...context.surfaces.flatMap((surface) => [
111
+ `- ${surface.id}: ${surface.purpose}`,
112
+ ...surface.paths.map((path) => ` - ${path}`)
113
+ ]),
114
+ "",
115
+ `Scripts: ${context.project.scripts.join(", ") || "none detected"}`,
116
+ `Dependencies: ${context.project.dependencies.join(", ") || "none detected"}`,
117
+ "",
118
+ "Representative files:",
119
+ ...context.representativeFiles.map((file) => `- ${file}`)
120
+ ].join(`
121
+ `);
122
+ }
123
+ function fitToBudget(text, maxChars) {
124
+ if (text.length <= maxChars)
125
+ return { text, truncated: !1 };
126
+ const marker = `
127
+ [context truncated by character budget]`;
128
+ if (maxChars <= marker.length)
129
+ return { text: marker.slice(0, maxChars), truncated: !0 };
130
+ const available = maxChars - marker.length, prefix = text.slice(0, available), lastLine = prefix.lastIndexOf(`
131
+ `);
132
+ return {
133
+ text: `${prefix.slice(0, lastLine > 0 ? lastLine : available)}${marker}`,
134
+ truncated: !0
135
+ };
136
+ }
137
+ function unstructuredBaseline(root, files) {
138
+ const readmePath = resolve(root, "README.md"), packagePath = resolve(root, "package.json"), readme = existsSync(readmePath) ? readFileSync(readmePath, "utf8").slice(0, 2000) : "", packageJson = existsSync(packagePath) ? readFileSync(packagePath, "utf8") : "";
139
+ return [
140
+ "Repository Structure:",
141
+ files.slice(0, 50).join(`
142
+ `),
143
+ readme ? `README.md (excerpt):
144
+ ${readme}` : "",
145
+ packageJson ? `package.json:
146
+ ${packageJson}` : ""
147
+ ].filter(Boolean).join(`
148
+
149
+ `);
150
+ }
151
+ export function buildProjectContext(repoPath, options = {}) {
152
+ const root = resolve(repoPath), maxChars = options.maxChars ?? DEFAULT_MAX_CHARS, maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES, model = options.model || "gpt-4o";
153
+ if (!Number.isInteger(maxChars) || maxChars < 256)
154
+ throw Error("maxChars must be an integer of at least 256");
155
+ if (!Number.isInteger(maxFiles) || maxFiles < 1)
156
+ throw Error("maxFiles must be a positive integer");
157
+ const files = collectFiles(root), included = representativeFiles(files, maxFiles), context = {
158
+ schema: STACKS_PROJECT_CONTEXT_SCHEMA,
159
+ schemaVersion: "1.0.0",
160
+ framework: "stacks",
161
+ architecture: {
162
+ pattern: "Model-View-Action",
163
+ overrideRule: "Application files under app/ override matching framework defaults; do not edit defaults for application customization.",
164
+ authoringOrder: ["model", "migration", "action", "route", "test"],
165
+ principles: [
166
+ "Prefer conventional paths and framework generators over bespoke glue code.",
167
+ "Declare domain shape once and derive validation, migrations, API surfaces, and types where supported.",
168
+ "Keep Actions transport-independent and make unsupported drivers fail loudly.",
169
+ "Read repository instruction files before changing source."
170
+ ]
171
+ },
172
+ project: readPackageSummary(root),
173
+ instructionFiles: files.filter((file) => /(?:^|\/)(?:AGENTS|CLAUDE)\.md$|^\.github\/copilot-instructions\.md$/.test(file)),
174
+ surfaces: surfaceDefinitions.map((surface) => ({ id: surface.id, purpose: surface.purpose, paths: surfacePaths(files, surface.prefixes) })).filter((surface) => surface.paths.length > 0),
175
+ representativeFiles: included,
176
+ exclusions: [
177
+ "dependency, build, cache, coverage, temporary, and vendor directories",
178
+ "lockfiles",
179
+ "environment, credential, private-key, and secret files"
180
+ ]
181
+ }, fitted = fitToBudget(renderContext(context), maxChars), baseline = unstructuredBaseline(root, files), outputTokens = estimateTokens(fitted.text, model), baselineTokens = estimateTokens(baseline, model), reduction = baselineTokens > 0 ? Math.round((1 - outputTokens / baselineTokens) * 1000) / 10 : null;
182
+ return {
183
+ context,
184
+ text: fitted.text,
185
+ metrics: {
186
+ candidateFiles: files.length,
187
+ includedFiles: included.length,
188
+ maxCharacters: maxChars,
189
+ outputCharacters: fitted.text.length,
190
+ estimatedTokens: outputTokens,
191
+ model,
192
+ truncated: fitted.truncated,
193
+ baselineMethod: "sorted-first-50-paths+readme-2000+package-json",
194
+ baselineCharacters: baseline.length,
195
+ baselineEstimatedTokens: baselineTokens,
196
+ estimatedTokenReductionPercent: reduction,
197
+ tokenEstimateIsHeuristic: !0
198
+ }
199
+ };
200
+ }
@@ -107,14 +107,16 @@ export async function chat(messages, options = {}) {
107
107
  model = config.model,
108
108
  temperature,
109
109
  topP,
110
- stop
111
- } = options, response = await fetch(`${config.host}/api/chat`, {
110
+ stop,
111
+ responseFormat
112
+ } = options, format = responseFormat?.type === "json_schema" ? responseFormat.json_schema.schema : responseFormat?.type === "json_object" ? "json" : void 0, response = await fetch(`${config.host}/api/chat`, {
112
113
  method: "POST",
113
114
  headers: { "Content-Type": "application/json" },
114
115
  body: JSON.stringify({
115
116
  model,
116
117
  messages,
117
118
  stream: !1,
119
+ format,
118
120
  options: {
119
121
  temperature,
120
122
  top_p: topP,
@@ -142,21 +142,38 @@ export async function chat(messages, options = {}) {
142
142
  maxTokens = DEFAULT_MAX_TOKENS,
143
143
  temperature,
144
144
  topP,
145
- stop
146
- } = options, normalizedMessages = normalizeMessagesForProvider(messages, "openai"), startedAt = Date.now(), response = await fetchWithRetry(`${config.baseUrl || DEFAULT_BASE_URL}/chat/completions`, {
145
+ stop,
146
+ tools,
147
+ toolChoice,
148
+ responseFormat
149
+ } = options, normalizedMessages = normalizeMessagesForProvider(messages, "openai"), startedAt = Date.now(), body = {
150
+ model,
151
+ max_tokens: maxTokens,
152
+ temperature,
153
+ top_p: topP,
154
+ stop,
155
+ messages: normalizedMessages
156
+ };
157
+ if (tools?.length)
158
+ body.tools = tools.map((tool) => ({
159
+ type: "function",
160
+ function: {
161
+ name: tool.name,
162
+ description: tool.description,
163
+ parameters: tool.parameters ?? { type: "object", properties: {} }
164
+ }
165
+ }));
166
+ if (toolChoice !== void 0)
167
+ body.tool_choice = typeof toolChoice === "object" ? { type: "function", function: { name: toolChoice.name } } : toolChoice;
168
+ if (responseFormat && responseFormat.type !== "text")
169
+ body.response_format = responseFormat;
170
+ const response = await fetchWithRetry(`${config.baseUrl || DEFAULT_BASE_URL}/chat/completions`, {
147
171
  method: "POST",
148
172
  headers: {
149
173
  "Content-Type": "application/json",
150
174
  Authorization: `Bearer ${config.apiKey}`
151
175
  },
152
- body: JSON.stringify({
153
- model,
154
- max_tokens: maxTokens,
155
- temperature,
156
- top_p: topP,
157
- stop,
158
- messages: normalizedMessages
159
- })
176
+ body: JSON.stringify(body)
160
177
  });
161
178
  if (!response.ok) {
162
179
  const error = await response.text();
package/dist/index.d.ts CHANGED
@@ -5,10 +5,14 @@ export type { SanitizeResult } from './utils/tokens';
5
5
  export * from './types';
6
6
  // Drivers
7
7
  export * from './drivers/index';
8
+ // Provider-neutral, config-driven client
9
+ export * from './client';
8
10
  // Agents
9
11
  export * from './agents/index';
10
12
  // Buddy - Voice AI Code Assistant
11
13
  export * from './buddy';
14
+ // Deterministic, budgeted project context for coding models and agents
15
+ export * from './context';
12
16
  // Text utilities
13
17
  export * from './text';
14
18
  // Image generation & vision
package/dist/index.js CHANGED
@@ -1,7 +1,9 @@
1
1
  export * from "./types";
2
2
  export * from "./drivers";
3
+ export * from "./client";
3
4
  export * from "./agents";
4
5
  export * from "./buddy";
6
+ export * from "./context";
5
7
  export * from "./text";
6
8
  export * from "./image";
7
9
  export * from "./search";
package/dist/types.d.ts CHANGED
@@ -157,7 +157,7 @@ export declare interface MCPConfig {
157
157
  }
158
158
  // AI module config (used by @stacksjs/config)
159
159
  export declare interface AIConfig {
160
- default?: string
160
+ default?: AIProvider | string
161
161
  models?: string[]
162
162
  drivers?: {
163
163
  anthropic?: AIDriverConfig & { anthropicVersion?: string }
@@ -168,6 +168,26 @@ export declare interface AIConfig {
168
168
  search?: SearchConfig
169
169
  mcp?: MCPConfig
170
170
  }
171
+ export declare interface ConfiguredAIOptions extends AIConfig {
172
+ drivers?: {
173
+ anthropic?: AIDriverConfig & { anthropicVersion?: string }
174
+ openai?: AIDriverConfig & { embeddingModel?: string }
175
+ ollama?: AIDriverConfig & { host?: string, embeddingModel?: string }
176
+ }
177
+ }
178
+ export declare interface GenerateObjectOptions extends ChatCompletionOptions {
179
+ attempts?: number
180
+ system?: string
181
+ }
182
+ export declare interface ConfiguredAIClient {
183
+ provider: AIProvider
184
+ generate: (messages: AIMessage[], options?: ChatCompletionOptions & { system?: string }) => Promise<AIResult>
185
+ generateObject: <T>(
186
+ messages: AIMessage[],
187
+ schema: Record<string, unknown>,
188
+ options?: GenerateObjectOptions,
189
+ ) => Promise<{ data: T, result: AIResult }>
190
+ }
171
191
  /**
172
192
  * Structured-output / JSON-mode response format. Modeled after
173
193
  * OpenAI's `response_format` but the Anthropic driver maps it to
@@ -183,3 +203,4 @@ export type AIResponseFormat = | { type: 'text' }
183
203
  strict?: boolean
184
204
  }
185
205
  }
206
+ export type AIProvider = 'anthropic' | 'openai' | 'ollama';
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@stacksjs/ai",
3
3
  "type": "module",
4
4
  "sideEffects": false,
5
- "version": "0.70.161",
5
+ "version": "0.70.163",
6
6
  "description": "Stacks Artificial Intelligence.",
7
7
  "author": "Chris Breuer",
8
8
  "contributors": [
@@ -60,7 +60,7 @@
60
60
  "prepublishOnly": "bun run build"
61
61
  },
62
62
  "dependencies": {
63
- "@stacksjs/ts-cloud": "^0.7.49"
63
+ "@stacksjs/ts-cloud": "^0.7.56"
64
64
  },
65
65
  "peerDependencies": {
66
66
  "@anthropic-ai/claude-agent-sdk": "^0.2.79"