@mainahq/core 0.2.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 +31 -0
- package/package.json +37 -0
- package/src/ai/__tests__/ai.test.ts +207 -0
- package/src/ai/__tests__/design-approaches.test.ts +192 -0
- package/src/ai/__tests__/spec-questions.test.ts +191 -0
- package/src/ai/__tests__/tiers.test.ts +110 -0
- package/src/ai/commit-msg.ts +28 -0
- package/src/ai/design-approaches.ts +76 -0
- package/src/ai/index.ts +205 -0
- package/src/ai/pr-summary.ts +60 -0
- package/src/ai/spec-questions.ts +74 -0
- package/src/ai/tiers.ts +52 -0
- package/src/ai/try-generate.ts +89 -0
- package/src/ai/validate.ts +66 -0
- package/src/benchmark/__tests__/reporter.test.ts +525 -0
- package/src/benchmark/__tests__/runner.test.ts +113 -0
- package/src/benchmark/__tests__/story-loader.test.ts +152 -0
- package/src/benchmark/reporter.ts +332 -0
- package/src/benchmark/runner.ts +91 -0
- package/src/benchmark/story-loader.ts +88 -0
- package/src/benchmark/types.ts +95 -0
- package/src/cache/__tests__/keys.test.ts +97 -0
- package/src/cache/__tests__/manager.test.ts +312 -0
- package/src/cache/__tests__/ttl.test.ts +94 -0
- package/src/cache/keys.ts +44 -0
- package/src/cache/manager.ts +231 -0
- package/src/cache/ttl.ts +77 -0
- package/src/config/__tests__/config.test.ts +376 -0
- package/src/config/index.ts +198 -0
- package/src/context/__tests__/budget.test.ts +179 -0
- package/src/context/__tests__/engine.test.ts +163 -0
- package/src/context/__tests__/episodic.test.ts +291 -0
- package/src/context/__tests__/relevance.test.ts +323 -0
- package/src/context/__tests__/retrieval.test.ts +143 -0
- package/src/context/__tests__/selector.test.ts +174 -0
- package/src/context/__tests__/semantic.test.ts +252 -0
- package/src/context/__tests__/treesitter.test.ts +229 -0
- package/src/context/__tests__/working.test.ts +236 -0
- package/src/context/budget.ts +130 -0
- package/src/context/engine.ts +394 -0
- package/src/context/episodic.ts +251 -0
- package/src/context/relevance.ts +325 -0
- package/src/context/retrieval.ts +325 -0
- package/src/context/selector.ts +93 -0
- package/src/context/semantic.ts +331 -0
- package/src/context/treesitter.ts +216 -0
- package/src/context/working.ts +192 -0
- package/src/db/__tests__/db.test.ts +151 -0
- package/src/db/index.ts +211 -0
- package/src/db/schema.ts +84 -0
- package/src/design/__tests__/design.test.ts +310 -0
- package/src/design/__tests__/generate-hld-lld.test.ts +109 -0
- package/src/design/__tests__/review.test.ts +561 -0
- package/src/design/index.ts +297 -0
- package/src/design/review.ts +327 -0
- package/src/explain/__tests__/explain.test.ts +173 -0
- package/src/explain/index.ts +181 -0
- package/src/features/__tests__/analyzer.test.ts +358 -0
- package/src/features/__tests__/checklist.test.ts +454 -0
- package/src/features/__tests__/numbering.test.ts +319 -0
- package/src/features/__tests__/quality.test.ts +295 -0
- package/src/features/__tests__/traceability.test.ts +147 -0
- package/src/features/analyzer.ts +445 -0
- package/src/features/checklist.ts +366 -0
- package/src/features/index.ts +18 -0
- package/src/features/numbering.ts +404 -0
- package/src/features/quality.ts +349 -0
- package/src/features/test-stubs.ts +157 -0
- package/src/features/traceability.ts +260 -0
- package/src/feedback/__tests__/async-feedback.test.ts +52 -0
- package/src/feedback/__tests__/collector.test.ts +219 -0
- package/src/feedback/__tests__/compress.test.ts +150 -0
- package/src/feedback/__tests__/preferences.test.ts +169 -0
- package/src/feedback/collector.ts +135 -0
- package/src/feedback/compress.ts +92 -0
- package/src/feedback/preferences.ts +108 -0
- package/src/git/__tests__/git.test.ts +62 -0
- package/src/git/index.ts +110 -0
- package/src/hooks/__tests__/runner.test.ts +266 -0
- package/src/hooks/index.ts +8 -0
- package/src/hooks/runner.ts +130 -0
- package/src/index.ts +356 -0
- package/src/init/__tests__/init.test.ts +228 -0
- package/src/init/index.ts +364 -0
- package/src/language/__tests__/detect.test.ts +77 -0
- package/src/language/__tests__/profile.test.ts +51 -0
- package/src/language/detect.ts +70 -0
- package/src/language/profile.ts +110 -0
- package/src/prompts/__tests__/defaults.test.ts +52 -0
- package/src/prompts/__tests__/engine.test.ts +183 -0
- package/src/prompts/__tests__/evolution-resolve.test.ts +169 -0
- package/src/prompts/__tests__/evolution.test.ts +187 -0
- package/src/prompts/__tests__/loader.test.ts +105 -0
- package/src/prompts/candidates/review-v2.md +55 -0
- package/src/prompts/defaults/ai-review.md +49 -0
- package/src/prompts/defaults/commit.md +30 -0
- package/src/prompts/defaults/context.md +26 -0
- package/src/prompts/defaults/design-approaches.md +57 -0
- package/src/prompts/defaults/design-hld-lld.md +55 -0
- package/src/prompts/defaults/design.md +53 -0
- package/src/prompts/defaults/explain.md +31 -0
- package/src/prompts/defaults/fix.md +32 -0
- package/src/prompts/defaults/index.ts +38 -0
- package/src/prompts/defaults/review.md +41 -0
- package/src/prompts/defaults/spec-questions.md +59 -0
- package/src/prompts/defaults/tests.md +72 -0
- package/src/prompts/engine.ts +137 -0
- package/src/prompts/evolution.ts +409 -0
- package/src/prompts/loader.ts +71 -0
- package/src/review/__tests__/review.test.ts +288 -0
- package/src/review/comprehensive.ts +362 -0
- package/src/review/index.ts +417 -0
- package/src/stats/__tests__/tracker.test.ts +323 -0
- package/src/stats/index.ts +11 -0
- package/src/stats/tracker.ts +492 -0
- package/src/ticket/__tests__/ticket.test.ts +273 -0
- package/src/ticket/index.ts +185 -0
- package/src/utils.ts +87 -0
- package/src/verify/__tests__/ai-review.test.ts +242 -0
- package/src/verify/__tests__/coverage.test.ts +83 -0
- package/src/verify/__tests__/detect.test.ts +175 -0
- package/src/verify/__tests__/diff-filter.test.ts +338 -0
- package/src/verify/__tests__/fix.test.ts +478 -0
- package/src/verify/__tests__/linters/clippy.test.ts +45 -0
- package/src/verify/__tests__/linters/go-vet.test.ts +27 -0
- package/src/verify/__tests__/linters/ruff.test.ts +64 -0
- package/src/verify/__tests__/mutation.test.ts +141 -0
- package/src/verify/__tests__/pipeline.test.ts +553 -0
- package/src/verify/__tests__/proof.test.ts +97 -0
- package/src/verify/__tests__/secretlint.test.ts +190 -0
- package/src/verify/__tests__/semgrep.test.ts +217 -0
- package/src/verify/__tests__/slop.test.ts +366 -0
- package/src/verify/__tests__/sonar.test.ts +113 -0
- package/src/verify/__tests__/syntax-guard.test.ts +227 -0
- package/src/verify/__tests__/trivy.test.ts +191 -0
- package/src/verify/__tests__/visual.test.ts +139 -0
- package/src/verify/ai-review.ts +276 -0
- package/src/verify/coverage.ts +134 -0
- package/src/verify/detect.ts +171 -0
- package/src/verify/diff-filter.ts +183 -0
- package/src/verify/fix.ts +317 -0
- package/src/verify/linters/clippy.ts +52 -0
- package/src/verify/linters/go-vet.ts +32 -0
- package/src/verify/linters/ruff.ts +47 -0
- package/src/verify/mutation.ts +143 -0
- package/src/verify/pipeline.ts +328 -0
- package/src/verify/proof.ts +277 -0
- package/src/verify/secretlint.ts +168 -0
- package/src/verify/semgrep.ts +170 -0
- package/src/verify/slop.ts +493 -0
- package/src/verify/sonar.ts +146 -0
- package/src/verify/syntax-guard.ts +251 -0
- package/src/verify/trivy.ts +161 -0
- package/src/verify/visual.ts +460 -0
- package/src/workflow/__tests__/context.test.ts +110 -0
- package/src/workflow/context.ts +81 -0
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
|
|
4
|
+
export interface MainaConfig {
|
|
5
|
+
models: {
|
|
6
|
+
mechanical: string;
|
|
7
|
+
standard: string;
|
|
8
|
+
architectural: string;
|
|
9
|
+
local: string;
|
|
10
|
+
};
|
|
11
|
+
provider: string;
|
|
12
|
+
budget: {
|
|
13
|
+
daily: number;
|
|
14
|
+
perTask: number;
|
|
15
|
+
alertAt: number;
|
|
16
|
+
};
|
|
17
|
+
apiKey?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const DEFAULT_CONFIG: MainaConfig = {
|
|
21
|
+
models: {
|
|
22
|
+
mechanical: "google/gemini-2.5-flash",
|
|
23
|
+
standard: "anthropic/claude-sonnet-4",
|
|
24
|
+
architectural: "anthropic/claude-sonnet-4",
|
|
25
|
+
local: "ollama/qwen3-coder-8b",
|
|
26
|
+
},
|
|
27
|
+
provider: "openrouter",
|
|
28
|
+
budget: {
|
|
29
|
+
daily: 5.0,
|
|
30
|
+
perTask: 0.5,
|
|
31
|
+
alertAt: 0.8,
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Returns a deep copy of the default config so callers cannot mutate the
|
|
37
|
+
* internal defaults.
|
|
38
|
+
*/
|
|
39
|
+
export function getDefaultConfig(): MainaConfig {
|
|
40
|
+
return {
|
|
41
|
+
...DEFAULT_CONFIG,
|
|
42
|
+
models: { ...DEFAULT_CONFIG.models },
|
|
43
|
+
budget: { ...DEFAULT_CONFIG.budget },
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Walks up the directory tree starting at `startDir` (defaults to
|
|
49
|
+
* process.cwd()) looking for `maina.config.ts` then `maina.config.js`.
|
|
50
|
+
* Returns the absolute path of the first match, or null if none found.
|
|
51
|
+
*/
|
|
52
|
+
export function findConfigFile(startDir?: string): string | null {
|
|
53
|
+
let dir = startDir ?? process.cwd();
|
|
54
|
+
const names = ["maina.config.ts", "maina.config.js"];
|
|
55
|
+
|
|
56
|
+
while (true) {
|
|
57
|
+
for (const name of names) {
|
|
58
|
+
const candidate = join(dir, name);
|
|
59
|
+
if (existsSync(candidate)) {
|
|
60
|
+
return candidate;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const parent = dirname(dir);
|
|
65
|
+
// Reached filesystem root — stop
|
|
66
|
+
if (parent === dir) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
dir = parent;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Finds and dynamically imports the maina config file, then deep-merges it
|
|
75
|
+
* with the defaults. Falls back to defaults silently on any error.
|
|
76
|
+
*/
|
|
77
|
+
export async function loadConfig(startDir?: string): Promise<MainaConfig> {
|
|
78
|
+
const configPath = findConfigFile(startDir);
|
|
79
|
+
|
|
80
|
+
if (configPath === null) {
|
|
81
|
+
return getDefaultConfig();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
const mod = await import(configPath);
|
|
86
|
+
const userConfig: Partial<MainaConfig> = mod.default ?? mod;
|
|
87
|
+
return { ...DEFAULT_CONFIG, ...userConfig };
|
|
88
|
+
} catch {
|
|
89
|
+
return getDefaultConfig();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Returns the API key from environment variables, preferring MAINA_API_KEY
|
|
95
|
+
* over OPENROUTER_API_KEY. When MAINA_HOST_MODE is set, also checks for
|
|
96
|
+
* ANTHROPIC_API_KEY (set by Claude Code and similar host agents).
|
|
97
|
+
* Returns null when no key is found.
|
|
98
|
+
*/
|
|
99
|
+
export function getApiKey(): string | null {
|
|
100
|
+
return (
|
|
101
|
+
process.env.MAINA_API_KEY ??
|
|
102
|
+
process.env.OPENROUTER_API_KEY ??
|
|
103
|
+
process.env.ANTHROPIC_API_KEY ??
|
|
104
|
+
null
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Resolves the active provider, allowing the MAINA_PROVIDER environment
|
|
110
|
+
* variable to override whatever is in the config.
|
|
111
|
+
*
|
|
112
|
+
* When running in host mode (MAINA_HOST_MODE=true or ANTHROPIC_API_KEY is set
|
|
113
|
+
* without explicit provider), auto-detects the appropriate provider:
|
|
114
|
+
* - ANTHROPIC_API_KEY → "anthropic"
|
|
115
|
+
* - Otherwise → config default
|
|
116
|
+
*/
|
|
117
|
+
export function resolveProvider(config: MainaConfig): string {
|
|
118
|
+
// Explicit override always wins
|
|
119
|
+
if (process.env.MAINA_PROVIDER) {
|
|
120
|
+
return process.env.MAINA_PROVIDER;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Host mode auto-detection: if running inside Claude Code or similar,
|
|
124
|
+
// ANTHROPIC_API_KEY is available but no explicit Maina key
|
|
125
|
+
if (isHostMode()) {
|
|
126
|
+
if (
|
|
127
|
+
process.env.ANTHROPIC_API_KEY &&
|
|
128
|
+
!process.env.MAINA_API_KEY &&
|
|
129
|
+
!process.env.OPENROUTER_API_KEY
|
|
130
|
+
) {
|
|
131
|
+
return "anthropic";
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return config.provider;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Detect if Maina is running inside a host agent environment
|
|
140
|
+
* (e.g., Claude Code, Cursor, Codex).
|
|
141
|
+
*
|
|
142
|
+
* Checks for:
|
|
143
|
+
* - MAINA_HOST_MODE=true (explicit opt-in)
|
|
144
|
+
* - CLAUDECODE=1 (Claude Code sets this — note: no underscore)
|
|
145
|
+
* - CLAUDE_CODE_ENTRYPOINT (Claude Code sets this to "cli")
|
|
146
|
+
* - CURSOR=1 (Cursor sets this)
|
|
147
|
+
* - ANTHROPIC_API_KEY without MAINA_API_KEY
|
|
148
|
+
*/
|
|
149
|
+
export function isHostMode(): boolean {
|
|
150
|
+
if (process.env.MAINA_HOST_MODE === "true") return true;
|
|
151
|
+
// Claude Code sets CLAUDECODE=1 (no underscore) and CLAUDE_CODE_ENTRYPOINT
|
|
152
|
+
if (process.env.CLAUDECODE === "1") return true;
|
|
153
|
+
if (process.env.CLAUDE_CODE_ENTRYPOINT) return true;
|
|
154
|
+
if (process.env.CURSOR === "1") return true;
|
|
155
|
+
// Infer host mode when we have an Anthropic key but no explicit Maina config
|
|
156
|
+
if (
|
|
157
|
+
process.env.ANTHROPIC_API_KEY &&
|
|
158
|
+
!process.env.MAINA_API_KEY &&
|
|
159
|
+
!process.env.OPENROUTER_API_KEY
|
|
160
|
+
) {
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* When running inside a host agent (Claude Code, Cursor), AI calls should
|
|
168
|
+
* be delegated to the host rather than making direct API calls.
|
|
169
|
+
*
|
|
170
|
+
* Returns structured prompt data that the host agent can process.
|
|
171
|
+
* The MCP server or skills package uses this to pass context to the host.
|
|
172
|
+
*
|
|
173
|
+
* NOTE: Currently unused — retained for MCP/skills host delegation (Sprint 10+).
|
|
174
|
+
*/
|
|
175
|
+
export interface HostDelegation {
|
|
176
|
+
mode: "host";
|
|
177
|
+
systemPrompt: string;
|
|
178
|
+
userPrompt: string;
|
|
179
|
+
task: string;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Check if AI should be delegated to host instead of direct API call.
|
|
184
|
+
*
|
|
185
|
+
* NOTE: In practice this currently returns true only when MAINA_HOST_MODE=true
|
|
186
|
+
* is set explicitly with no API keys. The common Claude Code scenario
|
|
187
|
+
* (CLAUDECODE=1 without API keys) triggers isHostMode() but also triggers
|
|
188
|
+
* this function's delegation. The generate() function handles this by
|
|
189
|
+
* returning a [HOST_DELEGATION] prompt string.
|
|
190
|
+
*/
|
|
191
|
+
export function shouldDelegateToHost(): boolean {
|
|
192
|
+
if (!isHostMode()) return false;
|
|
193
|
+
// If user has their own API key, use it directly
|
|
194
|
+
if (process.env.MAINA_API_KEY || process.env.OPENROUTER_API_KEY) return false;
|
|
195
|
+
if (process.env.ANTHROPIC_API_KEY) return false;
|
|
196
|
+
// In host mode with no key — delegate to host agent
|
|
197
|
+
return true;
|
|
198
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { BudgetAllocation, LayerContent } from "../budget";
|
|
3
|
+
import {
|
|
4
|
+
assembleBudget,
|
|
5
|
+
calculateTokens,
|
|
6
|
+
getBudgetRatio,
|
|
7
|
+
truncateToFit,
|
|
8
|
+
} from "../budget";
|
|
9
|
+
|
|
10
|
+
describe("calculateTokens", () => {
|
|
11
|
+
test("returns approximately chars/3.5", () => {
|
|
12
|
+
const text = "hello world"; // 11 chars → ceil(11/3.5) = ceil(3.14) = 4
|
|
13
|
+
expect(calculateTokens(text)).toBe(Math.ceil(text.length / 3.5));
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("returns 0 for empty string", () => {
|
|
17
|
+
expect(calculateTokens("")).toBe(0);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("always returns a whole number", () => {
|
|
21
|
+
const text = "abcde"; // 5 chars → ceil(5/3.5) = ceil(1.43) = 2
|
|
22
|
+
const result = calculateTokens(text);
|
|
23
|
+
expect(Number.isInteger(result)).toBe(true);
|
|
24
|
+
expect(result).toBe(2);
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe("getBudgetRatio", () => {
|
|
29
|
+
test("focused mode returns 0.4", () => {
|
|
30
|
+
expect(getBudgetRatio("focused")).toBe(0.4);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("default mode returns 0.6", () => {
|
|
34
|
+
expect(getBudgetRatio("default")).toBe(0.6);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("explore mode returns 0.8", () => {
|
|
38
|
+
expect(getBudgetRatio("explore")).toBe(0.8);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("assembleBudget", () => {
|
|
43
|
+
test("allocations sum correctly for default mode with default context window", () => {
|
|
44
|
+
const allocation = assembleBudget("default");
|
|
45
|
+
// working + episodic + semantic + retrieval + headroom === total
|
|
46
|
+
const layerSum =
|
|
47
|
+
allocation.working +
|
|
48
|
+
allocation.episodic +
|
|
49
|
+
allocation.semantic +
|
|
50
|
+
allocation.retrieval +
|
|
51
|
+
allocation.headroom;
|
|
52
|
+
expect(layerSum).toBe(allocation.total);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("total equals model context window", () => {
|
|
56
|
+
const allocation = assembleBudget("default", 100_000);
|
|
57
|
+
expect(allocation.total).toBe(100_000);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("headroom is correct for focused mode", () => {
|
|
61
|
+
const modelContext = 200_000;
|
|
62
|
+
const allocation = assembleBudget("focused", modelContext);
|
|
63
|
+
const ratio = 0.4;
|
|
64
|
+
const budget = Math.floor(modelContext * ratio);
|
|
65
|
+
const expectedHeadroom = modelContext - budget;
|
|
66
|
+
expect(allocation.headroom).toBe(expectedHeadroom);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("layer allocations sum to budget (not total) for explore mode", () => {
|
|
70
|
+
const modelContext = 200_000;
|
|
71
|
+
const allocation = assembleBudget("explore", modelContext);
|
|
72
|
+
const ratio = 0.8;
|
|
73
|
+
const budget = Math.floor(modelContext * ratio);
|
|
74
|
+
const layerSum =
|
|
75
|
+
allocation.working +
|
|
76
|
+
allocation.episodic +
|
|
77
|
+
allocation.semantic +
|
|
78
|
+
allocation.retrieval;
|
|
79
|
+
expect(layerSum).toBe(budget);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("working layer is always fully allocated", () => {
|
|
83
|
+
const modelContext = 200_000;
|
|
84
|
+
const allocationDefault = assembleBudget("default", modelContext);
|
|
85
|
+
const allocationFocused = assembleBudget("focused", modelContext);
|
|
86
|
+
// working = ~25% of budget; for focused: budget = 0.4 * 200_000 = 80_000
|
|
87
|
+
// working = floor(80_000 * 0.25) = 20_000
|
|
88
|
+
expect(allocationFocused.working).toBe(
|
|
89
|
+
Math.floor(Math.floor(modelContext * 0.4) * 0.25),
|
|
90
|
+
);
|
|
91
|
+
expect(allocationDefault.working).toBe(
|
|
92
|
+
Math.floor(Math.floor(modelContext * 0.6) * 0.25),
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe("truncateToFit", () => {
|
|
98
|
+
const makeLayers = (): LayerContent[] => [
|
|
99
|
+
{ name: "working", text: "working context", tokens: 100, priority: 0 },
|
|
100
|
+
{ name: "semantic", text: "semantic context", tokens: 200, priority: 1 },
|
|
101
|
+
{ name: "episodic", text: "episodic context", tokens: 300, priority: 2 },
|
|
102
|
+
{ name: "retrieval", text: "retrieval context", tokens: 400, priority: 3 },
|
|
103
|
+
];
|
|
104
|
+
|
|
105
|
+
const makeBudget = (
|
|
106
|
+
overrides: Partial<BudgetAllocation> = {},
|
|
107
|
+
): BudgetAllocation => ({
|
|
108
|
+
working: 100,
|
|
109
|
+
semantic: 200,
|
|
110
|
+
episodic: 300,
|
|
111
|
+
retrieval: 400,
|
|
112
|
+
headroom: 0,
|
|
113
|
+
total: 1000,
|
|
114
|
+
...overrides,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("returns all layers unchanged when within budget", () => {
|
|
118
|
+
const layers = makeLayers(); // total 1000 tokens
|
|
119
|
+
const budget = makeBudget(); // total 1000
|
|
120
|
+
const result = truncateToFit(layers, budget);
|
|
121
|
+
expect(result).toHaveLength(4);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("removes retrieval first when over budget", () => {
|
|
125
|
+
const layers = makeLayers(); // total 1000 tokens
|
|
126
|
+
// Budget only allows 600 tokens total (working+semantic+episodic = 600)
|
|
127
|
+
const budget = makeBudget({ retrieval: 0, total: 600 });
|
|
128
|
+
const result = truncateToFit(layers, budget);
|
|
129
|
+
const names = result.map((l) => l.name);
|
|
130
|
+
expect(names).not.toContain("retrieval");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("removes episodic after retrieval when still over budget", () => {
|
|
134
|
+
const layers = makeLayers(); // total 1000 tokens
|
|
135
|
+
// Budget only allows 300 tokens (working+semantic = 300)
|
|
136
|
+
const budget = makeBudget({ retrieval: 0, episodic: 0, total: 300 });
|
|
137
|
+
const result = truncateToFit(layers, budget);
|
|
138
|
+
const names = result.map((l) => l.name);
|
|
139
|
+
expect(names).not.toContain("retrieval");
|
|
140
|
+
expect(names).not.toContain("episodic");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("never removes working context", () => {
|
|
144
|
+
const layers = makeLayers();
|
|
145
|
+
// Tiny budget — only enough for working
|
|
146
|
+
const budget = makeBudget({
|
|
147
|
+
semantic: 0,
|
|
148
|
+
episodic: 0,
|
|
149
|
+
retrieval: 0,
|
|
150
|
+
total: 100,
|
|
151
|
+
});
|
|
152
|
+
const result = truncateToFit(layers, budget);
|
|
153
|
+
const names = result.map((l) => l.name);
|
|
154
|
+
expect(names).toContain("working");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("working context is always present even when over budget", () => {
|
|
158
|
+
const layers = makeLayers();
|
|
159
|
+
// Absurdly small budget
|
|
160
|
+
const budget = makeBudget({
|
|
161
|
+
working: 1,
|
|
162
|
+
semantic: 0,
|
|
163
|
+
episodic: 0,
|
|
164
|
+
retrieval: 0,
|
|
165
|
+
total: 1,
|
|
166
|
+
});
|
|
167
|
+
const result = truncateToFit(layers, budget);
|
|
168
|
+
expect(result.some((l) => l.name === "working")).toBe(true);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("returns layers sorted by priority after truncation", () => {
|
|
172
|
+
const layers = makeLayers();
|
|
173
|
+
const budget = makeBudget({ retrieval: 0, total: 600 });
|
|
174
|
+
const result = truncateToFit(layers, budget);
|
|
175
|
+
const priorities = result.map((l) => l.priority);
|
|
176
|
+
const sorted = [...priorities].sort((a, b) => a - b);
|
|
177
|
+
expect(priorities).toEqual(sorted);
|
|
178
|
+
});
|
|
179
|
+
});
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdirSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { assembleContext } from "../engine";
|
|
6
|
+
|
|
7
|
+
// Create a temporary .maina dir for tests
|
|
8
|
+
let tempMainaDir: string;
|
|
9
|
+
const repoRoot = process.cwd();
|
|
10
|
+
|
|
11
|
+
beforeAll(() => {
|
|
12
|
+
tempMainaDir = join(tmpdir(), `maina-engine-test-${Date.now()}`);
|
|
13
|
+
mkdirSync(join(tempMainaDir, "context"), { recursive: true });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterAll(() => {
|
|
17
|
+
try {
|
|
18
|
+
rmSync(tempMainaDir, { recursive: true, force: true });
|
|
19
|
+
} catch {
|
|
20
|
+
// ignore cleanup errors
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("assembleContext", () => {
|
|
25
|
+
test("assembleContext('commit') returns an AssembledContext object", async () => {
|
|
26
|
+
const result = await assembleContext("commit", {
|
|
27
|
+
repoRoot,
|
|
28
|
+
mainaDir: tempMainaDir,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
expect(result).toBeDefined();
|
|
32
|
+
expect(typeof result.text).toBe("string");
|
|
33
|
+
expect(typeof result.tokens).toBe("number");
|
|
34
|
+
expect(Array.isArray(result.layers)).toBe(true);
|
|
35
|
+
expect(result.mode).toBeDefined();
|
|
36
|
+
expect(result.budget).toBeDefined();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("assembleContext('commit') has budget mode 'focused'", async () => {
|
|
40
|
+
const result = await assembleContext("commit", {
|
|
41
|
+
repoRoot,
|
|
42
|
+
mainaDir: tempMainaDir,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
expect(result.mode).toBe("focused");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("assembleContext('commit') includes working layer", async () => {
|
|
49
|
+
const result = await assembleContext("commit", {
|
|
50
|
+
repoRoot,
|
|
51
|
+
mainaDir: tempMainaDir,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const workingLayer = result.layers.find((l) => l.name === "working");
|
|
55
|
+
expect(workingLayer).toBeDefined();
|
|
56
|
+
expect(workingLayer?.included).toBe(true);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("assembleContext returns tokens count > 0", async () => {
|
|
60
|
+
const result = await assembleContext("commit", {
|
|
61
|
+
repoRoot,
|
|
62
|
+
mainaDir: tempMainaDir,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
expect(result.tokens).toBeGreaterThan(0);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("assembleContext returns layer reports", async () => {
|
|
69
|
+
const result = await assembleContext("commit", {
|
|
70
|
+
repoRoot,
|
|
71
|
+
mainaDir: tempMainaDir,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
expect(result.layers.length).toBeGreaterThan(0);
|
|
75
|
+
for (const layer of result.layers) {
|
|
76
|
+
expect(typeof layer.name).toBe("string");
|
|
77
|
+
expect(typeof layer.tokens).toBe("number");
|
|
78
|
+
expect(typeof layer.entries).toBe("number");
|
|
79
|
+
expect(typeof layer.included).toBe("boolean");
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("assembled text is non-empty", async () => {
|
|
84
|
+
const result = await assembleContext("commit", {
|
|
85
|
+
repoRoot,
|
|
86
|
+
mainaDir: tempMainaDir,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
expect(result.text.length).toBeGreaterThan(0);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("working layer is always present in layer reports", async () => {
|
|
93
|
+
const result = await assembleContext("commit", {
|
|
94
|
+
repoRoot,
|
|
95
|
+
mainaDir: tempMainaDir,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
const workingLayer = result.layers.find((l) => l.name === "working");
|
|
99
|
+
expect(workingLayer).toBeDefined();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("assembleContext('context') includes more layers than 'commit'", async () => {
|
|
103
|
+
const [commitResult, contextResult] = await Promise.all([
|
|
104
|
+
assembleContext("commit", { repoRoot, mainaDir: tempMainaDir }),
|
|
105
|
+
assembleContext("context", { repoRoot, mainaDir: tempMainaDir }),
|
|
106
|
+
]);
|
|
107
|
+
|
|
108
|
+
const commitIncluded = commitResult.layers.filter((l) => l.included).length;
|
|
109
|
+
const contextIncluded = contextResult.layers.filter(
|
|
110
|
+
(l) => l.included,
|
|
111
|
+
).length;
|
|
112
|
+
|
|
113
|
+
expect(contextIncluded).toBeGreaterThanOrEqual(commitIncluded);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("assembleContext('context') has budget mode 'explore'", async () => {
|
|
117
|
+
const result = await assembleContext("context", {
|
|
118
|
+
repoRoot,
|
|
119
|
+
mainaDir: tempMainaDir,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
expect(result.mode).toBe("explore");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("assembleContext is resilient — returns valid result even with bad mainaDir", async () => {
|
|
126
|
+
const result = await assembleContext("commit", {
|
|
127
|
+
repoRoot,
|
|
128
|
+
mainaDir: join(tmpdir(), "nonexistent-maina-dir-xyz"),
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// Should not throw; should return a valid (possibly minimal) context
|
|
132
|
+
expect(result).toBeDefined();
|
|
133
|
+
expect(typeof result.text).toBe("string");
|
|
134
|
+
expect(typeof result.tokens).toBe("number");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("assembleContext with searchQuery includes retrieval layer for 'context' command", async () => {
|
|
138
|
+
const result = await assembleContext("context", {
|
|
139
|
+
repoRoot,
|
|
140
|
+
mainaDir: tempMainaDir,
|
|
141
|
+
searchQuery: "assembleContext",
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
const retrievalLayer = result.layers.find((l) => l.name === "retrieval");
|
|
145
|
+
// retrieval layer should be present (included or not depending on results)
|
|
146
|
+
expect(retrievalLayer).toBeDefined();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("budget allocation is populated with numeric values", async () => {
|
|
150
|
+
const result = await assembleContext("commit", {
|
|
151
|
+
repoRoot,
|
|
152
|
+
mainaDir: tempMainaDir,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
expect(typeof result.budget.working).toBe("number");
|
|
156
|
+
expect(typeof result.budget.episodic).toBe("number");
|
|
157
|
+
expect(typeof result.budget.semantic).toBe("number");
|
|
158
|
+
expect(typeof result.budget.retrieval).toBe("number");
|
|
159
|
+
expect(typeof result.budget.total).toBe("number");
|
|
160
|
+
expect(typeof result.budget.headroom).toBe("number");
|
|
161
|
+
expect(result.budget.total).toBeGreaterThan(0);
|
|
162
|
+
});
|
|
163
|
+
});
|