kontext-engine 0.1.3 → 0.1.5

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/cli/index.js CHANGED
@@ -2,10 +2,11 @@
2
2
 
3
3
  // src/cli/index.ts
4
4
  import { Command } from "commander";
5
+ import { createRequire as createRequire2 } from "module";
5
6
 
6
7
  // src/cli/commands/init.ts
7
- import fs5 from "fs";
8
- import path4 from "path";
8
+ import fs6 from "fs";
9
+ import path5 from "path";
9
10
 
10
11
  // src/indexer/discovery.ts
11
12
  import fs from "fs/promises";
@@ -778,6 +779,25 @@ function prepareChunkText(filePath, parent, text) {
778
779
  parts.push(text);
779
780
  return parts.join("\n");
780
781
  }
782
+ var MAX_RETRIES = 3;
783
+ var BASE_DELAY_MS = 500;
784
+ async function fetchWithRetry(url, init) {
785
+ let lastError = null;
786
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
787
+ const response = await fetch(url, init);
788
+ if (response.ok) return response;
789
+ if (response.status === 429 && attempt < MAX_RETRIES) {
790
+ const delay = BASE_DELAY_MS * Math.pow(2, attempt);
791
+ await new Promise((resolve) => setTimeout(resolve, delay));
792
+ lastError = new Error(`HTTP ${response.status}: ${response.statusText}`);
793
+ continue;
794
+ }
795
+ throw new Error(
796
+ `Embedding API error: HTTP ${response.status} ${response.statusText}`
797
+ );
798
+ }
799
+ throw lastError ?? new Error("Embedding API request failed after retries");
800
+ }
781
801
  var LOCAL_MODEL_ID = "Xenova/all-MiniLM-L6-v2";
782
802
  var LOCAL_DIMENSIONS = 384;
783
803
  var LOCAL_BATCH_SIZE = 32;
@@ -835,6 +855,87 @@ async function createLocalEmbedder() {
835
855
  }
836
856
  };
837
857
  }
858
+ var VOYAGE_API_URL = "https://api.voyageai.com/v1/embeddings";
859
+ var VOYAGE_MODEL = "voyage-code-3";
860
+ var VOYAGE_DEFAULT_DIMENSIONS = 1024;
861
+ var VOYAGE_BATCH_SIZE = 128;
862
+ function createVoyageEmbedder(apiKey, dimensions = VOYAGE_DEFAULT_DIMENSIONS) {
863
+ return {
864
+ name: VOYAGE_MODEL,
865
+ dimensions,
866
+ async embed(texts, onProgress) {
867
+ const results = [];
868
+ for (let i = 0; i < texts.length; i += VOYAGE_BATCH_SIZE) {
869
+ const batch = texts.slice(i, i + VOYAGE_BATCH_SIZE);
870
+ const vectors = await callVoyageAPI(apiKey, batch, "document", dimensions);
871
+ results.push(...vectors);
872
+ onProgress?.(Math.min(i + batch.length, texts.length), texts.length);
873
+ }
874
+ return results;
875
+ },
876
+ async embedSingle(text) {
877
+ const vectors = await callVoyageAPI(apiKey, [text], "query", dimensions);
878
+ return vectors[0];
879
+ }
880
+ };
881
+ }
882
+ async function callVoyageAPI(apiKey, texts, inputType, dimensions) {
883
+ const response = await fetchWithRetry(VOYAGE_API_URL, {
884
+ method: "POST",
885
+ headers: {
886
+ "Content-Type": "application/json",
887
+ Authorization: `Bearer ${apiKey}`
888
+ },
889
+ body: JSON.stringify({
890
+ model: VOYAGE_MODEL,
891
+ input: texts,
892
+ input_type: inputType,
893
+ output_dimension: dimensions
894
+ })
895
+ });
896
+ const json = await response.json();
897
+ return json.data.map((d) => normalizeVector(new Float32Array(d.embedding)));
898
+ }
899
+ var OPENAI_API_URL = "https://api.openai.com/v1/embeddings";
900
+ var OPENAI_MODEL = "text-embedding-3-large";
901
+ var OPENAI_DEFAULT_DIMENSIONS = 1024;
902
+ var OPENAI_BATCH_SIZE = 128;
903
+ function createOpenAIEmbedder(apiKey, dimensions = OPENAI_DEFAULT_DIMENSIONS) {
904
+ return {
905
+ name: OPENAI_MODEL,
906
+ dimensions,
907
+ async embed(texts, onProgress) {
908
+ const results = [];
909
+ for (let i = 0; i < texts.length; i += OPENAI_BATCH_SIZE) {
910
+ const batch = texts.slice(i, i + OPENAI_BATCH_SIZE);
911
+ const vectors = await callOpenAIAPI(apiKey, batch, dimensions);
912
+ results.push(...vectors);
913
+ onProgress?.(Math.min(i + batch.length, texts.length), texts.length);
914
+ }
915
+ return results;
916
+ },
917
+ async embedSingle(text) {
918
+ const vectors = await callOpenAIAPI(apiKey, [text], dimensions);
919
+ return vectors[0];
920
+ }
921
+ };
922
+ }
923
+ async function callOpenAIAPI(apiKey, texts, dimensions) {
924
+ const response = await fetchWithRetry(OPENAI_API_URL, {
925
+ method: "POST",
926
+ headers: {
927
+ "Content-Type": "application/json",
928
+ Authorization: `Bearer ${apiKey}`
929
+ },
930
+ body: JSON.stringify({
931
+ model: OPENAI_MODEL,
932
+ input: texts,
933
+ dimensions
934
+ })
935
+ });
936
+ const json = await response.json();
937
+ return json.data.map((d) => normalizeVector(new Float32Array(d.embedding)));
938
+ }
838
939
 
839
940
  // src/utils/errors.ts
840
941
  var ErrorCode = {
@@ -876,6 +977,12 @@ var ConfigError = class extends KontextError {
876
977
  this.name = "ConfigError";
877
978
  }
878
979
  };
980
+ var DatabaseError = class extends KontextError {
981
+ constructor(message, code, cause) {
982
+ super(message, code, cause);
983
+ this.name = "DatabaseError";
984
+ }
985
+ };
879
986
 
880
987
  // src/utils/error-boundary.ts
881
988
  function handleCommandError(err, logger, verbose) {
@@ -1050,7 +1157,8 @@ function searchVectors(db, query, limit) {
1050
1157
 
1051
1158
  // src/storage/db.ts
1052
1159
  var DEFAULT_DIMENSIONS = 384;
1053
- function createDatabase(dbPath, dimensions = DEFAULT_DIMENSIONS) {
1160
+ var VECTOR_DIMENSIONS_META_KEY = "vector_dimensions";
1161
+ function createDatabase(dbPath, dimensions) {
1054
1162
  const dir = path3.dirname(dbPath);
1055
1163
  if (!fs4.existsSync(dir)) {
1056
1164
  fs4.mkdirSync(dir, { recursive: true });
@@ -1059,7 +1167,8 @@ function createDatabase(dbPath, dimensions = DEFAULT_DIMENSIONS) {
1059
1167
  db.pragma("journal_mode = WAL");
1060
1168
  db.pragma("foreign_keys = ON");
1061
1169
  sqliteVec.load(db);
1062
- initializeSchema(db, dimensions);
1170
+ initializeSchema(db, dimensions ?? DEFAULT_DIMENSIONS);
1171
+ ensureVectorDimensions(db, dimensions);
1063
1172
  const stmtUpsertFile = db.prepare(`
1064
1173
  INSERT INTO files (path, language, hash, last_indexed, size)
1065
1174
  VALUES (@path, @language, @hash, @lastIndexed, @size)
@@ -1068,6 +1177,7 @@ function createDatabase(dbPath, dimensions = DEFAULT_DIMENSIONS) {
1068
1177
  hash = excluded.hash,
1069
1178
  last_indexed = excluded.last_indexed,
1070
1179
  size = excluded.size
1180
+ RETURNING id
1071
1181
  `);
1072
1182
  const stmtGetFile = db.prepare(
1073
1183
  "SELECT id, path, language, hash, last_indexed as lastIndexed, size FROM files WHERE path = ?"
@@ -1111,18 +1221,20 @@ function createDatabase(dbPath, dimensions = DEFAULT_DIMENSIONS) {
1111
1221
  );
1112
1222
  return {
1113
1223
  upsertFile(file) {
1114
- const result = stmtUpsertFile.run({
1224
+ const row = stmtUpsertFile.get({
1115
1225
  path: file.path,
1116
1226
  language: file.language,
1117
1227
  hash: file.hash,
1118
1228
  lastIndexed: Date.now(),
1119
1229
  size: file.size
1120
1230
  });
1121
- if (result.changes > 0 && result.lastInsertRowid) {
1122
- return Number(result.lastInsertRowid);
1231
+ if (!row?.id) {
1232
+ throw new DatabaseError(
1233
+ `Failed to upsert file: ${file.path}`,
1234
+ ErrorCode.DB_WRITE_FAILED
1235
+ );
1123
1236
  }
1124
- const existing = stmtGetFile.get(file.path);
1125
- return existing?.id ?? 0;
1237
+ return row.id;
1126
1238
  },
1127
1239
  getFile(filePath) {
1128
1240
  const row = stmtGetFile.get(filePath);
@@ -1165,15 +1277,17 @@ function createDatabase(dbPath, dimensions = DEFAULT_DIMENSIONS) {
1165
1277
  return row.lastIndexed;
1166
1278
  },
1167
1279
  deleteFile(filePath) {
1168
- const file = stmtGetFile.get(filePath);
1169
- if (file) {
1170
- const chunkRows = stmtGetChunkIdsByFile.all(file.id);
1171
- const chunkIds = chunkRows.map((r) => r.id);
1172
- if (chunkIds.length > 0) {
1173
- deleteVectorsByChunkIds(db, chunkIds);
1280
+ db.transaction(() => {
1281
+ const file = stmtGetFile.get(filePath);
1282
+ if (file) {
1283
+ const chunkRows = stmtGetChunkIdsByFile.all(file.id);
1284
+ const chunkIds = chunkRows.map((r) => r.id);
1285
+ if (chunkIds.length > 0) {
1286
+ deleteVectorsByChunkIds(db, chunkIds);
1287
+ }
1174
1288
  }
1175
- }
1176
- stmtDeleteFile.run(filePath);
1289
+ stmtDeleteFile.run(filePath);
1290
+ })();
1177
1291
  },
1178
1292
  insertChunks(fileId, chunks) {
1179
1293
  const ids = [];
@@ -1268,12 +1382,14 @@ function createDatabase(dbPath, dimensions = DEFAULT_DIMENSIONS) {
1268
1382
  }));
1269
1383
  },
1270
1384
  deleteChunksByFile(fileId) {
1271
- const chunkRows = stmtGetChunkIdsByFile.all(fileId);
1272
- const chunkIds = chunkRows.map((r) => r.id);
1273
- if (chunkIds.length > 0) {
1274
- deleteVectorsByChunkIds(db, chunkIds);
1275
- }
1276
- stmtDeleteChunksByFile.run(fileId);
1385
+ db.transaction(() => {
1386
+ const chunkRows = stmtGetChunkIdsByFile.all(fileId);
1387
+ const chunkIds = chunkRows.map((r) => r.id);
1388
+ if (chunkIds.length > 0) {
1389
+ deleteVectorsByChunkIds(db, chunkIds);
1390
+ }
1391
+ stmtDeleteChunksByFile.run(fileId);
1392
+ })();
1277
1393
  },
1278
1394
  insertDependency(sourceChunkId, targetChunkId, type) {
1279
1395
  stmtInsertDep.run(sourceChunkId, targetChunkId, type);
@@ -1335,105 +1451,440 @@ function getMetaVersion(db) {
1335
1451
  return 0;
1336
1452
  }
1337
1453
  }
1454
+ function ensureVectorDimensions(db, expectedDimensions) {
1455
+ const actual = getExistingVectorDimensions(db);
1456
+ const stored = db.prepare("SELECT value FROM meta WHERE key = ?").get(VECTOR_DIMENSIONS_META_KEY);
1457
+ const storedValue = stored?.value;
1458
+ const storedDimensions = storedValue ? Number.parseInt(storedValue, 10) : void 0;
1459
+ if (storedDimensions !== void 0 && (!Number.isInteger(storedDimensions) || storedDimensions <= 0)) {
1460
+ throw new DatabaseError(
1461
+ `Invalid stored vector dimensions metadata: ${storedValue ?? "unknown"}`,
1462
+ ErrorCode.DB_CORRUPTED
1463
+ );
1464
+ }
1465
+ if (actual !== null && storedDimensions !== void 0 && storedDimensions !== actual) {
1466
+ throw new DatabaseError(
1467
+ `Vector dimensions metadata mismatch: meta=${storedDimensions}, table=${actual}.`,
1468
+ ErrorCode.DB_CORRUPTED
1469
+ );
1470
+ }
1471
+ if (expectedDimensions === void 0) {
1472
+ if (storedDimensions !== void 0) return;
1473
+ const dimensions = actual ?? DEFAULT_DIMENSIONS;
1474
+ db.prepare(
1475
+ "INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)"
1476
+ ).run(VECTOR_DIMENSIONS_META_KEY, String(dimensions));
1477
+ return;
1478
+ }
1479
+ if (!stored) {
1480
+ const dimensions = actual ?? expectedDimensions;
1481
+ db.prepare(
1482
+ "INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)"
1483
+ ).run(VECTOR_DIMENSIONS_META_KEY, String(dimensions));
1484
+ if (actual !== null && actual !== expectedDimensions) {
1485
+ throw new DatabaseError(
1486
+ `Vector dimension mismatch: index uses ${actual} dims, but config requests ${expectedDimensions} dims. Rebuild the index.`,
1487
+ ErrorCode.CONFIG_INVALID
1488
+ );
1489
+ }
1490
+ return;
1491
+ }
1492
+ if (storedDimensions !== expectedDimensions) {
1493
+ throw new DatabaseError(
1494
+ `Vector dimension mismatch: index uses ${storedDimensions} dims, but config requests ${expectedDimensions} dims. Rebuild the index.`,
1495
+ ErrorCode.CONFIG_INVALID
1496
+ );
1497
+ }
1498
+ }
1499
+ function getExistingVectorDimensions(db) {
1500
+ const row = db.prepare("SELECT sql FROM sqlite_master WHERE name = 'chunk_vectors'").get();
1501
+ const sql = row?.sql;
1502
+ if (!sql) return null;
1503
+ const match = sql.match(/embedding\s+float\[(\d+)\]/i);
1504
+ if (!match) return null;
1505
+ return Number.parseInt(match[1], 10);
1506
+ }
1338
1507
 
1339
- // src/cli/commands/init.ts
1508
+ // src/cli/commands/config.ts
1509
+ import fs5 from "fs";
1510
+ import path4 from "path";
1340
1511
  var CTX_DIR = ".ctx";
1341
- var DB_FILENAME = "index.db";
1342
1512
  var CONFIG_FILENAME = "config.json";
1343
- var GITIGNORE_ENTRY = ".ctx/";
1344
- function ensureGitignore(projectRoot) {
1345
- const gitignorePath = path4.join(projectRoot, ".gitignore");
1346
- if (fs5.existsSync(gitignorePath)) {
1347
- const content = fs5.readFileSync(gitignorePath, "utf-8");
1348
- if (content.includes(GITIGNORE_ENTRY)) return;
1349
- const suffix = content.endsWith("\n") ? "" : "\n";
1350
- fs5.writeFileSync(gitignorePath, `${content}${suffix}${GITIGNORE_ENTRY}
1351
- `);
1352
- } else {
1353
- fs5.writeFileSync(gitignorePath, `${GITIGNORE_ENTRY}
1354
- `);
1513
+ var DEFAULT_CONFIG = {
1514
+ embedder: {
1515
+ provider: "local",
1516
+ model: "Xenova/all-MiniLM-L6-v2",
1517
+ dimensions: 384
1518
+ },
1519
+ search: {
1520
+ defaultLimit: 10,
1521
+ strategies: ["vector", "fts", "ast", "path"],
1522
+ weights: { vector: 1, fts: 0.8, ast: 0.9, path: 0.7, dependency: 0.6 }
1523
+ },
1524
+ watch: {
1525
+ debounceMs: 500,
1526
+ ignored: []
1527
+ },
1528
+ llm: {
1529
+ provider: null,
1530
+ model: null
1531
+ }
1532
+ };
1533
+ var VALID_EMBEDDER_PROVIDERS = /* @__PURE__ */ new Set(["local", "voyage", "openai"]);
1534
+ var VALID_LLM_PROVIDERS = /* @__PURE__ */ new Set(["gemini", "openai", "anthropic"]);
1535
+ var VALIDATION_RULES = {
1536
+ "embedder.provider": {
1537
+ validate: (v) => typeof v === "string" && VALID_EMBEDDER_PROVIDERS.has(v),
1538
+ message: `Must be one of: ${[...VALID_EMBEDDER_PROVIDERS].join(", ")}`
1539
+ },
1540
+ "embedder.dimensions": {
1541
+ validate: (v) => typeof v === "number" && v > 0 && Number.isInteger(v),
1542
+ message: "Must be a positive integer"
1543
+ },
1544
+ "search.defaultLimit": {
1545
+ validate: (v) => typeof v === "number" && v > 0 && Number.isInteger(v),
1546
+ message: "Must be a positive integer"
1547
+ },
1548
+ "watch.debounceMs": {
1549
+ validate: (v) => typeof v === "number" && v >= 0 && Number.isInteger(v),
1550
+ message: "Must be a non-negative integer"
1551
+ },
1552
+ "llm.provider": {
1553
+ validate: (v) => v === null || typeof v === "string" && VALID_LLM_PROVIDERS.has(v),
1554
+ message: `Must be null or one of: ${[...VALID_LLM_PROVIDERS].join(", ")}`
1555
+ }
1556
+ };
1557
+ function resolveCtxDir(projectPath) {
1558
+ const absoluteRoot = path4.resolve(projectPath);
1559
+ const ctxDir = path4.join(absoluteRoot, CTX_DIR);
1560
+ if (!fs5.existsSync(ctxDir)) {
1561
+ throw new ConfigError(
1562
+ `Project not initialized. Run "ctx init" first. (${CTX_DIR}/ not found)`,
1563
+ ErrorCode.NOT_INITIALIZED
1564
+ );
1355
1565
  }
1566
+ return ctxDir;
1356
1567
  }
1357
- function ensureConfig(ctxDir) {
1358
- const configPath2 = path4.join(ctxDir, CONFIG_FILENAME);
1359
- if (fs5.existsSync(configPath2)) return;
1360
- const config = {
1361
- version: 1,
1362
- dimensions: 384,
1363
- model: "all-MiniLM-L6-v2"
1364
- };
1365
- fs5.writeFileSync(configPath2, JSON.stringify(config, null, 2) + "\n");
1568
+ function configPath(ctxDir) {
1569
+ return path4.join(ctxDir, CONFIG_FILENAME);
1366
1570
  }
1367
- function formatDuration(ms) {
1368
- if (ms < 1e3) return `${Math.round(ms)}ms`;
1369
- return `${(ms / 1e3).toFixed(1)}s`;
1571
+ function readConfig(ctxDir) {
1572
+ const filePath = configPath(ctxDir);
1573
+ if (!fs5.existsSync(filePath)) {
1574
+ writeConfig(ctxDir, DEFAULT_CONFIG);
1575
+ return structuredClone(DEFAULT_CONFIG);
1576
+ }
1577
+ const raw = fs5.readFileSync(filePath, "utf-8");
1578
+ const parsed = JSON.parse(raw);
1579
+ return mergeWithDefaults(parsed);
1370
1580
  }
1371
- function formatBytes(bytes) {
1372
- if (bytes < 1024) return `${bytes} B`;
1373
- if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
1374
- return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
1581
+ function writeConfig(ctxDir, config) {
1582
+ fs5.writeFileSync(
1583
+ configPath(ctxDir),
1584
+ JSON.stringify(config, null, 2) + "\n"
1585
+ );
1375
1586
  }
1376
- function formatLanguageSummary(counts) {
1377
- const entries = [...counts.entries()].sort((a, b) => b[1] - a[1]).map(([lang, count]) => `${lang}: ${count}`);
1378
- return entries.join(", ");
1587
+ function mergeWithDefaults(partial) {
1588
+ return {
1589
+ embedder: { ...DEFAULT_CONFIG.embedder, ...partial.embedder },
1590
+ search: {
1591
+ ...DEFAULT_CONFIG.search,
1592
+ ...partial.search,
1593
+ weights: { ...DEFAULT_CONFIG.search.weights, ...partial.search?.weights }
1594
+ },
1595
+ watch: { ...DEFAULT_CONFIG.watch, ...partial.watch },
1596
+ llm: { ...DEFAULT_CONFIG.llm, ...partial.llm }
1597
+ };
1379
1598
  }
1380
- async function runInit(projectPath, options = {}) {
1381
- const log = options.log ?? console.log;
1382
- const absoluteRoot = path4.resolve(projectPath);
1383
- const start = performance.now();
1384
- log(`Indexing ${absoluteRoot}...`);
1385
- const ctxDir = path4.join(absoluteRoot, CTX_DIR);
1386
- if (!fs5.existsSync(ctxDir)) fs5.mkdirSync(ctxDir, { recursive: true });
1387
- ensureGitignore(absoluteRoot);
1388
- ensureConfig(ctxDir);
1389
- const dbPath = path4.join(ctxDir, DB_FILENAME);
1390
- const db = createDatabase(dbPath);
1391
- try {
1392
- const discovered = await discoverFiles({
1393
- root: absoluteRoot,
1394
- extraIgnore: [".ctx/"]
1395
- });
1396
- const languageCounts = /* @__PURE__ */ new Map();
1397
- for (const file of discovered) {
1398
- languageCounts.set(
1399
- file.language,
1400
- (languageCounts.get(file.language) ?? 0) + 1
1401
- );
1402
- }
1403
- log(
1404
- ` Discovered ${discovered.length} files` + (discovered.length > 0 ? ` (${formatLanguageSummary(languageCounts)})` : "")
1405
- );
1406
- const changes = await computeChanges(discovered, db);
1407
- const filesToProcess = [
1408
- ...changes.added.map((p) => ({ path: p, reason: "added" })),
1409
- ...changes.modified.map((p) => ({ path: p, reason: "modified" }))
1410
- ];
1411
- if (changes.unchanged.length > 0) {
1412
- log(` ${changes.unchanged.length} unchanged files skipped`);
1599
+ function getNestedValue(obj, key) {
1600
+ const parts = key.split(".");
1601
+ let current = obj;
1602
+ for (const part of parts) {
1603
+ if (current === null || current === void 0 || typeof current !== "object") {
1604
+ return void 0;
1413
1605
  }
1414
- if (changes.deleted.length > 0) {
1415
- log(` ${changes.deleted.length} deleted files removed`);
1606
+ current = current[part];
1607
+ }
1608
+ return current;
1609
+ }
1610
+ function setNestedValue(obj, key, value) {
1611
+ const parts = key.split(".");
1612
+ let current = obj;
1613
+ for (let i = 0; i < parts.length - 1; i++) {
1614
+ const part = parts[i];
1615
+ if (typeof current[part] !== "object" || current[part] === null) {
1616
+ current[part] = {};
1416
1617
  }
1417
- if (changes.added.length > 0) {
1418
- log(` ${changes.added.length} new files to index`);
1618
+ current = current[part];
1619
+ }
1620
+ current[parts[parts.length - 1]] = value;
1621
+ }
1622
+ function hasNestedKey(obj, key) {
1623
+ const parts = key.split(".");
1624
+ let current = obj;
1625
+ for (const part of parts) {
1626
+ if (current === null || current === void 0 || typeof current !== "object") {
1627
+ return false;
1419
1628
  }
1420
- if (changes.modified.length > 0) {
1421
- log(` ${changes.modified.length} modified files to re-index`);
1629
+ if (!(part in current)) {
1630
+ return false;
1422
1631
  }
1423
- for (const deletedPath of changes.deleted) {
1424
- db.deleteFile(deletedPath);
1632
+ current = current[part];
1633
+ }
1634
+ return true;
1635
+ }
1636
+ function parseValue(rawValue) {
1637
+ if (rawValue === "null") return null;
1638
+ if (rawValue === "true") return true;
1639
+ if (rawValue === "false") return false;
1640
+ const num = Number(rawValue);
1641
+ if (!Number.isNaN(num) && rawValue.trim() !== "") return num;
1642
+ if (rawValue.startsWith("[") || rawValue.startsWith("{")) {
1643
+ try {
1644
+ return JSON.parse(rawValue);
1645
+ } catch {
1425
1646
  }
1426
- await initParser();
1427
- const allChunksWithMeta = [];
1428
- let filesProcessed = 0;
1429
- for (const { path: relPath } of filesToProcess) {
1430
- const discovered_file = discovered.find((f) => f.path === relPath);
1431
- if (!discovered_file) continue;
1432
- const existingFile = db.getFile(relPath);
1433
- if (existingFile) {
1434
- db.deleteChunksByFile(existingFile.id);
1435
- }
1436
- let nodes;
1647
+ }
1648
+ return rawValue;
1649
+ }
1650
+ function runConfigShow(projectPath) {
1651
+ const ctxDir = resolveCtxDir(projectPath);
1652
+ const config = readConfig(ctxDir);
1653
+ return {
1654
+ config,
1655
+ text: JSON.stringify(config, null, 2)
1656
+ };
1657
+ }
1658
+ function runConfigGet(projectPath, key) {
1659
+ const ctxDir = resolveCtxDir(projectPath);
1660
+ const config = readConfig(ctxDir);
1661
+ return getNestedValue(config, key);
1662
+ }
1663
+ function runConfigSet(projectPath, key, rawValue) {
1664
+ const ctxDir = resolveCtxDir(projectPath);
1665
+ const config = readConfig(ctxDir);
1666
+ const value = parseValue(rawValue);
1667
+ const rule = VALIDATION_RULES[key];
1668
+ if (rule && !rule.validate(value)) {
1669
+ throw new ConfigError(`Invalid value for "${key}": ${rule.message}`, ErrorCode.CONFIG_INVALID);
1670
+ }
1671
+ setNestedValue(config, key, value);
1672
+ writeConfig(ctxDir, config);
1673
+ }
1674
+ function runConfigReset(projectPath, key) {
1675
+ const ctxDir = resolveCtxDir(projectPath);
1676
+ if (!key) {
1677
+ writeConfig(ctxDir, structuredClone(DEFAULT_CONFIG));
1678
+ return;
1679
+ }
1680
+ if (!hasNestedKey(DEFAULT_CONFIG, key)) {
1681
+ throw new ConfigError(`Invalid config key: ${key}`, ErrorCode.CONFIG_INVALID);
1682
+ }
1683
+ const config = readConfig(ctxDir);
1684
+ const defaultValue = getNestedValue(
1685
+ DEFAULT_CONFIG,
1686
+ key
1687
+ );
1688
+ setNestedValue(
1689
+ config,
1690
+ key,
1691
+ structuredClone(defaultValue)
1692
+ );
1693
+ writeConfig(ctxDir, config);
1694
+ }
1695
+ function registerConfigCommand(program2) {
1696
+ const cmd = program2.command("config").description("Show or modify configuration");
1697
+ function configErrorHandler(err) {
1698
+ const verbose = program2.opts()["verbose"] === true;
1699
+ const logger = createLogger({ level: verbose ? LogLevel.DEBUG : LogLevel.INFO });
1700
+ process.exitCode = handleCommandError(err, logger, verbose);
1701
+ }
1702
+ cmd.command("show").description("Show current configuration").action(() => {
1703
+ try {
1704
+ const output = runConfigShow(process.cwd());
1705
+ console.log(output.text);
1706
+ } catch (err) {
1707
+ configErrorHandler(err);
1708
+ }
1709
+ });
1710
+ cmd.command("get <key>").description("Get a configuration value (dot notation)").action((key) => {
1711
+ try {
1712
+ const value = runConfigGet(process.cwd(), key);
1713
+ console.log(
1714
+ typeof value === "object" ? JSON.stringify(value, null, 2) : String(value)
1715
+ );
1716
+ } catch (err) {
1717
+ configErrorHandler(err);
1718
+ }
1719
+ });
1720
+ cmd.command("set <key> <value>").description("Set a configuration value (dot notation)").action((key, value) => {
1721
+ try {
1722
+ runConfigSet(process.cwd(), key, value);
1723
+ console.log(`Set ${key} = ${value}`);
1724
+ } catch (err) {
1725
+ configErrorHandler(err);
1726
+ }
1727
+ });
1728
+ cmd.command("reset [key]").description("Reset configuration to defaults or reset a specific key").action((key) => {
1729
+ try {
1730
+ runConfigReset(process.cwd(), key);
1731
+ if (key) {
1732
+ console.log(`Reset ${key} to default.`);
1733
+ } else {
1734
+ console.log("Configuration reset to defaults.");
1735
+ }
1736
+ } catch (err) {
1737
+ configErrorHandler(err);
1738
+ }
1739
+ });
1740
+ }
1741
+
1742
+ // src/cli/embedder.ts
1743
+ function getProjectEmbedderConfig(projectPath) {
1744
+ const { config } = runConfigShow(projectPath);
1745
+ return config.embedder;
1746
+ }
1747
+ async function createProjectEmbedder(projectPath) {
1748
+ const config = getProjectEmbedderConfig(projectPath);
1749
+ validateProjectEmbedderConfig(config);
1750
+ switch (config.provider) {
1751
+ case "local":
1752
+ return await createLocalEmbedder();
1753
+ case "voyage": {
1754
+ const apiKey = requireApiKey("CTX_VOYAGE_KEY", "voyage");
1755
+ return createVoyageEmbedder(apiKey, config.dimensions);
1756
+ }
1757
+ case "openai": {
1758
+ const apiKey = requireApiKey("CTX_OPENAI_KEY", "openai");
1759
+ return createOpenAIEmbedder(apiKey, config.dimensions);
1760
+ }
1761
+ default:
1762
+ throw new ConfigError(
1763
+ `Unsupported embedder provider "${config.provider}". Use local, voyage, or openai.`,
1764
+ ErrorCode.CONFIG_INVALID
1765
+ );
1766
+ }
1767
+ }
1768
+ function requireApiKey(envVar, provider) {
1769
+ const value = process.env[envVar];
1770
+ if (typeof value === "string" && value.length > 0) return value;
1771
+ throw new ConfigError(
1772
+ `Embedder provider "${provider}" requires ${envVar}. Export ${envVar} before running this command.`,
1773
+ ErrorCode.CONFIG_INVALID
1774
+ );
1775
+ }
1776
+ function validateProjectEmbedderConfig(config) {
1777
+ if (!Number.isInteger(config.dimensions) || config.dimensions <= 0) {
1778
+ throw new ConfigError(
1779
+ `Invalid embedder.dimensions (${String(config.dimensions)}). Must be a positive integer.`,
1780
+ ErrorCode.CONFIG_INVALID
1781
+ );
1782
+ }
1783
+ if (config.provider === "local" && config.dimensions !== 384) {
1784
+ throw new ConfigError(
1785
+ 'Local embedder requires "embedder.dimensions" = 384. Update config or switch provider.',
1786
+ ErrorCode.CONFIG_INVALID
1787
+ );
1788
+ }
1789
+ }
1790
+
1791
+ // src/cli/commands/init.ts
1792
+ var CTX_DIR2 = ".ctx";
1793
+ var DB_FILENAME = "index.db";
1794
+ var CONFIG_FILENAME2 = "config.json";
1795
+ var GITIGNORE_ENTRY = ".ctx/";
1796
+ function ensureGitignore(projectRoot) {
1797
+ const gitignorePath = path5.join(projectRoot, ".gitignore");
1798
+ if (fs6.existsSync(gitignorePath)) {
1799
+ const content = fs6.readFileSync(gitignorePath, "utf-8");
1800
+ if (content.includes(GITIGNORE_ENTRY)) return;
1801
+ const suffix = content.endsWith("\n") ? "" : "\n";
1802
+ fs6.writeFileSync(gitignorePath, `${content}${suffix}${GITIGNORE_ENTRY}
1803
+ `);
1804
+ } else {
1805
+ fs6.writeFileSync(gitignorePath, `${GITIGNORE_ENTRY}
1806
+ `);
1807
+ }
1808
+ }
1809
+ function ensureConfig(ctxDir) {
1810
+ const configPath2 = path5.join(ctxDir, CONFIG_FILENAME2);
1811
+ if (fs6.existsSync(configPath2)) return;
1812
+ fs6.writeFileSync(
1813
+ configPath2,
1814
+ JSON.stringify(DEFAULT_CONFIG, null, 2) + "\n"
1815
+ );
1816
+ }
1817
+ function formatDuration(ms) {
1818
+ if (ms < 1e3) return `${Math.round(ms)}ms`;
1819
+ return `${(ms / 1e3).toFixed(1)}s`;
1820
+ }
1821
+ function formatBytes(bytes) {
1822
+ if (bytes < 1024) return `${bytes} B`;
1823
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
1824
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
1825
+ }
1826
+ function formatLanguageSummary(counts) {
1827
+ const entries = [...counts.entries()].sort((a, b) => b[1] - a[1]).map(([lang, count]) => `${lang}: ${count}`);
1828
+ return entries.join(", ");
1829
+ }
1830
+ async function runInit(projectPath, options = {}) {
1831
+ const log = options.log ?? console.log;
1832
+ const absoluteRoot = path5.resolve(projectPath);
1833
+ const start = performance.now();
1834
+ log(`Indexing ${absoluteRoot}...`);
1835
+ const ctxDir = path5.join(absoluteRoot, CTX_DIR2);
1836
+ if (!fs6.existsSync(ctxDir)) fs6.mkdirSync(ctxDir, { recursive: true });
1837
+ ensureGitignore(absoluteRoot);
1838
+ ensureConfig(ctxDir);
1839
+ const embedderConfig = getProjectEmbedderConfig(absoluteRoot);
1840
+ const dbPath = path5.join(ctxDir, DB_FILENAME);
1841
+ const db = createDatabase(dbPath, embedderConfig.dimensions);
1842
+ try {
1843
+ const discovered = await discoverFiles({
1844
+ root: absoluteRoot,
1845
+ extraIgnore: [".ctx/"]
1846
+ });
1847
+ const languageCounts = /* @__PURE__ */ new Map();
1848
+ for (const file of discovered) {
1849
+ languageCounts.set(
1850
+ file.language,
1851
+ (languageCounts.get(file.language) ?? 0) + 1
1852
+ );
1853
+ }
1854
+ log(
1855
+ ` Discovered ${discovered.length} files` + (discovered.length > 0 ? ` (${formatLanguageSummary(languageCounts)})` : "")
1856
+ );
1857
+ const changes = await computeChanges(discovered, db);
1858
+ const filesToProcess = [
1859
+ ...changes.added.map((p) => ({ path: p, reason: "added" })),
1860
+ ...changes.modified.map((p) => ({ path: p, reason: "modified" }))
1861
+ ];
1862
+ if (changes.unchanged.length > 0) {
1863
+ log(` ${changes.unchanged.length} unchanged files skipped`);
1864
+ }
1865
+ if (changes.deleted.length > 0) {
1866
+ log(` ${changes.deleted.length} deleted files removed`);
1867
+ }
1868
+ if (changes.added.length > 0) {
1869
+ log(` ${changes.added.length} new files to index`);
1870
+ }
1871
+ if (changes.modified.length > 0) {
1872
+ log(` ${changes.modified.length} modified files to re-index`);
1873
+ }
1874
+ for (const deletedPath of changes.deleted) {
1875
+ db.deleteFile(deletedPath);
1876
+ }
1877
+ await initParser();
1878
+ const allChunksWithMeta = [];
1879
+ let filesProcessed = 0;
1880
+ for (const { path: relPath } of filesToProcess) {
1881
+ const discovered_file = discovered.find((f) => f.path === relPath);
1882
+ if (!discovered_file) continue;
1883
+ const existingFile = db.getFile(relPath);
1884
+ if (existingFile) {
1885
+ db.deleteChunksByFile(existingFile.id);
1886
+ }
1887
+ let nodes;
1437
1888
  try {
1438
1889
  nodes = await parseFile(discovered_file.absolutePath, discovered_file.language);
1439
1890
  } catch {
@@ -1475,7 +1926,7 @@ async function runInit(projectPath, options = {}) {
1475
1926
  log(` ${allChunksWithMeta.length} chunks created`);
1476
1927
  let vectorsCreated = 0;
1477
1928
  if (!options.skipEmbedding && allChunksWithMeta.length > 0) {
1478
- const embedder = await createEmbedder();
1929
+ const embedder = await createEmbedder(absoluteRoot);
1479
1930
  const texts = allChunksWithMeta.map(
1480
1931
  (cm) => prepareChunkText(cm.fileRelPath, cm.chunk.parent, cm.chunk.text)
1481
1932
  );
@@ -1491,13 +1942,13 @@ async function runInit(projectPath, options = {}) {
1491
1942
  vectorsCreated = vectors.length;
1492
1943
  }
1493
1944
  const durationMs = performance.now() - start;
1494
- const dbSize = fs5.existsSync(dbPath) ? fs5.statSync(dbPath).size : 0;
1945
+ const dbSize = fs6.existsSync(dbPath) ? fs6.statSync(dbPath).size : 0;
1495
1946
  log("");
1496
1947
  log(`\u2713 Indexed in ${formatDuration(durationMs)}`);
1497
1948
  log(
1498
1949
  ` ${discovered.length} files \u2192 ${allChunksWithMeta.length} chunks` + (vectorsCreated > 0 ? ` \u2192 ${vectorsCreated} vectors` : "")
1499
1950
  );
1500
- log(` Database: ${CTX_DIR}/${DB_FILENAME} (${formatBytes(dbSize)})`);
1951
+ log(` Database: ${CTX_DIR2}/${DB_FILENAME} (${formatBytes(dbSize)})`);
1501
1952
  return {
1502
1953
  filesDiscovered: discovered.length,
1503
1954
  filesAdded: changes.added.length,
@@ -1513,8 +1964,8 @@ async function runInit(projectPath, options = {}) {
1513
1964
  db.close();
1514
1965
  }
1515
1966
  }
1516
- async function createEmbedder() {
1517
- return createLocalEmbedder();
1967
+ async function createEmbedder(projectPath) {
1968
+ return createProjectEmbedder(projectPath);
1518
1969
  }
1519
1970
  function registerInitCommand(program2) {
1520
1971
  program2.command("init [path]").description("Index current directory or specified path").action(async (inputPath) => {
@@ -1535,8 +1986,8 @@ function registerInitCommand(program2) {
1535
1986
  }
1536
1987
 
1537
1988
  // src/cli/commands/query.ts
1538
- import fs6 from "fs";
1539
- import path5 from "path";
1989
+ import fs7 from "fs";
1990
+ import path6 from "path";
1540
1991
 
1541
1992
  // src/search/vector.ts
1542
1993
  function distanceToScore(distance) {
@@ -1577,7 +2028,15 @@ async function vectorSearch(db, embedder, query, limit, filters) {
1577
2028
 
1578
2029
  // src/search/fts.ts
1579
2030
  function sanitizeFtsQuery(query) {
1580
- return query.replace(/[?()":^~{}!+\-\\]/g, " ").replace(/(?<!\w)\*/g, " ").replace(/\s+/g, " ").trim();
2031
+ const tokenized = query.replace(/[^A-Za-z0-9_*]+/g, " ").trim();
2032
+ if (tokenized.length === 0) return "";
2033
+ const sanitizedTerms = tokenized.split(/\s+/).map((term) => {
2034
+ const hasTrailingWildcard = /\*+$/.test(term);
2035
+ const base = term.replace(/\*/g, "");
2036
+ if (base.length === 0) return "";
2037
+ return hasTrailingWildcard ? `${base}*` : base;
2038
+ }).filter((term) => term.length > 0);
2039
+ return sanitizedTerms.join(" ");
1581
2040
  }
1582
2041
  function bm25ToScore(rank) {
1583
2042
  return 1 / (1 + Math.abs(rank));
@@ -1722,27 +2181,56 @@ function pathKeywordSearch(db, query, limit) {
1722
2181
  }
1723
2182
  if (scoredPaths.length === 0) return [];
1724
2183
  scoredPaths.sort((a, b) => b.score - a.score);
1725
- const results = [];
2184
+ const matchedFiles = [];
1726
2185
  for (const { filePath, score } of scoredPaths) {
1727
- if (results.length >= limit) break;
1728
2186
  const file = db.getFile(filePath);
1729
2187
  if (!file) continue;
1730
2188
  const chunks = db.getChunksByFile(file.id);
1731
- for (const chunk of chunks) {
2189
+ if (chunks.length === 0) continue;
2190
+ matchedFiles.push({
2191
+ filePath: file.path,
2192
+ language: file.language,
2193
+ score,
2194
+ chunks
2195
+ });
2196
+ }
2197
+ if (matchedFiles.length === 0) return [];
2198
+ const results = [];
2199
+ const pushChunk = (filePath, language, score, chunk) => {
2200
+ results.push({
2201
+ chunkId: chunk.id,
2202
+ filePath,
2203
+ lineStart: chunk.lineStart,
2204
+ lineEnd: chunk.lineEnd,
2205
+ name: chunk.name,
2206
+ type: chunk.type,
2207
+ exported: chunk.exports,
2208
+ text: chunk.text,
2209
+ score,
2210
+ language
2211
+ });
2212
+ };
2213
+ for (const matched of matchedFiles) {
2214
+ if (results.length >= limit) break;
2215
+ pushChunk(
2216
+ matched.filePath,
2217
+ matched.language,
2218
+ matched.score,
2219
+ matched.chunks[0]
2220
+ );
2221
+ }
2222
+ let offset = 1;
2223
+ while (results.length < limit) {
2224
+ let addedInRound = false;
2225
+ for (const matched of matchedFiles) {
1732
2226
  if (results.length >= limit) break;
1733
- results.push({
1734
- chunkId: chunk.id,
1735
- filePath: file.path,
1736
- lineStart: chunk.lineStart,
1737
- lineEnd: chunk.lineEnd,
1738
- name: chunk.name,
1739
- type: chunk.type,
1740
- exported: chunk.exports,
1741
- text: chunk.text,
1742
- score,
1743
- language: file.language
1744
- });
2227
+ const chunk = matched.chunks[offset];
2228
+ if (!chunk) continue;
2229
+ pushChunk(matched.filePath, matched.language, matched.score, chunk);
2230
+ addedInRound = true;
1745
2231
  }
2232
+ if (!addedInRound) break;
2233
+ offset++;
1746
2234
  }
1747
2235
  return results;
1748
2236
  }
@@ -1941,8 +2429,91 @@ function renormalize(results) {
1941
2429
  }));
1942
2430
  }
1943
2431
 
2432
+ // src/steering/classify.ts
2433
+ var SYMBOL_CAMEL_RE = /^[a-z][a-zA-Z0-9]*$/;
2434
+ var SYMBOL_PASCAL_RE = /^[A-Z][a-zA-Z0-9]*$/;
2435
+ var SYMBOL_SNAKE_RE = /^[a-z]+(?:_[a-z]+)+$/;
2436
+ var SYMBOL_UPPER_RE = /^[A-Z]+(?:_[A-Z]+)*$/;
2437
+ var PATH_EXTENSION_RE = /\.(?:ts|tsx|js|jsx|mjs|cjs|py|go|rs|java|kt|swift|rb|php|cs|cpp|c|h|hpp|json|yaml|yml|toml|md|sql|sh|bash)$/i;
2438
+ var QUESTION_WORDS = /* @__PURE__ */ new Set([
2439
+ "how",
2440
+ "what",
2441
+ "where",
2442
+ "why",
2443
+ "when",
2444
+ "which",
2445
+ "show",
2446
+ "explain",
2447
+ "find",
2448
+ "list"
2449
+ ]);
2450
+ var STOP_WORDS = /* @__PURE__ */ new Set([
2451
+ "the",
2452
+ "a",
2453
+ "an",
2454
+ "is",
2455
+ "are",
2456
+ "was",
2457
+ "were",
2458
+ "do",
2459
+ "does",
2460
+ "did",
2461
+ "to",
2462
+ "for",
2463
+ "of",
2464
+ "in",
2465
+ "on",
2466
+ "with",
2467
+ "by",
2468
+ "and",
2469
+ "or"
2470
+ ]);
2471
+ function defaultMultipliers() {
2472
+ return {
2473
+ vector: 1,
2474
+ fts: 1,
2475
+ ast: 1,
2476
+ path: 1,
2477
+ dependency: 1
2478
+ };
2479
+ }
2480
+ function isSymbolQuery(query) {
2481
+ return SYMBOL_CAMEL_RE.test(query) || SYMBOL_PASCAL_RE.test(query) || SYMBOL_SNAKE_RE.test(query) || SYMBOL_UPPER_RE.test(query);
2482
+ }
2483
+ function isPathQuery(query) {
2484
+ return query.includes("/") || PATH_EXTENSION_RE.test(query);
2485
+ }
2486
+ function isNaturalLanguageQuery(query) {
2487
+ const lower = query.toLowerCase();
2488
+ const words = lower.split(/\s+/).filter((w) => w.length > 0);
2489
+ const hasQuestionWord = words.some((w) => QUESTION_WORDS.has(w));
2490
+ const hasStopWord = words.some((w) => STOP_WORDS.has(w));
2491
+ return hasQuestionWord || words.length >= 4 && hasStopWord;
2492
+ }
2493
+ function classifyQuery(query) {
2494
+ const trimmed = query.trim();
2495
+ const multipliers = defaultMultipliers();
2496
+ if (isPathQuery(trimmed)) {
2497
+ multipliers.path = 2;
2498
+ multipliers.ast = 0.5;
2499
+ return { kind: "path", multipliers };
2500
+ }
2501
+ if (isSymbolQuery(trimmed)) {
2502
+ multipliers.ast = 1.5;
2503
+ multipliers.vector = 0.5;
2504
+ return { kind: "symbol", multipliers };
2505
+ }
2506
+ if (isNaturalLanguageQuery(trimmed)) {
2507
+ multipliers.vector = 1.5;
2508
+ multipliers.path = 1.2;
2509
+ multipliers.ast = 0.7;
2510
+ return { kind: "natural_language", multipliers };
2511
+ }
2512
+ return { kind: "keyword", multipliers };
2513
+ }
2514
+
1944
2515
  // src/cli/commands/query.ts
1945
- var CTX_DIR2 = ".ctx";
2516
+ var CTX_DIR3 = ".ctx";
1946
2517
  var DB_FILENAME2 = "index.db";
1947
2518
  var SNIPPET_MAX_LENGTH = 200;
1948
2519
  var STRATEGY_WEIGHTS = {
@@ -1952,6 +2523,26 @@ var STRATEGY_WEIGHTS = {
1952
2523
  path: 0.7,
1953
2524
  dependency: 0.6
1954
2525
  };
2526
+ function getEffectiveStrategyWeights(query) {
2527
+ const { multipliers } = classifyQuery(query);
2528
+ return {
2529
+ vector: STRATEGY_WEIGHTS.vector * multipliers.vector,
2530
+ fts: STRATEGY_WEIGHTS.fts * multipliers.fts,
2531
+ ast: STRATEGY_WEIGHTS.ast * multipliers.ast,
2532
+ path: STRATEGY_WEIGHTS.path * multipliers.path,
2533
+ dependency: STRATEGY_WEIGHTS.dependency * multipliers.dependency
2534
+ };
2535
+ }
2536
+ function normalizeLimit(limit) {
2537
+ if (!Number.isFinite(limit)) return 0;
2538
+ return Math.max(0, Math.trunc(limit));
2539
+ }
2540
+ function resolveQueryStrategies(query, strategies, source) {
2541
+ if (source !== "default") return strategies;
2542
+ if (classifyQuery(query).kind !== "natural_language") return strategies;
2543
+ if (strategies.includes("vector")) return strategies;
2544
+ return [...strategies, "vector"];
2545
+ }
1955
2546
  function truncateSnippet(text) {
1956
2547
  const oneLine = text.replace(/\n/g, " ").replace(/\s+/g, " ").trim();
1957
2548
  if (oneLine.length <= SNIPPET_MAX_LENGTH) return oneLine;
@@ -1992,20 +2583,25 @@ function isPathLike(query) {
1992
2583
  return query.includes("/") || query.includes("*") || query.includes(".");
1993
2584
  }
1994
2585
  async function runQuery(projectPath, query, options) {
1995
- const absoluteRoot = path5.resolve(projectPath);
1996
- const dbPath = path5.join(absoluteRoot, CTX_DIR2, DB_FILENAME2);
1997
- if (!fs6.existsSync(dbPath)) {
2586
+ const limit = normalizeLimit(options.limit);
2587
+ const absoluteRoot = path6.resolve(projectPath);
2588
+ const dbPath = path6.join(absoluteRoot, CTX_DIR3, DB_FILENAME2);
2589
+ if (!fs7.existsSync(dbPath)) {
1998
2590
  throw new KontextError(
1999
- `Project not initialized. Run "ctx init" first. (${CTX_DIR2}/${DB_FILENAME2} not found)`,
2591
+ `Project not initialized. Run "ctx init" first. (${CTX_DIR3}/${DB_FILENAME2} not found)`,
2000
2592
  ErrorCode.NOT_INITIALIZED
2001
2593
  );
2002
2594
  }
2003
2595
  const start = performance.now();
2004
- const db = createDatabase(dbPath);
2596
+ const embedderConfig = getProjectEmbedderConfig(absoluteRoot);
2597
+ const db = createDatabase(dbPath, embedderConfig.dimensions);
2005
2598
  try {
2006
- const strategyResults = await runStrategies(db, query, options);
2599
+ const strategyResults = await runStrategies(db, absoluteRoot, query, {
2600
+ ...options,
2601
+ limit
2602
+ });
2007
2603
  const pathBoostTerms = extractPathBoostTerms(query);
2008
- const fused = fusionMergeWithPathBoost(strategyResults, options.limit, pathBoostTerms);
2604
+ const fused = fusionMergeWithPathBoost(strategyResults, limit, pathBoostTerms);
2009
2605
  const outputResults = fused.map(toOutputResult);
2010
2606
  const searchTimeMs = Math.round(performance.now() - start);
2011
2607
  const text = options.format === "text" ? formatTextOutput(query, outputResults) : void 0;
@@ -2023,14 +2619,16 @@ async function runQuery(projectPath, query, options) {
2023
2619
  db.close();
2024
2620
  }
2025
2621
  }
2026
- async function runStrategies(db, query, options) {
2622
+ async function runStrategies(db, projectPath, query, options) {
2027
2623
  const results = [];
2028
2624
  const filters = options.language ? { language: options.language } : void 0;
2029
2625
  const limit = options.limit * 3;
2626
+ const effectiveWeights = getEffectiveStrategyWeights(query);
2030
2627
  for (const strategy of options.strategies) {
2031
- const weight = STRATEGY_WEIGHTS[strategy];
2628
+ const weight = effectiveWeights[strategy];
2032
2629
  const searchResults = await executeStrategy(
2033
2630
  db,
2631
+ projectPath,
2034
2632
  strategy,
2035
2633
  query,
2036
2634
  limit,
@@ -2042,10 +2640,10 @@ async function runStrategies(db, query, options) {
2042
2640
  }
2043
2641
  return results;
2044
2642
  }
2045
- async function executeStrategy(db, strategy, query, limit, filters) {
2643
+ async function executeStrategy(db, projectPath, strategy, query, limit, filters) {
2046
2644
  switch (strategy) {
2047
2645
  case "vector": {
2048
- const embedder = await loadEmbedder();
2646
+ const embedder = await loadEmbedder(projectPath);
2049
2647
  return vectorSearch(db, embedder, query, limit, filters);
2050
2648
  }
2051
2649
  case "fts":
@@ -2078,24 +2676,38 @@ async function executeStrategy(db, strategy, query, limit, filters) {
2078
2676
  }
2079
2677
  }
2080
2678
  var embedderInstance = null;
2081
- async function loadEmbedder() {
2082
- if (embedderInstance) return embedderInstance;
2083
- embedderInstance = await createLocalEmbedder();
2679
+ var embedderKey = null;
2680
+ function getCacheKey(projectPath) {
2681
+ const config = getProjectEmbedderConfig(projectPath);
2682
+ return `${projectPath}:${config.provider}:${config.model}:${config.dimensions}`;
2683
+ }
2684
+ async function loadEmbedder(projectPath) {
2685
+ const cacheKey = getCacheKey(projectPath);
2686
+ if (embedderInstance && embedderKey === cacheKey) return embedderInstance;
2687
+ embedderInstance = await createProjectEmbedder(projectPath);
2688
+ embedderKey = cacheKey;
2084
2689
  return embedderInstance;
2085
2690
  }
2086
2691
  function registerQueryCommand(program2) {
2087
- program2.command("query <query>").description("Multi-strategy code search").option("-l, --limit <n>", "Max results", "10").option(
2692
+ program2.command("query <query>").alias("find").description("Multi-strategy code search").option("-l, --limit <n>", "Max results", "10").option(
2088
2693
  "-s, --strategy <list>",
2089
2694
  "Comma-separated strategies: vector,fts,ast,path",
2090
2695
  "fts,ast,path"
2091
- ).option("--language <lang>", "Filter by language").option("-f, --format <fmt>", "Output format: json|text", "json").option("--no-vectors", "Skip vector search").action(async (query, opts) => {
2696
+ ).option("--language <lang>", "Filter by language").option("-f, --format <fmt>", "Output format: json|text", "json").option("--no-vectors", "Skip vector search").action(async (query, opts, command) => {
2092
2697
  const projectPath = process.cwd();
2093
2698
  const verbose = program2.opts()["verbose"] === true;
2094
2699
  const logger = createLogger({ level: verbose ? LogLevel.DEBUG : LogLevel.INFO });
2095
- const strategies = (opts["strategy"] ?? "fts,ast,path").split(",").map((s) => s.trim());
2700
+ const strategySource = command.getOptionValueSource("strategy");
2701
+ const parsedStrategies = (opts["strategy"] ?? "fts,ast,path").split(",").map((s) => s.trim());
2702
+ const strategies = resolveQueryStrategies(
2703
+ query,
2704
+ parsedStrategies,
2705
+ strategySource === "default" ? "default" : strategySource ? "cli" : "unknown"
2706
+ );
2707
+ const limit = normalizeLimit(parseInt(opts["limit"] ?? "10", 10));
2096
2708
  try {
2097
2709
  const output = await runQuery(projectPath, query, {
2098
- limit: parseInt(opts["limit"] ?? "10", 10),
2710
+ limit,
2099
2711
  strategies,
2100
2712
  language: opts["language"],
2101
2713
  format: opts["format"] ?? "json"
@@ -2117,8 +2729,8 @@ function registerQueryCommand(program2) {
2117
2729
  }
2118
2730
 
2119
2731
  // src/cli/commands/ask.ts
2120
- import fs7 from "fs";
2121
- import path6 from "path";
2732
+ import fs8 from "fs";
2733
+ import path7 from "path";
2122
2734
 
2123
2735
  // src/steering/prompts.ts
2124
2736
  var PLAN_SYSTEM_PROMPT = `You are a code-search strategy planner for a TypeScript/JavaScript codebase.
@@ -2348,7 +2960,7 @@ function createAnthropicProvider(apiKey) {
2348
2960
  }
2349
2961
  };
2350
2962
  }
2351
- var STOP_WORDS = /* @__PURE__ */ new Set([
2963
+ var STOP_WORDS2 = /* @__PURE__ */ new Set([
2352
2964
  // Interrogatives & conjunctions
2353
2965
  "how",
2354
2966
  "does",
@@ -2459,6 +3071,83 @@ var STOP_WORDS = /* @__PURE__ */ new Set([
2459
3071
  ]);
2460
3072
  var CODE_IDENT_RE = /^(?:[a-z]+(?:[A-Z][a-z]*)+|[A-Z][a-zA-Z]+|[a-z]+(?:_[a-z]+)+|[A-Z]+(?:_[A-Z]+)+)$/;
2461
3073
  var DOTTED_IDENT_RE = /[a-zA-Z_]\w*(?:\.[a-zA-Z_]\w*)+/g;
3074
+ var COMMON_STEMS = {
3075
+ authentication: "auth",
3076
+ authorization: "auth",
3077
+ configuration: "config",
3078
+ initialization: "init",
3079
+ initialize: "init",
3080
+ initializing: "init",
3081
+ implementation: "impl",
3082
+ implements: "impl",
3083
+ implementing: "impl",
3084
+ dependency: "dep",
3085
+ dependencies: "dep",
3086
+ middleware: "middleware",
3087
+ validation: "valid",
3088
+ validator: "valid",
3089
+ serialize: "serial",
3090
+ serialization: "serial",
3091
+ deserialize: "deserial",
3092
+ database: "db",
3093
+ logging: "log",
3094
+ logger: "log",
3095
+ testing: "test",
3096
+ handler: "handle",
3097
+ handling: "handle",
3098
+ callback: "callback",
3099
+ subscriber: "subscribe",
3100
+ subscription: "subscribe",
3101
+ rendering: "render",
3102
+ renderer: "render",
3103
+ transformer: "transform",
3104
+ transformation: "transform",
3105
+ connection: "connect",
3106
+ connecting: "connect",
3107
+ connector: "connect",
3108
+ migrating: "migrate",
3109
+ migration: "migrate",
3110
+ scheduling: "schedule",
3111
+ scheduler: "schedule",
3112
+ parsing: "parse",
3113
+ parser: "parse",
3114
+ routing: "route",
3115
+ router: "route",
3116
+ indexing: "index",
3117
+ indexer: "index",
3118
+ subscribing: "subscribe"
3119
+ };
3120
+ var STEM_SUFFIXES = [
3121
+ "tion",
3122
+ "sion",
3123
+ "ment",
3124
+ "ness",
3125
+ "ing",
3126
+ "er",
3127
+ "or",
3128
+ "able",
3129
+ "ible",
3130
+ "ity",
3131
+ "ous",
3132
+ "ive",
3133
+ "ful",
3134
+ "less",
3135
+ "ly"
3136
+ ];
3137
+ function getStemVariant(term) {
3138
+ const lower = term.toLowerCase();
3139
+ const mapped = COMMON_STEMS[lower];
3140
+ if (mapped && mapped !== lower) return mapped;
3141
+ if (!/^[a-z][a-z0-9_]*$/.test(lower)) return null;
3142
+ for (const suffix of STEM_SUFFIXES) {
3143
+ if (!lower.endsWith(suffix)) continue;
3144
+ const stem = lower.slice(0, -suffix.length);
3145
+ if (stem.length >= 4 && stem !== lower) {
3146
+ return stem;
3147
+ }
3148
+ }
3149
+ return null;
3150
+ }
2462
3151
  function extractSearchTerms(query) {
2463
3152
  const terms = [];
2464
3153
  const seen = /* @__PURE__ */ new Set();
@@ -2469,16 +3158,23 @@ function extractSearchTerms(query) {
2469
3158
  terms.push(term);
2470
3159
  }
2471
3160
  };
3161
+ const addTermAndVariants = (term) => {
3162
+ addUnique(term);
3163
+ const variant = getStemVariant(term);
3164
+ if (variant && variant !== term.toLowerCase()) {
3165
+ addUnique(variant);
3166
+ }
3167
+ };
2472
3168
  const dottedMatches = query.match(DOTTED_IDENT_RE) ?? [];
2473
- for (const m of dottedMatches) addUnique(m);
3169
+ for (const m of dottedMatches) addTermAndVariants(m);
2474
3170
  const pathTokens = query.split(/\s+/).filter((t) => t.includes("/"));
2475
- for (const p of pathTokens) addUnique(p.replace(/[?!,;]+$/g, ""));
3171
+ for (const p of pathTokens) addTermAndVariants(p.replace(/[?!,;]+$/g, ""));
2476
3172
  const words = query.replace(/[^a-zA-Z0-9_.\s/-]/g, " ").split(/\s+/).filter((w) => w.length >= 2);
2477
3173
  for (const w of words) {
2478
3174
  const lower = w.toLowerCase();
2479
3175
  if (seen.has(lower)) continue;
2480
- if (STOP_WORDS.has(lower) && !CODE_IDENT_RE.test(w)) continue;
2481
- addUnique(w);
3176
+ if (STOP_WORDS2.has(lower) && !CODE_IDENT_RE.test(w)) continue;
3177
+ addTermAndVariants(w);
2482
3178
  }
2483
3179
  if (terms.length === 0) {
2484
3180
  const allWords = query.replace(/[^a-zA-Z0-9_\s]/g, " ").split(/\s+/).filter((w) => w.length >= 2);
@@ -2495,17 +3191,42 @@ var VALID_STRATEGIES = /* @__PURE__ */ new Set([
2495
3191
  "dependency"
2496
3192
  ]);
2497
3193
  function buildFallbackPlan(query) {
2498
- const keywords = extractSearchTerms(query);
2499
- const strategies = [
2500
- { strategy: "fts", query: keywords, weight: 0.8, reason: "Full-text keyword search" },
2501
- { strategy: "ast", query: keywords, weight: 0.9, reason: "Structural symbol search" },
2502
- { strategy: "path", query: keywords, weight: 0.7, reason: "Path keyword search" }
2503
- ];
3194
+ const strategies = buildFallbackStrategies(query);
2504
3195
  return {
2505
3196
  interpretation: `Searching for: ${query}`,
2506
3197
  strategies
2507
3198
  };
2508
3199
  }
3200
+ function buildFallbackStrategies(query) {
3201
+ const keywords = extractSearchTerms(query);
3202
+ const { multipliers } = classifyQuery(query);
3203
+ return [
3204
+ {
3205
+ strategy: "vector",
3206
+ query,
3207
+ weight: 1 * multipliers.vector,
3208
+ reason: "Semantic search over natural language intent"
3209
+ },
3210
+ {
3211
+ strategy: "fts",
3212
+ query: keywords,
3213
+ weight: 0.8 * multipliers.fts,
3214
+ reason: "Full-text keyword search"
3215
+ },
3216
+ {
3217
+ strategy: "ast",
3218
+ query: keywords,
3219
+ weight: 0.9 * multipliers.ast,
3220
+ reason: "Structural symbol search"
3221
+ },
3222
+ {
3223
+ strategy: "path",
3224
+ query: keywords,
3225
+ weight: 0.7 * multipliers.path,
3226
+ reason: "Path keyword search"
3227
+ }
3228
+ ];
3229
+ }
2509
3230
  function parseSearchPlan(raw, query) {
2510
3231
  const jsonMatch = raw.match(/\{[\s\S]*\}/);
2511
3232
  if (!jsonMatch) return buildFallbackPlan(query);
@@ -2585,10 +3306,14 @@ async function steer(provider, query, limit, searchExecutor) {
2585
3306
  }
2586
3307
 
2587
3308
  // src/cli/commands/ask.ts
2588
- var CTX_DIR3 = ".ctx";
3309
+ var CTX_DIR4 = ".ctx";
2589
3310
  var DB_FILENAME3 = "index.db";
2590
3311
  var SNIPPET_MAX_LENGTH2 = 200;
2591
3312
  var FALLBACK_NOTICE = "No LLM provider configured. Set CTX_GEMINI_KEY, CTX_OPENAI_KEY, or CTX_ANTHROPIC_KEY. Running basic search instead.";
3313
+ function normalizeLimit2(limit) {
3314
+ if (!Number.isFinite(limit)) return 0;
3315
+ return Math.max(0, Math.trunc(limit));
3316
+ }
2592
3317
  var PROVIDER_ENV_MAP = {
2593
3318
  gemini: "CTX_GEMINI_KEY",
2594
3319
  openai: "CTX_OPENAI_KEY",
@@ -2670,13 +3395,13 @@ function formatTextOutput2(output) {
2670
3395
  );
2671
3396
  return lines.join("\n");
2672
3397
  }
2673
- function createSearchExecutor(db, query) {
3398
+ function createSearchExecutor(db, projectPath, query) {
2674
3399
  const pathBoostTerms = extractPathBoostTerms(query);
2675
3400
  return async (strategies, limit) => {
2676
3401
  const strategyResults = [];
2677
3402
  const fetchLimit = limit * 3;
2678
3403
  for (const plan of strategies) {
2679
- const results = await executeStrategy2(db, plan, fetchLimit);
3404
+ const results = await executeStrategy2(db, projectPath, plan, fetchLimit);
2680
3405
  if (results.length > 0) {
2681
3406
  strategyResults.push({
2682
3407
  strategy: plan.strategy,
@@ -2695,10 +3420,10 @@ function extractSymbolNames2(query) {
2695
3420
  function isPathLike2(query) {
2696
3421
  return query.includes("/") || query.includes("*") || query.includes(".");
2697
3422
  }
2698
- async function executeStrategy2(db, plan, limit) {
3423
+ async function executeStrategy2(db, projectPath, plan, limit) {
2699
3424
  switch (plan.strategy) {
2700
3425
  case "vector": {
2701
- const embedder = await loadEmbedder2();
3426
+ const embedder = await loadEmbedder2(projectPath);
2702
3427
  return vectorSearch(db, embedder, plan.query, limit);
2703
3428
  }
2704
3429
  case "fts":
@@ -2727,19 +3452,21 @@ async function executeStrategy2(db, plan, limit) {
2727
3452
  }
2728
3453
  }
2729
3454
  var embedderInstance2 = null;
2730
- async function loadEmbedder2() {
2731
- if (embedderInstance2) return embedderInstance2;
2732
- embedderInstance2 = await createLocalEmbedder();
3455
+ var embedderKey2 = null;
3456
+ function getCacheKey2(projectPath) {
3457
+ const config = getProjectEmbedderConfig(projectPath);
3458
+ return `${projectPath}:${config.provider}:${config.model}:${config.dimensions}`;
3459
+ }
3460
+ async function loadEmbedder2(projectPath) {
3461
+ const cacheKey = getCacheKey2(projectPath);
3462
+ if (embedderInstance2 && embedderKey2 === cacheKey) return embedderInstance2;
3463
+ embedderInstance2 = await createProjectEmbedder(projectPath);
3464
+ embedderKey2 = cacheKey;
2733
3465
  return embedderInstance2;
2734
3466
  }
2735
- async function fallbackSearch(db, query, limit) {
2736
- const executor = createSearchExecutor(db, query);
2737
- const keywords = extractSearchTerms(query);
2738
- const fallbackStrategies = [
2739
- { strategy: "fts", query: keywords, weight: 0.8, reason: "fallback keyword search" },
2740
- { strategy: "ast", query: keywords, weight: 0.9, reason: "fallback structural search" },
2741
- { strategy: "path", query: keywords, weight: 0.7, reason: "fallback path search" }
2742
- ];
3467
+ async function fallbackSearch(db, projectPath, query, limit) {
3468
+ const executor = createSearchExecutor(db, projectPath, query);
3469
+ const fallbackStrategies = buildFallbackStrategies(query);
2743
3470
  const results = await executor(fallbackStrategies, limit);
2744
3471
  return {
2745
3472
  query,
@@ -2756,37 +3483,39 @@ async function fallbackSearch(db, query, limit) {
2756
3483
  };
2757
3484
  }
2758
3485
  async function runAsk(projectPath, query, options) {
2759
- const absoluteRoot = path6.resolve(projectPath);
2760
- const dbPath = path6.join(absoluteRoot, CTX_DIR3, DB_FILENAME3);
2761
- if (!fs7.existsSync(dbPath)) {
3486
+ const limit = normalizeLimit2(options.limit);
3487
+ const absoluteRoot = path7.resolve(projectPath);
3488
+ const dbPath = path7.join(absoluteRoot, CTX_DIR4, DB_FILENAME3);
3489
+ if (!fs8.existsSync(dbPath)) {
2762
3490
  throw new KontextError(
2763
- `Project not initialized. Run "ctx init" first. (${CTX_DIR3}/${DB_FILENAME3} not found)`,
3491
+ `Project not initialized. Run "ctx init" first. (${CTX_DIR4}/${DB_FILENAME3} not found)`,
2764
3492
  ErrorCode.NOT_INITIALIZED
2765
3493
  );
2766
3494
  }
2767
- const db = createDatabase(dbPath);
3495
+ const embedderConfig = getProjectEmbedderConfig(absoluteRoot);
3496
+ const db = createDatabase(dbPath, embedderConfig.dimensions);
2768
3497
  try {
2769
3498
  const provider = options.provider ?? null;
2770
3499
  if (!provider) {
2771
- const output = await fallbackSearch(db, query, options.limit);
3500
+ const output = await fallbackSearch(db, absoluteRoot, query, limit);
2772
3501
  output.warning = FALLBACK_NOTICE;
2773
3502
  if (options.format === "text") {
2774
3503
  output.text = formatTextOutput2(output);
2775
3504
  }
2776
3505
  return output;
2777
3506
  }
2778
- const executor = createSearchExecutor(db, query);
3507
+ const executor = createSearchExecutor(db, absoluteRoot, query);
2779
3508
  if (options.noExplain) {
2780
- return await runNoExplain(provider, query, options, executor);
3509
+ return await runNoExplain(provider, query, limit, options, executor);
2781
3510
  }
2782
- return await runWithSteering(provider, query, options, executor);
3511
+ return await runWithSteering(provider, query, limit, options, executor);
2783
3512
  } finally {
2784
3513
  db.close();
2785
3514
  }
2786
3515
  }
2787
- async function runNoExplain(provider, query, options, executor) {
3516
+ async function runNoExplain(provider, query, limit, options, executor) {
2788
3517
  const plan = await planSearch(provider, query);
2789
- const results = await executor(plan.strategies, options.limit);
3518
+ const results = await executor(plan.strategies, limit);
2790
3519
  const output = {
2791
3520
  query,
2792
3521
  interpretation: plan.interpretation,
@@ -2804,8 +3533,8 @@ async function runNoExplain(provider, query, options, executor) {
2804
3533
  }
2805
3534
  return output;
2806
3535
  }
2807
- async function runWithSteering(provider, query, options, executor) {
2808
- const result = await steer(provider, query, options.limit, executor);
3536
+ async function runWithSteering(provider, query, limit, options, executor) {
3537
+ const result = await steer(provider, query, limit, executor);
2809
3538
  const output = {
2810
3539
  query,
2811
3540
  interpretation: result.interpretation,
@@ -2830,9 +3559,10 @@ function registerAskCommand(program2) {
2830
3559
  const logger = createLogger({ level: verbose ? LogLevel.DEBUG : LogLevel.INFO });
2831
3560
  const providerName = opts["provider"];
2832
3561
  const provider = detectProvider(providerName);
3562
+ const limit = normalizeLimit2(parseInt(String(opts["limit"] ?? "10"), 10));
2833
3563
  try {
2834
3564
  const output = await runAsk(projectPath, query, {
2835
- limit: parseInt(String(opts["limit"] ?? "10"), 10),
3565
+ limit,
2836
3566
  format: opts["format"] ?? "text",
2837
3567
  provider: provider ?? void 0,
2838
3568
  noExplain: opts["explain"] === false
@@ -2856,13 +3586,6 @@ function registerAskCommand(program2) {
2856
3586
  });
2857
3587
  }
2858
3588
 
2859
- // src/cli/commands/find.ts
2860
- function registerFindCommand(program2) {
2861
- program2.command("find <query>").description("Natural language code search").option("--full", "Include source code in output").option("--json", "Machine-readable JSON output").option("--no-llm", "Skip steering LLM, raw vector search only").option("-l, --limit <n>", "Max results", "5").option("--language <lang>", "Filter by language").action((_query, _options) => {
2862
- console.log("ctx find \u2014 not yet implemented");
2863
- });
2864
- }
2865
-
2866
3589
  // src/cli/commands/update.ts
2867
3590
  function registerUpdateCommand(program2) {
2868
3591
  program2.command("update").description("Incremental re-index of changed files").action(() => {
@@ -2871,12 +3594,12 @@ function registerUpdateCommand(program2) {
2871
3594
  }
2872
3595
 
2873
3596
  // src/cli/commands/watch.ts
2874
- import fs8 from "fs";
2875
- import path8 from "path";
3597
+ import fs9 from "fs";
3598
+ import path9 from "path";
2876
3599
 
2877
3600
  // src/watcher/watcher.ts
2878
3601
  import { watch } from "chokidar";
2879
- import path7 from "path";
3602
+ import path8 from "path";
2880
3603
  var DEFAULT_DEBOUNCE_MS = 500;
2881
3604
  var ALWAYS_IGNORED_DIRS = /* @__PURE__ */ new Set([
2882
3605
  "node_modules",
@@ -2888,15 +3611,15 @@ var ALWAYS_IGNORED_DIRS = /* @__PURE__ */ new Set([
2888
3611
  ]);
2889
3612
  var WATCHED_EXTENSIONS = new Set(Object.keys(LANGUAGE_MAP));
2890
3613
  function isWatchedFile(filePath) {
2891
- const ext = path7.extname(filePath).toLowerCase();
3614
+ const ext = path8.extname(filePath).toLowerCase();
2892
3615
  return WATCHED_EXTENSIONS.has(ext);
2893
3616
  }
2894
3617
  function createWatcher(options, events) {
2895
3618
  const debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS;
2896
- const projectPath = path7.resolve(options.projectPath);
3619
+ const projectPath = path8.resolve(options.projectPath);
2897
3620
  const extraIgnored = new Set(options.ignored ?? []);
2898
3621
  function isIgnored(filePath) {
2899
- const segments = filePath.split(path7.sep);
3622
+ const segments = filePath.split(path8.sep);
2900
3623
  for (const seg of segments) {
2901
3624
  if (ALWAYS_IGNORED_DIRS.has(seg)) return true;
2902
3625
  if (extraIgnored.has(seg)) return true;
@@ -2954,13 +3677,13 @@ function createWatcher(options, events) {
2954
3677
  }
2955
3678
 
2956
3679
  // src/cli/commands/watch.ts
2957
- var CTX_DIR4 = ".ctx";
3680
+ var CTX_DIR5 = ".ctx";
2958
3681
  var DB_FILENAME4 = "index.db";
2959
3682
  function timestamp() {
2960
3683
  return (/* @__PURE__ */ new Date()).toLocaleTimeString("en-GB", { hour12: false });
2961
3684
  }
2962
3685
  function detectLanguage(filePath) {
2963
- const ext = path8.extname(filePath).toLowerCase();
3686
+ const ext = path9.extname(filePath).toLowerCase();
2964
3687
  return LANGUAGE_MAP[ext] ?? null;
2965
3688
  }
2966
3689
  function formatDuration2(ms) {
@@ -2969,7 +3692,7 @@ function formatDuration2(ms) {
2969
3692
  }
2970
3693
  async function hashFile(absolutePath) {
2971
3694
  const { createHash: createHash3 } = await import("crypto");
2972
- const content = fs8.readFileSync(absolutePath);
3695
+ const content = fs9.readFileSync(absolutePath);
2973
3696
  return createHash3("sha256").update(content).digest("hex");
2974
3697
  }
2975
3698
  async function reindexChanges(db, changes, projectPath, options) {
@@ -2979,25 +3702,21 @@ async function reindexChanges(db, changes, projectPath, options) {
2979
3702
  let chunksUpdated = 0;
2980
3703
  const allChunksWithMeta = [];
2981
3704
  for (const change of changes) {
2982
- const absolutePath = path8.join(projectPath, change.path);
3705
+ const absolutePath = path9.join(projectPath, change.path);
2983
3706
  const language = detectLanguage(change.path);
2984
3707
  if (change.type === "unlink") {
2985
3708
  log(`[${timestamp()}] Deleted: ${change.path}`);
2986
- const existingFile2 = db.getFile(change.path);
2987
- if (existingFile2) {
3709
+ const existingFile = db.getFile(change.path);
3710
+ if (existingFile) {
2988
3711
  db.deleteFile(change.path);
2989
3712
  }
2990
3713
  filesProcessed++;
2991
3714
  continue;
2992
3715
  }
2993
3716
  if (!language) continue;
2994
- if (!fs8.existsSync(absolutePath)) continue;
3717
+ if (!fs9.existsSync(absolutePath)) continue;
2995
3718
  const label = change.type === "add" ? "Added" : "Changed";
2996
3719
  log(`[${timestamp()}] ${label}: ${change.path}`);
2997
- const existingFile = db.getFile(change.path);
2998
- if (existingFile) {
2999
- db.deleteChunksByFile(existingFile.id);
3000
- }
3001
3720
  let nodes;
3002
3721
  try {
3003
3722
  nodes = await parseFile(absolutePath, language);
@@ -3007,27 +3726,32 @@ async function reindexChanges(db, changes, projectPath, options) {
3007
3726
  }
3008
3727
  const chunks = chunkFile(nodes, change.path);
3009
3728
  const hash = await hashFile(absolutePath);
3010
- const size = fs8.statSync(absolutePath).size;
3011
- const fileId = db.upsertFile({
3012
- path: change.path,
3013
- language,
3014
- hash,
3015
- size
3729
+ const size = fs9.statSync(absolutePath).size;
3730
+ const chunkRows = chunks.map((c) => ({
3731
+ lineStart: c.lineStart,
3732
+ lineEnd: c.lineEnd,
3733
+ type: c.type,
3734
+ name: c.name,
3735
+ parent: c.parent,
3736
+ text: c.text,
3737
+ imports: c.imports,
3738
+ exports: c.exports,
3739
+ hash: c.hash
3740
+ }));
3741
+ let chunkIds = [];
3742
+ db.transaction(() => {
3743
+ const existingFile = db.getFile(change.path);
3744
+ if (existingFile) {
3745
+ db.deleteChunksByFile(existingFile.id);
3746
+ }
3747
+ const fileId = db.upsertFile({
3748
+ path: change.path,
3749
+ language,
3750
+ hash,
3751
+ size
3752
+ });
3753
+ chunkIds = db.insertChunks(fileId, chunkRows);
3016
3754
  });
3017
- const chunkIds = db.insertChunks(
3018
- fileId,
3019
- chunks.map((c) => ({
3020
- lineStart: c.lineStart,
3021
- lineEnd: c.lineEnd,
3022
- type: c.type,
3023
- name: c.name,
3024
- parent: c.parent,
3025
- text: c.text,
3026
- imports: c.imports,
3027
- exports: c.exports,
3028
- hash: c.hash
3029
- }))
3030
- );
3031
3755
  for (let i = 0; i < chunks.length; i++) {
3032
3756
  allChunksWithMeta.push({
3033
3757
  fileRelPath: change.path,
@@ -3038,7 +3762,7 @@ async function reindexChanges(db, changes, projectPath, options) {
3038
3762
  filesProcessed++;
3039
3763
  }
3040
3764
  if (!options.skipEmbedding && allChunksWithMeta.length > 0) {
3041
- const embedder = await loadEmbedder3();
3765
+ const embedder = await loadEmbedder3(projectPath);
3042
3766
  const texts = allChunksWithMeta.map(
3043
3767
  (cm) => prepareChunkText(cm.fileRelPath, cm.chunk.parent, cm.chunk.text)
3044
3768
  );
@@ -3054,27 +3778,36 @@ async function reindexChanges(db, changes, projectPath, options) {
3054
3778
  return { filesProcessed, chunksUpdated, durationMs };
3055
3779
  }
3056
3780
  var embedderInstance3 = null;
3057
- async function loadEmbedder3() {
3058
- if (embedderInstance3) return embedderInstance3;
3059
- embedderInstance3 = await createLocalEmbedder();
3781
+ var embedderKey3 = null;
3782
+ function getCacheKey3(projectPath) {
3783
+ const config = getProjectEmbedderConfig(projectPath);
3784
+ return `${projectPath}:${config.provider}:${config.model}:${config.dimensions}`;
3785
+ }
3786
+ async function loadEmbedder3(projectPath) {
3787
+ const cacheKey = getCacheKey3(projectPath);
3788
+ if (embedderInstance3 && embedderKey3 === cacheKey) return embedderInstance3;
3789
+ embedderInstance3 = await createProjectEmbedder(projectPath);
3790
+ embedderKey3 = cacheKey;
3060
3791
  return embedderInstance3;
3061
3792
  }
3062
3793
  async function runWatch(projectPath, options = {}) {
3063
- const absoluteRoot = path8.resolve(projectPath);
3064
- const dbPath = path8.join(absoluteRoot, CTX_DIR4, DB_FILENAME4);
3794
+ const absoluteRoot = path9.resolve(projectPath);
3795
+ const dbPath = path9.join(absoluteRoot, CTX_DIR5, DB_FILENAME4);
3065
3796
  const log = options.log ?? console.log;
3066
3797
  if (options.init) {
3067
3798
  await runInit(absoluteRoot, { log, skipEmbedding: options.skipEmbedding });
3068
3799
  }
3069
- if (!fs8.existsSync(dbPath)) {
3800
+ if (!fs9.existsSync(dbPath)) {
3070
3801
  throw new KontextError(
3071
- `Project not initialized. Run "ctx init" first or use --init flag. (${CTX_DIR4}/${DB_FILENAME4} not found)`,
3802
+ `Project not initialized. Run "ctx init" first or use --init flag. (${CTX_DIR5}/${DB_FILENAME4} not found)`,
3072
3803
  ErrorCode.NOT_INITIALIZED
3073
3804
  );
3074
3805
  }
3075
3806
  await initParser();
3076
- const db = createDatabase(dbPath);
3807
+ const embedderConfig = getProjectEmbedderConfig(absoluteRoot);
3808
+ const db = createDatabase(dbPath, embedderConfig.dimensions);
3077
3809
  let watcherHandle = null;
3810
+ let reindexQueue = Promise.resolve();
3078
3811
  const watcher = createWatcher(
3079
3812
  {
3080
3813
  projectPath: absoluteRoot,
@@ -3083,7 +3816,7 @@ async function runWatch(projectPath, options = {}) {
3083
3816
  },
3084
3817
  {
3085
3818
  onChange: (changes) => {
3086
- void (async () => {
3819
+ reindexQueue = reindexQueue.then(async () => {
3087
3820
  try {
3088
3821
  const result = await reindexChanges(db, changes, absoluteRoot, {
3089
3822
  skipEmbedding: options.skipEmbedding,
@@ -3099,7 +3832,7 @@ async function runWatch(projectPath, options = {}) {
3099
3832
  `[${timestamp()}] Error: ${err instanceof Error ? err.message : String(err)}`
3100
3833
  );
3101
3834
  }
3102
- })();
3835
+ });
3103
3836
  },
3104
3837
  onError: (err) => {
3105
3838
  log(`[${timestamp()}] Watcher error: ${err.message}`);
@@ -3115,6 +3848,7 @@ async function runWatch(projectPath, options = {}) {
3115
3848
  await watcherHandle.stop();
3116
3849
  watcherHandle = null;
3117
3850
  }
3851
+ await reindexQueue;
3118
3852
  db.close();
3119
3853
  log("Stopped watching. Database saved.");
3120
3854
  }
@@ -3149,11 +3883,11 @@ function registerWatchCommand(program2) {
3149
3883
  }
3150
3884
 
3151
3885
  // src/cli/commands/status.ts
3152
- import fs9 from "fs";
3153
- import path9 from "path";
3154
- var CTX_DIR5 = ".ctx";
3886
+ import fs10 from "fs";
3887
+ import path10 from "path";
3888
+ var CTX_DIR6 = ".ctx";
3155
3889
  var DB_FILENAME5 = "index.db";
3156
- var CONFIG_FILENAME2 = "config.json";
3890
+ var CONFIG_FILENAME3 = "config.json";
3157
3891
  function formatBytes2(bytes) {
3158
3892
  if (bytes < 1024) return `${bytes} B`;
3159
3893
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
@@ -3168,15 +3902,17 @@ function formatTimestamp(raw) {
3168
3902
  function capitalize(s) {
3169
3903
  return s.charAt(0).toUpperCase() + s.slice(1);
3170
3904
  }
3171
- function readConfig(ctxDir) {
3172
- const configPath2 = path9.join(ctxDir, CONFIG_FILENAME2);
3173
- if (!fs9.existsSync(configPath2)) return null;
3905
+ function readConfig2(ctxDir) {
3906
+ const configPath2 = path10.join(ctxDir, CONFIG_FILENAME3);
3907
+ if (!fs10.existsSync(configPath2)) return null;
3174
3908
  try {
3175
- const raw = fs9.readFileSync(configPath2, "utf-8");
3909
+ const raw = fs10.readFileSync(configPath2, "utf-8");
3176
3910
  const parsed = JSON.parse(raw);
3911
+ const embedder = parsed.embedder;
3177
3912
  return {
3178
- model: parsed.model ?? "unknown",
3179
- dimensions: parsed.dimensions ?? 0
3913
+ provider: embedder?.provider ?? parsed.provider ?? "unknown",
3914
+ model: embedder?.model ?? parsed.model ?? "unknown",
3915
+ dimensions: embedder?.dimensions ?? parsed.dimensions ?? 0
3180
3916
  };
3181
3917
  } catch {
3182
3918
  return null;
@@ -3195,7 +3931,7 @@ function formatStatus(projectPath, output) {
3195
3931
  `Kontext Status \u2014 ${projectPath}`,
3196
3932
  "",
3197
3933
  ` Initialized: Yes`,
3198
- ` Database: ${CTX_DIR5}/${DB_FILENAME5} (${formatBytes2(output.dbSizeBytes)})`
3934
+ ` Database: ${CTX_DIR6}/${DB_FILENAME5} (${formatBytes2(output.dbSizeBytes)})`
3199
3935
  ];
3200
3936
  if (output.lastIndexed) {
3201
3937
  lines.push(` Last indexed: ${formatTimestamp(output.lastIndexed)}`);
@@ -3218,17 +3954,17 @@ function formatStatus(projectPath, output) {
3218
3954
  if (output.config) {
3219
3955
  lines.push("");
3220
3956
  lines.push(
3221
- ` Embedder: local (${output.config.model}, ${output.config.dimensions} dims)`
3957
+ ` Embedder: ${output.config.provider} (${output.config.model}, ${output.config.dimensions} dims)`
3222
3958
  );
3223
3959
  }
3224
3960
  lines.push("");
3225
3961
  return lines.join("\n");
3226
3962
  }
3227
3963
  async function runStatus(projectPath) {
3228
- const absoluteRoot = path9.resolve(projectPath);
3229
- const ctxDir = path9.join(absoluteRoot, CTX_DIR5);
3230
- const dbPath = path9.join(ctxDir, DB_FILENAME5);
3231
- if (!fs9.existsSync(dbPath)) {
3964
+ const absoluteRoot = path10.resolve(projectPath);
3965
+ const ctxDir = path10.join(absoluteRoot, CTX_DIR6);
3966
+ const dbPath = path10.join(ctxDir, DB_FILENAME5);
3967
+ if (!fs10.existsSync(dbPath)) {
3232
3968
  const output = {
3233
3969
  initialized: false,
3234
3970
  fileCount: 0,
@@ -3249,8 +3985,8 @@ async function runStatus(projectPath) {
3249
3985
  const vectorCount = db.getVectorCount();
3250
3986
  const languages = db.getLanguageBreakdown();
3251
3987
  const lastIndexed = db.getLastIndexed();
3252
- const config = readConfig(ctxDir);
3253
- const dbSizeBytes = fs9.statSync(dbPath).size;
3988
+ const config = readConfig2(ctxDir);
3989
+ const dbSizeBytes = fs10.statSync(dbPath).size;
3254
3990
  const output = {
3255
3991
  initialized: true,
3256
3992
  fileCount,
@@ -3303,205 +4039,6 @@ function registerChunkCommand(program2) {
3303
4039
  });
3304
4040
  }
3305
4041
 
3306
- // src/cli/commands/config.ts
3307
- import fs10 from "fs";
3308
- import path10 from "path";
3309
- var CTX_DIR6 = ".ctx";
3310
- var CONFIG_FILENAME3 = "config.json";
3311
- var DEFAULT_CONFIG = {
3312
- embedder: {
3313
- provider: "local",
3314
- model: "Xenova/all-MiniLM-L6-v2",
3315
- dimensions: 384
3316
- },
3317
- search: {
3318
- defaultLimit: 10,
3319
- strategies: ["vector", "fts", "ast", "path"],
3320
- weights: { vector: 1, fts: 0.8, ast: 0.9, path: 0.7, dependency: 0.6 }
3321
- },
3322
- watch: {
3323
- debounceMs: 500,
3324
- ignored: []
3325
- },
3326
- llm: {
3327
- provider: null,
3328
- model: null
3329
- }
3330
- };
3331
- var VALID_EMBEDDER_PROVIDERS = /* @__PURE__ */ new Set(["local", "voyage", "openai"]);
3332
- var VALID_LLM_PROVIDERS = /* @__PURE__ */ new Set(["gemini", "openai", "anthropic"]);
3333
- var VALIDATION_RULES = {
3334
- "embedder.provider": {
3335
- validate: (v) => typeof v === "string" && VALID_EMBEDDER_PROVIDERS.has(v),
3336
- message: `Must be one of: ${[...VALID_EMBEDDER_PROVIDERS].join(", ")}`
3337
- },
3338
- "embedder.dimensions": {
3339
- validate: (v) => typeof v === "number" && v > 0 && Number.isInteger(v),
3340
- message: "Must be a positive integer"
3341
- },
3342
- "search.defaultLimit": {
3343
- validate: (v) => typeof v === "number" && v > 0 && Number.isInteger(v),
3344
- message: "Must be a positive integer"
3345
- },
3346
- "watch.debounceMs": {
3347
- validate: (v) => typeof v === "number" && v >= 0 && Number.isInteger(v),
3348
- message: "Must be a non-negative integer"
3349
- },
3350
- "llm.provider": {
3351
- validate: (v) => v === null || typeof v === "string" && VALID_LLM_PROVIDERS.has(v),
3352
- message: `Must be null or one of: ${[...VALID_LLM_PROVIDERS].join(", ")}`
3353
- }
3354
- };
3355
- function resolveCtxDir(projectPath) {
3356
- const absoluteRoot = path10.resolve(projectPath);
3357
- const ctxDir = path10.join(absoluteRoot, CTX_DIR6);
3358
- if (!fs10.existsSync(ctxDir)) {
3359
- throw new ConfigError(
3360
- `Project not initialized. Run "ctx init" first. (${CTX_DIR6}/ not found)`,
3361
- ErrorCode.NOT_INITIALIZED
3362
- );
3363
- }
3364
- return ctxDir;
3365
- }
3366
- function configPath(ctxDir) {
3367
- return path10.join(ctxDir, CONFIG_FILENAME3);
3368
- }
3369
- function readConfig2(ctxDir) {
3370
- const filePath = configPath(ctxDir);
3371
- if (!fs10.existsSync(filePath)) {
3372
- writeConfig(ctxDir, DEFAULT_CONFIG);
3373
- return structuredClone(DEFAULT_CONFIG);
3374
- }
3375
- const raw = fs10.readFileSync(filePath, "utf-8");
3376
- const parsed = JSON.parse(raw);
3377
- return mergeWithDefaults(parsed);
3378
- }
3379
- function writeConfig(ctxDir, config) {
3380
- fs10.writeFileSync(
3381
- configPath(ctxDir),
3382
- JSON.stringify(config, null, 2) + "\n"
3383
- );
3384
- }
3385
- function mergeWithDefaults(partial) {
3386
- return {
3387
- embedder: { ...DEFAULT_CONFIG.embedder, ...partial.embedder },
3388
- search: {
3389
- ...DEFAULT_CONFIG.search,
3390
- ...partial.search,
3391
- weights: { ...DEFAULT_CONFIG.search.weights, ...partial.search?.weights }
3392
- },
3393
- watch: { ...DEFAULT_CONFIG.watch, ...partial.watch },
3394
- llm: { ...DEFAULT_CONFIG.llm, ...partial.llm }
3395
- };
3396
- }
3397
- function getNestedValue(obj, key) {
3398
- const parts = key.split(".");
3399
- let current = obj;
3400
- for (const part of parts) {
3401
- if (current === null || current === void 0 || typeof current !== "object") {
3402
- return void 0;
3403
- }
3404
- current = current[part];
3405
- }
3406
- return current;
3407
- }
3408
- function setNestedValue(obj, key, value) {
3409
- const parts = key.split(".");
3410
- let current = obj;
3411
- for (let i = 0; i < parts.length - 1; i++) {
3412
- const part = parts[i];
3413
- if (typeof current[part] !== "object" || current[part] === null) {
3414
- current[part] = {};
3415
- }
3416
- current = current[part];
3417
- }
3418
- current[parts[parts.length - 1]] = value;
3419
- }
3420
- function parseValue(rawValue) {
3421
- if (rawValue === "null") return null;
3422
- if (rawValue === "true") return true;
3423
- if (rawValue === "false") return false;
3424
- const num = Number(rawValue);
3425
- if (!Number.isNaN(num) && rawValue.trim() !== "") return num;
3426
- if (rawValue.startsWith("[") || rawValue.startsWith("{")) {
3427
- try {
3428
- return JSON.parse(rawValue);
3429
- } catch {
3430
- }
3431
- }
3432
- return rawValue;
3433
- }
3434
- function runConfigShow(projectPath) {
3435
- const ctxDir = resolveCtxDir(projectPath);
3436
- const config = readConfig2(ctxDir);
3437
- return {
3438
- config,
3439
- text: JSON.stringify(config, null, 2)
3440
- };
3441
- }
3442
- function runConfigGet(projectPath, key) {
3443
- const ctxDir = resolveCtxDir(projectPath);
3444
- const config = readConfig2(ctxDir);
3445
- return getNestedValue(config, key);
3446
- }
3447
- function runConfigSet(projectPath, key, rawValue) {
3448
- const ctxDir = resolveCtxDir(projectPath);
3449
- const config = readConfig2(ctxDir);
3450
- const value = parseValue(rawValue);
3451
- const rule = VALIDATION_RULES[key];
3452
- if (rule && !rule.validate(value)) {
3453
- throw new ConfigError(`Invalid value for "${key}": ${rule.message}`, ErrorCode.CONFIG_INVALID);
3454
- }
3455
- setNestedValue(config, key, value);
3456
- writeConfig(ctxDir, config);
3457
- }
3458
- function runConfigReset(projectPath) {
3459
- const ctxDir = resolveCtxDir(projectPath);
3460
- writeConfig(ctxDir, structuredClone(DEFAULT_CONFIG));
3461
- }
3462
- function registerConfigCommand(program2) {
3463
- const cmd = program2.command("config").description("Show or modify configuration");
3464
- function configErrorHandler(err) {
3465
- const verbose = program2.opts()["verbose"] === true;
3466
- const logger = createLogger({ level: verbose ? LogLevel.DEBUG : LogLevel.INFO });
3467
- process.exitCode = handleCommandError(err, logger, verbose);
3468
- }
3469
- cmd.command("show").description("Show current configuration").action(() => {
3470
- try {
3471
- const output = runConfigShow(process.cwd());
3472
- console.log(output.text);
3473
- } catch (err) {
3474
- configErrorHandler(err);
3475
- }
3476
- });
3477
- cmd.command("get <key>").description("Get a configuration value (dot notation)").action((key) => {
3478
- try {
3479
- const value = runConfigGet(process.cwd(), key);
3480
- console.log(
3481
- typeof value === "object" ? JSON.stringify(value, null, 2) : String(value)
3482
- );
3483
- } catch (err) {
3484
- configErrorHandler(err);
3485
- }
3486
- });
3487
- cmd.command("set <key> <value>").description("Set a configuration value (dot notation)").action((key, value) => {
3488
- try {
3489
- runConfigSet(process.cwd(), key, value);
3490
- console.log(`Set ${key} = ${value}`);
3491
- } catch (err) {
3492
- configErrorHandler(err);
3493
- }
3494
- });
3495
- cmd.command("reset").description("Reset configuration to defaults").action(() => {
3496
- try {
3497
- runConfigReset(process.cwd());
3498
- console.log("Configuration reset to defaults.");
3499
- } catch (err) {
3500
- configErrorHandler(err);
3501
- }
3502
- });
3503
- }
3504
-
3505
4042
  // src/cli/commands/auth.ts
3506
4043
  function registerAuthCommand(program2) {
3507
4044
  program2.command("auth").description("Set API keys for LLM and embedding providers").action(() => {
@@ -3510,12 +4047,13 @@ function registerAuthCommand(program2) {
3510
4047
  }
3511
4048
 
3512
4049
  // src/cli/index.ts
4050
+ var require3 = createRequire2(import.meta.url);
4051
+ var packageJson = require3("../../package.json");
3513
4052
  var program = new Command();
3514
- program.name("ctx").description("Kontext \u2014 Context engine for AI coding agents").version("0.1.0").option("--verbose", "Enable verbose/debug output");
4053
+ program.name("ctx").description("Kontext \u2014 Context engine for AI coding agents").version(packageJson.version ?? "0.0.0").option("--verbose", "Enable verbose/debug output");
3515
4054
  registerInitCommand(program);
3516
4055
  registerQueryCommand(program);
3517
4056
  registerAskCommand(program);
3518
- registerFindCommand(program);
3519
4057
  registerUpdateCommand(program);
3520
4058
  registerWatchCommand(program);
3521
4059
  registerStatusCommand(program);