@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 +30 -0
- package/dist/buddy.js +3 -21
- package/dist/client.d.ts +9 -0
- package/dist/client.js +172 -0
- package/dist/context.d.ts +53 -0
- package/dist/context.js +200 -0
- package/dist/drivers/ollama/index.js +4 -2
- package/dist/drivers/openai/index.js +27 -10
- package/dist/index.d.ts +4 -0
- package/dist/index.js +2 -0
- package/dist/types.d.ts +22 -1
- package/package.json +2 -2
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,
|
|
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
|
-
|
|
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");
|
package/dist/client.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/context.js
ADDED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
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.
|
|
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.
|
|
63
|
+
"@stacksjs/ts-cloud": "^0.7.56"
|
|
64
64
|
},
|
|
65
65
|
"peerDependencies": {
|
|
66
66
|
"@anthropic-ai/claude-agent-sdk": "^0.2.79"
|