ctxman 3.1.0 → 3.1.1
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 +2 -1
- package/bin/cm-ask.js +16 -70
- package/bin/cm-audit.js +4 -7
- package/examples/github-actions/ctxman-check.yml +1 -1
- package/lib/analyzers/performance-dashboard.js +7 -3
- package/lib/api/mcp/server.js +98 -1
- package/lib/parsers/gitignore-parser.js +4 -1
- package/lib/policy/audit.js +19 -1
- package/lib/policy/index.js +1 -0
- package/lib/rag/ask.js +122 -0
- package/lib/rag/sources/FileDataSource.js +6 -3
- package/lib/utils/logger.js +5 -2
- package/lib/wizards/config-generator.js +20 -18
- package/lib/wizards/project-detector.js +2 -1
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -322,7 +322,8 @@ ctxman mcp # run the server (stdio)
|
|
|
322
322
|
The installer merges into the client's configuration and keeps a `.bak` of the previous file.
|
|
323
323
|
`--root` confines every tool to one directory. Tools: `analyze_codebase`, `generate_context`,
|
|
324
324
|
`git_diff`, `search_code`, `list_methods`, `repo_map`, `pr_context`, `get_symbol`,
|
|
325
|
-
`count_tokens`, `explain_history`, `pack_context`.
|
|
325
|
+
`count_tokens`, `explain_history`, `pack_context`, `check_policy`, `audit_log`, `ask_codebase`.
|
|
326
|
+
Details:
|
|
326
327
|
[docs/commands/mcp.md](docs/commands/mcp.md).
|
|
327
328
|
|
|
328
329
|
### 🛡️ Export Policy and Audit Log
|
package/bin/cm-ask.js
CHANGED
|
@@ -1,10 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import { TransformersEmbeddingProvider } from '../lib/rag/EmbeddingProvider.js';
|
|
4
|
-
import { Indexer } from '../lib/rag/Indexer.js';
|
|
5
|
-
import { FileDataSource } from '../lib/rag/sources/FileDataSource.js';
|
|
6
|
-
import path from 'path';
|
|
7
|
-
import fs from 'fs/promises';
|
|
2
|
+
import { askCodebase } from '../lib/rag/ask.js';
|
|
8
3
|
|
|
9
4
|
async function main() {
|
|
10
5
|
const args = process.argv.slice(2);
|
|
@@ -15,86 +10,37 @@ async function main() {
|
|
|
15
10
|
process.exit(1);
|
|
16
11
|
}
|
|
17
12
|
|
|
18
|
-
// 1. Initialize RAG Components
|
|
19
|
-
let provider;
|
|
20
|
-
try {
|
|
21
|
-
// Try to load local transformers
|
|
22
|
-
provider = new TransformersEmbeddingProvider();
|
|
23
|
-
// Lazy init will handle model download on first use
|
|
24
|
-
} catch (e) {
|
|
25
|
-
console.error('⚠️ Failed to initialize embedding provider:', e.message);
|
|
26
|
-
process.exit(1);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
// Use LanceDB in .ctxman/self-knowledge
|
|
30
|
-
const storePath = path.join(process.cwd(), '.ctxman', 'self-knowledge');
|
|
31
|
-
const store = VectorStoreFactory.create('lancedb', provider, { path: storePath });
|
|
32
|
-
|
|
33
|
-
// 2. Check if indexed
|
|
34
|
-
const isIndexed = await checkIndexExists(storePath);
|
|
35
|
-
|
|
36
|
-
if (!isIndexed) {
|
|
37
|
-
console.log('📚 Index not found. Auto-indexing current project...');
|
|
38
|
-
await indexProject(store);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// 3. Search
|
|
42
13
|
console.log(`🔍 Searching for: "${query}"...`);
|
|
43
|
-
const results = await
|
|
14
|
+
const { results, removedByPolicy } = await askCodebase(process.cwd(), query, {
|
|
15
|
+
onProgress: (event) => {
|
|
16
|
+
if (event === 'indexing') {
|
|
17
|
+
console.log('📚 Index not found. Auto-indexing current project...');
|
|
18
|
+
console.log('⏳ Indexing files (this may take a moment)...');
|
|
19
|
+
} else if (event === 'indexed') {
|
|
20
|
+
console.log('✅ Indexing complete.');
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
});
|
|
44
24
|
|
|
25
|
+
if (removedByPolicy > 0) {
|
|
26
|
+
console.log(`🛡️ Removed ${removedByPolicy} results denied by the export policy`);
|
|
27
|
+
}
|
|
45
28
|
if (results.length === 0) {
|
|
46
29
|
console.log('❌ No relevant context found.');
|
|
47
30
|
return;
|
|
48
31
|
}
|
|
49
32
|
|
|
50
|
-
// 4. Display Results (or feed to LLM if we had one configured)
|
|
51
33
|
console.log(`\nFound ${results.length} relevant snippets:\n`);
|
|
52
|
-
|
|
53
34
|
results.forEach((r, i) => {
|
|
54
|
-
console.log(`[${i + 1}] ${r.
|
|
35
|
+
console.log(`[${i + 1}] ${r.file} (Score: ${r.score?.toFixed(4)})`);
|
|
55
36
|
console.log('─'.repeat(40));
|
|
56
|
-
// Show a small snippet around the match if possible, but we stored full text.
|
|
57
|
-
// Let's just show the first 200 chars or so.
|
|
58
37
|
console.log(r.text.substring(0, 300).replace(/\n/g, ' ') + '...');
|
|
59
38
|
console.log('\n');
|
|
60
39
|
});
|
|
61
|
-
|
|
62
|
-
// Future: Pass `results` to an LLM API to generate a real answer.
|
|
63
|
-
// For now, this validates the retrieval pipeline works from CLI.
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
async function checkIndexExists(storePath) {
|
|
67
|
-
try {
|
|
68
|
-
await fs.access(storePath);
|
|
69
|
-
// Also check if it has tables (LanceDB creates folders)
|
|
70
|
-
const files = await fs.readdir(storePath);
|
|
71
|
-
return files.length > 0;
|
|
72
|
-
} catch {
|
|
73
|
-
return false;
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
async function indexProject(store) {
|
|
78
|
-
const indexer = new Indexer(store);
|
|
79
|
-
const fileSource = new FileDataSource();
|
|
80
|
-
indexer.registerSource(fileSource);
|
|
81
|
-
|
|
82
|
-
console.log('⏳ Indexing files (this may take a moment)...');
|
|
83
|
-
try {
|
|
84
|
-
await indexer.index('local-files', {
|
|
85
|
-
targetDir: process.cwd(),
|
|
86
|
-
include: ['.js', '.md', '.ts', '.json'], // Configurable?
|
|
87
|
-
exclude: ['node_modules', '.git', 'dist', 'coverage', '.ctxman'],
|
|
88
|
-
});
|
|
89
|
-
} catch (error) {
|
|
90
|
-
console.error(`❌ ${error.message}`);
|
|
91
|
-
process.exit(1);
|
|
92
|
-
}
|
|
93
|
-
console.log('✅ Indexing complete.');
|
|
94
40
|
}
|
|
95
41
|
|
|
96
42
|
main().catch((err) => {
|
|
97
|
-
// Message only: missing optional dependencies
|
|
43
|
+
// Message only: missing optional dependencies and indexing failures surface here
|
|
98
44
|
console.error('❌ Error:', err.message);
|
|
99
45
|
process.exit(1);
|
|
100
46
|
});
|
package/bin/cm-audit.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import path from 'path';
|
|
8
8
|
import {
|
|
9
|
-
|
|
9
|
+
auditLogFiles,
|
|
10
10
|
readAuditLog,
|
|
11
11
|
filterAuditEvents,
|
|
12
12
|
formatAuditTable,
|
|
@@ -84,15 +84,12 @@ function main() {
|
|
|
84
84
|
|
|
85
85
|
// The policy's audit logs; the default location when no policy names one
|
|
86
86
|
function auditFiles() {
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
const policy = loadPolicy(root);
|
|
90
|
-
if (policy.audit.paths.length > 0) return policy.audit.paths;
|
|
91
|
-
} catch (error) {
|
|
87
|
+
const { files, error } = auditLogFiles(process.cwd());
|
|
88
|
+
if (error) {
|
|
92
89
|
console.error(`⚠️ ${error.message}`);
|
|
93
90
|
console.error(` Reading the default log ${DEFAULT_AUDIT_PATH} instead.`);
|
|
94
91
|
}
|
|
95
|
-
return
|
|
92
|
+
return files;
|
|
96
93
|
}
|
|
97
94
|
|
|
98
95
|
function valueOf(args, flag) {
|
|
@@ -96,9 +96,13 @@ export class PerformanceDashboard {
|
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
const metricsFile = path.join(this.storagePath, 'metrics.json');
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
99
|
+
let data = [];
|
|
100
|
+
try {
|
|
101
|
+
data = JSON.parse(fs.readFileSync(metricsFile, 'utf8'));
|
|
102
|
+
} catch (error) {
|
|
103
|
+
// No metrics yet; an unreadable file stops the save, as before
|
|
104
|
+
if (error.code !== 'ENOENT') throw error;
|
|
105
|
+
}
|
|
102
106
|
|
|
103
107
|
data.push(...this.metrics);
|
|
104
108
|
fs.writeFileSync(metricsFile, JSON.stringify(data, null, 2));
|
package/lib/api/mcp/server.js
CHANGED
|
@@ -23,7 +23,16 @@ import { getSymbol } from '../../search/symbols.js';
|
|
|
23
23
|
import { countForModel } from '../../count/index.js';
|
|
24
24
|
import { explainHistory } from '../../why/index.js';
|
|
25
25
|
import { packContext, describePack, TOOL_DEFAULT_BUDGET } from '../../pack/index.js';
|
|
26
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
loadPolicy,
|
|
28
|
+
guardExport,
|
|
29
|
+
PolicyConfigError,
|
|
30
|
+
auditLogFiles,
|
|
31
|
+
readAuditLog,
|
|
32
|
+
filterAuditEvents,
|
|
33
|
+
parseSince,
|
|
34
|
+
} from '../../policy/index.js';
|
|
35
|
+
import { askCodebase, DEFAULT_ASK_LIMIT } from '../../rag/ask.js';
|
|
27
36
|
import fs from 'fs';
|
|
28
37
|
import path from 'path';
|
|
29
38
|
import { createRequire } from 'module';
|
|
@@ -33,6 +42,10 @@ const { version } = createRequire(import.meta.url)('../../../package.json');
|
|
|
33
42
|
// Tools whose `path` may also name a single file
|
|
34
43
|
const FILE_PATH_TOOLS = new Set(['list_methods']);
|
|
35
44
|
|
|
45
|
+
const DEFAULT_AUDIT_LIMIT = 50;
|
|
46
|
+
// Characters of each ask_codebase snippet (the index stores whole files)
|
|
47
|
+
const ASK_SNIPPET_CHARS = 2000;
|
|
48
|
+
|
|
36
49
|
class MCPServer {
|
|
37
50
|
/**
|
|
38
51
|
* @param {Object} [options]
|
|
@@ -340,6 +353,56 @@ class MCPServer {
|
|
|
340
353
|
required: ['path'],
|
|
341
354
|
},
|
|
342
355
|
},
|
|
356
|
+
{
|
|
357
|
+
name: 'check_policy',
|
|
358
|
+
description:
|
|
359
|
+
'The effective export policy of a project (.ctxman/policy.json plus the organisation policy named by CTXMAN_POLICY): denied paths, redaction and token limits, allowed target models, AI-tool ignore files and audit logging. Reports whether the policy files are valid and, if not, every error.',
|
|
360
|
+
inputSchema: {
|
|
361
|
+
type: 'object',
|
|
362
|
+
properties: {
|
|
363
|
+
path: { type: 'string', description: 'Project root' },
|
|
364
|
+
},
|
|
365
|
+
required: ['path'],
|
|
366
|
+
},
|
|
367
|
+
},
|
|
368
|
+
{
|
|
369
|
+
name: 'audit_log',
|
|
370
|
+
description:
|
|
371
|
+
"Exports recorded in the project's audit log while the export policy enables audit: when, by whom, which command, where to, which files, token count and a SHA-256 of the exported text. Newest last.",
|
|
372
|
+
inputSchema: {
|
|
373
|
+
type: 'object',
|
|
374
|
+
properties: {
|
|
375
|
+
path: { type: 'string', description: 'Project root' },
|
|
376
|
+
since: {
|
|
377
|
+
type: 'string',
|
|
378
|
+
description:
|
|
379
|
+
'Only exports at or after this date (2026-10-01), ISO time or age (30m, 12h, 7d, 2w)',
|
|
380
|
+
},
|
|
381
|
+
limit: {
|
|
382
|
+
type: 'number',
|
|
383
|
+
description: `The last N events (default ${DEFAULT_AUDIT_LIMIT}; 0 = all)`,
|
|
384
|
+
},
|
|
385
|
+
},
|
|
386
|
+
required: ['path'],
|
|
387
|
+
},
|
|
388
|
+
},
|
|
389
|
+
{
|
|
390
|
+
name: 'ask_codebase',
|
|
391
|
+
description:
|
|
392
|
+
'Semantic search: the files whose content is closest in meaning to a question, from a local embedding index (.ctxman/self-knowledge, built on first use; the first call downloads the embedding model and can take minutes). Needs the optional @xenova/transformers and @lancedb/lancedb packages. For exact names or keywords, search_code is faster.',
|
|
393
|
+
inputSchema: {
|
|
394
|
+
type: 'object',
|
|
395
|
+
properties: {
|
|
396
|
+
path: { type: 'string', description: 'Project root' },
|
|
397
|
+
query: { type: 'string', description: 'The question, in natural language' },
|
|
398
|
+
limit: {
|
|
399
|
+
type: 'number',
|
|
400
|
+
description: `How many files (default ${DEFAULT_ASK_LIMIT})`,
|
|
401
|
+
},
|
|
402
|
+
},
|
|
403
|
+
required: ['path', 'query'],
|
|
404
|
+
},
|
|
405
|
+
},
|
|
343
406
|
],
|
|
344
407
|
};
|
|
345
408
|
});
|
|
@@ -666,6 +729,40 @@ class MCPServer {
|
|
|
666
729
|
],
|
|
667
730
|
};
|
|
668
731
|
}
|
|
732
|
+
case 'check_policy': {
|
|
733
|
+
let result;
|
|
734
|
+
try {
|
|
735
|
+
result = { valid: true, ...loadPolicy(args.path).toJSON() };
|
|
736
|
+
} catch (error) {
|
|
737
|
+
if (!(error instanceof PolicyConfigError)) throw error;
|
|
738
|
+
result = { valid: false, file: error.file, errors: error.errors };
|
|
739
|
+
}
|
|
740
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
741
|
+
}
|
|
742
|
+
case 'audit_log': {
|
|
743
|
+
const since = args.since === undefined ? null : parseSince(String(args.since));
|
|
744
|
+
const limit =
|
|
745
|
+
Number.isInteger(args.limit) && args.limit >= 0 ? args.limit : DEFAULT_AUDIT_LIMIT;
|
|
746
|
+
// Only the policy's own logs: a tool argument never names a file to read
|
|
747
|
+
const { files, error } = auditLogFiles(args.path);
|
|
748
|
+
const { events, skipped, missing } = readAuditLog(files);
|
|
749
|
+
const matching = filterAuditEvents(events, { since });
|
|
750
|
+
const result = {
|
|
751
|
+
files: files.filter((file) => !missing.includes(file)),
|
|
752
|
+
total: matching.length,
|
|
753
|
+
events: filterAuditEvents(matching, { limit }),
|
|
754
|
+
skippedLines: skipped,
|
|
755
|
+
};
|
|
756
|
+
if (error) result.policyError = error.message;
|
|
757
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
758
|
+
}
|
|
759
|
+
case 'ask_codebase': {
|
|
760
|
+
const result = await askCodebase(args.path, args.query, {
|
|
761
|
+
limit: Number.isInteger(args.limit) ? args.limit : undefined,
|
|
762
|
+
maxChars: ASK_SNIPPET_CHARS,
|
|
763
|
+
});
|
|
764
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
765
|
+
}
|
|
669
766
|
default:
|
|
670
767
|
throw new Error(`Unknown tool: ${name}`);
|
|
671
768
|
}
|
|
@@ -43,7 +43,10 @@ class GitIgnoreParser {
|
|
|
43
43
|
} catch (error) {
|
|
44
44
|
// A directory, or a file that doesn't exist or can't be read
|
|
45
45
|
if (error.code === 'EISDIR') {
|
|
46
|
-
|
|
46
|
+
// Quoted and escaped: a line break in the path cannot start a line of its own
|
|
47
|
+
console.warn(
|
|
48
|
+
`Warning: ${JSON.stringify(String(filePath))} is a directory, expected a file`
|
|
49
|
+
);
|
|
47
50
|
}
|
|
48
51
|
return [];
|
|
49
52
|
}
|
package/lib/policy/audit.js
CHANGED
|
@@ -12,7 +12,7 @@ import os from 'os';
|
|
|
12
12
|
import path from 'path';
|
|
13
13
|
import { execFileSync } from 'child_process';
|
|
14
14
|
import { createRequire } from 'module';
|
|
15
|
-
import { relativePathOf } from './policy.js';
|
|
15
|
+
import { relativePathOf, loadPolicy, DEFAULT_AUDIT_PATH } from './policy.js';
|
|
16
16
|
|
|
17
17
|
const { version: CTXMAN_VERSION } = createRequire(import.meta.url)('../../package.json');
|
|
18
18
|
|
|
@@ -167,6 +167,24 @@ export function recordExport(policy, event = {}, { warn = defaultWarn } = {}) {
|
|
|
167
167
|
return record;
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
+
/**
|
|
171
|
+
* The audit logs of a project's policy, or the default location when the policy names none
|
|
172
|
+
* @param {string} root - Project root
|
|
173
|
+
* @param {Object} [options]
|
|
174
|
+
* @param {object} [options.env=process.env] - Environment for loading the policy
|
|
175
|
+
* @returns {{files: string[], error: Error|null}} `error` when the policy could not be
|
|
176
|
+
* loaded (the default location is returned then)
|
|
177
|
+
*/
|
|
178
|
+
export function auditLogFiles(root, { env = process.env } = {}) {
|
|
179
|
+
try {
|
|
180
|
+
const policy = loadPolicy(root, { env });
|
|
181
|
+
if (policy.audit.paths.length > 0) return { files: policy.audit.paths, error: null };
|
|
182
|
+
return { files: [path.join(root, DEFAULT_AUDIT_PATH)], error: null };
|
|
183
|
+
} catch (error) {
|
|
184
|
+
return { files: [path.join(root, DEFAULT_AUDIT_PATH)], error };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
170
188
|
/**
|
|
171
189
|
* Read audit logs. Lines that are not JSON objects are skipped and counted; events that
|
|
172
190
|
* appear in several logs (organisation and project) are listed once.
|
package/lib/policy/index.js
CHANGED
package/lib/rag/ask.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Semantic search over a project (`ctxman ask` and the MCP `ask_codebase` tool): an
|
|
3
|
+
* embedding index under .ctxman/self-knowledge, built on first use, then the snippets
|
|
4
|
+
* closest to the question. Needs the optional @xenova/transformers and @lancedb/lancedb
|
|
5
|
+
* packages; the embedding model is downloaded on first use.
|
|
6
|
+
*
|
|
7
|
+
* Files left out by .gitignore, .contextignore, .contextinclude or the export policy are
|
|
8
|
+
* not indexed, and results from an older index are checked against the policy again.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fs from 'fs/promises';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import { Scanner } from '../core/Scanner.js';
|
|
14
|
+
import { redactSecrets } from '../utils/secret-redactor.js';
|
|
15
|
+
import { VectorStoreFactory } from './VectorStoreFactory.js';
|
|
16
|
+
import { TransformersEmbeddingProvider } from './EmbeddingProvider.js';
|
|
17
|
+
import { Indexer } from './Indexer.js';
|
|
18
|
+
import { FileDataSource } from './sources/FileDataSource.js';
|
|
19
|
+
|
|
20
|
+
export const ASK_STORE_DIR = path.join('.ctxman', 'self-knowledge');
|
|
21
|
+
export const DEFAULT_ASK_LIMIT = 5;
|
|
22
|
+
|
|
23
|
+
const INCLUDE = ['.js', '.md', '.ts', '.json'];
|
|
24
|
+
const EXCLUDE = ['node_modules', '.git', 'dist', 'coverage', '.ctxman'];
|
|
25
|
+
// Extra rows asked of the store, so results the policy removes still leave `limit`
|
|
26
|
+
const OVERFETCH = 10;
|
|
27
|
+
|
|
28
|
+
async function hasIndex(storePath) {
|
|
29
|
+
try {
|
|
30
|
+
return (await fs.readdir(storePath)).length > 0;
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const toPosix = (p) => p.split(path.sep).join('/');
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {string} root - Project directory
|
|
40
|
+
* @param {string} query - The question
|
|
41
|
+
* @param {Object} [options]
|
|
42
|
+
* @param {number} [options.limit=5] - Snippets to return
|
|
43
|
+
* @param {boolean} [options.redact=true] - Redact secrets in the snippets
|
|
44
|
+
* @param {number} [options.maxChars] - Cut each snippet to this many characters
|
|
45
|
+
* @param {(event: 'indexing'|'indexed') => void} [options.onProgress]
|
|
46
|
+
* @param {(storePath: string) => object} [options.createStore] - Vector store factory
|
|
47
|
+
* (tests); default: LanceDB with the local embedding model
|
|
48
|
+
* @returns {Promise<{query: string, indexed: boolean, results: Array<{file: string,
|
|
49
|
+
* score: number|null, text: string, truncated: boolean}>, removedByPolicy: number,
|
|
50
|
+
* redactions: number}>}
|
|
51
|
+
* @throws {Error} When the question is empty, the policy is invalid, the optional packages
|
|
52
|
+
* are missing or indexing fails
|
|
53
|
+
*/
|
|
54
|
+
export async function askCodebase(root, query, options = {}) {
|
|
55
|
+
const question = typeof query === 'string' ? query.trim() : '';
|
|
56
|
+
if (!question) throw new Error('A question is required');
|
|
57
|
+
const limit =
|
|
58
|
+
Number.isInteger(options.limit) && options.limit > 0 ? options.limit : DEFAULT_ASK_LIMIT;
|
|
59
|
+
const projectRoot = path.resolve(root);
|
|
60
|
+
const storePath = path.join(projectRoot, ASK_STORE_DIR);
|
|
61
|
+
const onProgress = options.onProgress || (() => {});
|
|
62
|
+
|
|
63
|
+
const scanner = new Scanner(projectRoot);
|
|
64
|
+
// Load now: an invalid policy fails the command instead of being skipped
|
|
65
|
+
scanner.getPolicy();
|
|
66
|
+
const relative = (fullPath) => toPosix(path.relative(projectRoot, fullPath));
|
|
67
|
+
const left = (fullPath) => {
|
|
68
|
+
const rel = relative(fullPath);
|
|
69
|
+
return rel.startsWith('../') || path.isAbsolute(rel);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const store = options.createStore
|
|
73
|
+
? options.createStore(storePath)
|
|
74
|
+
: VectorStoreFactory.create('lancedb', new TransformersEmbeddingProvider(), {
|
|
75
|
+
path: storePath,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
let indexed = false;
|
|
79
|
+
if (!(await hasIndex(storePath))) {
|
|
80
|
+
onProgress('indexing');
|
|
81
|
+
const indexer = new Indexer(store);
|
|
82
|
+
indexer.registerSource(new FileDataSource());
|
|
83
|
+
await indexer.index('local-files', {
|
|
84
|
+
targetDir: projectRoot,
|
|
85
|
+
include: INCLUDE,
|
|
86
|
+
exclude: EXCLUDE,
|
|
87
|
+
skip: (fullPath) =>
|
|
88
|
+
scanner.shouldIgnore(path.relative(projectRoot, fullPath), fullPath) ||
|
|
89
|
+
scanner.isDenied(relative(fullPath)),
|
|
90
|
+
});
|
|
91
|
+
indexed = true;
|
|
92
|
+
onProgress('indexed');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const rows = await store.search(question, limit + OVERFETCH);
|
|
96
|
+
const results = [];
|
|
97
|
+
let removedByPolicy = 0;
|
|
98
|
+
let redactions = 0;
|
|
99
|
+
for (const row of rows) {
|
|
100
|
+
const fullPath = row.metadata?.path;
|
|
101
|
+
if (typeof fullPath !== 'string' || left(fullPath)) continue;
|
|
102
|
+
const file = relative(fullPath);
|
|
103
|
+
if (scanner.isDenied(file)) {
|
|
104
|
+
removedByPolicy++;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (results.length === limit) continue;
|
|
108
|
+
let text = String(row.text ?? '');
|
|
109
|
+
if (options.redact !== false) {
|
|
110
|
+
const redacted = redactSecrets(text);
|
|
111
|
+
text = redacted.text;
|
|
112
|
+
redactions += redacted.findings.length;
|
|
113
|
+
}
|
|
114
|
+
const truncated = Boolean(options.maxChars) && text.length > options.maxChars;
|
|
115
|
+
if (truncated) text = text.slice(0, options.maxChars);
|
|
116
|
+
results.push({ file, score: row.score ?? null, text, truncated });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { query: question, indexed, results, removedByPolicy, redactions };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export default askCodebase;
|
|
@@ -19,11 +19,13 @@ export class FileDataSource extends DataSourcePlugin {
|
|
|
19
19
|
const maxDepth = options.maxDepth || 5;
|
|
20
20
|
const include = options.include || ['.js', '.md', '.ts', '.json'];
|
|
21
21
|
const exclude = options.exclude || ['node_modules', '.git', 'dist', 'coverage'];
|
|
22
|
+
// (fullPath, isDirectory) => true leaves a file or directory out
|
|
23
|
+
const skip = options.skip || (() => false);
|
|
22
24
|
|
|
23
|
-
yield* this.walkDirectory(targetDir, maxDepth, include, exclude);
|
|
25
|
+
yield* this.walkDirectory(targetDir, maxDepth, include, exclude, skip);
|
|
24
26
|
}
|
|
25
27
|
|
|
26
|
-
async *walkDirectory(dir, depth, include, exclude) {
|
|
28
|
+
async *walkDirectory(dir, depth, include, exclude, skip = () => false) {
|
|
27
29
|
if (depth < 0) return;
|
|
28
30
|
|
|
29
31
|
let entries;
|
|
@@ -38,9 +40,10 @@ export class FileDataSource extends DataSourcePlugin {
|
|
|
38
40
|
const fullPath = path.join(dir, entry.name);
|
|
39
41
|
|
|
40
42
|
if (exclude.some((ex) => fullPath.includes(ex))) continue;
|
|
43
|
+
if (skip(fullPath, entry.isDirectory())) continue;
|
|
41
44
|
|
|
42
45
|
if (entry.isDirectory()) {
|
|
43
|
-
yield* this.walkDirectory(fullPath, depth - 1, include, exclude);
|
|
46
|
+
yield* this.walkDirectory(fullPath, depth - 1, include, exclude, skip);
|
|
44
47
|
} else if (entry.isFile()) {
|
|
45
48
|
const ext = path.extname(entry.name);
|
|
46
49
|
if (include.length > 0 && !include.includes(ext)) continue;
|
package/lib/utils/logger.js
CHANGED
|
@@ -94,13 +94,16 @@ class Logger {
|
|
|
94
94
|
formatMessage(level, message, meta = {}) {
|
|
95
95
|
const timestamp = this.getTimestamp();
|
|
96
96
|
const icon = this.icons[level];
|
|
97
|
+
// One entry per line: line breaks in the message (a path, an error text) are written
|
|
98
|
+
// escaped, so the message cannot add entries of its own
|
|
99
|
+
const text = String(message).replace(/\r\n|\r|\n/g, '\\n');
|
|
97
100
|
|
|
98
101
|
// Console format (with colors)
|
|
99
|
-
const consoleMessage = `${this.colors[level]}${icon} [${level.toUpperCase()}]${this.colors.reset} ${
|
|
102
|
+
const consoleMessage = `${this.colors[level]}${icon} [${level.toUpperCase()}]${this.colors.reset} ${text}`;
|
|
100
103
|
|
|
101
104
|
// File format (plain text with metadata)
|
|
102
105
|
const metaString = Object.keys(meta).length > 0 ? ` | ${JSON.stringify(meta)}` : '';
|
|
103
|
-
const fileMessage = `[${timestamp}] [${level.toUpperCase()}] ${
|
|
106
|
+
const fileMessage = `[${timestamp}] [${level.toUpperCase()}] ${text}${metaString}`;
|
|
104
107
|
|
|
105
108
|
return { consoleMessage, fileMessage };
|
|
106
109
|
}
|
|
@@ -81,37 +81,39 @@ class ConfigGenerator {
|
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
const filePath = path.join(this.projectRoot, filename);
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
// Check if should skip
|
|
87
|
-
if (exists && !force) {
|
|
84
|
+
const skip = () =>
|
|
88
85
|
result.skipped.push({
|
|
89
86
|
type,
|
|
90
87
|
filename,
|
|
91
88
|
reason: 'File already exists (use --force to overwrite)',
|
|
92
89
|
});
|
|
93
|
-
continue;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// Generate file content
|
|
97
|
-
const content = this.generateFileContent(type, patterns);
|
|
98
90
|
|
|
99
91
|
if (dryRun) {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
92
|
+
const exists = fs.existsSync(filePath);
|
|
93
|
+
if (exists && !force) {
|
|
94
|
+
skip();
|
|
95
|
+
continue;
|
|
104
96
|
}
|
|
97
|
+
const content = this.generateFileContent(type, patterns);
|
|
98
|
+
(exists ? result.overwritten : result.created).push({ type, filename, content });
|
|
105
99
|
continue;
|
|
106
100
|
}
|
|
107
101
|
|
|
108
|
-
|
|
102
|
+
const content = this.generateFileContent(type, patterns);
|
|
103
|
+
// Create exclusively first, so an existing file is found by the write itself rather
|
|
104
|
+
// than by a separate check that could be out of date
|
|
109
105
|
try {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
result.overwritten.push({ type, filename });
|
|
113
|
-
} else {
|
|
106
|
+
try {
|
|
107
|
+
fs.writeFileSync(filePath, content, { encoding: 'utf-8', flag: 'wx' });
|
|
114
108
|
result.created.push({ type, filename });
|
|
109
|
+
} catch (error) {
|
|
110
|
+
if (error.code !== 'EEXIST') throw error;
|
|
111
|
+
if (!force) {
|
|
112
|
+
skip();
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
fs.writeFileSync(filePath, content, 'utf-8');
|
|
116
|
+
result.overwritten.push({ type, filename });
|
|
115
117
|
}
|
|
116
118
|
} catch (error) {
|
|
117
119
|
result.errors.push({
|
|
@@ -514,7 +514,8 @@ class ProjectDetector {
|
|
|
514
514
|
* @returns {boolean} True if matches
|
|
515
515
|
*/
|
|
516
516
|
matchesGlob(filename, pattern) {
|
|
517
|
-
|
|
517
|
+
// Every regex character is literal except `*`, which matches any run of characters
|
|
518
|
+
const regex = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
|
|
518
519
|
return new RegExp(`^${regex}$`).test(filename);
|
|
519
520
|
}
|
|
520
521
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ctxman",
|
|
3
|
-
"version": "3.1.
|
|
3
|
+
"version": "3.1.1",
|
|
4
4
|
"description": "AI Development Platform with plugin architecture, Git integration, REST API, watch mode, LLM optimization, and support for 14+ languages. Features include TOON format (40-50% token reduction), real-time analysis, and comprehensive context generation.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -83,7 +83,7 @@
|
|
|
83
83
|
],
|
|
84
84
|
"author": "Hakkı Sağdıç",
|
|
85
85
|
"license": "MIT",
|
|
86
|
-
"homepage": "https://github.
|
|
86
|
+
"homepage": "https://hakkisagdic.github.io/ctxman/",
|
|
87
87
|
"repository": {
|
|
88
88
|
"type": "git",
|
|
89
89
|
"url": "git+https://github.com/hakkisagdic/ctxman.git"
|