@thegitai/cli 1.0.0-preview.5 → 1.0.0-preview.51

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.
Files changed (76) hide show
  1. package/README.md +93 -4
  2. package/dist/bin/ai.js +142 -112
  3. package/dist/bin/browser-host.js +265 -0
  4. package/dist/src/agent-mode.js +32 -7
  5. package/dist/src/api/auth.js +10 -4
  6. package/dist/src/api/browser-login.js +149 -37
  7. package/dist/src/api/chat.js +680 -166
  8. package/dist/src/api/contracts.js +55 -1
  9. package/dist/src/api/default-host.js +1 -0
  10. package/dist/src/api/http.js +151 -7
  11. package/dist/src/api/models.js +29 -10
  12. package/dist/src/api/startup-handshake.js +36 -0
  13. package/dist/src/api/turn-recovery.js +189 -0
  14. package/dist/src/background-jobs.js +4 -2
  15. package/dist/src/browser/bridge.js +262 -0
  16. package/dist/src/browser/framing.js +42 -0
  17. package/dist/src/browser/native-host.js +219 -0
  18. package/dist/src/browser/protocol.js +46 -0
  19. package/dist/src/browser/session-bridge.js +130 -0
  20. package/dist/src/cli-args.js +19 -5
  21. package/dist/src/client-environment.js +2 -0
  22. package/dist/src/core/clipboard.js +7 -13
  23. package/dist/src/core/image-limits.js +56 -0
  24. package/dist/src/core/image-path-extractor.js +70 -3
  25. package/dist/src/core/session-image-store.js +246 -0
  26. package/dist/src/edit-journal.js +33 -2
  27. package/dist/src/executor.js +25 -3
  28. package/dist/src/help-text.js +112 -21
  29. package/dist/src/patcher.js +43 -4
  30. package/dist/src/permissions.js +288 -0
  31. package/dist/src/preferences.js +39 -0
  32. package/dist/src/quota-notice.js +35 -0
  33. package/dist/src/session-safety.js +0 -12
  34. package/dist/src/session-store.js +121 -20
  35. package/dist/src/session.js +19 -4
  36. package/dist/src/signin.js +58 -0
  37. package/dist/src/tool-executor.js +13 -46
  38. package/dist/src/tools/browser.js +641 -0
  39. package/dist/src/tools/delete-file.js +20 -4
  40. package/dist/src/tools/index.js +32 -10
  41. package/dist/src/tools/patch-file.js +12 -26
  42. package/dist/src/tools/read-file.js +5 -0
  43. package/dist/src/tools/read-image-file.js +85 -0
  44. package/dist/src/tools/replace-document-text.js +28 -18
  45. package/dist/src/tools/restore-checkpoint.js +0 -1
  46. package/dist/src/tools/run-command.js +14 -71
  47. package/dist/src/tools/run-node-script.js +12 -81
  48. package/dist/src/tools/save-generated-image.js +120 -0
  49. package/dist/src/tools/str-replace.js +12 -26
  50. package/dist/src/tools/undo-edit.js +1 -6
  51. package/dist/src/tools/write-file.js +67 -11
  52. package/dist/src/ui/prompt-history-store.js +1 -1
  53. package/dist/src/ui/repl.js +949 -279
  54. package/dist/src/ui/slash-commands.js +121 -0
  55. package/dist/src/ui/tui/attention.js +25 -0
  56. package/dist/src/ui/tui/bridge.js +38 -5
  57. package/dist/src/ui/tui/build-frame.js +686 -223
  58. package/dist/src/ui/tui/composer-layout.js +76 -0
  59. package/dist/src/ui/tui/desktop-notification.js +118 -0
  60. package/dist/src/ui/tui/markdown-render.js +11 -2
  61. package/dist/src/ui/tui/shell-input.js +256 -73
  62. package/dist/src/ui/tui/shortcuts.js +61 -0
  63. package/dist/src/ui/tui/terminal-theme.js +28 -0
  64. package/dist/src/ui/tui/user-input.js +593 -0
  65. package/dist/src/update-check.js +205 -0
  66. package/dist/src/usage.js +5 -2
  67. package/dist/src/utils.js +13 -0
  68. package/dist/src/version.js +52 -2
  69. package/package.json +22 -9
  70. package/dist/src/project-index.js +0 -233
  71. package/dist/src/tools/code-intel.js +0 -472
  72. package/dist/src/tools/find-symbol.js +0 -70
  73. package/dist/src/tools/hover-symbol.js +0 -95
  74. package/dist/src/tools/list-symbols.js +0 -55
  75. package/dist/src/tools/search-code.js +0 -37
  76. package/dist/src/tools/signature-help.js +0 -118
@@ -0,0 +1,205 @@
1
+ import { spawn } from 'node:child_process';
2
+ import path from 'node:path';
3
+ import { stdin as input, stdout as output } from 'node:process';
4
+ import { emitKeypressEvents } from 'node:readline';
5
+ import readline from 'node:readline/promises';
6
+ import { fileURLToPath } from 'node:url';
7
+ import chalk from './colors.js';
8
+ import { isAbortError } from './utils.js';
9
+ import { compareCliVersions, UNKNOWN_CLI_VERSION } from './version.js';
10
+ const PACKAGE_NAME = '@thegitai/cli';
11
+ export function resolveInstallCommand(version, moduleDir = path.dirname(fileURLToPath(import.meta.url))) {
12
+ const spec = `${PACKAGE_NAME}@${version}`;
13
+ const location = moduleDir.replace(/\\/g, '/').toLowerCase();
14
+ if (location.includes('/pnpm/global/')) {
15
+ return { program: 'pnpm', args: ['add', '-g', spec] };
16
+ }
17
+ if (location.includes('/.bun/install/global/')) {
18
+ return { program: 'bun', args: ['add', '-g', spec] };
19
+ }
20
+ return { program: 'npm', args: ['install', '-g', spec] };
21
+ }
22
+ export function buildUpdateOffer({ currentVersion, latestVersion, moduleDir, }) {
23
+ if (!latestVersion)
24
+ return null;
25
+ if (currentVersion === UNKNOWN_CLI_VERSION)
26
+ return null;
27
+ const ordering = compareCliVersions(currentVersion, latestVersion);
28
+ if (ordering === null || ordering >= 0)
29
+ return null;
30
+ const { program, args } = resolveInstallCommand(latestVersion, moduleDir);
31
+ return {
32
+ currentVersion,
33
+ latestVersion,
34
+ program,
35
+ args,
36
+ display: [program, ...args].join(' '),
37
+ };
38
+ }
39
+ export const UPDATE_OPTIONS = ['update', 'skip'];
40
+ const UPDATE_KEY_HINT = '↑/↓ choose • Enter accepts • Ctrl+C quits';
41
+ function formatUpdateHeader(offer) {
42
+ return [
43
+ '',
44
+ ` ${chalk.yellow('✨ Update available')} — ${chalk.dim(offer.currentVersion)} → ${chalk.bold(offer.latestVersion)}`,
45
+ '',
46
+ ' TheGitAI works best with the latest version. TheGitAI may not work',
47
+ ' optimally if not updated.',
48
+ '',
49
+ ].join('\n');
50
+ }
51
+ export function formatUpdateOptions(offer, selectedIndex = 0) {
52
+ const rows = [
53
+ { note: `(runs \`${offer.display}\`)`, text: 'Update now' },
54
+ { note: '(Agent may not work optimally)', text: 'Skip' },
55
+ ];
56
+ return [
57
+ ...rows.map(({ note, text }, index) => index === selectedIndex
58
+ ? ` ${chalk.cyan(`› ${index + 1}. ${text}`)} ${chalk.dim(note)}`
59
+ : ` ${chalk.dim(`${index + 1}.`)} ${text} ${chalk.dim(note)}`),
60
+ '',
61
+ ` ${chalk.dim(UPDATE_KEY_HINT)}`,
62
+ '',
63
+ ].join('\n');
64
+ }
65
+ export function formatUpdateScreen(offer, selectedIndex = 0) {
66
+ return `${formatUpdateHeader(offer)}\n${formatUpdateOptions(offer, selectedIndex)}`;
67
+ }
68
+ export function readUpdateKey(key, selectedIndex) {
69
+ const name = key.name ?? '';
70
+ if (key.ctrl && (name === 'c' || name === 'd')) {
71
+ return { kind: 'answer', answer: 'cancel' };
72
+ }
73
+ if (name === 'return' || name === 'enter') {
74
+ return { kind: 'answer', answer: UPDATE_OPTIONS[selectedIndex] ?? 'update' };
75
+ }
76
+ if (name === 'up' || (name === 'tab' && key.sequence === '\u001b[Z')) {
77
+ return { kind: 'select', index: Math.max(selectedIndex - 1, 0) };
78
+ }
79
+ if (name === 'down' || name === 'tab') {
80
+ return { kind: 'select', index: Math.min(selectedIndex + 1, UPDATE_OPTIONS.length - 1) };
81
+ }
82
+ const digit = Number.parseInt(key.sequence ?? '', 10);
83
+ if (Number.isInteger(digit) && digit >= 1 && digit <= UPDATE_OPTIONS.length) {
84
+ return { kind: 'answer', answer: UPDATE_OPTIONS[digit - 1] };
85
+ }
86
+ return { kind: 'ignore' };
87
+ }
88
+ export function readUpdateChoice(answer) {
89
+ const value = answer.trim().toLowerCase();
90
+ if (value === '' || value === '1' || value === 'y' || value === 'yes') {
91
+ return 'update';
92
+ }
93
+ return 'skip';
94
+ }
95
+ export async function resolveUpdateAnswer(question) {
96
+ try {
97
+ return readUpdateChoice(await question());
98
+ }
99
+ catch (error) {
100
+ if (isAbortError(error))
101
+ return 'cancel';
102
+ throw error;
103
+ }
104
+ }
105
+ export function renderedRowCount(text, columns) {
106
+ const width = Math.max(1, columns);
107
+ const lines = text.split('\n');
108
+ if (lines[lines.length - 1] === '')
109
+ lines.pop();
110
+ return lines.reduce((rows, line) => {
111
+ const visible = [...line.replace(/\x1b\[[0-9;]*m/g, '')].length;
112
+ return rows + Math.max(1, Math.ceil(visible / width));
113
+ }, 0);
114
+ }
115
+ async function askUpdateChoiceWithKeys(offer) {
116
+ let selected = 0;
117
+ const show = (index) => {
118
+ const rows = renderedRowCount(`${formatUpdateOptions(offer, selected)}\n`, output.columns ?? 80);
119
+ selected = index;
120
+ output.write(`\u001b[${rows}A\u001b[0J${formatUpdateOptions(offer, selected)}\n`);
121
+ };
122
+ emitKeypressEvents(input);
123
+ const wasRaw = input.isRaw === true;
124
+ input.setRawMode(true);
125
+ input.resume();
126
+ try {
127
+ return await new Promise((resolve) => {
128
+ const onKeypress = (_text, key) => {
129
+ const action = readUpdateKey(key ?? {}, selected);
130
+ if (action.kind === 'ignore')
131
+ return;
132
+ if (action.kind === 'select') {
133
+ if (action.index !== selected)
134
+ show(action.index);
135
+ return;
136
+ }
137
+ const chosen = UPDATE_OPTIONS.indexOf(action.answer);
138
+ if (chosen >= 0 && chosen !== selected)
139
+ show(chosen);
140
+ input.off('keypress', onKeypress);
141
+ resolve(action.answer);
142
+ };
143
+ input.on('keypress', onKeypress);
144
+ });
145
+ }
146
+ finally {
147
+ if (!wasRaw)
148
+ input.setRawMode(false);
149
+ input.pause();
150
+ }
151
+ }
152
+ async function askUpdateChoice(offer) {
153
+ if (input.isTTY && output.isTTY)
154
+ return await askUpdateChoiceWithKeys(offer);
155
+ const rl = readline.createInterface({ input, output });
156
+ try {
157
+ return await resolveUpdateAnswer(() => rl.question(' ▸ '));
158
+ }
159
+ finally {
160
+ rl.close();
161
+ }
162
+ }
163
+ export async function runUpdate(offer) {
164
+ return await new Promise((resolve) => {
165
+ const child = spawn(offer.program, offer.args, {
166
+ stdio: 'inherit',
167
+ shell: process.platform === 'win32',
168
+ });
169
+ child.on('error', () => resolve(false));
170
+ child.on('close', (code) => resolve(code === 0));
171
+ });
172
+ }
173
+ export async function offerUpdateAtStartup({ currentVersion, latestVersion, ask = askUpdateChoice, install = runUpdate, write = (text) => {
174
+ process.stdout.write(text);
175
+ }, }) {
176
+ const offer = buildUpdateOffer({ currentVersion, latestVersion });
177
+ if (!offer)
178
+ return 'none';
179
+ write(`${formatUpdateScreen(offer)}\n`);
180
+ const answer = await ask(offer);
181
+ if (answer === 'cancel') {
182
+ write(`\n ${chalk.dim('Cancelled. Nothing was installed.')}\n\n`);
183
+ return 'cancelled';
184
+ }
185
+ if (answer === 'skip') {
186
+ write(`\n ${chalk.yellow(`Continuing on ${offer.currentVersion} — you'll be asked again next time.`)}\n\n`);
187
+ return 'skipped';
188
+ }
189
+ write(`\n ${chalk.dim(`$ ${offer.display}`)}\n\n`);
190
+ if (await install(offer)) {
191
+ write(`\n ${chalk.green(`✓ Updated to ${offer.latestVersion}.`)} Run ${chalk.bold('ai')} again to start.\n\n`);
192
+ return 'updated';
193
+ }
194
+ write([
195
+ '',
196
+ ` ${chalk.red('✖ The update did not complete.')} Run this yourself, then start ${chalk.bold('ai')} again:`,
197
+ '',
198
+ ` ${chalk.bold(offer.display)}`,
199
+ '',
200
+ ` ${chalk.dim(`Starting on ${offer.currentVersion} for now.`)}`,
201
+ '',
202
+ '',
203
+ ].join('\n'));
204
+ return 'failed';
205
+ }
package/dist/src/usage.js CHANGED
@@ -1,9 +1,12 @@
1
- const USAGE_WINDOWS = [
1
+ export const USAGE_WINDOWS = [
2
2
  ['5-hour', '5-hourly'],
3
3
  ['Weekly', 'weekly'],
4
4
  ['Monthly', 'monthly'],
5
5
  ];
6
- function formatUtcMinute(value) {
6
+ export function usageWindowLabel(key) {
7
+ return USAGE_WINDOWS.find(([, id]) => id === key)?.[0] ?? '';
8
+ }
9
+ export function formatUtcMinute(value) {
7
10
  if (!value)
8
11
  return 'no active usage';
9
12
  const date = new Date(value);
package/dist/src/utils.js CHANGED
@@ -1,3 +1,7 @@
1
+ export function isAbortError(error) {
2
+ const err = error;
3
+ return err?.name === 'AbortError' || err?.code === 'ABORT_ERR';
4
+ }
1
5
  export function cosineSimilarity(a, b) {
2
6
  let dot = 0;
3
7
  let magA = 0;
@@ -17,6 +21,15 @@ export function truncate(text, maxChars = 4000) {
17
21
  return text;
18
22
  return `${text.slice(0, maxChars)}\n... (truncated)`;
19
23
  }
24
+ export function singleLinePreview(text, maxChars) {
25
+ const collapsed = String(text ?? '')
26
+ .replace(/\n\.\.\. \(truncated\)\s*$/, '…')
27
+ .replace(/\s+/g, ' ')
28
+ .trim();
29
+ if (collapsed.length <= maxChars)
30
+ return collapsed;
31
+ return `${collapsed.slice(0, Math.max(0, maxChars - 1)).trimEnd()}…`;
32
+ }
20
33
  export function clampInteger(value, fallback, max) {
21
34
  const numeric = Number(value);
22
35
  if (!Number.isFinite(numeric) || numeric <= 0) {
@@ -2,7 +2,7 @@ import { readFileSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  const PACKAGE_NAME = '@thegitai/cli';
5
- const UNKNOWN_VERSION = '0.0.0';
5
+ export const UNKNOWN_CLI_VERSION = '0.0.0';
6
6
  export function getCliVersion() {
7
7
  let dir = path.dirname(fileURLToPath(import.meta.url));
8
8
  for (let depth = 0; depth < 6; depth++) {
@@ -19,7 +19,7 @@ export function getCliVersion() {
19
19
  break;
20
20
  dir = parent;
21
21
  }
22
- return UNKNOWN_VERSION;
22
+ return UNKNOWN_CLI_VERSION;
23
23
  }
24
24
  export function getPlatformTag() {
25
25
  return `${process.platform}-${process.arch}`;
@@ -27,3 +27,53 @@ export function getPlatformTag() {
27
27
  export function formatVersionLine() {
28
28
  return `ai ${getCliVersion()} (${getPlatformTag()}, node ${process.version})`;
29
29
  }
30
+ const VERSION_PATTERN = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
31
+ function parseVersion(value) {
32
+ const match = VERSION_PATTERN.exec(String(value ?? '').trim());
33
+ if (!match)
34
+ return null;
35
+ return {
36
+ core: [Number(match[1]), Number(match[2]), Number(match[3])],
37
+ prerelease: match[4] ? match[4].split('.') : [],
38
+ };
39
+ }
40
+ function comparePrereleaseIdentifier(left, right) {
41
+ const leftNumeric = /^\d+$/.test(left);
42
+ const rightNumeric = /^\d+$/.test(right);
43
+ if (leftNumeric && rightNumeric) {
44
+ const a = Number(left);
45
+ const b = Number(right);
46
+ return a === b ? 0 : a < b ? -1 : 1;
47
+ }
48
+ if (leftNumeric !== rightNumeric)
49
+ return leftNumeric ? -1 : 1;
50
+ return left === right ? 0 : left < right ? -1 : 1;
51
+ }
52
+ export function isReadableCliVersion(value) {
53
+ return parseVersion(value) !== null;
54
+ }
55
+ export function compareCliVersions(left, right) {
56
+ const a = parseVersion(left);
57
+ const b = parseVersion(right);
58
+ if (!a || !b)
59
+ return null;
60
+ for (let index = 0; index < 3; index++) {
61
+ if (a.core[index] !== b.core[index]) {
62
+ return a.core[index] < b.core[index] ? -1 : 1;
63
+ }
64
+ }
65
+ if (!a.prerelease.length || !b.prerelease.length) {
66
+ if (a.prerelease.length === b.prerelease.length)
67
+ return 0;
68
+ return a.prerelease.length ? -1 : 1;
69
+ }
70
+ const shared = Math.min(a.prerelease.length, b.prerelease.length);
71
+ for (let index = 0; index < shared; index++) {
72
+ const ordering = comparePrereleaseIdentifier(a.prerelease[index], b.prerelease[index]);
73
+ if (ordering !== 0)
74
+ return ordering;
75
+ }
76
+ if (a.prerelease.length === b.prerelease.length)
77
+ return 0;
78
+ return a.prerelease.length < b.prerelease.length ? -1 : 1;
79
+ }
package/package.json CHANGED
@@ -1,12 +1,18 @@
1
1
  {
2
2
  "name": "@thegitai/cli",
3
- "version": "1.0.0-preview.5",
4
- "description": "TheGitAI is an AI coding agent for your terminal. It indexes your repository, writes and edits files, runs commands, and builds features with you.",
3
+ "version": "1.0.0-preview.51",
4
+ "description": "TheGitAI is an agentic AI coding tool for your terminal. It reads and searches your repository, writes and edits files, runs your tests, and verifies the change before handing it back.",
5
5
  "keywords": [
6
- "ai",
6
+ "agentic-ai",
7
7
  "ai-coding-agent",
8
8
  "coding-agent",
9
+ "ai-agent",
10
+ "agentic-coding",
11
+ "autonomous-agent",
12
+ "ai-pair-programming",
9
13
  "coding-assistant",
14
+ "code-assistant",
15
+ "ai",
10
16
  "terminal",
11
17
  "cli",
12
18
  "developer-tools"
@@ -14,14 +20,16 @@
14
20
  "license": "SEE LICENSE IN LICENSE",
15
21
  "homepage": "https://thegit.ai",
16
22
  "bugs": {
23
+ "url": "https://github.com/thegitai/thegitai-cli/issues",
17
24
  "email": "support@thegit.ai"
18
25
  },
19
26
  "type": "module",
20
27
  "engines": {
21
- "node": ">=24"
28
+ "node": ">=22"
22
29
  },
23
30
  "bin": {
24
- "ai": "./dist/bin/ai.js"
31
+ "ai": "./dist/bin/ai.js",
32
+ "thegitai": "./dist/bin/ai.js"
25
33
  },
26
34
  "files": [
27
35
  "dist",
@@ -37,12 +45,17 @@
37
45
  "@lydell/node-pty-linux-x64": "1.1.0",
38
46
  "@lydell/node-pty-win32-arm64": "1.1.0",
39
47
  "@lydell/node-pty-win32-x64": "1.1.0",
40
- "@thegitai/tui-darwin-arm64": "1.0.0-preview.5",
41
- "@thegitai/tui-darwin-x64": "1.0.0-preview.5",
42
- "@thegitai/tui-linux-x64": "1.0.0-preview.5",
43
- "@thegitai/tui-win32-x64": "1.0.0-preview.5",
48
+ "@thegitai/tui-darwin-arm64": "1.0.0-preview.51",
49
+ "@thegitai/tui-darwin-x64": "1.0.0-preview.51",
50
+ "@thegitai/tui-linux-arm64": "1.0.0-preview.51",
51
+ "@thegitai/tui-linux-x64": "1.0.0-preview.51",
52
+ "@thegitai/tui-win32-x64": "1.0.0-preview.51",
44
53
  "@vscode/ripgrep": "1.18.0"
45
54
  },
55
+ "repository": {
56
+ "type": "git",
57
+ "url": "git+https://github.com/thegitai/thegitai-cli.git"
58
+ },
46
59
  "publishConfig": {
47
60
  "access": "public"
48
61
  }
@@ -1,233 +0,0 @@
1
- import { existsSync, statSync } from 'node:fs';
2
- import path from 'node:path';
3
- import { listProjectFiles, scanFiles, shouldIgnorePath, } from './scanner.js';
4
- import { truncate } from './utils.js';
5
- function normalizeProjectFilePath(rootDir, filePath) {
6
- const resolvedRoot = path.resolve(rootDir);
7
- const resolvedFile = path.resolve(resolvedRoot, filePath);
8
- const relative = path.relative(resolvedRoot, resolvedFile);
9
- if (relative.startsWith('..') || path.isAbsolute(relative)) {
10
- return null;
11
- }
12
- return relative.replace(/\\/g, '/');
13
- }
14
- function getFileSignature(rootDir, relPath) {
15
- try {
16
- const stat = statSync(path.join(rootDir, relPath));
17
- if (!stat.isFile()) {
18
- return null;
19
- }
20
- return `${stat.size}:${stat.mtimeMs}`;
21
- }
22
- catch {
23
- return null;
24
- }
25
- }
26
- function setChunksForFile(index, relPath, chunks) {
27
- if (chunks.length === 0) {
28
- index.chunksByFile.delete(relPath);
29
- index.fileSignatures.delete(relPath);
30
- return;
31
- }
32
- index.chunksByFile.set(relPath, chunks);
33
- const signature = getFileSignature(index.rootDir, relPath);
34
- if (signature) {
35
- index.fileSignatures.set(relPath, signature);
36
- }
37
- }
38
- function removeFile(index, relPath) {
39
- index.chunksByFile.delete(relPath);
40
- index.fileSignatures.delete(relPath);
41
- }
42
- function countIndexedChunks(index) {
43
- return Array.from(index.chunksByFile.values()).reduce((sum, chunks) => sum + chunks.length, 0);
44
- }
45
- async function initializeIndex(index) {
46
- if (index.initialized) {
47
- return countIndexedChunks(index);
48
- }
49
- if (!index._initializing) {
50
- index._initializing = scanProjectIntoIndex(index).finally(() => {
51
- index._initializing = null;
52
- });
53
- }
54
- return index._initializing;
55
- }
56
- async function scanProjectIntoIndex(index) {
57
- const files = listProjectFiles(index.rootDir);
58
- const chunks = await scanFiles(index.rootDir, files);
59
- index.fileSignatures.clear();
60
- index.chunksByFile.clear();
61
- for (const filePath of files) {
62
- const signature = getFileSignature(index.rootDir, filePath);
63
- if (signature) {
64
- index.fileSignatures.set(filePath, signature);
65
- }
66
- }
67
- for (const chunk of chunks) {
68
- const current = index.chunksByFile.get(chunk.filePath) ?? [];
69
- current.push(chunk);
70
- index.chunksByFile.set(chunk.filePath, current);
71
- }
72
- index.initialized = true;
73
- index.onStatus?.(`Local code index ready: ${index.fileSignatures.size.toLocaleString()} files.`);
74
- return chunks.length;
75
- }
76
- function queryTerms(query) {
77
- const parts = query
78
- .toLowerCase()
79
- .split(/[^a-z0-9_./-]+/i)
80
- .map((part) => part.trim())
81
- .filter(Boolean);
82
- return [...new Set(parts)];
83
- }
84
- function countOccurrences(haystack, needle) {
85
- if (!needle)
86
- return 0;
87
- let count = 0;
88
- let cursor = 0;
89
- while (cursor < haystack.length) {
90
- const index = haystack.indexOf(needle, cursor);
91
- if (index === -1)
92
- break;
93
- count += 1;
94
- cursor = index + needle.length;
95
- }
96
- return count;
97
- }
98
- function scoreChunk(terms, chunk) {
99
- const filePath = chunk.filePath.toLowerCase();
100
- const content = chunk.content.toLowerCase();
101
- let score = 0;
102
- for (const term of terms) {
103
- if (term.length < 2)
104
- continue;
105
- score += countOccurrences(filePath, term) * 3;
106
- score += countOccurrences(content, term);
107
- if (chunk.label?.toLowerCase().includes(term)) {
108
- score += 2;
109
- }
110
- }
111
- return score;
112
- }
113
- function flattenChunks(index) {
114
- return Array.from(index.chunksByFile.values()).flat();
115
- }
116
- export function createIndex({ rootDir, onStatus = null, onContextLog = null, }) {
117
- return {
118
- rootDir: path.resolve(rootDir),
119
- initialized: false,
120
- _initializing: null,
121
- fileSignatures: new Map(),
122
- chunksByFile: new Map(),
123
- onStatus,
124
- onContextLog,
125
- };
126
- }
127
- export async function searchIndex(index, query, limit = 10) {
128
- await initializeIndex(index);
129
- const terms = queryTerms(query);
130
- if (terms.length === 0) {
131
- return { results: [], retrievalTokensUsed: 0 };
132
- }
133
- const scored = flattenChunks(index)
134
- .map((chunk) => ({
135
- ...chunk,
136
- score: scoreChunk(terms, chunk),
137
- }))
138
- .filter((chunk) => chunk.score > 0)
139
- .sort((a, b) => b.score - a.score || a.filePath.localeCompare(b.filePath))
140
- .slice(0, Math.max(1, Math.min(limit, 20)));
141
- if (scored.length) {
142
- const visible = scored
143
- .slice(0, 6)
144
- .map((chunk) => `${chunk.filePath}:${chunk.startLine}-${chunk.endLine}`)
145
- .join(', ');
146
- index.onContextLog?.(`Local search hit: ${visible}`);
147
- }
148
- return {
149
- results: scored,
150
- retrievalTokensUsed: 0,
151
- };
152
- }
153
- export function formatIndexResults(results) {
154
- return results.map((chunk) => ({
155
- ...chunk,
156
- content: truncate(chunk.content, 1200),
157
- }));
158
- }
159
- export function listIndexFiles(index) {
160
- return [...index.fileSignatures.keys()].sort();
161
- }
162
- export async function upsertIndexFile(index, filePath) {
163
- if (!index.initialized) {
164
- return { indexedChunks: 0, retrievalTokensUsed: 0 };
165
- }
166
- const relPath = normalizeProjectFilePath(index.rootDir, filePath);
167
- if (!relPath || shouldIgnorePath(relPath)) {
168
- return { indexedChunks: 0, retrievalTokensUsed: 0 };
169
- }
170
- const chunks = existsSync(path.join(index.rootDir, relPath))
171
- ? await scanFiles(index.rootDir, [relPath])
172
- : [];
173
- setChunksForFile(index, relPath, chunks);
174
- return {
175
- indexedChunks: chunks.length,
176
- retrievalTokensUsed: 0,
177
- };
178
- }
179
- export async function removeIndexFile(index, filePath) {
180
- const relPath = normalizeProjectFilePath(index.rootDir, filePath);
181
- if (!relPath)
182
- return;
183
- removeFile(index, relPath);
184
- }
185
- export async function syncIndexFromDisk(index) {
186
- if (!index.initialized) {
187
- const indexedChunks = await initializeIndex(index);
188
- return {
189
- added: index.fileSignatures.size,
190
- modified: 0,
191
- removed: 0,
192
- indexedChunks,
193
- retrievalTokensUsed: 0,
194
- };
195
- }
196
- const currentFiles = new Set(listProjectFiles(index.rootDir));
197
- let added = 0;
198
- let modified = 0;
199
- let removed = 0;
200
- let indexedChunks = 0;
201
- for (const existing of [...index.fileSignatures.keys()]) {
202
- if (!currentFiles.has(existing)) {
203
- removeFile(index, existing);
204
- removed += 1;
205
- }
206
- }
207
- for (const relPath of currentFiles) {
208
- const nextSignature = getFileSignature(index.rootDir, relPath);
209
- if (!nextSignature)
210
- continue;
211
- const previousSignature = index.fileSignatures.get(relPath);
212
- if (!previousSignature) {
213
- const chunks = await scanFiles(index.rootDir, [relPath]);
214
- setChunksForFile(index, relPath, chunks);
215
- added += 1;
216
- indexedChunks += chunks.length;
217
- continue;
218
- }
219
- if (previousSignature !== nextSignature) {
220
- const chunks = await scanFiles(index.rootDir, [relPath]);
221
- setChunksForFile(index, relPath, chunks);
222
- modified += 1;
223
- indexedChunks += chunks.length;
224
- }
225
- }
226
- return {
227
- added,
228
- modified,
229
- removed,
230
- indexedChunks,
231
- retrievalTokensUsed: 0,
232
- };
233
- }