scalix 1.0.0 → 1.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/dist/commands.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import fs from 'node:fs/promises';
2
+ import { existsSync, readFileSync } from 'node:fs';
2
3
  import path from 'node:path';
4
+ import { execSync } from 'node:child_process';
3
5
  import { logger } from './logger.js';
4
6
  import { PROJECT_LOOKUP_DIRS, PROJECT_CONFIG_DIR } from './constants.js';
5
7
  const COMMAND_DIRS = PROJECT_LOOKUP_DIRS.map(d => `${d}/commands`);
@@ -16,6 +18,9 @@ function parseFrontmatter(content) {
16
18
  }
17
19
  return { meta, body: match[2] };
18
20
  }
21
+ function parseAllowedTools(spec) {
22
+ return spec.split(',').map(s => s.trim()).filter(Boolean);
23
+ }
19
24
  export async function loadCustomCommands(cwd = process.cwd()) {
20
25
  const commands = [];
21
26
  for (const dir of COMMAND_DIRS) {
@@ -35,11 +40,17 @@ export async function loadCustomCommands(cwd = process.cwd()) {
35
40
  const raw = await fs.readFile(filePath, 'utf-8');
36
41
  const { meta, body } = parseFrontmatter(raw);
37
42
  const name = entry.name.replace(/\.md$/, '');
43
+ const fm = meta;
38
44
  commands.push({
39
45
  name,
40
- description: meta.description || `Custom command: ${name}`,
46
+ description: fm.description || `Custom command: ${name}`,
41
47
  template: body.trim(),
42
48
  source: filePath,
49
+ frontmatter: fm,
50
+ allowedTools: fm['allowed-tools'] ? parseAllowedTools(fm['allowed-tools']) : undefined,
51
+ model: fm.model || undefined,
52
+ argumentHint: fm['argument-hint'] || undefined,
53
+ disableModelInvocation: fm['disable-model-invocation'] === 'true',
43
54
  });
44
55
  }
45
56
  catch (err) {
@@ -49,15 +60,52 @@ export async function loadCustomCommands(cwd = process.cwd()) {
49
60
  }
50
61
  return commands;
51
62
  }
63
+ function expandFileReferences(text, cwd) {
64
+ return text.replace(/@([\w./-]+\.\w+)/g, (_match, filePath) => {
65
+ const resolved = path.resolve(cwd, filePath);
66
+ if (!existsSync(resolved))
67
+ return `@${filePath} (file not found)`;
68
+ try {
69
+ const content = readFileSync(resolved, 'utf-8');
70
+ return `\n<file path="${filePath}">\n${content}\n</file>\n`;
71
+ }
72
+ catch {
73
+ return `@${filePath} (read error)`;
74
+ }
75
+ });
76
+ }
77
+ function expandInlineBash(text) {
78
+ return text.replace(/!`([^`]+)`/g, (_match, command) => {
79
+ try {
80
+ return execSync(command, { encoding: 'utf-8', timeout: 10_000 }).trim();
81
+ }
82
+ catch (err) {
83
+ return `(command failed: ${err.message})`;
84
+ }
85
+ });
86
+ }
87
+ function expandPositionalArgs(text, args) {
88
+ const parts = args.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || [];
89
+ const cleaned = parts.map(p => p.replace(/^["']|["']$/g, ''));
90
+ let result = text;
91
+ for (let i = 0; i < 9; i++) {
92
+ const placeholder = `$${i + 1}`;
93
+ result = result.split(placeholder).join(cleaned[i] || '');
94
+ }
95
+ return result;
96
+ }
52
97
  export function expandCommand(command, args) {
53
98
  const safeArgs = args
54
99
  .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, '')
55
100
  .replace(/\$/g, '\\$')
56
101
  .replace(/`/g, '\\`');
57
102
  let prompt = command.template;
103
+ prompt = expandPositionalArgs(prompt, args);
58
104
  prompt = prompt.replace(/\$ARGS/g, safeArgs);
59
105
  prompt = prompt.replace(/\$CWD/g, process.cwd());
60
106
  prompt = prompt.replace(/\$DATE/g, new Date().toISOString().split('T')[0]);
107
+ prompt = expandFileReferences(prompt, process.cwd());
108
+ prompt = expandInlineBash(prompt);
61
109
  return prompt;
62
110
  }
63
111
  export function commandsSummary(commands) {
@@ -65,7 +113,10 @@ export function commandsSummary(commands) {
65
113
  return `No custom commands. Add .md files to ${PROJECT_CONFIG_DIR}/commands/`;
66
114
  const lines = ['Custom commands:'];
67
115
  for (const cmd of commands) {
68
- lines.push(` /${cmd.name} — ${cmd.description}`);
116
+ const hint = cmd.argumentHint ? ` <${cmd.argumentHint}>` : '';
117
+ const model = cmd.model ? ` [model: ${cmd.model}]` : '';
118
+ const tools = cmd.allowedTools ? ` [tools: ${cmd.allowedTools.length}]` : '';
119
+ lines.push(` /${cmd.name}${hint} — ${cmd.description}${model}${tools}`);
69
120
  }
70
121
  return lines.join('\n');
71
122
  }
package/dist/constants.js CHANGED
@@ -1,4 +1,4 @@
1
- export const VERSION = '1.0.0';
1
+ export const VERSION = '1.1.0';
2
2
  export const PRODUCT_NAME = 'Scalix-Coder';
3
3
  export const USER_AGENT = `${PRODUCT_NAME}/${VERSION}`;
4
4
  export const DEFAULT_BASE_URL = process.env.SCALIX_BASE_URL || 'https://api.scalix.world/v1';
@@ -199,10 +199,16 @@ export const TOOL_DEFAULTS = {
199
199
  export const SUB_AGENT_DEFAULTS = {
200
200
  timeoutMs: 600_000,
201
201
  agentToolTimeoutMs: 600_000,
202
- maxSteps: 100,
203
- exploreMaxSteps: 50,
204
- maxContextTokens: 800_000,
205
- compressionThreshold: 0.85,
202
+ maxSteps: 500,
203
+ exploreMaxSteps: 200,
204
+ maxContextTokens: 1_000_000,
205
+ compressionThreshold: 0.80,
206
+ };
207
+ // ─── Multi-Agent Defaults ───
208
+ export const MULTI_AGENT_DEFAULTS = {
209
+ maxConcurrentAgents: 10,
210
+ maxDepth: 5,
211
+ globalTokenBudget: 5_000_000,
206
212
  };
207
213
  // ─── Headless Defaults ───
208
214
  export const HEADLESS_DEFAULTS = {
@@ -233,10 +239,10 @@ export const PROJECT_INDEX_DEFAULTS = {
233
239
  symbolFileLimit: 1_000,
234
240
  statusDisplayLimit: 50,
235
241
  };
236
- // ─── Git Identity (GitHub App bot) ───
242
+ // ─── Git Identity ───
237
243
  export const GIT_BOT_IDENTITY = {
238
- name: 'scalix-coder[bot]',
239
- email: '280249884+scalix-coder[bot]@users.noreply.github.com',
244
+ name: 'Scalix Coder',
245
+ email: '283104927+scalix-coder@users.noreply.github.com',
240
246
  };
241
247
  // ─── Git Context Defaults ───
242
248
  export const GIT_CONTEXT_DEFAULTS = {
@@ -263,16 +269,37 @@ export const TOKEN_ESTIMATION = {
263
269
  simpleCharsPerToken: 4,
264
270
  };
265
271
  // ─── Network Security ───
266
- export const PRIVATE_NETWORK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', '0.0.0.0']);
272
+ export const PRIVATE_NETWORK_HOSTNAMES = new Set([
273
+ 'localhost', '127.0.0.1', '::1', '[::1]', '0.0.0.0',
274
+ '::ffff:127.0.0.1', '[::ffff:127.0.0.1]',
275
+ ]);
267
276
  export const PRIVATE_NETWORK_SUFFIXES = ['.local', '.internal'];
268
277
  export const PRIVATE_NETWORK_CIDRS = [
269
278
  /^10\./,
270
279
  /^172\.(1[6-9]|2\d|3[01])\./,
271
280
  /^192\.168\./,
272
281
  /^169\.254\./,
282
+ /^\[?fe80:/i,
283
+ /^\[?fc00:/i,
284
+ /^\[?fd[0-9a-f]{2}:/i,
285
+ /^\[?::ffff:(10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|127\.)/i,
273
286
  ];
287
+ let _networkOverrides = {};
288
+ export function setNetworkSecurityOverrides(overrides) {
289
+ _networkOverrides = {
290
+ allowAll: overrides.allowAll,
291
+ blockedHosts: overrides.blockedHosts ? new Set(overrides.blockedHosts.map(h => h.toLowerCase())) : undefined,
292
+ allowedHosts: overrides.allowedHosts ? new Set(overrides.allowedHosts.map(h => h.toLowerCase())) : undefined,
293
+ };
294
+ }
274
295
  export function isPrivateHost(hostname) {
275
296
  const lower = hostname.toLowerCase();
297
+ if (_networkOverrides.allowAll)
298
+ return false;
299
+ if (_networkOverrides.allowedHosts?.has(lower))
300
+ return false;
301
+ if (_networkOverrides.blockedHosts?.has(lower))
302
+ return true;
276
303
  if (PRIVATE_NETWORK_HOSTNAMES.has(lower))
277
304
  return true;
278
305
  if (PRIVATE_NETWORK_SUFFIXES.some(s => lower.endsWith(s)))
@@ -298,6 +325,13 @@ export const COMPRESSION_DEFAULTS = {
298
325
  keepRatio: 0.4,
299
326
  truncateDisplay: 1000,
300
327
  };
328
+ // ─── Update Check Defaults ───
329
+ export const UPDATE_DEFAULTS = {
330
+ checkIntervalMs: 24 * 60 * 60 * 1000,
331
+ registryUrl: 'https://registry.npmjs.org/scalix/latest',
332
+ fetchTimeoutMs: 5_000,
333
+ npmPackageName: 'scalix',
334
+ };
301
335
  // ─── Complexity Patterns (for model routing) ───
302
336
  export const COMPLEXITY_PATTERNS = {
303
337
  simple: [
@@ -4,6 +4,7 @@ import { collectGitInfo, gitInfoToContext } from './git-context.js';
4
4
  import { loadMemory, memoryToContext } from './project-memory.js';
5
5
  import { buildProjectIndex, indexToContext } from './project-index.js';
6
6
  import { loadScopedRules, rulesToContext } from './scoped-rules.js';
7
+ import { retrieveContext, snippetsToContext, RETRIEVAL_DEFAULTS } from './context-retriever.js';
7
8
  async function readJsonSafe(filePath) {
8
9
  try {
9
10
  return JSON.parse(await fs.readFile(filePath, 'utf-8'));
@@ -102,7 +103,7 @@ export async function buildProjectContext(cwd) {
102
103
  const projectType = await detectProjectType(cwd);
103
104
  // Gather context in parallel
104
105
  const [gitCtx, memory, rules, projectConfig] = await Promise.all([
105
- Promise.resolve(collectGitInfo(cwd)).then(gitInfoToContext).catch(() => ''),
106
+ collectGitInfo(cwd).then(gitInfoToContext).catch(() => ''),
106
107
  loadMemory(cwd).then(memoryToContext).catch(() => ''),
107
108
  loadScopedRules(cwd).then(rulesToContext).catch(() => ''),
108
109
  loadProjectConfig(cwd),
@@ -149,3 +150,9 @@ export function contextToInstructions(baseInstructions, ctx) {
149
150
  }
150
151
  return sections.filter(Boolean).join('\n\n');
151
152
  }
153
+ export async function buildQueryContext(query, cwd, config) {
154
+ const index = await buildProjectIndex(cwd);
155
+ const effectiveConfig = config ? { ...RETRIEVAL_DEFAULTS, ...config } : RETRIEVAL_DEFAULTS;
156
+ const result = await retrieveContext(query, index, effectiveConfig);
157
+ return snippetsToContext(result.snippets);
158
+ }
@@ -0,0 +1,257 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { estimateTokens } from './token-counter.js';
4
+ export const RETRIEVAL_DEFAULTS = {
5
+ enabled: true,
6
+ maxSnippets: 5,
7
+ maxTokensPerSnippet: 500,
8
+ maxTotalTokens: 3000,
9
+ previewLines: 30,
10
+ minRelevanceScore: 0.3,
11
+ };
12
+ const STOP_WORDS = new Set([
13
+ 'a', 'an', 'the', 'is', 'are', 'was', 'were', 'be', 'been',
14
+ 'in', 'on', 'at', 'to', 'for', 'of', 'with', 'by', 'from',
15
+ 'and', 'or', 'but', 'not', 'no', 'so', 'if', 'then', 'else',
16
+ 'do', 'does', 'did', 'will', 'would', 'should', 'could', 'can',
17
+ 'has', 'have', 'had', 'this', 'that', 'these', 'those',
18
+ 'it', 'its', 'i', 'me', 'my', 'we', 'our', 'you', 'your',
19
+ 'he', 'she', 'they', 'them', 'what', 'which', 'who', 'how',
20
+ 'when', 'where', 'why', 'all', 'each', 'every', 'any', 'some',
21
+ 'about', 'up', 'out', 'just', 'also', 'than', 'very',
22
+ 'code', 'file', 'function', 'change', 'make', 'use', 'using',
23
+ 'want', 'need', 'like', 'please', 'help', 'show', 'get',
24
+ ]);
25
+ const FILE_HINT_RE = /(?:^|[\s`"'(])([.\w][\w./-]*\/[\w./-]+\.\w{1,10})(?=[\s`"'),.:;]|$)/gm;
26
+ const CAMEL_RE = /\b([A-Z][a-zA-Z]{2,})\b/g;
27
+ const SNAKE_RE = /\b([a-z][a-z0-9]*(?:_[a-z0-9]+){1,})\b/g;
28
+ const INTENT_PATTERNS = {
29
+ debug: /\b(?:fix|debug|error|bug|broken|crash|fail|issue|wrong|problem)\b/i,
30
+ create: /\b(?:create|add|new|implement|build|generate|write|introduce)\b/i,
31
+ modify: /\b(?:change|update|modify|refactor|rename|move|replace|improve|optimize)\b/i,
32
+ explore: /\b(?:read|show|explain|understand|find|search|look|what|where|how|list|describe)\b/i,
33
+ general: /./,
34
+ };
35
+ export function analyzeQuery(query) {
36
+ const words = query.toLowerCase().split(/\s+/).filter(Boolean);
37
+ const keywords = words.filter((w) => !STOP_WORDS.has(w) && w.length > 2);
38
+ const fileHints = [];
39
+ for (const m of query.matchAll(FILE_HINT_RE)) {
40
+ fileHints.push(m[1]);
41
+ }
42
+ const symbolHints = [];
43
+ for (const m of query.matchAll(CAMEL_RE)) {
44
+ symbolHints.push(m[1]);
45
+ }
46
+ for (const m of query.matchAll(SNAKE_RE)) {
47
+ symbolHints.push(m[1]);
48
+ }
49
+ let intentType = 'general';
50
+ for (const [intent, re] of Object.entries(INTENT_PATTERNS)) {
51
+ if (intent === 'general')
52
+ continue;
53
+ if (re.test(query)) {
54
+ intentType = intent;
55
+ break;
56
+ }
57
+ }
58
+ return { keywords, fileHints, symbolHints, intentType };
59
+ }
60
+ function scoreFiles(analysis, index) {
61
+ const symbolsByFile = new Map();
62
+ for (const sym of index.symbols) {
63
+ const existing = symbolsByFile.get(sym.file) ?? [];
64
+ existing.push(sym);
65
+ symbolsByFile.set(sym.file, existing);
66
+ }
67
+ const importGraph = new Map();
68
+ for (const imp of index.imports) {
69
+ const deps = importGraph.get(imp.from) ?? new Set();
70
+ deps.add(imp.to);
71
+ importGraph.set(imp.from, deps);
72
+ }
73
+ const recentFiles = new Set(index.files
74
+ .sort((a, b) => b.modifiedAt - a.modifiedAt)
75
+ .slice(0, 20)
76
+ .map((f) => f.relativePath));
77
+ const directMentionFiles = new Set();
78
+ for (const hint of analysis.fileHints) {
79
+ for (const f of index.files) {
80
+ if (f.relativePath === hint || f.relativePath.endsWith(hint)) {
81
+ directMentionFiles.add(f.relativePath);
82
+ }
83
+ }
84
+ }
85
+ const scores = [];
86
+ for (const file of index.files) {
87
+ let score = 0;
88
+ const reasons = [];
89
+ const matchingSymbols = [];
90
+ // Direct file mention
91
+ if (directMentionFiles.has(file.relativePath)) {
92
+ score += 1.0;
93
+ reasons.push('directly mentioned');
94
+ }
95
+ // Symbol match
96
+ const fileSymbols = symbolsByFile.get(file.relativePath) ?? [];
97
+ for (const sym of fileSymbols) {
98
+ const nameLower = sym.name.toLowerCase();
99
+ if (analysis.symbolHints.some((h) => h.toLowerCase() === nameLower)) {
100
+ score += 0.6;
101
+ matchingSymbols.push(sym.name);
102
+ reasons.push(`symbol "${sym.name}"`);
103
+ }
104
+ else if (analysis.keywords.some((k) => nameLower.includes(k))) {
105
+ score += 0.3;
106
+ matchingSymbols.push(sym.name);
107
+ reasons.push(`keyword in symbol "${sym.name}"`);
108
+ }
109
+ }
110
+ // Import proximity — file is imported by a directly-mentioned file
111
+ for (const mentionedFile of directMentionFiles) {
112
+ const deps = importGraph.get(mentionedFile);
113
+ if (deps?.has(file.relativePath)) {
114
+ score += 0.3;
115
+ reasons.push(`imported by ${mentionedFile}`);
116
+ }
117
+ }
118
+ // Keyword in path
119
+ const pathLower = file.relativePath.toLowerCase();
120
+ for (const kw of analysis.keywords) {
121
+ if (pathLower.includes(kw)) {
122
+ score += 0.2;
123
+ reasons.push(`keyword "${kw}" in path`);
124
+ }
125
+ }
126
+ // Recency bonus
127
+ if (recentFiles.has(file.relativePath)) {
128
+ score += 0.1;
129
+ reasons.push('recently modified');
130
+ }
131
+ if (score > 0) {
132
+ scores.push({
133
+ file: file.relativePath,
134
+ score: Math.min(score, 2.0),
135
+ matchingSymbols,
136
+ reason: reasons.join(', '),
137
+ });
138
+ }
139
+ }
140
+ return scores.sort((a, b) => b.score - a.score);
141
+ }
142
+ async function generatePreview(filePath, projectRoot, matchingSymbols, maxLines) {
143
+ try {
144
+ const absPath = path.join(projectRoot, filePath);
145
+ const content = await fs.readFile(absPath, 'utf-8');
146
+ const lines = content.split('\n');
147
+ if (matchingSymbols.length > 0) {
148
+ // Show lines around matching symbols
149
+ const ranges = [];
150
+ for (const sym of matchingSymbols.slice(0, 3)) {
151
+ const start = Math.max(0, sym.line - 2);
152
+ const end = Math.min(lines.length - 1, sym.line + 8);
153
+ ranges.push([start, end]);
154
+ }
155
+ // Merge overlapping ranges
156
+ ranges.sort((a, b) => a[0] - b[0]);
157
+ const merged = [ranges[0]];
158
+ for (let i = 1; i < ranges.length; i++) {
159
+ const prev = merged[merged.length - 1];
160
+ if (ranges[i][0] <= prev[1] + 2) {
161
+ prev[1] = Math.max(prev[1], ranges[i][1]);
162
+ }
163
+ else {
164
+ merged.push(ranges[i]);
165
+ }
166
+ }
167
+ const parts = [];
168
+ let totalLines = 0;
169
+ for (const [start, end] of merged) {
170
+ if (totalLines >= maxLines)
171
+ break;
172
+ const available = maxLines - totalLines;
173
+ const rangeEnd = Math.min(end, start + available - 1);
174
+ for (let i = start; i <= rangeEnd; i++) {
175
+ parts.push(`${i + 1}\t${lines[i]}`);
176
+ totalLines++;
177
+ }
178
+ if (rangeEnd < end)
179
+ break;
180
+ parts.push('...');
181
+ }
182
+ return parts.join('\n');
183
+ }
184
+ // No matching symbols — show first N lines
185
+ return lines
186
+ .slice(0, maxLines)
187
+ .map((l, i) => `${i + 1}\t${l}`)
188
+ .join('\n');
189
+ }
190
+ catch {
191
+ return '(file could not be read)';
192
+ }
193
+ }
194
+ export async function retrieveContext(query, index, config = RETRIEVAL_DEFAULTS) {
195
+ const analysis = analyzeQuery(query);
196
+ if (analysis.keywords.length === 0 && analysis.fileHints.length === 0 && analysis.symbolHints.length === 0) {
197
+ return { snippets: [], totalTokens: 0, queryTerms: [] };
198
+ }
199
+ const scored = scoreFiles(analysis, index);
200
+ const candidates = scored.filter((s) => s.score >= config.minRelevanceScore).slice(0, config.maxSnippets);
201
+ const symbolsByFile = new Map();
202
+ for (const sym of index.symbols) {
203
+ const existing = symbolsByFile.get(sym.file) ?? [];
204
+ existing.push(sym);
205
+ symbolsByFile.set(sym.file, existing);
206
+ }
207
+ const snippets = [];
208
+ let totalTokens = 0;
209
+ for (const candidate of candidates) {
210
+ if (totalTokens >= config.maxTotalTokens)
211
+ break;
212
+ const matchingSymbols = (symbolsByFile.get(candidate.file) ?? [])
213
+ .filter((s) => candidate.matchingSymbols.includes(s.name));
214
+ const preview = await generatePreview(candidate.file, index.root, matchingSymbols, config.previewLines);
215
+ let tokens = estimateTokens(preview);
216
+ let truncatedPreview = preview;
217
+ if (tokens > config.maxTokensPerSnippet) {
218
+ const lines = preview.split('\n');
219
+ const kept = [];
220
+ let running = 0;
221
+ for (const line of lines) {
222
+ const lt = estimateTokens(line);
223
+ if (running + lt > config.maxTokensPerSnippet)
224
+ break;
225
+ kept.push(line);
226
+ running += lt;
227
+ }
228
+ truncatedPreview = kept.join('\n');
229
+ tokens = running;
230
+ }
231
+ if (totalTokens + tokens > config.maxTotalTokens)
232
+ break;
233
+ snippets.push({
234
+ file: candidate.file,
235
+ relevanceScore: candidate.score,
236
+ symbols: candidate.matchingSymbols,
237
+ preview: truncatedPreview,
238
+ reason: candidate.reason,
239
+ });
240
+ totalTokens += tokens;
241
+ }
242
+ return { snippets, totalTokens, queryTerms: analysis.keywords };
243
+ }
244
+ export function snippetsToContext(snippets) {
245
+ if (snippets.length === 0)
246
+ return '';
247
+ const parts = ['## Relevant Code Context',
248
+ 'The following file excerpts may be relevant. Use this as ground truth rather than guessing.\n'];
249
+ for (const s of snippets) {
250
+ const symbolStr = s.symbols.length > 0 ? `\nSymbols: ${s.symbols.join(', ')}` : '';
251
+ parts.push(`### ${s.file} (score: ${s.relevanceScore.toFixed(1)})${symbolStr}`);
252
+ parts.push('```');
253
+ parts.push(s.preview);
254
+ parts.push('```\n');
255
+ }
256
+ return parts.join('\n');
257
+ }
@@ -1,27 +1,30 @@
1
- import { execSync } from 'node:child_process';
1
+ import { exec } from 'node:child_process';
2
+ import { promisify } from 'node:util';
2
3
  import { TOOL_DEFAULTS, GIT_CONTEXT_DEFAULTS, PROJECT_INDEX_DEFAULTS } from './constants.js';
3
- function run(cmd, cwd) {
4
+ const execAsync = promisify(exec);
5
+ async function run(cmd, cwd) {
4
6
  try {
5
- return execSync(cmd, { cwd, encoding: 'utf-8', timeout: TOOL_DEFAULTS.gitTimeoutMs, stdio: ['pipe', 'pipe', 'pipe'] }).trim();
7
+ const { stdout } = await execAsync(cmd, { cwd, encoding: 'utf-8', timeout: TOOL_DEFAULTS.gitTimeoutMs });
8
+ return stdout.trim();
6
9
  }
7
10
  catch {
8
11
  return '';
9
12
  }
10
13
  }
11
- function isGitRepo(cwd) {
12
- return run('git rev-parse --is-inside-work-tree', cwd) === 'true';
14
+ async function isGitRepo(cwd) {
15
+ return (await run('git rev-parse --is-inside-work-tree', cwd)) === 'true';
13
16
  }
14
- function getBranch(cwd) {
15
- return run('git branch --show-current', cwd) || run('git rev-parse --short HEAD', cwd) || 'unknown';
17
+ async function getBranch(cwd) {
18
+ return (await run('git branch --show-current', cwd)) || (await run('git rev-parse --short HEAD', cwd)) || 'unknown';
16
19
  }
17
- function getRemoteBranch(cwd) {
20
+ async function getRemoteBranch(cwd) {
18
21
  return run('git rev-parse --abbrev-ref @{upstream} 2>/dev/null', cwd);
19
22
  }
20
- function getRemoteUrl(cwd) {
23
+ async function getRemoteUrl(cwd) {
21
24
  return run('git remote get-url origin 2>/dev/null', cwd);
22
25
  }
23
- function getAheadBehind(cwd) {
24
- const result = run('git rev-list --left-right --count @{upstream}...HEAD 2>/dev/null', cwd);
26
+ async function getAheadBehind(cwd) {
27
+ const result = await run('git rev-list --left-right --count @{upstream}...HEAD 2>/dev/null', cwd);
25
28
  if (!result)
26
29
  return { ahead: 0, behind: 0 };
27
30
  const parts = result.split('\t');
@@ -29,12 +32,12 @@ function getAheadBehind(cwd) {
29
32
  return { ahead: 0, behind: 0 };
30
33
  return { ahead: Number(parts[1]) || 0, behind: Number(parts[0]) || 0 };
31
34
  }
32
- function getStashCount(cwd) {
33
- const result = run('git stash list 2>/dev/null', cwd);
35
+ async function getStashCount(cwd) {
36
+ const result = await run('git stash list 2>/dev/null', cwd);
34
37
  return result ? result.split('\n').filter(Boolean).length : 0;
35
38
  }
36
- function getStatus(cwd) {
37
- const result = run('git status --porcelain', cwd);
39
+ async function getStatus(cwd) {
40
+ const result = await run('git status --porcelain', cwd);
38
41
  if (!result)
39
42
  return [];
40
43
  return result.split('\n').filter(Boolean).map((line) => {
@@ -68,10 +71,10 @@ function getStatus(cwd) {
68
71
  return { path: filePath, status, staged };
69
72
  });
70
73
  }
71
- function getRecentCommits(cwd, count = GIT_CONTEXT_DEFAULTS.recentCommitsCount) {
74
+ async function getRecentCommits(cwd, count = GIT_CONTEXT_DEFAULTS.recentCommitsCount) {
72
75
  const SEP = '<%>';
73
76
  const format = `%H${SEP}%h${SEP}%s${SEP}%an${SEP}%ar`;
74
- const result = run(`git log --pretty=format:"${format}" --shortstat -n ${count} 2>/dev/null`, cwd);
77
+ const result = await run(`git log --pretty=format:"${format}" --shortstat -n ${count} 2>/dev/null`, cwd);
75
78
  if (!result)
76
79
  return [];
77
80
  const commits = [];
@@ -103,8 +106,8 @@ function getRecentCommits(cwd, count = GIT_CONTEXT_DEFAULTS.recentCommitsCount)
103
106
  }
104
107
  return commits;
105
108
  }
106
- export function collectGitInfo(cwd) {
107
- if (!isGitRepo(cwd)) {
109
+ export async function collectGitInfo(cwd) {
110
+ if (!(await isGitRepo(cwd))) {
108
111
  return {
109
112
  isRepo: false,
110
113
  branch: '',
@@ -116,16 +119,16 @@ export function collectGitInfo(cwd) {
116
119
  remoteUrl: '',
117
120
  };
118
121
  }
119
- return {
120
- isRepo: true,
121
- branch: getBranch(cwd),
122
- remoteBranch: getRemoteBranch(cwd),
123
- status: getStatus(cwd),
124
- recentCommits: getRecentCommits(cwd, GIT_CONTEXT_DEFAULTS.recentCommitsContextCount),
125
- stashCount: getStashCount(cwd),
126
- aheadBehind: getAheadBehind(cwd),
127
- remoteUrl: getRemoteUrl(cwd),
128
- };
122
+ const [branch, remoteBranch, status, recentCommits, stashCount, aheadBehind, remoteUrl] = await Promise.all([
123
+ getBranch(cwd),
124
+ getRemoteBranch(cwd),
125
+ getStatus(cwd),
126
+ getRecentCommits(cwd, GIT_CONTEXT_DEFAULTS.recentCommitsContextCount),
127
+ getStashCount(cwd),
128
+ getAheadBehind(cwd),
129
+ getRemoteUrl(cwd),
130
+ ]);
131
+ return { isRepo: true, branch, remoteBranch, status, recentCommits, stashCount, aheadBehind, remoteUrl };
129
132
  }
130
133
  export function gitInfoToContext(info) {
131
134
  if (!info.isRepo)