scai 0.1.22 โ†’ 0.1.24

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.
@@ -1,66 +1,63 @@
1
+ import readline from 'readline';
1
2
  import { searchFiles } from "../db/fileIndex.js";
2
3
  import { generate } from "../lib/generate.js";
3
- import { summaryModule } from "../pipeline/modules/summaryModule.js";
4
4
  export async function runAskCommand(query) {
5
5
  if (!query) {
6
- console.error('โŒ Please provide a search query.\n๐Ÿ‘‰ Usage: scai ask "keyword"');
6
+ query = await promptOnce('๐Ÿง  Ask your question:\n> ');
7
+ }
8
+ query = query.trim();
9
+ if (!query) {
10
+ console.error('โŒ No question provided.\n๐Ÿ‘‰ Usage: scai ask "your question"');
7
11
  return;
8
12
  }
9
13
  console.log(`๐Ÿ” Searching for: "${query}"\n`);
10
- // Use vector-based search
11
- const results = await searchFiles(query, 5); // Or 3 if you want fewer
12
- if (results.length === 0) {
13
- console.log('โš ๏ธ No similar embeddings found. Asking the model for context instead...');
14
- }
15
- else {
14
+ const start = Date.now();
15
+ const results = await searchFiles(query, 5);
16
+ const duration = Date.now() - start;
17
+ console.log(`โฑ๏ธ searchFiles took ${duration}ms and returned ${results.length} result(s)`);
18
+ if (results.length > 0) {
16
19
  console.log('๐Ÿ“Š Closest files based on semantic similarity:');
17
- results.forEach(file => {
18
- console.log(`๐Ÿ“„ Path: ${file?.path}`);
20
+ results.forEach((file, i) => {
21
+ console.log(` ${i + 1}. ๐Ÿ“„ Path: ${file?.path} | Score: ${file?.score?.toFixed(3)}`);
19
22
  });
20
23
  }
24
+ else {
25
+ console.log('โš ๏ธ No similar embeddings found. Asking the model for context instead...');
26
+ }
27
+ // ๐Ÿง  Use stored summaries directly
21
28
  let allSummaries = '';
22
29
  for (const file of results) {
23
- try {
24
- if (!file?.summary) {
25
- console.warn(`โš ๏ธ No summary available for file: ${file?.path}`);
26
- continue;
27
- }
28
- console.log(`๐Ÿ“ Using cached summary for file: ${file?.path}`);
29
- const summaryResponse = await summaryModule.run({ content: file?.summary ? file.summary : '', filepath: file?.path });
30
- if (summaryResponse.summary) {
31
- allSummaries += `\n${summaryResponse.summary}`;
32
- }
33
- }
34
- catch (err) {
35
- console.error(`โŒ Error processing file: ${file?.path}`, err instanceof Error ? err.message : err);
30
+ if (!file?.summary) {
31
+ console.warn(`โš ๏ธ No summary available for file: ${file?.path}`);
32
+ continue;
36
33
  }
34
+ console.log(`๐Ÿ“ Using stored summary for: ${file.path}`);
35
+ allSummaries += `\n${file.summary}`;
37
36
  }
38
- if (allSummaries.trim()) {
39
- console.log('๐Ÿง  Summaries found, sending them to the model for synthesis...');
40
- try {
41
- const input = {
42
- content: `${query}\n\n${allSummaries}`,
43
- filepath: '',
44
- };
45
- const modelResponse = await generate(input, 'llama3');
46
- console.log(`\n๐Ÿ“ Model response:\n${modelResponse.content}`);
47
- }
48
- catch (err) {
49
- console.error('โŒ Model request failed:', err);
50
- }
37
+ const input = {
38
+ content: allSummaries ? `${query}\n\n${allSummaries}` : query,
39
+ filepath: '',
40
+ };
41
+ try {
42
+ console.log(allSummaries.trim()
43
+ ? '๐Ÿง  Summaries found, sending them to the model for synthesis...'
44
+ : 'โš ๏ธ No summaries found. Asking the model for context only...');
45
+ const modelResponse = await generate(input, 'llama3');
46
+ console.log(`\n๐Ÿ“ Model response:\n${modelResponse.content}`);
51
47
  }
52
- else {
53
- console.log('โš ๏ธ No summaries found. Asking the model for context only...');
54
- try {
55
- const input = {
56
- content: query,
57
- filepath: '',
58
- };
59
- const modelResponse = await generate(input, 'llama3');
60
- console.log(`\n๐Ÿ“ Model response:\n${modelResponse.content}`);
61
- }
62
- catch (err) {
63
- console.error('โŒ Model request failed:', err);
64
- }
48
+ catch (err) {
49
+ console.error('โŒ Model request failed:', err);
65
50
  }
66
51
  }
52
+ function promptOnce(promptText) {
53
+ return new Promise(resolve => {
54
+ const rl = readline.createInterface({
55
+ input: process.stdin,
56
+ output: process.stdout
57
+ });
58
+ rl.question(promptText, answer => {
59
+ rl.close();
60
+ resolve(answer.trim());
61
+ });
62
+ });
63
+ }
@@ -1,80 +1,28 @@
1
- import { summaryModule } from '../pipeline/modules/summaryModule.js';
2
- import { db } from '../db/client.js';
3
- import fs from 'fs/promises';
4
1
  import fsSync from 'fs';
5
- import os from 'os';
2
+ import { LOG_PATH, PID_PATH } from '../constants.js';
3
+ import { log } from '../utils/log.js';
4
+ import { spawn } from 'child_process';
5
+ import { fileURLToPath } from 'url';
6
6
  import path from 'path';
7
- import { generateEmbedding } from '../lib/generateEmbedding.js';
8
- import { IGNORED_EXTENSIONS } from '../config/IgnoredExtensions.js';
9
- const MAX_FILES = 1000;
10
- const DAEMON_DURATION_MINUTES = 25;
11
- const DAEMON_INTERVAL_MINUTES = 30;
12
- const PID_PATH = path.join(os.homedir(), '.scai/daemon.pid');
13
- // Helper function to check if a file should be ignored
14
- const shouldIgnoreFile = (filePath) => {
15
- const ext = path.extname(filePath).toLowerCase();
16
- return IGNORED_EXTENSIONS.includes(ext);
17
- };
18
- export async function runDaemonBatch() {
19
- console.log('๐Ÿ“ฅ Daemon batch: scanning for files to summarize...');
20
- const rows = db.prepare(`
21
- SELECT path, type FROM files
22
- WHERE summary IS NULL OR summary = ''
23
- ORDER BY last_modified DESC
24
- LIMIT ?
25
- `).all(MAX_FILES);
26
- if (rows.length === 0) {
27
- console.log('โœ… No files left to summarize.');
28
- return;
7
+ // ๐Ÿš€ Ensure daemon starts in the background
8
+ export async function startDaemon() {
9
+ if (fsSync.existsSync(PID_PATH)) {
10
+ log(`โš ๏ธ Daemon already running (PID file found at ${PID_PATH}). Skipping launch.`);
29
11
  }
30
- for (const row of rows) {
31
- if (shouldIgnoreFile(row.path)) {
32
- console.log(`โš ๏ธ Ignored file (unwanted extension): ${row.path}`);
33
- continue;
34
- }
35
- try {
36
- const content = await fs.readFile(row.path, 'utf-8');
37
- const result = await summaryModule.run({ content, filepath: row.path });
38
- const summary = result?.summary?.trim() ? result.summary : null;
39
- let embedding = null;
40
- if (summary) {
41
- const vector = await generateEmbedding(summary);
42
- if (vector)
43
- embedding = JSON.stringify(vector);
44
- }
45
- // Using named parameters for better readability and flexibility
46
- db.prepare(`
47
- UPDATE files
48
- SET summary = @summary, embedding = @embedding, indexed_at = datetime('now')
49
- WHERE path = @path
50
- `).run({ summary, embedding, path: row.path });
51
- console.log(`๐Ÿ“ Summarized: ${row.path}`);
52
- console.log(`๐Ÿ”ข Embedded: ${row.path}`);
53
- }
54
- catch (err) {
55
- console.warn(`โš ๏ธ Failed: ${row.path}`, err instanceof Error ? err.message : err);
56
- }
57
- }
58
- }
59
- export async function runDaemonScheduler() {
60
- // Write PID to file
61
- fsSync.mkdirSync(path.dirname(PID_PATH), { recursive: true });
62
- fsSync.writeFileSync(PID_PATH, process.pid.toString(), 'utf-8');
63
- console.log('๐Ÿง  Daemon started. PID:', process.pid);
64
- console.log('โฑ๏ธ Will run every 30 minutes for 10 minutes.');
65
- console.log('๐Ÿง  Background summarizer started. Will run every 30 minutes for 10 minutes.');
66
- const startDaemonCycle = async () => {
67
- const startTime = Date.now();
68
- const endTime = startTime + DAEMON_DURATION_MINUTES * 60 * 1000;
69
- while (Date.now() < endTime) {
70
- await runDaemonBatch();
71
- await new Promise(res => setTimeout(res, 60 * 1000)); // 1 min pause between mini-batches
72
- }
73
- console.log(`โฑ๏ธ Daemon completed 10-minute cycle. Next in ${DAEMON_INTERVAL_MINUTES} min.`);
74
- };
75
- // Repeat every 30 minutes
76
- while (true) {
77
- await startDaemonCycle();
78
- await new Promise(res => setTimeout(res, DAEMON_INTERVAL_MINUTES * 60 * 1000));
12
+ else {
13
+ log('๐Ÿš€ Starting summarizer daemon in background mode...');
14
+ log(`๐Ÿ“ Logs will be saved to: ${LOG_PATH}`);
15
+ // Before starting the background process, set the environment variable
16
+ process.env.BACKGROUND_MODE = 'true'; // Set the mode to background
17
+ // Compute absolute path to the background worker (adjust path if needed)
18
+ const __filename = fileURLToPath(import.meta.url);
19
+ const __dirname = path.dirname(__filename);
20
+ const daemonWorkerPath = path.join(__dirname, '../daemon/daemonWorker.js');
21
+ // Spawn the daemonWorker.js file in the background
22
+ const child = spawn(process.execPath, [daemonWorkerPath], {
23
+ detached: true, // Detach the process so it runs independently
24
+ stdio: ['ignore', 'ignore', 'ignore'], // Suppress the output
25
+ });
26
+ child.unref(); // Allow the parent process to exit without waiting for the child
79
27
  }
80
28
  }
@@ -4,37 +4,48 @@ import { initSchema } from '../db/schema.js';
4
4
  import { indexFile } from '../db/fileIndex.js';
5
5
  import { shouldIgnoreFile } from '../utils/shouldIgnoreFiles.js';
6
6
  import { detectFileType } from '../utils/detectFileType.js';
7
- import { runDaemonScheduler } from './DaemonCmd.js';
7
+ import { startDaemon } from './DaemonCmd.js';
8
8
  import { IGNORED_FOLDER_GLOBS } from '../config/IgnoredPaths.js';
9
- import { db } from '../db/client.js';
10
- const IGNORE = [
11
- '**/node_modules/**', '**/dist/**', '**/build/**',
12
- '**/coverage/**', '**/.git/**', '**/*.test.*'
13
- ];
14
- export async function runIndexCommand(targetDir = process.cwd(), options = {}) {
15
- const resolvedDir = path.resolve(targetDir);
16
- console.log(`๐Ÿ“‚ Indexing files in: ${resolvedDir}`);
17
- initSchema();
18
- // ๐Ÿง  Check if another directory has already been indexed
19
- const indexedPaths = db.prepare(`
20
- SELECT DISTINCT path FROM files LIMIT 100
21
- `).all();
22
- const knownRoot = indexedPaths.length > 0
23
- ? path.dirname(indexedPaths[0].path)
24
- : null;
25
- if (knownRoot && !resolvedDir.startsWith(knownRoot) && !options.force) {
26
- console.warn(`โš ๏ธ You're indexing a different folder than before:
27
- - Previously: ${knownRoot}
28
- - Now: ${resolvedDir}
29
-
30
- This will add more files into the existing index and may reduce accuracy or performance.
31
-
32
- Use --force to continue, or consider clearing the index:
33
- scai reset-db
34
-
35
- Aborting.`);
9
+ import { Config } from '../config.js';
10
+ import { DB_PATH } from '../constants.js';
11
+ import { log } from '../utils/log.js';
12
+ import lockfile from 'proper-lockfile';
13
+ // ๐Ÿง  Lock the database to prevent simultaneous access
14
+ async function lockDb() {
15
+ try {
16
+ const lock = await lockfile.lock(DB_PATH); // DB_PATH from constants.ts
17
+ return lock;
18
+ }
19
+ catch (err) {
20
+ log('โŒ Failed to acquire DB lock: ' + err);
21
+ throw err;
22
+ }
23
+ }
24
+ export async function runIndexCommand(targetDir, options = {}) {
25
+ try {
26
+ initSchema();
27
+ }
28
+ catch (err) {
29
+ console.error('โŒ Failed to initialize schema:', err);
36
30
  process.exit(1);
37
31
  }
32
+ let resolvedDir;
33
+ if (options.force) {
34
+ // Force: use passed dir or fallback to cwd, no config updates
35
+ resolvedDir = path.resolve(targetDir || process.cwd());
36
+ console.warn('โš ๏ธ Running in --force mode. Config will not be updated.');
37
+ }
38
+ else if (targetDir) {
39
+ // User provided a directory: resolve and persist to config
40
+ resolvedDir = path.resolve(targetDir);
41
+ Config.setIndexDir(resolvedDir);
42
+ }
43
+ else {
44
+ // Use configured indexDir or fallback to cwd
45
+ resolvedDir = Config.getIndexDir() || process.cwd();
46
+ Config.setIndexDir(resolvedDir); // persist if not yet saved
47
+ }
48
+ log(`๐Ÿ“‚ Indexing files in: ${resolvedDir}`);
38
49
  const files = await fg('**/*.*', {
39
50
  cwd: resolvedDir,
40
51
  ignore: IGNORED_FOLDER_GLOBS,
@@ -42,25 +53,25 @@ Aborting.`);
42
53
  });
43
54
  const countByExt = {};
44
55
  let count = 0;
56
+ const release = await lockDb(); // Lock the DB before starting
45
57
  for (const file of files) {
46
58
  if (shouldIgnoreFile(file))
47
59
  continue;
48
60
  try {
49
61
  const type = detectFileType(file);
50
- indexFile(file, null, type); // empty summary for now
62
+ indexFile(file, null, type); // Index file without summary
51
63
  const ext = path.extname(file);
52
64
  countByExt[ext] = (countByExt[ext] || 0) + 1;
53
- console.log(`๐Ÿ“„ Indexed: ${path.relative(resolvedDir, file)}`);
65
+ log(`๐Ÿ“„ Indexed: ${path.relative(resolvedDir, file)}`);
54
66
  count++;
55
67
  }
56
68
  catch (err) {
57
- console.warn(`โš ๏ธ Skipped ${file}:`, err instanceof Error ? err.message : err);
69
+ log(`โš ๏ธ Skipped in indexCmd ${file}: ${err instanceof Error ? err.message : err}`);
58
70
  }
59
71
  }
60
- console.log('๐Ÿ“Š Indexed files by extension:', countByExt);
61
- console.log(`โœ… Done. Indexed ${count} files.`);
62
- if (options.detached) {
63
- console.log('๐Ÿš€ Starting summarizer daemon in background mode...');
64
- runDaemonScheduler();
65
- }
72
+ log('๐Ÿ“Š Indexed files by extension:', JSON.stringify(countByExt, null, 2));
73
+ log(`โœ… Done. Indexed ${count} files.`);
74
+ await release(); // Release the DB lock after indexing is done
75
+ // Auto-start daemon if not already running
76
+ startDaemon();
66
77
  }
@@ -1,19 +1,57 @@
1
1
  import fs from 'fs';
2
- import path from 'path';
2
+ import fsp from 'fs/promises';
3
3
  import { db } from '../db/client.js';
4
- export function resetDatabase() {
5
- const dbPath = path.resolve(process.cwd(), '.scai/db.sqlite');
4
+ import { DB_PATH, SCAI_HOME } from '../constants.js';
5
+ import lockfile from 'proper-lockfile';
6
+ import path from 'path';
7
+ function getBackupDir() {
8
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
9
+ return path.join(SCAI_HOME, `backup-${timestamp}`);
10
+ }
11
+ async function backupScaiFolder() {
12
+ const backupDir = getBackupDir();
13
+ try {
14
+ await fsp.mkdir(backupDir, { recursive: true });
15
+ const files = await fsp.readdir(SCAI_HOME);
16
+ for (const file of files) {
17
+ const srcPath = path.join(SCAI_HOME, file);
18
+ const destPath = path.join(backupDir, file);
19
+ const stat = await fsp.stat(srcPath);
20
+ if (stat.isFile()) {
21
+ await fsp.copyFile(srcPath, destPath);
22
+ }
23
+ }
24
+ console.log(`๐Ÿ“ฆ Backed up .scai folder to: ${backupDir}`);
25
+ return backupDir;
26
+ }
27
+ catch (err) {
28
+ console.warn('โš ๏ธ Failed to back up .scai folder:', err instanceof Error ? err.message : err);
29
+ return null;
30
+ }
31
+ }
32
+ export async function resetDatabase() {
33
+ console.log('๐Ÿ” Backing up existing .scai folder...');
34
+ await backupScaiFolder();
6
35
  try {
7
- db.close(); // ๐Ÿ”’ Make sure the DB is closed
36
+ db.close();
8
37
  console.log('๐Ÿ”’ Closed SQLite database connection.');
9
38
  }
10
39
  catch (err) {
11
- console.warn('โš ๏ธ Could not close database:', err);
40
+ console.warn('โš ๏ธ Could not close database:', err instanceof Error ? err.message : err);
12
41
  }
13
- if (fs.existsSync(dbPath)) {
42
+ try {
43
+ const releaseLock = await lockfile.unlock(DB_PATH).catch(() => null);
44
+ if (releaseLock) {
45
+ console.log('๐Ÿ”“ Released database lock.');
46
+ }
47
+ }
48
+ catch (err) {
49
+ console.warn('โš ๏ธ Failed to release database lock:', err instanceof Error ? err.message : err);
50
+ }
51
+ if (fs.existsSync(DB_PATH)) {
14
52
  try {
15
- fs.unlinkSync(dbPath);
16
- console.log('๐Ÿงน Deleted existing database.');
53
+ fs.unlinkSync(DB_PATH);
54
+ console.log(`๐Ÿงน Deleted existing database at ${DB_PATH}`);
17
55
  }
18
56
  catch (err) {
19
57
  console.error('โŒ Failed to delete DB file:', err instanceof Error ? err.message : err);
@@ -21,7 +59,14 @@ export function resetDatabase() {
21
59
  }
22
60
  }
23
61
  else {
24
- console.log('โ„น๏ธ No existing database found.');
62
+ console.log('โ„น๏ธ No existing database found at:', DB_PATH);
63
+ }
64
+ try {
65
+ fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
66
+ console.log('๐Ÿ“ Ensured that the database directory exists.');
67
+ }
68
+ catch (err) {
69
+ console.warn('โš ๏ธ Could not ensure DB directory exists:', err instanceof Error ? err.message : err);
25
70
  }
26
71
  console.log('โœ… Database has been reset. You can now re-run: scai index');
27
72
  }
@@ -0,0 +1,15 @@
1
+ // src/config/StopWords.ts
2
+ /**
3
+ * These common words are ignored from search queries
4
+ * to reduce noise and improve FTS and embedding match quality.
5
+ */
6
+ export const STOP_WORDS = new Set([
7
+ 'a', 'an', 'and', 'are', 'as', 'at', 'be', 'but', 'by',
8
+ 'for', 'if', 'in', 'into', 'is', 'it', 'no', 'not',
9
+ 'of', 'on', 'or', 'such', 'that', 'the', 'their',
10
+ 'then', 'there', 'these', 'they', 'this', 'to', 'was',
11
+ 'will', 'with', 'what', 'which', 'who', 'whom', 'where',
12
+ 'when', 'why', 'how', 'from', 'all', 'any', 'can',
13
+ 'did', 'do', 'has', 'have', 'i', 'me', 'my', 'you',
14
+ 'your', 'we', 'us', 'our'
15
+ ]);
package/dist/constants.js CHANGED
@@ -1,19 +1,49 @@
1
1
  import os from 'os';
2
2
  import path from 'path';
3
3
  import fs from 'fs';
4
+ /**
5
+ * The base directory where internal SCAI config/state is stored:
6
+ * ~/.scai
7
+ */
4
8
  export const SCAI_HOME = path.join(os.homedir(), '.scai');
9
+ /**
10
+ * Full path to the SQLite database used by SCAI:
11
+ * ~/.scai/db.sqlite
12
+ */
5
13
  export const DB_PATH = path.join(SCAI_HOME, 'db.sqlite');
14
+ /**
15
+ * Path to the daemon process ID file (if running in background mode):
16
+ * ~/.scai/daemon.pid
17
+ */
6
18
  export const PID_PATH = path.join(SCAI_HOME, 'daemon.pid');
19
+ /**
20
+ * Path to the config file that stores user settings like model, language, indexDir, etc.:
21
+ * ~/.scai/config.json
22
+ */
7
23
  export const CONFIG_PATH = path.join(SCAI_HOME, 'config.json');
8
- // Function to read config and get the indexDir on-demand
24
+ /**
25
+ * Path to the daemon log file:
26
+ * ~/.scai/daemon.log
27
+ */
28
+ export const LOG_PATH = path.join(SCAI_HOME, 'daemon.log');
29
+ /**
30
+ * Get the active index directory.
31
+ *
32
+ * - If the user has configured an `indexDir`, use it.
33
+ * - If not, default to the userโ€™s home directory (`~`), not `.scai`.
34
+ */
9
35
  export function getIndexDir() {
10
36
  try {
11
37
  const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8'));
12
- return config.indexDir || path.join(os.homedir(), 'defaultIndex'); // Default if not set
38
+ return config.indexDir || os.homedir(); // ๐Ÿ  Default: ~
13
39
  }
14
40
  catch (e) {
15
- return path.join(os.homedir(), 'defaultIndex'); // Fallback if no config file
41
+ return os.homedir(); // ๐Ÿ  Fallback if config file is missing or invalid
16
42
  }
17
43
  }
18
- // On-demand index directory retrieval
44
+ /**
45
+ * On-demand index directory to scan for files.
46
+ *
47
+ * Used by indexing logic (`scai index`) to determine what folder to scan.
48
+ */
19
49
  export const INDEX_DIR = getIndexDir();
@@ -0,0 +1,65 @@
1
+ import { summaryModule } from '../pipeline/modules/summaryModule.js';
2
+ import { db } from '../db/client.js';
3
+ import fs from 'fs/promises';
4
+ import fsSync from 'fs';
5
+ import { generateEmbedding } from '../lib/generateEmbedding.js';
6
+ import { DB_PATH } from '../constants.js';
7
+ import { log } from '../utils/log.js';
8
+ import lockfile from 'proper-lockfile';
9
+ import { shouldIgnoreFile } from '../utils/shouldIgnoreFiles.js';
10
+ const MAX_FILES_PER_BATCH = 5;
11
+ async function lockDb() {
12
+ try {
13
+ return await lockfile.lock(DB_PATH);
14
+ }
15
+ catch (err) {
16
+ log('โŒ Failed to acquire DB lock: ' + err);
17
+ throw err;
18
+ }
19
+ }
20
+ export async function runDaemonBatch() {
21
+ const rows = db.prepare(`
22
+ SELECT path, type FROM files
23
+ WHERE summary IS NULL OR summary = ''
24
+ ORDER BY last_modified DESC
25
+ LIMIT ?
26
+ `).all(MAX_FILES_PER_BATCH);
27
+ if (rows.length === 0) {
28
+ log('โœ… No files left to summarize.');
29
+ return false;
30
+ }
31
+ const release = await lockDb();
32
+ for (const row of rows) {
33
+ if (!fsSync.existsSync(row.path)) {
34
+ log(`โš ๏ธ Skipped missing file: ${row.path}`);
35
+ continue;
36
+ }
37
+ if (shouldIgnoreFile(row.path)) {
38
+ log(`โš ๏ธ Skipped (extension): ${row.path}`);
39
+ continue;
40
+ }
41
+ try {
42
+ const content = await fs.readFile(row.path, 'utf-8');
43
+ const result = await summaryModule.run({ content, filepath: row.path });
44
+ const summary = result?.summary?.trim() || null;
45
+ let embedding = null;
46
+ if (summary) {
47
+ const vector = await generateEmbedding(summary);
48
+ if (vector)
49
+ embedding = JSON.stringify(vector);
50
+ }
51
+ db.prepare(`
52
+ UPDATE files
53
+ SET summary = @summary, embedding = @embedding, indexed_at = datetime('now')
54
+ WHERE path = @path
55
+ `).run({ summary, embedding, path: row.path });
56
+ log(`๐Ÿ“ Summarized: ${row.path}`);
57
+ }
58
+ catch (err) {
59
+ log(`โŒ Failed: ${row.path}: ${err instanceof Error ? err.message : String(err)}`);
60
+ }
61
+ await new Promise(resolve => setTimeout(resolve, 200));
62
+ }
63
+ await release();
64
+ return true;
65
+ }
@@ -0,0 +1,27 @@
1
+ import fsSync from 'fs';
2
+ import { LOG_PATH, PID_PATH, SCAI_HOME } from '../constants.js';
3
+ import { log } from '../utils/log.js';
4
+ import { runDaemonBatch } from '../daemon/daemonBatch.js'; // โœ… now from utils
5
+ const SLEEP_MS = 30 * 1000;
6
+ const IDLE_SLEEP_MS = 4 * SLEEP_MS;
7
+ function sleep(ms) {
8
+ return new Promise(resolve => setTimeout(resolve, ms));
9
+ }
10
+ async function runDaemonScheduler() {
11
+ fsSync.mkdirSync(SCAI_HOME, { recursive: true });
12
+ fsSync.writeFileSync(PID_PATH, process.pid.toString(), 'utf-8');
13
+ fsSync.appendFileSync(LOG_PATH, `\n\n๐Ÿง  Daemon started at ${new Date().toISOString()} โ€” PID ${process.pid}\n`);
14
+ let cycles = 0;
15
+ while (true) {
16
+ const didWork = await runDaemonBatch();
17
+ cycles++;
18
+ if (cycles % 20 === 0) {
19
+ log(`๐ŸŒ€ Still running. Cycles: ${cycles}`);
20
+ }
21
+ await sleep(didWork ? SLEEP_MS : IDLE_SLEEP_MS);
22
+ }
23
+ }
24
+ runDaemonScheduler().catch(err => {
25
+ log(`โŒ Daemon crashed: ${err instanceof Error ? err.message : String(err)}`);
26
+ process.exit(1);
27
+ });
package/dist/db/client.js CHANGED
@@ -1,5 +1,9 @@
1
1
  import Database from 'better-sqlite3';
2
2
  import fs from 'fs';
3
3
  import { DB_PATH, SCAI_HOME } from '../constants.js';
4
+ // Ensure the directory exists
4
5
  fs.mkdirSync(SCAI_HOME, { recursive: true });
6
+ // Open the database connection
5
7
  export const db = new Database(DB_PATH);
8
+ // Set journal_mode to WAL for better concurrency
9
+ db.pragma('journal_mode = WAL');
@@ -1,114 +1,123 @@
1
- // File: src/db/fileIndex.ts
2
1
  import { db } from './client.js';
3
2
  import fs from 'fs';
3
+ import path from 'path';
4
4
  import { generateEmbedding } from '../lib/generateEmbedding.js';
5
- import * as sqlTemplates from './sqlTemplates.js'; // Import the SQL templates
5
+ import * as sqlTemplates from './sqlTemplates.js';
6
+ import { sanitizeQueryForFts } from '../utils/sanitizeQuery.js';
7
+ /**
8
+ * Index a file into the local SQLite database.
9
+ *
10
+ * - Normalizes the file path for cross-platform compatibility.
11
+ * - Extracts file metadata (last modified time).
12
+ * - Performs an UPSERT into the `files` table with the latest summary/type/timestamp.
13
+ */
6
14
  export function indexFile(filePath, summary, type) {
7
15
  const stats = fs.statSync(filePath);
8
16
  const lastModified = stats.mtime.toISOString();
9
- // 1) INSERT new rows (only when path not present)
10
- const insertStmt = db.prepare(sqlTemplates.insertFileTemplate);
11
- insertStmt.run({ path: filePath, summary, type, lastModified });
12
- // 2) UPDATE metadata if file already existed and changed
13
- const updateStmt = db.prepare(sqlTemplates.updateFileTemplate);
14
- updateStmt.run({ path: filePath, type, lastModified });
15
- // Step 1: Delete from FTS where the path matches
16
- db.prepare(sqlTemplates.deleteFromFtsTemplate).run({ path: filePath });
17
- // Step 2: Insert into FTS with the same id
18
- db.prepare(sqlTemplates.insertIntoFtsTemplate).run({ path: filePath, summary });
17
+ const indexedAt = new Date().toISOString();
18
+ const normalizedPath = path.normalize(filePath).replace(/\\/g, '/');
19
+ db.prepare(sqlTemplates.upsertFileTemplate).run({
20
+ path: normalizedPath,
21
+ summary,
22
+ type,
23
+ lastModified,
24
+ indexedAt,
25
+ embedding: null
26
+ });
27
+ console.log(`๐Ÿ“„ Indexed: ${normalizedPath}`);
19
28
  }
29
+ /**
30
+ * Perform a raw keyword-based full-text search using the FTS5 index.
31
+ */
20
32
  export function queryFiles(query, limit = 10) {
21
- // Sanitize the query by removing or escaping special characters
22
33
  const safeQuery = query
23
34
  .trim()
24
35
  .split(/\s+/)
25
36
  .map(token => {
26
- token = token
27
- .replace(/[?*\\"]/g, '') // Remove question marks, asterisks, backslashes, and double quotes
28
- .replace(/'/g, "''"); // Escape single quotes for SQL safety
29
- // For multi-word queries, wrap the token in quotes for exact phrase matching
30
- if (token.includes(' ')) {
31
- return `"${token}"`; // Exact phrase match for multi-word tokens
32
- }
33
- return `${token}*`; // Prefix match for single tokens
37
+ token = token.replace(/[?*\\"]/g, '').replace(/'/g, "''");
38
+ return token.includes(' ') ? `"${token}"` : `${token}*`;
34
39
  })
35
40
  .join(' OR ');
36
- // Log the constructed query for debugging purposes
37
41
  console.log(`Executing search query: ${safeQuery}`);
38
- // Execute the query with safeQuery and limit as parameters
39
- const sql = `
40
- SELECT f.path, f.summary, f.type, f.last_modified, f.indexed_at,
41
- bm25(files_fts) AS rank
42
- FROM files_fts
43
- JOIN files f ON files_fts.rowid = f.id
44
- WHERE files_fts MATCH :query
45
- ORDER BY rank
46
- LIMIT :limit
47
- `;
48
- const results = db.prepare(sql).all({ query: safeQuery, limit });
42
+ const results = db.prepare(`
43
+ SELECT f.id, f.path, f.summary, f.type, f.last_modified, f.indexed_at
44
+ FROM files f
45
+ JOIN files_fts fts ON f.id = fts.rowid
46
+ WHERE fts.files_fts MATCH ?
47
+ LIMIT ?
48
+ `).all(safeQuery, limit);
49
+ console.log(`Search returned ${results.length} results.`);
50
+ results.forEach(result => {
51
+ console.log(`๐Ÿ“„ Found in FTS search: ${result.path}`);
52
+ });
49
53
  return results;
50
54
  }
51
- export function cosineSimilarity(a, b) {
52
- const dot = a.reduce((sum, ai, i) => sum + ai * b[i], 0);
53
- const magA = Math.sqrt(a.reduce((sum, ai) => sum + ai * ai, 0));
54
- const magB = Math.sqrt(b.reduce((sum, bi) => sum + bi * bi, 0));
55
- return dot / (magA * magB);
56
- }
55
+ /**
56
+ * Perform a hybrid semantic + keyword-based search.
57
+ */
57
58
  export async function searchFiles(query, topK = 5) {
58
- // Generate the query embedding
59
+ console.log(`๐Ÿง  Searching for query: "${query}"`);
59
60
  const embedding = await generateEmbedding(query);
60
- if (!embedding)
61
+ if (!embedding) {
62
+ console.log('โš ๏ธ Failed to generate embedding for query');
61
63
  return [];
62
- // Sanitize the query by removing or escaping special characters
63
- const safeQuery = query
64
- .trim()
65
- .split(/\s+/)
66
- .map(token => {
67
- token = token
68
- .replace(/[?*\\"]/g, '') // Remove question marks, asterisks, backslashes, and double quotes
69
- .replace(/'/g, "''"); // Escape single quotes for SQL safety
70
- // For multi-word queries, wrap the token in quotes for exact phrase matching
71
- if (token.includes(' ')) {
72
- return `"${token}"`; // Exact phrase match for multi-word tokens
73
- }
74
- return `${token}*`; // Prefix match for single tokens
75
- })
76
- .join(' OR ');
77
- // Log the constructed query for debugging purposes
78
- console.log(`Executing search query: ${safeQuery}`);
79
- // Fetch BM25 scores from the FTS using the safeQuery string directly
80
- const ftsResults = db.prepare(sqlTemplates.fetchBm25ScoresTemplate).all({ query: safeQuery });
64
+ }
65
+ const safeQuery = sanitizeQueryForFts(query);
66
+ console.log(`Executing search query in FTS5: ${safeQuery}`);
67
+ const ftsResults = db.prepare(`
68
+ SELECT fts.rowid AS id, f.path, f.summary, f.type, bm25(files_fts) AS bm25Score
69
+ FROM files f
70
+ JOIN files_fts fts ON f.id = fts.rowid
71
+ WHERE fts.files_fts MATCH ?
72
+ AND f.embedding IS NOT NULL
73
+ ORDER BY bm25Score DESC
74
+ LIMIT ?
75
+ `).all(safeQuery, topK);
76
+ console.log(`FTS search returned ${ftsResults.length} results`);
77
+ if (ftsResults.length === 0) {
78
+ console.log('โš ๏ธ No results found from FTS search');
79
+ return [];
80
+ }
81
+ ftsResults.forEach(result => {
82
+ console.log(`๐Ÿ“„ FTS found: ${result.path}`);
83
+ });
81
84
  const bm25Min = Math.min(...ftsResults.map(r => r.bm25Score));
82
85
  const bm25Max = Math.max(...ftsResults.map(r => r.bm25Score));
83
- // Calculate final score combining BM25 and cosine similarity
84
86
  const scored = ftsResults.map(result => {
85
87
  try {
86
- // Fetch embedding for each file from the `files` table
87
- const embResult = db.prepare(sqlTemplates.fetchEmbeddingTemplate).get({ path: result.path });
88
- // Check if embedding exists and has the correct structure
89
- if (!embResult || typeof embResult.embedding !== 'string')
88
+ const embResult = db.prepare(sqlTemplates.fetchEmbeddingTemplate).get({
89
+ path: result.path,
90
+ });
91
+ if (!embResult || typeof embResult.embedding !== 'string') {
92
+ console.log(`โš ๏ธ No embedding for file: ${result.path}`);
90
93
  return null;
91
- // Parse the embedding
94
+ }
92
95
  const vector = JSON.parse(embResult.embedding);
93
96
  const sim = cosineSimilarity(embedding, vector);
94
- // Normalize BM25 scores
95
97
  const normalizedBm25 = 1 - ((result.bm25Score - bm25Min) / (bm25Max - bm25Min + 1e-5));
96
- const normalizedSim = sim; // cosineSimilarity is already 0โ€“1
97
- const finalScore = 0.7 * normalizedSim + 0.3 * normalizedBm25;
98
+ const finalScore = 0.7 * sim + 0.3 * normalizedBm25;
98
99
  return {
99
100
  path: result.path,
100
101
  summary: result.summary,
101
102
  score: finalScore,
102
- sim: normalizedSim,
103
- bm25: normalizedBm25
103
+ sim,
104
+ bm25: normalizedBm25,
104
105
  };
105
106
  }
106
107
  catch (err) {
107
- console.error(`Error processing embedding for file: ${result.path}`, err);
108
+ console.error(`โŒ Error processing embedding for file: ${result.path}`, err);
108
109
  return null;
109
110
  }
110
- }).filter(Boolean)
111
+ })
112
+ .filter((r) => r !== null)
111
113
  .sort((a, b) => b.score - a.score)
112
114
  .slice(0, topK);
115
+ console.log(`Returning top ${topK} results based on combined score`);
113
116
  return scored;
114
117
  }
118
+ function cosineSimilarity(a, b) {
119
+ const dot = a.reduce((sum, ai, i) => sum + ai * b[i], 0);
120
+ const magA = Math.sqrt(a.reduce((sum, ai) => sum + ai * ai, 0));
121
+ const magB = Math.sqrt(b.reduce((sum, bi) => sum + bi * bi, 0));
122
+ return dot / (magA * magB);
123
+ }
package/dist/db/schema.js CHANGED
@@ -11,9 +11,25 @@ export function initSchema() {
11
11
  embedding TEXT
12
12
  );
13
13
 
14
- -- FTS5 table for fast fullโ€‘text search of summaries and paths
15
14
  CREATE VIRTUAL TABLE IF NOT EXISTS files_fts
16
- USING fts5(path, summary, content='');
15
+ USING fts5(path, summary, content='files', content_rowid='id');
16
+
17
+ -- FTS Triggers to keep files_fts in sync
18
+ CREATE TRIGGER IF NOT EXISTS files_ai AFTER INSERT ON files BEGIN
19
+ INSERT INTO files_fts(rowid, path, summary)
20
+ VALUES (new.id, new.path, new.summary);
21
+ END;
22
+
23
+ CREATE TRIGGER IF NOT EXISTS files_au AFTER UPDATE ON files BEGIN
24
+ UPDATE files_fts SET
25
+ path = new.path,
26
+ summary = new.summary
27
+ WHERE rowid = new.id;
28
+ END;
29
+
30
+ CREATE TRIGGER IF NOT EXISTS files_ad AFTER DELETE ON files BEGIN
31
+ DELETE FROM files_fts WHERE rowid = old.id;
32
+ END;
17
33
  `);
18
- console.log('โœ… SQLite schema initialized');
34
+ console.log('โœ… SQLite schema initialized with FTS5 triggers');
19
35
  }
@@ -1,29 +1,23 @@
1
- // Template for inserting or ignoring new file entries
2
- export const insertFileTemplate = `
3
- INSERT OR IGNORE INTO files
4
- (path, summary, type, indexed_at, last_modified)
5
- VALUES (:path, :summary, :type, datetime('now'), :lastModified)
1
+ // Upsert file metadata into `files`
2
+ export const upsertFileTemplate = `
3
+ INSERT INTO files (path, summary, type, last_modified, indexed_at, embedding)
4
+ VALUES (:path, :summary, :type, :lastModified, :indexedAt, :embedding)
5
+ ON CONFLICT(path) DO UPDATE SET
6
+ summary = CASE
7
+ WHEN excluded.summary IS NOT NULL AND excluded.summary != files.summary
8
+ THEN excluded.summary
9
+ ELSE files.summary
10
+ END,
11
+ type = excluded.type,
12
+ last_modified = excluded.last_modified,
13
+ indexed_at = excluded.indexed_at,
14
+ embedding = CASE
15
+ WHEN excluded.embedding IS NOT NULL AND excluded.embedding != files.embedding
16
+ THEN excluded.embedding
17
+ ELSE files.embedding
18
+ END
6
19
  `;
7
- // Template for updating file metadata if it has changed
8
- export const updateFileTemplate = `
9
- UPDATE files
10
- SET type = :type,
11
- last_modified = :lastModified,
12
- indexed_at = datetime('now')
13
- WHERE path = :path
14
- AND last_modified != :lastModified
15
- `;
16
- // Template for deleting a file from FTS
17
- export const deleteFromFtsTemplate = `
18
- DELETE FROM files_fts
19
- WHERE rowid = (SELECT id FROM files WHERE path = :path)
20
- `;
21
- // Template for inserting a file into FTS with its ID
22
- export const insertIntoFtsTemplate = `
23
- INSERT INTO files_fts(rowid, path, summary)
24
- VALUES((SELECT id FROM files WHERE path = :path), :path, :summary)
25
- `;
26
- // Template for fetching BM25 scores from FTS
20
+ // Fetch search results with BM25 ranking
27
21
  export const fetchBm25ScoresTemplate = `
28
22
  SELECT f.path, f.summary, f.type, bm25(files_fts) AS bm25Score
29
23
  FROM files_fts
@@ -31,7 +25,17 @@ export const fetchBm25ScoresTemplate = `
31
25
  WHERE files_fts MATCH :query
32
26
  LIMIT 50
33
27
  `;
34
- // Template for fetching embedding for a specific file
28
+ // Fetch embedding vector for a file
35
29
  export const fetchEmbeddingTemplate = `
36
30
  SELECT embedding FROM files WHERE path = :path
37
31
  `;
32
+ // Used for non-embedding query in `queryFiles()`
33
+ export const rawQueryTemplate = `
34
+ SELECT f.path, f.summary, f.type, f.last_modified, f.indexed_at,
35
+ bm25(files_fts) AS rank
36
+ FROM files_fts
37
+ JOIN files f ON files_fts.rowid = f.id
38
+ WHERE files_fts MATCH :query
39
+ ORDER BY rank
40
+ LIMIT :limit
41
+ `;
package/dist/index.js CHANGED
@@ -5,9 +5,6 @@ import { Config } from './config.js';
5
5
  import { createRequire } from 'module';
6
6
  const require = createRequire(import.meta.url);
7
7
  const { version } = require('../package.json');
8
- // ๐Ÿง  Commands
9
- import { checkEnv } from "./commands/EnvCmd.js";
10
- import { checkGit } from "./commands/GitCmd.js";
11
8
  import { suggestCommitMessage } from "./commands/CommitSuggesterCmd.js";
12
9
  import { handleRefactor } from "./commands/RefactorCmd.js";
13
10
  import { generateTests } from "./commands/TestGenCmd.js";
@@ -18,7 +15,7 @@ import { runModulePipelineFromCLI } from './commands/ModulePipelineCmd.js';
18
15
  import { runIndexCommand } from './commands/IndexCmd.js';
19
16
  import { resetDatabase } from './commands/ResetDbCmd.js';
20
17
  import { runQueryCommand } from './commands/QueryCmd.js';
21
- import { runDaemonBatch } from './commands/DaemonCmd.js';
18
+ import { startDaemon } from './commands/DaemonCmd.js';
22
19
  import { runStopDaemonCommand } from "./commands/StopDaemonCmd.js";
23
20
  import { runAskCommand } from './commands/AskCmd.js';
24
21
  // ๐ŸŽ›๏ธ CLI Setup
@@ -34,12 +31,14 @@ cmd
34
31
  await bootstrap();
35
32
  console.log('โœ… Model initialization completed!');
36
33
  });
34
+ cmd
35
+ .command('sugg')
36
+ .description('Suggest a commit message from staged changes')
37
+ .option('-c, --commit', 'Automatically commit with suggested message')
38
+ .action(suggestCommitMessage);
37
39
  // ๐Ÿ”ง Group: Git-related commands
38
40
  const git = cmd.command('git').description('Git utilities');
39
- git
40
- .command('status')
41
- .description('Check Git status')
42
- .action(checkGit);
41
+ // The sugg command under the 'git' group
43
42
  git
44
43
  .command('sugg')
45
44
  .description('Suggest a commit message from staged changes')
@@ -66,16 +65,6 @@ gen
66
65
  .command('tests <file>')
67
66
  .description('Generate a Jest test file for the specified JS/TS module')
68
67
  .action((file) => generateTests(file));
69
- // ๐Ÿ” Indexing
70
- cmd
71
- .command('index [targetDir]')
72
- .description('Index supported files in the given directory (or current folder if none)')
73
- .option('-d, --detached', 'Run summarizer daemon after indexing')
74
- .option('--force', 'Force indexing even if another folder has already been indexed')
75
- .action((targetDir, options) => {
76
- const resolvedDir = targetDir ? path.resolve(targetDir) : process.cwd();
77
- runIndexCommand(resolvedDir, { detached: options.detached, force: options.force });
78
- });
79
68
  // โš™๏ธ Group: Configuration settings
80
69
  const set = cmd.command('set').description('Set configuration values');
81
70
  set
@@ -100,34 +89,47 @@ set
100
89
  Config.show();
101
90
  });
102
91
  // ๐Ÿงช Diagnostics and info
103
- cmd
104
- .command('env')
105
- .description('Check environment variables')
106
- .action(checkEnv);
107
92
  cmd
108
93
  .command('config')
109
94
  .description('Show the currently active model and language settings')
110
95
  .action(() => {
111
96
  Config.show();
112
97
  });
98
+ // Add explanation about alpha features directly in the help menu
99
+ cmd.addHelpText('after', `
100
+ ๐Ÿšจ Alpha Features:
101
+ - The "index", "daemon", "stop-daemon", "reset-db" commands are considered alpha features.
102
+ - These commands are in active development and may change in the future.
103
+
104
+ ๐Ÿ’ก Use with caution and expect possible changes or instability.
105
+ `);
106
+ // ๐Ÿ” Indexing
107
+ cmd
108
+ .command('index [targetDir]')
109
+ .description('Index supported files in the given directory (or current folder if none)')
110
+ .option('--force', 'Force indexing even if another folder has already been indexed')
111
+ .action((targetDir, options) => {
112
+ runIndexCommand(targetDir, { force: options.force });
113
+ });
113
114
  // ๐Ÿง  Query and assistant
114
115
  cmd
115
116
  .command('query <query>')
116
117
  .description('Search indexed files by keyword')
117
118
  .action(runQueryCommand);
118
119
  cmd
119
- .command('ask')
120
- .description('Ask a question using file summaries and a local model')
121
- .argument('<question...>', 'The question to ask')
122
- .action((question) => {
123
- const q = question.join(' ');
124
- runAskCommand(q);
120
+ .command('ask [question...]') // <- the ... makes it variadic
121
+ .description('Ask a question based on indexed files')
122
+ .action((questionParts) => {
123
+ const fullQuery = questionParts?.join(' ');
124
+ runAskCommand(fullQuery);
125
125
  });
126
126
  // ๐Ÿ› ๏ธ Background tasks and maintenance
127
127
  cmd
128
128
  .command('daemon')
129
129
  .description('Run background summarization of indexed files')
130
- .action(runDaemonBatch);
130
+ .action(async () => {
131
+ await startDaemon(); // ignore the return value
132
+ });
131
133
  cmd
132
134
  .command('stop-daemon')
133
135
  .description('Stop the background summarizer daemon')
@@ -0,0 +1,15 @@
1
+ import fs from 'fs';
2
+ import { LOG_PATH } from '../constants.js';
3
+ export function log(...args) {
4
+ const timestamp = new Date().toISOString();
5
+ const message = args.map(arg => typeof arg === 'string' ? arg : JSON.stringify(arg, null, 2)).join(' ');
6
+ const isBackground = process.env.BACKGROUND_MODE === 'true';
7
+ if (isBackground) {
8
+ // If running in background, log to a file
9
+ fs.appendFileSync(LOG_PATH, `[${timestamp}] ${message}\n`);
10
+ }
11
+ else {
12
+ // Otherwise, log to the console
13
+ console.log(`[${timestamp}] ${message}`);
14
+ }
15
+ }
@@ -0,0 +1,16 @@
1
+ // src/utils/sanitizeQuery.ts
2
+ import { STOP_WORDS } from '../config/StopWords.js';
3
+ export function sanitizeQueryForFts(input) {
4
+ const tokens = input
5
+ .trim()
6
+ .split(/\s+/)
7
+ .map(token => token.toLowerCase())
8
+ .filter(token => token.length > 2 &&
9
+ !STOP_WORDS.has(token) &&
10
+ /^[a-z0-9]+$/.test(token))
11
+ .map(token => token.replace(/[?*\\"]/g, '').replace(/'/g, "''") + '*');
12
+ // ๐Ÿ‘‡ Prevent FTS syntax errors by returning a catch-all query
13
+ if (tokens.length === 0)
14
+ return '*';
15
+ return tokens.join(' OR ');
16
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "scai",
3
- "version": "0.1.22",
3
+ "version": "0.1.24",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "scai": "./dist/index.js"
@@ -27,12 +27,14 @@
27
27
  "better-sqlite3": "^12.1.1",
28
28
  "commander": "^11.0.0",
29
29
  "fast-glob": "^3.3.3",
30
- "ora": "^8.2.0"
30
+ "ora": "^8.2.0",
31
+ "proper-lockfile": "^4.1.2"
31
32
  },
32
33
  "devDependencies": {
33
34
  "@types/better-sqlite3": "^7.6.13",
34
35
  "@types/jest": "^30.0.0",
35
36
  "@types/node": "^24.0.1",
37
+ "@types/proper-lockfile": "^4.1.4",
36
38
  "jest": "^30.0.2",
37
39
  "ts-jest": "^29.4.0",
38
40
  "typescript": "^5.8.3"