minovative-mind-cli 2.10.0 → 2.11.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.
@@ -10,17 +10,52 @@ const LANGUAGE_ALIASES = {
10
10
  js: 'node',
11
11
  javascript: 'node',
12
12
  node: 'node',
13
+ cjs: 'node',
14
+ mjs: 'node',
13
15
  ts: 'ts-node',
14
16
  typescript: 'ts-node',
15
17
  'ts-node': 'ts-node',
18
+ tsx: 'ts-node',
19
+ mts: 'ts-node',
20
+ cts: 'ts-node',
16
21
  py: 'python',
22
+ py3: 'python',
17
23
  python: 'python',
24
+ python3: 'python',
18
25
  sh: 'bash',
19
26
  bash: 'bash',
27
+ shell: 'bash',
28
+ zsh: 'bash',
20
29
  go: 'go',
30
+ golang: 'go',
21
31
  rs: 'rust',
22
32
  rust: 'rust',
33
+ rustc: 'rust',
34
+ c: 'c',
35
+ cpp: 'cpp',
36
+ 'c++': 'cpp',
37
+ cc: 'cpp',
38
+ cxx: 'cpp',
39
+ cplusplus: 'cpp',
40
+ rb: 'ruby',
41
+ ruby: 'ruby',
42
+ php: 'php',
43
+ java: 'java',
23
44
  };
45
+ /** Supported canonical language names list. */
46
+ export const SUPPORTED_LANGUAGES = [
47
+ 'node',
48
+ 'ts-node',
49
+ 'python',
50
+ 'bash',
51
+ 'go',
52
+ 'rust',
53
+ 'c',
54
+ 'cpp',
55
+ 'ruby',
56
+ 'php',
57
+ 'java',
58
+ ];
24
59
  /**
25
60
  * Normalizes user/AI provided language string to a standard runtime identifier.
26
61
  */
@@ -28,6 +63,135 @@ export function normalizeLanguage(lang) {
28
63
  const key = lang.toLowerCase().trim();
29
64
  return LANGUAGE_ALIASES[key] || key;
30
65
  }
66
+ /**
67
+ * Detects programming language heuristic markers directly from script source code syntax.
68
+ * Useful when language is omitted or set to 'auto'.
69
+ *
70
+ * @param code - The source code to analyze.
71
+ * @returns Detected runtime identifier (e.g., 'python', 'rust', 'go', 'cpp', 'c', 'bash', 'ruby', 'php', 'java', 'ts-node', 'node').
72
+ */
73
+ export function detectLanguageFromCode(code) {
74
+ const trimmed = code.trim();
75
+ if (!trimmed)
76
+ return 'node';
77
+ // Shebang check
78
+ if (trimmed.startsWith('#!')) {
79
+ const firstLine = trimmed.split('\n')[0].toLowerCase();
80
+ if (firstLine.includes('python'))
81
+ return 'python';
82
+ if (firstLine.includes('bash') || firstLine.includes('sh') || firstLine.includes('zsh'))
83
+ return 'bash';
84
+ if (firstLine.includes('node'))
85
+ return 'node';
86
+ if (firstLine.includes('ruby'))
87
+ return 'ruby';
88
+ if (firstLine.includes('php'))
89
+ return 'php';
90
+ }
91
+ // PHP marker
92
+ if (trimmed.startsWith('<?php') || trimmed.includes('<?php')) {
93
+ return 'php';
94
+ }
95
+ // Rust markers
96
+ if (/\b(fn\s+main\s*\(|println!\s*\(|eprintln!\s*\(|use\s+std::|impl\s+[A-Z]\w*|pub\s+fn\b|match\s+\w+\s*\{)/.test(code)) {
97
+ return 'rust';
98
+ }
99
+ // Go markers
100
+ if (/\bpackage\s+main\b/.test(code) ||
101
+ (/\bfunc\s+main\s*\(/.test(code) && /\bimport\s+(\(|")/.test(code))) {
102
+ return 'go';
103
+ }
104
+ // C++ markers
105
+ if (/#\s*include\s*<(iostream|vector|string|memory|algorithm|map|set|utility)>/.test(code) ||
106
+ /\bstd::(cout|cin|cerr|endl|vector|string|make_unique|make_shared)\b/.test(code) ||
107
+ /\btemplate\s*<\s*typename\b/.test(code)) {
108
+ return 'cpp';
109
+ }
110
+ // C markers
111
+ if (/#\s*include\s*<(stdio\.h|stdlib\.h|string\.h|unistd\.h|math\.h)>/.test(code) ||
112
+ (/\bprintf\s*\(/.test(code) && /\bint\s+main\s*\(/.test(code))) {
113
+ return 'c';
114
+ }
115
+ // Java markers
116
+ if (/\bpublic\s+class\s+[A-Z]\w*/.test(code) ||
117
+ /\bpublic\s+static\s+void\s+main\s*\(/.test(code) ||
118
+ /\bSystem\.(out|err)\.println\b/.test(code)) {
119
+ return 'java';
120
+ }
121
+ // Python markers
122
+ if (/\b(def\s+[a-zA-Z_]\w*\s*\(|import\s+[a-zA-Z_]\w*|from\s+[a-zA-Z_]\w*\s+import|if\s+__name__\s*==\s*['"]__main__['"])/.test(code) ||
123
+ (/:\s*$/.test(trimmed) && /\b(elif|except|finally|pass|yield)\b/.test(code))) {
124
+ return 'python';
125
+ }
126
+ // Ruby markers
127
+ if (/\b(def\s+[a-zA-Z_]\w*(\s*\(|\s*\n)|puts\s+|require_relative\s+|attr_accessor\s+:)/.test(code) &&
128
+ /\bend\b/.test(code)) {
129
+ return 'ruby';
130
+ }
131
+ // TypeScript markers
132
+ if (/\b(interface\s+[A-Z]\w*|type\s+[A-Z]\w*\s*=|:\s*(string|number|boolean|Record<|Array<|Promise<|void|unknown|never)\b|as\s+const\b)/.test(code)) {
133
+ return 'ts-node';
134
+ }
135
+ // Bash markers
136
+ if (/\b(echo\s+['"].*['"]|if\s+\[\s+.*\s+\];\s*then|fi\b|done\b|export\s+[A-Z_]+=\S+)/.test(code) &&
137
+ !code.includes('console.log')) {
138
+ return 'bash';
139
+ }
140
+ return 'node';
141
+ }
142
+ /**
143
+ * Detects the dominant programming language / runtime for a workspace based on project manifest files.
144
+ *
145
+ * @param workspaceRoot - Path to the workspace root directory.
146
+ * @returns Detected runtime identifier (e.g., 'rust', 'go', 'python', 'cpp', 'ts-node', 'node').
147
+ */
148
+ export async function detectProjectRuntime(workspaceRoot) {
149
+ const probeFiles = [
150
+ { file: 'Cargo.toml', runtime: 'rust' },
151
+ { file: 'go.mod', runtime: 'go' },
152
+ { file: 'pyproject.toml', runtime: 'python' },
153
+ { file: 'requirements.txt', runtime: 'python' },
154
+ { file: 'Pipfile', runtime: 'python' },
155
+ { file: 'setup.py', runtime: 'python' },
156
+ { file: 'CMakeLists.txt', runtime: 'cpp' },
157
+ { file: 'Gemfile', runtime: 'ruby' },
158
+ { file: 'composer.json', runtime: 'php' },
159
+ { file: 'pom.xml', runtime: 'java' },
160
+ { file: 'build.gradle', runtime: 'java' },
161
+ { file: 'tsconfig.json', runtime: 'ts-node' },
162
+ { file: 'package.json', runtime: 'node' },
163
+ ];
164
+ for (const { file, runtime } of probeFiles) {
165
+ try {
166
+ await fs.access(path.join(workspaceRoot, file));
167
+ return runtime;
168
+ }
169
+ catch {
170
+ // Continue searching
171
+ }
172
+ }
173
+ return 'node';
174
+ }
175
+ /**
176
+ * Resolves the effective runtime language by combining explicit language input,
177
+ * source code syntax heuristics, and workspace project manifests.
178
+ *
179
+ * @param workspaceRoot - Path to workspace root directory.
180
+ * @param language - Optional language parameter passed by user/agent.
181
+ * @param code - Optional source code string to inspect.
182
+ */
183
+ export async function resolveEffectiveRuntime(workspaceRoot, language, code) {
184
+ if (language && language.toLowerCase().trim() !== 'auto') {
185
+ return normalizeLanguage(language);
186
+ }
187
+ if (code && code.trim()) {
188
+ const detectedFromCode = detectLanguageFromCode(code);
189
+ if (detectedFromCode !== 'node') {
190
+ return detectedFromCode;
191
+ }
192
+ }
193
+ return detectProjectRuntime(workspaceRoot);
194
+ }
31
195
  /**
32
196
  * Detects whether the workspace package.json specifies `"type": "module"`.
33
197
  * Returns `'module'` or `'commonjs'`.
@@ -72,6 +236,11 @@ export async function resolveScriptExtension(workspaceRoot, language, code) {
72
236
  bash: '.sh',
73
237
  go: '.go',
74
238
  rust: '.rs',
239
+ c: '.c',
240
+ cpp: '.cpp',
241
+ ruby: '.rb',
242
+ php: '.php',
243
+ java: '.java',
75
244
  };
76
245
  return defaultExts[normLang] || '.js';
77
246
  }
@@ -89,6 +258,7 @@ function buildTempPath(code, ext) {
89
258
  */
90
259
  function buildCommand(language, scriptPath) {
91
260
  const normLang = normalizeLanguage(language);
261
+ const binExt = os.platform() === 'win32' ? '.exe' : '';
92
262
  switch (normLang) {
93
263
  case 'node':
94
264
  return `node "${scriptPath}"`;
@@ -101,10 +271,23 @@ function buildCommand(language, scriptPath) {
101
271
  case 'go':
102
272
  return `go run "${scriptPath}"`;
103
273
  case 'rust': {
104
- const binExt = os.platform() === 'win32' ? '.exe' : '';
105
274
  const binPath = scriptPath.replace(/\.rs$/, binExt);
106
275
  return `rustc "${scriptPath}" -o "${binPath}" && "${binPath}"`;
107
276
  }
277
+ case 'c': {
278
+ const binPath = scriptPath.replace(/\.c$/, binExt);
279
+ return `gcc "${scriptPath}" -o "${binPath}" && "${binPath}"`;
280
+ }
281
+ case 'cpp': {
282
+ const binPath = scriptPath.replace(/\.cpp$/, binExt);
283
+ return `g++ -std=c++17 "${scriptPath}" -o "${binPath}" && "${binPath}"`;
284
+ }
285
+ case 'ruby':
286
+ return `ruby "${scriptPath}"`;
287
+ case 'php':
288
+ return `php "${scriptPath}"`;
289
+ case 'java':
290
+ return `java "${scriptPath}"`;
108
291
  default:
109
292
  return null;
110
293
  }
@@ -134,30 +317,39 @@ function truncateOutput(text, max) {
134
317
  export async function runEphemeralScript(workspaceRoot, language, code, options) {
135
318
  const timeoutMs = options?.timeoutMs ?? 60_000;
136
319
  const maxOutputChars = options?.maxOutputChars ?? 100_000;
137
- const normLang = normalizeLanguage(language);
320
+ const normLang = await resolveEffectiveRuntime(workspaceRoot, language, code);
138
321
  const ext = await resolveScriptExtension(workspaceRoot, normLang, code);
139
322
  const scriptPath = buildTempPath(code, ext);
140
323
  const cmd = buildCommand(normLang, scriptPath);
141
324
  if (!cmd) {
142
325
  return {
143
326
  stdout: '',
144
- stderr: `Unsupported language runtime: "${language}". Supported: node, ts-node, python, bash, go, rust`,
327
+ stderr: `Unsupported language runtime: "${language}". Supported: ${SUPPORTED_LANGUAGES.join(', ')}`,
145
328
  exitCode: 1,
146
329
  };
147
330
  }
148
331
  // Collect all temp files created so we can clean them up unconditionally
149
332
  const tempFiles = [scriptPath];
333
+ const binExt = os.platform() === 'win32' ? '.exe' : '';
150
334
  if (normLang === 'rust') {
151
- const binExt = os.platform() === 'win32' ? '.exe' : '';
152
335
  tempFiles.push(scriptPath.replace(/\.rs$/, binExt));
336
+ tempFiles.push(scriptPath.replace(/\.rs$/, '.pdb'));
337
+ }
338
+ else if (normLang === 'c') {
339
+ tempFiles.push(scriptPath.replace(/\.c$/, binExt));
340
+ }
341
+ else if (normLang === 'cpp') {
342
+ tempFiles.push(scriptPath.replace(/\.cpp$/, binExt));
153
343
  }
154
344
  try {
155
345
  await fs.writeFile(scriptPath, code, 'utf-8');
346
+ const mergedEnv = options?.env ? { ...process.env, ...options.env } : process.env;
156
347
  const { stdout, stderr } = await execAsync(cmd, {
157
348
  cwd: workspaceRoot,
158
349
  timeout: timeoutMs,
159
350
  maxBuffer: 1024 * 1024, // 1 MB buffer
160
351
  signal: options?.abortSignal,
352
+ env: mergedEnv,
161
353
  });
162
354
  return {
163
355
  stdout: truncateOutput(stdout.trim(), maxOutputChars),
@@ -170,6 +362,9 @@ export async function runEphemeralScript(workspaceRoot, language, code, options)
170
362
  if (err.name === 'AbortError' || err.message?.includes('abort')) {
171
363
  return { stdout: '', stderr: 'Analysis script aborted by user.', exitCode: 130 };
172
364
  }
365
+ if (err.killed && err.signal === 'SIGTERM') {
366
+ return { stdout: '', stderr: `Execution timed out after ${timeoutMs}ms.`, exitCode: 124 };
367
+ }
173
368
  const stdout = truncateOutput((err.stdout || '').trim(), maxOutputChars);
174
369
  const stderr = truncateOutput((err.stderr || '').trim(), maxOutputChars);
175
370
  const exitCode = typeof err.code === 'number' ? err.code : 1;
@@ -179,7 +374,7 @@ export async function runEphemeralScript(workspaceRoot, language, code, options)
179
374
  // Guarantee temp file cleanup regardless of success or failure
180
375
  for (const tmpFile of tempFiles) {
181
376
  try {
182
- await fs.rm(tmpFile, { force: true });
377
+ await fs.rm(tmpFile, { force: true, recursive: true });
183
378
  }
184
379
  catch {
185
380
  // Ignore cleanup errors — temp files will be reaped by the OS eventually
@@ -1,12 +1,12 @@
1
1
  /**
2
2
  * @file Utility functions for preparing and sanitizing context-related
3
3
  * injection strings to be sent to the AI model. Includes handling of CDATA block
4
- * formatting to prevent nesting or breaking XML-like structure.
4
+ * formatting, character/token budgeting, and lightweight AST-scoped context extraction.
5
5
  */
6
6
  import { ContextAgentResult } from '../services/contextAgent.js';
7
7
  /**
8
8
  * Sanitizes file content string to prevent nesting or breakout issues when wrapped in CDATA.
9
- * Replaces occurrences of "]]>" with an escaped equivalent containing a zero-width space.
9
+ * Replaces occurrences of "]]\u200B>" with an escaped equivalent containing a zero-width space.
10
10
  *
11
11
  * @param content The raw content string to sanitize.
12
12
  * @returns The sanitized string safe to place inside a CDATA section.
@@ -17,7 +17,23 @@ export declare function sanitizeForCDATA(content: string): string;
17
17
  * This is used to inject project profiles, directories, summary, and relevant files'
18
18
  * content into the prompt context for the LLM.
19
19
  *
20
+ * Supports an optional character budget constraint to prevent oversized prompts.
21
+ *
20
22
  * @param context The collected context data from ContextAgent.
23
+ * @param maxChars Optional maximum character budget for the generated prompt injection.
21
24
  * @returns A formatted string containing project profile, structure, investigation summaries, and relevant file contents.
22
25
  */
23
- export declare function buildContextInjection(context: ContextAgentResult): string;
26
+ export declare function buildContextInjection(context: ContextAgentResult, maxChars?: number): string;
27
+ /**
28
+ * Constructs a lightweight, AST-scoped XML/Markdown-like project context string from ContextAgentResult.
29
+ * Uses `extractDeclarationsOutline` to replace full implementation bodies in relevant files with
30
+ * compact declarations outlines (types, interfaces, class skeletons, function signatures),
31
+ * drastically conserving context tokens during orchestration and sub-agent dispatch.
32
+ *
33
+ * Supports an optional character budget constraint.
34
+ *
35
+ * @param context The collected context data from ContextAgent.
36
+ * @param maxChars Optional maximum character budget for the generated prompt injection.
37
+ * @returns A formatted string containing project profile, structure, investigation summaries, and AST-scoped file outlines.
38
+ */
39
+ export declare function buildScopedContextInjection(context: ContextAgentResult, maxChars?: number): string;
@@ -1,30 +1,30 @@
1
1
  /**
2
2
  * @file Utility functions for preparing and sanitizing context-related
3
3
  * injection strings to be sent to the AI model. Includes handling of CDATA block
4
- * formatting to prevent nesting or breaking XML-like structure.
4
+ * formatting, character/token budgeting, and lightweight AST-scoped context extraction.
5
5
  */
6
6
  import { changeLogger } from '../services/changeLogger.js';
7
+ import { extractDeclarationsOutline } from './symbolExtractor.js';
7
8
  /**
8
9
  * Sanitizes file content string to prevent nesting or breakout issues when wrapped in CDATA.
9
- * Replaces occurrences of "]]>" with an escaped equivalent containing a zero-width space.
10
+ * Replaces occurrences of "]]\u200B>" with an escaped equivalent containing a zero-width space.
10
11
  *
11
12
  * @param content The raw content string to sanitize.
12
13
  * @returns The sanitized string safe to place inside a CDATA section.
13
14
  */
14
15
  export function sanitizeForCDATA(content) {
15
- // Prevent CDATA breakout by escaping ]]\\u200B>
16
+ // Prevent CDATA breakout by escaping ]]>
16
17
  return content.replace(new RegExp('\\]\\]>', 'g'), ']]\\\\u200B>');
17
18
  }
18
19
  /**
19
- * Constructs a structured XML/Markdown-like project context string from ContextAgentResult.
20
- * This is used to inject project profiles, directories, summary, and relevant files'
21
- * content into the prompt context for the LLM.
20
+ * Formats the base context header (project profile, directory tree, investigation summary,
21
+ * web search findings, and recent changesets).
22
22
  *
23
23
  * @param context The collected context data from ContextAgent.
24
- * @returns A formatted string containing project profile, structure, investigation summaries, and relevant file contents.
24
+ * @returns Base markdown/XML context string before file sections.
25
25
  */
26
- export function buildContextInjection(context) {
27
- let injection = `<project_context>
26
+ function buildBaseContext(context) {
27
+ let base = `<project_context>
28
28
  ## Project Profile
29
29
  ${context.projectType}
30
30
 
@@ -35,44 +35,162 @@ ${context.projectTree}
35
35
  ${context.summary}
36
36
  `;
37
37
  if (context.webSearchSummary) {
38
- injection += `
38
+ base += `
39
39
  ## Web Search Findings
40
40
  ${context.webSearchSummary}
41
41
  `;
42
42
  }
43
43
  const changeHistory = changeLogger.getHistory();
44
44
  if (changeHistory.length > 0) {
45
- injection += `
45
+ base += `
46
46
  ## Recent Workspace Changes Log (Current / Recent Sessions)
47
47
  `;
48
48
  for (const changeSet of changeHistory) {
49
49
  const timeStr = new Date(changeSet.timestamp).toISOString();
50
50
  const statusSuffix = changeSet.status ? ` [${changeSet.status}]` : '';
51
- injection += `- [${timeStr}] ${changeSet.description}${statusSuffix}\n`;
51
+ base += `- [${timeStr}] ${changeSet.description}${statusSuffix}\n`;
52
52
  if (changeSet.changes && changeSet.changes.length > 0) {
53
53
  for (const fileChange of changeSet.changes) {
54
- injection += ` - ${fileChange.action}: ${fileChange.filePath}\n`;
54
+ base += ` - ${fileChange.action}: ${fileChange.filePath}\n`;
55
55
  }
56
56
  }
57
57
  }
58
58
  }
59
- if (context.relevantFiles.size > 0) {
60
- injection += `\n## Relevant File Contents\n`;
61
- for (const [filePath, contentObj] of context.relevantFiles.entries()) {
62
- const contentText = contentObj.text;
63
- let aliasAttr = '';
64
- if (filePath.startsWith('@')) {
65
- const slashIndex = filePath.indexOf('/');
66
- const alias = slashIndex === -1 ? filePath.substring(1) : filePath.substring(1, slashIndex);
67
- aliasAttr = ` workspace="${alias}"`;
68
- }
69
- injection += `<workspace_file path="${filePath}"${aliasAttr}>
59
+ return base;
60
+ }
61
+ /**
62
+ * Formats a single file entry wrapped in XML workspace_file and CDATA tags.
63
+ *
64
+ * @param filePath Path or alias of the file.
65
+ * @param contentText File content or declarations outline.
66
+ * @param scoped Whether this content represents an AST-scoped outline.
67
+ * @returns Formatted workspace_file block.
68
+ */
69
+ function formatWorkspaceFileBlock(filePath, contentText, scoped = false) {
70
+ let aliasAttr = '';
71
+ if (filePath.startsWith('@')) {
72
+ const slashIndex = filePath.indexOf('/');
73
+ const alias = slashIndex === -1 ? filePath.substring(1) : filePath.substring(1, slashIndex);
74
+ aliasAttr = ` workspace="${alias}"`;
75
+ }
76
+ const scopedAttr = scoped ? ' scoped="outline"' : '';
77
+ return `<workspace_file path="${filePath}"${aliasAttr}${scopedAttr}>
70
78
  <content_data><![CDATA[
71
79
  ${sanitizeForCDATA(contentText)}
72
80
  ]]\\u200B></content_data>
73
81
  </workspace_file>\n`;
82
+ }
83
+ /**
84
+ * Assembles and injects relevant file contents into the context injection string,
85
+ * adhering to character/token budget constraints if specified.
86
+ *
87
+ * @param baseContext The base header context string.
88
+ * @param relevantFiles Map of file paths to their content objects.
89
+ * @param maxChars Optional maximum total character budget for the entire injection.
90
+ * @param useScopedOutline If true, extracts compact AST declarations outlines instead of raw contents.
91
+ * @returns The complete context injection string.
92
+ */
93
+ function assembleContextWithFiles(baseContext, relevantFiles, maxChars, useScopedOutline = false) {
94
+ const closingTag = '</project_context>';
95
+ const hasBudget = typeof maxChars === 'number' && maxChars > 0;
96
+ // If no files are present, return base context closed
97
+ if (!relevantFiles || relevantFiles.size === 0) {
98
+ let result = `${baseContext.trimEnd()}\n${closingTag}`;
99
+ if (hasBudget && result.length > maxChars) {
100
+ const budgetForBase = Math.max(0, maxChars - closingTag.length - 1);
101
+ result = `${baseContext.slice(0, budgetForBase).trimEnd()}\n${closingTag}`;
102
+ }
103
+ return result;
104
+ }
105
+ let injection = baseContext;
106
+ // If base context itself exceeds budget, prune base context first
107
+ if (hasBudget && injection.length + closingTag.length > maxChars) {
108
+ const budgetForBase = Math.max(0, maxChars - closingTag.length - 1);
109
+ return `${injection.slice(0, budgetForBase).trimEnd()}\n${closingTag}`;
110
+ }
111
+ const filesHeader = '\n## Relevant File Contents\n';
112
+ if (hasBudget && injection.length + filesHeader.length + closingTag.length > maxChars) {
113
+ return `${injection.trimEnd()}\n${closingTag}`;
114
+ }
115
+ injection += filesHeader;
116
+ const fileEntries = Array.from(relevantFiles.entries());
117
+ const truncationNotice = '\n\n... [Content truncated to fit token budget] ...';
118
+ const safetyAllowance = 1; // for newline before closing tag
119
+ for (let i = 0; i < fileEntries.length; i++) {
120
+ const [filePath, contentObj] = fileEntries[i];
121
+ let contentText = contentObj.text;
122
+ if (useScopedOutline) {
123
+ contentText = extractDeclarationsOutline(contentText, filePath) || contentText;
124
+ }
125
+ if (!hasBudget) {
126
+ injection += formatWorkspaceFileBlock(filePath, contentText, useScopedOutline);
127
+ continue;
128
+ }
129
+ const remainingFilesCount = fileEntries.length - i - 1;
130
+ const omissionNoticeEstimate = remainingFilesCount > 0
131
+ ? `<!-- ${remainingFilesCount} additional relevant file(s) omitted to stay within token budget -->\n`.length
132
+ : 0;
133
+ const remainingBudgetForThisFile = maxChars - injection.length - closingTag.length - omissionNoticeEstimate - safetyAllowance;
134
+ const fullBlock = formatWorkspaceFileBlock(filePath, contentText, useScopedOutline);
135
+ if (fullBlock.length <= remainingBudgetForThisFile) {
136
+ injection += fullBlock;
137
+ continue;
74
138
  }
139
+ // Try partial/truncated block
140
+ const emptyBlock = formatWorkspaceFileBlock(filePath, '', useScopedOutline);
141
+ const wrapperOverhead = emptyBlock.length;
142
+ const availableContentChars = remainingBudgetForThisFile - wrapperOverhead - truncationNotice.length;
143
+ let includedTruncated = false;
144
+ if (availableContentChars > 50) {
145
+ const truncatedContent = contentText.slice(0, availableContentChars) + truncationNotice;
146
+ injection += formatWorkspaceFileBlock(filePath, truncatedContent, useScopedOutline);
147
+ includedTruncated = true;
148
+ }
149
+ const remainingFiles = fileEntries.length - (includedTruncated ? i + 1 : i);
150
+ if (remainingFiles > 0) {
151
+ const notice = `<!-- ${remainingFiles} additional relevant file(s) omitted to stay within token budget -->\n`;
152
+ if (injection.length + notice.length + closingTag.length + safetyAllowance <= maxChars) {
153
+ injection += notice;
154
+ }
155
+ }
156
+ break;
75
157
  }
76
- injection += `</project_context>`;
77
- return injection;
158
+ let result = `${injection.trimEnd()}\n${closingTag}`;
159
+ // Final hard boundary guard
160
+ if (hasBudget && result.length > maxChars) {
161
+ const keepChars = Math.max(0, maxChars - closingTag.length - 1);
162
+ result = `${result.slice(0, keepChars).trimEnd()}\n${closingTag}`;
163
+ }
164
+ return result;
165
+ }
166
+ /**
167
+ * Constructs a structured XML/Markdown-like project context string from ContextAgentResult.
168
+ * This is used to inject project profiles, directories, summary, and relevant files'
169
+ * content into the prompt context for the LLM.
170
+ *
171
+ * Supports an optional character budget constraint to prevent oversized prompts.
172
+ *
173
+ * @param context The collected context data from ContextAgent.
174
+ * @param maxChars Optional maximum character budget for the generated prompt injection.
175
+ * @returns A formatted string containing project profile, structure, investigation summaries, and relevant file contents.
176
+ */
177
+ export function buildContextInjection(context, maxChars) {
178
+ const baseContext = buildBaseContext(context);
179
+ return assembleContextWithFiles(baseContext, context.relevantFiles, maxChars, false);
180
+ }
181
+ /**
182
+ * Constructs a lightweight, AST-scoped XML/Markdown-like project context string from ContextAgentResult.
183
+ * Uses `extractDeclarationsOutline` to replace full implementation bodies in relevant files with
184
+ * compact declarations outlines (types, interfaces, class skeletons, function signatures),
185
+ * drastically conserving context tokens during orchestration and sub-agent dispatch.
186
+ *
187
+ * Supports an optional character budget constraint.
188
+ *
189
+ * @param context The collected context data from ContextAgent.
190
+ * @param maxChars Optional maximum character budget for the generated prompt injection.
191
+ * @returns A formatted string containing project profile, structure, investigation summaries, and AST-scoped file outlines.
192
+ */
193
+ export function buildScopedContextInjection(context, maxChars) {
194
+ const baseContext = buildBaseContext(context);
195
+ return assembleContextWithFiles(baseContext, context.relevantFiles, maxChars, true);
78
196
  }
@@ -12,11 +12,102 @@ export interface HistoryTextOptions {
12
12
  initialValue?: string;
13
13
  /** An array of previous commands or strings to allow navigation through. */
14
14
  history?: string[];
15
+ /** Maximum number of history entries to keep in memory for navigation. Defaults to 100. */
16
+ maxHistoryLength?: number;
15
17
  /** Callback to validate the user input. */
16
18
  validate?: (value: string) => string | Error | undefined;
17
19
  }
18
20
  /**
19
- * A custom text prompt that supports command history navigation using Up/Down arrow keys.
21
+ * Configuration options for dynamic token budget calculation.
22
+ */
23
+ export interface TokenBudgetConfig {
24
+ /** Total maximum tokens available in the context window (e.g., 1_000_000 for Gemini 1.5 Pro). */
25
+ totalBudget: number;
26
+ /** Tokens reserved for model generation/output. Defaults to 8,192. */
27
+ reservedForOutput?: number;
28
+ /** Tokens reserved for system instructions. Defaults to 4,096. */
29
+ systemPromptBudget?: number;
30
+ /** Percentage of remaining tokens to reserve as safety headroom (0.0 to 1.0). Defaults to 0.1 (10%). */
31
+ safetyMarginPct?: number;
32
+ }
33
+ /**
34
+ * Resulting token allocations across prompt components.
35
+ */
36
+ export interface TokenBudgetAllocation {
37
+ /** Maximum tokens allocated for file context and workspace tree. */
38
+ contextBudget: number;
39
+ /** Maximum tokens allocated for conversation history. */
40
+ historyBudget: number;
41
+ /** Tokens reserved for model output generation. */
42
+ outputBudget: number;
43
+ /** Tokens reserved for system prompt & directives. */
44
+ systemBudget: number;
45
+ /** Headroom tokens reserved for safety / uncounted overhead. */
46
+ safetyMargin: number;
47
+ }
48
+ /**
49
+ * Approximate token count for a given text string using character and word heuristics.
50
+ * Defaults to a safe ratio of ~3.8 characters per token for multi-language code and text.
51
+ *
52
+ * @param text The input string to estimate.
53
+ * @returns Estimated number of tokens.
54
+ */
55
+ export declare function estimateTokenCount(text: string | null | undefined): number;
56
+ /**
57
+ * Calculates dynamic token budget distributions for context injection, history retention,
58
+ * and system prompts based on the overall context window size.
59
+ *
60
+ * @param config Configuration parameters for the token budget.
61
+ * @returns An object containing granular token allocations.
62
+ */
63
+ export declare function calculateDynamicTokenBudget(config: TokenBudgetConfig): TokenBudgetAllocation;
64
+ /**
65
+ * Prunes a text string so that its estimated token count does not exceed the specified maximum.
66
+ *
67
+ * @param text The text to prune.
68
+ * @param maxTokens Maximum allowable tokens for this text.
69
+ * @param options Pruning options.
70
+ * @returns Pruned string, with an optional truncation marker.
71
+ */
72
+ export declare function pruneTextToTokenBudget(text: string, maxTokens: number, options?: {
73
+ /** Marker to append/prepend indicating truncation. */
74
+ truncationMarker?: string;
75
+ /** If true, prunes from the start (keeping the end). If false, prunes from the end (keeping the start). */
76
+ fromStart?: boolean;
77
+ }): string;
78
+ /**
79
+ * Optimizes an array of history strings by trimming whitespace, deduplicating consecutive items,
80
+ * removing empty entries, and capping the array length to a maximum threshold.
81
+ *
82
+ * @param history Raw array of history entries.
83
+ * @param maxEntries Maximum number of entries to retain. Defaults to 100.
84
+ * @returns Cleaned and bounded array of history entries.
85
+ */
86
+ export declare function optimizeHistoryForContext(history: string[] | undefined, maxEntries?: number): string[];
87
+ /**
88
+ * Formats a list of conversation turns into a unified history context string, dynamically
89
+ * pruning older turns if the total estimated tokens exceed the allocated budget.
90
+ *
91
+ * @param history Array of conversation turns with role and text content.
92
+ * @param maxTokens Maximum allowable tokens for the formatted history.
93
+ * @param options Formatting options.
94
+ * @returns Formatted and budget-constrained conversation history string.
95
+ */
96
+ export declare function formatHistoryWithTokenBudget(history: Array<{
97
+ role: string;
98
+ text?: string;
99
+ parts?: any[];
100
+ }>, maxTokens?: number, options?: {
101
+ /** Minimum number of recent turns to always keep regardless of budget if possible. Defaults to 2. */
102
+ keepRecentTurns?: number;
103
+ /** Custom label prefix for user turns. Defaults to "User". */
104
+ userLabel?: string;
105
+ /** Custom label prefix for assistant/model turns. Defaults to "Assistant". */
106
+ modelLabel?: string;
107
+ }): string;
108
+ /**
109
+ * A custom text prompt that supports command history navigation using Up/Down arrow keys
110
+ * with memory bounds and dynamic input optimization.
20
111
  *
21
112
  * @param opts - The configuration options for the prompt.
22
113
  * @returns A promise that resolves to the user's input string or a symbol if cancelled.