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/.env.example +29 -0
- package/PARITY-PLAN.md +116 -0
- package/README.md +1 -1
- package/bin/scalix.js +1 -0
- package/dist/agent-definitions.js +100 -0
- package/dist/agent-registry.js +226 -0
- package/dist/agent.js +363 -74
- package/dist/api-client.js +77 -0
- package/dist/cli.js +724 -76
- package/dist/commands.js +53 -2
- package/dist/constants.js +43 -9
- package/dist/context-builder.js +8 -1
- package/dist/context-retriever.js +257 -0
- package/dist/git-context.js +32 -29
- package/dist/grounding-checker.js +154 -0
- package/dist/headless.js +149 -20
- package/dist/hooks.js +148 -10
- package/dist/injection-detector.js +86 -0
- package/dist/ipc-server.js +6 -2
- package/dist/keychain.js +109 -0
- package/dist/markdown.js +46 -3
- package/dist/plugins.js +132 -0
- package/dist/project-index.js +22 -1
- package/dist/sandbox.js +201 -11
- package/dist/sessions.js +96 -2
- package/dist/settings.js +194 -8
- package/dist/skills.js +269 -0
- package/dist/sub-agent.js +83 -8
- package/dist/task-board.js +105 -0
- package/dist/terminal-notify.js +73 -0
- package/dist/text-utils.js +146 -0
- package/dist/tools-advanced.js +304 -49
- package/dist/tools-browser.js +280 -19
- package/dist/tools-extra.js +9 -8
- package/dist/tools-vision.js +4 -3
- package/dist/tools.js +13 -3
- package/dist/update-checker.js +111 -0
- package/dist/worktree.js +172 -0
- package/package.json +8 -3
- package/app.py +0 -55
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:
|
|
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
|
-
|
|
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.
|
|
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:
|
|
203
|
-
exploreMaxSteps:
|
|
204
|
-
maxContextTokens:
|
|
205
|
-
compressionThreshold: 0.
|
|
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
|
|
242
|
+
// ─── Git Identity ───
|
|
237
243
|
export const GIT_BOT_IDENTITY = {
|
|
238
|
-
name: '
|
|
239
|
-
email: '
|
|
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([
|
|
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: [
|
package/dist/context-builder.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/dist/git-context.js
CHANGED
|
@@ -1,27 +1,30 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
4
|
+
const execAsync = promisify(exec);
|
|
5
|
+
async function run(cmd, cwd) {
|
|
4
6
|
try {
|
|
5
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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)
|