context-mcp-server 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +464 -0
- package/codegraph/__init__.py +0 -0
- package/codegraph/__main__.py +24 -0
- package/codegraph/__pycache__/__init__.cpython-313.pyc +0 -0
- package/codegraph/__pycache__/__main__.cpython-313.pyc +0 -0
- package/codegraph/__pycache__/cache.cpython-313.pyc +0 -0
- package/codegraph/__pycache__/config.cpython-313.pyc +0 -0
- package/codegraph/__pycache__/report.cpython-313.pyc +0 -0
- package/codegraph/__pycache__/scanner.cpython-313.pyc +0 -0
- package/codegraph/__pycache__/server.cpython-313.pyc +0 -0
- package/codegraph/cache.py +137 -0
- package/codegraph/config.py +31 -0
- package/codegraph/extractors/__init__.py +0 -0
- package/codegraph/extractors/__pycache__/__init__.cpython-313.pyc +0 -0
- package/codegraph/extractors/__pycache__/ast_extractor.cpython-313.pyc +0 -0
- package/codegraph/extractors/__pycache__/audio_extractor.cpython-313.pyc +0 -0
- package/codegraph/extractors/__pycache__/doc_extractor.cpython-313.pyc +0 -0
- package/codegraph/extractors/__pycache__/image_extractor.cpython-313.pyc +0 -0
- package/codegraph/extractors/ast_extractor.py +222 -0
- package/codegraph/extractors/audio_extractor.py +8 -0
- package/codegraph/extractors/doc_extractor.py +34 -0
- package/codegraph/extractors/image_extractor.py +26 -0
- package/codegraph/graph/__init__.py +0 -0
- package/codegraph/graph/__pycache__/__init__.cpython-313.pyc +0 -0
- package/codegraph/graph/__pycache__/builder.cpython-313.pyc +0 -0
- package/codegraph/graph/__pycache__/clustering.cpython-313.pyc +0 -0
- package/codegraph/graph/__pycache__/query.cpython-313.pyc +0 -0
- package/codegraph/graph/builder.py +145 -0
- package/codegraph/graph/clustering.py +40 -0
- package/codegraph/graph/query.py +283 -0
- package/codegraph/report.py +115 -0
- package/codegraph/scanner.py +92 -0
- package/codegraph/server.py +514 -0
- package/package.json +62 -0
- package/src/cli.js +1010 -0
- package/src/config.js +89 -0
- package/src/db.js +786 -0
- package/src/guard.js +20 -0
- package/src/hooks/autoContext.js +17 -0
- package/src/hooks/autoLink.js +7 -0
- package/src/http.js +765 -0
- package/src/index.js +47 -0
- package/src/search.js +50 -0
- package/src/server.js +80 -0
- package/src/summarizer.js +124 -0
- package/src/templates/AGENTS.md +76 -0
- package/src/templates/CLAUDE.md +94 -0
- package/src/templates/GEMINI.md +76 -0
- package/src/templates/cursor-rules.mdc +41 -0
- package/src/templates/windsurf-rules.md +35 -0
- package/src/tools/codegraph.js +215 -0
- package/src/tools/context.js +188 -0
- package/src/tools/discussion.js +123 -0
- package/src/tools/errorCheck.js +65 -0
- package/src/tools/fileTools.js +185 -0
- package/src/tools/gitTools.js +259 -0
- package/src/tools/search.js +55 -0
- package/src/vector.js +153 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { readdirSync, statSync, readFileSync, writeFileSync, mkdirSync, existsSync, rmSync, openSync, writeSync, fsyncSync, closeSync, renameSync } from 'node:fs';
|
|
2
|
+
import { join as pathJoin, resolve as pathResolve, dirname } from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { saveAutoContext } from '../hooks/autoContext.js';
|
|
5
|
+
import { guardPath } from '../guard.js';
|
|
6
|
+
|
|
7
|
+
// Atomic write: write to .tmp → fsync → rename
|
|
8
|
+
// Guarantees the target is never left in a partial state if process dies mid-write
|
|
9
|
+
function atomicWrite(filePath, data) {
|
|
10
|
+
const tmp = filePath + '.patch-' + randomUUID();
|
|
11
|
+
try {
|
|
12
|
+
const fd = openSync(tmp, 'w');
|
|
13
|
+
writeSync(fd, data, 0, 'utf8');
|
|
14
|
+
fsyncSync(fd);
|
|
15
|
+
closeSync(fd);
|
|
16
|
+
renameSync(tmp, filePath);
|
|
17
|
+
} catch (err) {
|
|
18
|
+
try { rmSync(tmp, { force: true }); } catch {}
|
|
19
|
+
throw err;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const definitions = [
|
|
24
|
+
{
|
|
25
|
+
name: 'create_dir',
|
|
26
|
+
description: 'Create a directory (and any missing parent directories). Safe to call if already exists.',
|
|
27
|
+
inputSchema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
|
|
28
|
+
outputSchema: { type: 'object', properties: { path: { type: 'string' }, existed: { type: 'boolean' }, message: { type: 'string' } } },
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
name: 'list_dir',
|
|
32
|
+
description: 'List the contents of a local directory.',
|
|
33
|
+
inputSchema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
|
|
34
|
+
outputSchema: { type: 'object', properties: { directory: { type: 'string' }, items: { type: 'array' } } },
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
name: 'read_file',
|
|
38
|
+
description: 'Read the text contents of a local file.',
|
|
39
|
+
inputSchema: { type: 'object', properties: { path: { type: 'string' } }, required: ['path'] },
|
|
40
|
+
outputSchema: { type: 'object', properties: { file: { type: 'string' }, content: { type: 'string' } } },
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
name: 'write_file',
|
|
44
|
+
description: 'Create a new file or overwrite an existing file.',
|
|
45
|
+
inputSchema: { type: 'object', properties: { path: { type: 'string' }, content: { type: 'string' } }, required: ['path', 'content'] },
|
|
46
|
+
outputSchema: { type: 'object', properties: { file: { type: 'string' }, message: { type: 'string' } } },
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: 'patch_file',
|
|
50
|
+
description:
|
|
51
|
+
`Apply targeted string replacement(s) to a file.\n` +
|
|
52
|
+
`Single edit: pass old_str + new_str.\n` +
|
|
53
|
+
`Multi edit: pass edits:[{old_str, new_str, description?}] — atomic.\n` +
|
|
54
|
+
`Use dry_run:true to validate without writing.`,
|
|
55
|
+
inputSchema: {
|
|
56
|
+
type: 'object',
|
|
57
|
+
properties: {
|
|
58
|
+
path: { type: 'string' },
|
|
59
|
+
old_str: { type: 'string' },
|
|
60
|
+
new_str: { type: 'string' },
|
|
61
|
+
edits: { type: 'array', items: { type: 'object' } },
|
|
62
|
+
dry_run: { type: 'boolean' },
|
|
63
|
+
backup: { type: 'boolean' },
|
|
64
|
+
},
|
|
65
|
+
required: ['path'],
|
|
66
|
+
},
|
|
67
|
+
outputSchema: { type: 'object' },
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: 'delete_file',
|
|
71
|
+
description: 'Delete a local file. Pass recursive:true to delete a directory and its contents.',
|
|
72
|
+
inputSchema: { type: 'object', properties: { path: { type: 'string' }, recursive: { type: 'boolean', description: 'Required to delete a directory. Defaults to false.' } }, required: ['path'] },
|
|
73
|
+
outputSchema: { type: 'object', properties: { path: { type: 'string' }, message: { type: 'string' } } },
|
|
74
|
+
},
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
export async function handle(name, args, state) {
|
|
78
|
+
switch (name) {
|
|
79
|
+
case 'create_dir': {
|
|
80
|
+
const dirPath = guardPath(args.path, state.projectRootPath);
|
|
81
|
+
const existed = existsSync(dirPath);
|
|
82
|
+
mkdirSync(dirPath, { recursive: true });
|
|
83
|
+
return { path: dirPath, existed, message: existed ? `Already exists: ${dirPath}` : `Created: ${dirPath}` };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
case 'list_dir': {
|
|
87
|
+
const dirPath = guardPath(args.path || '.', state.projectRootPath);
|
|
88
|
+
const items = readdirSync(dirPath).map(name => {
|
|
89
|
+
const full = pathJoin(dirPath, name);
|
|
90
|
+
try { const s = statSync(full); return { name, type: s.isDirectory() ? 'dir' : 'file', size: s.size }; }
|
|
91
|
+
catch { return { name, type: 'unknown' }; }
|
|
92
|
+
}).sort((a, b) => a.type === b.type ? a.name.localeCompare(b.name) : a.type === 'dir' ? -1 : 1);
|
|
93
|
+
return { directory: dirPath, items };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
case 'read_file': {
|
|
97
|
+
const filePath = guardPath(args.path, state.projectRootPath);
|
|
98
|
+
return { file: filePath, content: readFileSync(filePath, 'utf8') };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
case 'write_file': {
|
|
102
|
+
const filePath = guardPath(args.path, state.projectRootPath);
|
|
103
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
104
|
+
atomicWrite(filePath, args.content);
|
|
105
|
+
saveAutoContext({
|
|
106
|
+
title: `wrote ${filePath.split(/[\\/]/).pop()}`,
|
|
107
|
+
content: `write_file: created/overwrote ${filePath}`,
|
|
108
|
+
type: 'code',
|
|
109
|
+
files: [{ path: filePath, action: 'modified' }],
|
|
110
|
+
tags: ['file-write'],
|
|
111
|
+
state,
|
|
112
|
+
});
|
|
113
|
+
return { file: filePath, message: `Successfully wrote: ${filePath}` };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
case 'patch_file': {
|
|
117
|
+
const filePath = guardPath(args.path, state.projectRootPath);
|
|
118
|
+
if (!args.old_str && !args.edits?.length) throw new Error('old_str or edits[] required');
|
|
119
|
+
|
|
120
|
+
const raw = readFileSync(filePath, 'utf8');
|
|
121
|
+
const hasCRLF = raw.includes('\r\n');
|
|
122
|
+
const original = hasCRLF ? raw.replace(/\r\n/g, '\n') : raw;
|
|
123
|
+
|
|
124
|
+
const editList = args.edits?.length
|
|
125
|
+
? args.edits.map((e, i) => ({ old_str: e.old_str.replace(/\r\n/g, '\n'), new_str: (e.new_str ?? '').replace(/\r\n/g, '\n'), description: e.description || `edit ${i + 1}` }))
|
|
126
|
+
: [{ old_str: args.old_str.replace(/\r\n/g, '\n'), new_str: (args.new_str ?? '').replace(/\r\n/g, '\n'), description: 'edit 1' }];
|
|
127
|
+
|
|
128
|
+
const resolved = editList.map((edit, i) => {
|
|
129
|
+
const occ = original.split(edit.old_str).length - 1;
|
|
130
|
+
if (occ === 0) throw new Error(`patch_file edit ${i + 1}: old_str not found in ${filePath}`);
|
|
131
|
+
if (occ > 1) throw new Error(`patch_file edit ${i + 1}: old_str matches ${occ} times — must match exactly once`);
|
|
132
|
+
const pos = original.indexOf(edit.old_str);
|
|
133
|
+
return { ...edit, pos, end: pos + edit.old_str.length, index: i + 1 };
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const sorted = [...resolved].sort((a, b) => a.pos - b.pos);
|
|
137
|
+
for (let i = 0; i < sorted.length - 1; i++) {
|
|
138
|
+
if (sorted[i].end > sorted[i + 1].pos) throw new Error(`patch_file: edit ${sorted[i].index} and ${sorted[i + 1].index} overlap`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (args.dry_run) {
|
|
142
|
+
return { dry_run: true, file: filePath, edits: sorted.map(e => ({ index: e.index, description: e.description, match: true, position: e.pos })), message: `Dry run: all ${sorted.length} edit(s) matched.` };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (args.backup) atomicWrite(filePath + '.bak', raw);
|
|
146
|
+
|
|
147
|
+
let patched = original;
|
|
148
|
+
for (const edit of [...sorted].reverse()) {
|
|
149
|
+
patched = patched.slice(0, edit.pos) + edit.new_str + patched.slice(edit.end);
|
|
150
|
+
}
|
|
151
|
+
if (hasCRLF) patched = patched.replace(/\n/g, '\r\n');
|
|
152
|
+
atomicWrite(filePath, patched);
|
|
153
|
+
|
|
154
|
+
const totalRemoved = sorted.reduce((s, e) => s + e.old_str.split('\n').length, 0);
|
|
155
|
+
const totalAdded = sorted.reduce((s, e) => s + e.new_str.split('\n').length, 0);
|
|
156
|
+
saveAutoContext({
|
|
157
|
+
title: `patched ${filePath.split(/[\\/]/).pop()}${sorted.length > 1 ? ` (${sorted.length} edits)` : ''}`,
|
|
158
|
+
content: `patch_file: ${sorted.length} edit(s) in ${filePath}\n` +
|
|
159
|
+
sorted.map(e => ` ${e.description}: -${e.old_str.split('\n').length} +${e.new_str.split('\n').length} lines`).join('\n'),
|
|
160
|
+
type: 'code',
|
|
161
|
+
files: [{ path: filePath, action: 'modified' }],
|
|
162
|
+
tags: ['file-patch'],
|
|
163
|
+
state,
|
|
164
|
+
});
|
|
165
|
+
return { success: true, file: filePath, edits_applied: sorted.length,
|
|
166
|
+
edits: sorted.map(e => ({ index: e.index, description: e.description, lines_removed: e.old_str.split('\n').length, lines_added: e.new_str.split('\n').length })),
|
|
167
|
+
total_lines_removed: totalRemoved, total_lines_added: totalAdded,
|
|
168
|
+
message: `Patched ${filePath.split(/[\\/]/).pop()}: ${sorted.length} edit(s), -${totalRemoved} +${totalAdded} lines.`,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
case 'delete_file': {
|
|
173
|
+
const filePath = guardPath(args.path, state.projectRootPath);
|
|
174
|
+
const isDir = existsSync(filePath) && statSync(filePath).isDirectory();
|
|
175
|
+
if (isDir && !args.recursive) {
|
|
176
|
+
throw new Error(`"${filePath}" is a directory. Pass recursive:true to delete it and its contents.`);
|
|
177
|
+
}
|
|
178
|
+
rmSync(filePath, { recursive: !!args.recursive });
|
|
179
|
+
return { path: filePath, message: `Deleted: ${filePath}` };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
default:
|
|
183
|
+
throw new Error(`Unknown file tool: ${name}`);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { resolve as pathResolve } from 'node:path';
|
|
3
|
+
import { saveAutoContext } from '../hooks/autoContext.js';
|
|
4
|
+
import { guardPath } from '../guard.js';
|
|
5
|
+
|
|
6
|
+
const MAX_DIFF_LENGTH = 10000;
|
|
7
|
+
|
|
8
|
+
function runGit(argArr, cwd) {
|
|
9
|
+
const dir = cwd || process.cwd();
|
|
10
|
+
try {
|
|
11
|
+
return execFileSync('git', argArr, { cwd: dir, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
12
|
+
} catch (err) {
|
|
13
|
+
const msg = ((err.stderr || '') + (err.stdout || '') || err.message || '').trim();
|
|
14
|
+
throw new Error(`git ${argArr[0]} failed: ${msg}`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function resolveCwd(args, state) {
|
|
19
|
+
const raw = args.cwd ? pathResolve(args.cwd) : process.cwd();
|
|
20
|
+
// If a project root is configured, validate cwd is within it
|
|
21
|
+
if (state.projectRootPath) return guardPath(raw, state.projectRootPath);
|
|
22
|
+
return raw;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const definitions = [
|
|
26
|
+
{
|
|
27
|
+
name: 'git_status',
|
|
28
|
+
description: 'Show working tree status — current branch, staged, unstaged, and untracked files.',
|
|
29
|
+
inputSchema: { type: 'object', properties: { cwd: { type: 'string' } } },
|
|
30
|
+
outputSchema: { type: 'object', properties: { branch: { type: 'string' }, clean: { type: 'boolean' }, staged: { type: 'array' }, unstaged: { type: 'array' }, untracked: { type: 'array' } } },
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: 'git_diff',
|
|
34
|
+
description: 'Show file changes. Use staged:true for cached diff. Optionally scope to a path.',
|
|
35
|
+
inputSchema: { type: 'object', properties: { staged: { type: 'boolean' }, path: { type: 'string' }, cwd: { type: 'string' } } },
|
|
36
|
+
outputSchema: { type: 'object', properties: { diff: { type: 'string' }, staged: { type: 'boolean' } } },
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
name: 'git_log',
|
|
40
|
+
description: 'Show recent commit history — hash, author, date, message.',
|
|
41
|
+
inputSchema: { type: 'object', properties: { limit: { type: 'number' }, path: { type: 'string' }, cwd: { type: 'string' } } },
|
|
42
|
+
outputSchema: { type: 'object', properties: { commits: { type: 'array' }, count: { type: 'number' } } },
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
name: 'git_add',
|
|
46
|
+
description: 'Stage files for commit. Pass paths:["."] to stage everything.',
|
|
47
|
+
inputSchema: { type: 'object', properties: { paths: { type: 'array', items: { type: 'string' } }, cwd: { type: 'string' } }, required: ['paths'] },
|
|
48
|
+
outputSchema: { type: 'object', properties: { success: { type: 'boolean' }, staged: { type: 'array' }, message: { type: 'string' } } },
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
name: 'git_commit',
|
|
52
|
+
description: 'Commit staged changes. Set all:true to auto-stage tracked modified files first. Auto-saves context entry.',
|
|
53
|
+
inputSchema: { type: 'object', properties: { message: { type: 'string' }, all: { type: 'boolean' }, cwd: { type: 'string' } }, required: ['message'] },
|
|
54
|
+
outputSchema: { type: 'object', properties: { success: { type: 'boolean' }, hash: { type: 'string' }, branch: { type: 'string' }, message: { type: 'string' }, files: { type: 'array' } } },
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
name: 'git_push',
|
|
58
|
+
description: 'Push current branch to remote.',
|
|
59
|
+
inputSchema: { type: 'object', properties: { remote: { type: 'string' }, branch: { type: 'string' }, cwd: { type: 'string' } } },
|
|
60
|
+
outputSchema: { type: 'object', properties: { success: { type: 'boolean' }, remote: { type: 'string' }, branch: { type: 'string' }, output: { type: 'string' } } },
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
name: 'git_pull',
|
|
64
|
+
description: 'Pull from remote and merge into current branch.',
|
|
65
|
+
inputSchema: { type: 'object', properties: { remote: { type: 'string' }, branch: { type: 'string' }, cwd: { type: 'string' } } },
|
|
66
|
+
outputSchema: { type: 'object', properties: { success: { type: 'boolean' }, remote: { type: 'string' }, output: { type: 'string' } } },
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
name: 'git_branch',
|
|
70
|
+
description: 'List, create, or checkout branches.',
|
|
71
|
+
inputSchema: { type: 'object', properties: { action: { type: 'string', enum: ['list', 'create', 'checkout'] }, name: { type: 'string' }, cwd: { type: 'string' } } },
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
name: 'git_stash',
|
|
75
|
+
description: 'Stash or restore work-in-progress changes.',
|
|
76
|
+
inputSchema: { type: 'object', properties: { action: { type: 'string', enum: ['save', 'pop', 'list', 'drop'] }, message: { type: 'string' }, ref: { type: 'string' }, cwd: { type: 'string' } } },
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
name: 'git_reset',
|
|
80
|
+
description: 'Unstage files or reset HEAD. Use mode:file + path to restore a single file.',
|
|
81
|
+
inputSchema: { type: 'object', properties: { mode: { type: 'string', enum: ['soft', 'mixed', 'hard', 'file'] }, path: { type: 'string' }, ref: { type: 'string' }, cwd: { type: 'string' } } },
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
name: 'git_show',
|
|
85
|
+
description: 'Show full diff and metadata for a specific commit.',
|
|
86
|
+
inputSchema: { type: 'object', properties: { ref: { type: 'string' }, cwd: { type: 'string' } } },
|
|
87
|
+
},
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
export async function handle(name, args, state) {
|
|
91
|
+
switch (name) {
|
|
92
|
+
case 'git_status': {
|
|
93
|
+
const cwd = resolveCwd(args, state);
|
|
94
|
+
const porcelain = runGit(['status', '--porcelain'], cwd);
|
|
95
|
+
const branch = runGit(['rev-parse', '--abbrev-ref', 'HEAD'], cwd);
|
|
96
|
+
const lines = porcelain ? porcelain.split('\n').filter(Boolean) : [];
|
|
97
|
+
return {
|
|
98
|
+
branch,
|
|
99
|
+
clean: lines.length === 0,
|
|
100
|
+
staged: lines.filter(l => l[0] !== ' ' && l[0] !== '?').map(l => l.slice(3)),
|
|
101
|
+
unstaged: lines.filter(l => l[1] === 'M' || l[1] === 'D').map(l => l.slice(3)),
|
|
102
|
+
untracked: lines.filter(l => l.startsWith('??')).map(l => l.slice(3)),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
case 'git_diff': {
|
|
107
|
+
const cwd = resolveCwd(args, state);
|
|
108
|
+
const argv = ['diff'];
|
|
109
|
+
if (args.staged) argv.push('--cached');
|
|
110
|
+
if (args.path) { argv.push('--'); argv.push(guardPath(args.path, state.projectRootPath || cwd)); }
|
|
111
|
+
let diff = runGit(argv, cwd);
|
|
112
|
+
if (diff.length > MAX_DIFF_LENGTH) diff = diff.slice(0, MAX_DIFF_LENGTH) + '\n…(truncated)';
|
|
113
|
+
return { diff: diff || '(no changes)', staged: !!args.staged };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
case 'git_log': {
|
|
117
|
+
const cwd = resolveCwd(args, state);
|
|
118
|
+
const limit = Math.min(Math.max(1, parseInt(args.limit) || 10), 200);
|
|
119
|
+
const argv = ['log', '--pretty=format:%H\t%an\t%ad\t%s', '--date=short', `-n${limit}`];
|
|
120
|
+
if (args.path) { argv.push('--'); argv.push(guardPath(args.path, state.projectRootPath || cwd)); }
|
|
121
|
+
const raw = runGit(argv, cwd);
|
|
122
|
+
const commits = raw
|
|
123
|
+
? raw.split('\n').filter(Boolean).map(line => {
|
|
124
|
+
const [hash, author, date, ...msg] = line.split('\t');
|
|
125
|
+
return { hash: hash.slice(0, 8), author, date, message: msg.join('\t') };
|
|
126
|
+
})
|
|
127
|
+
: [];
|
|
128
|
+
return { commits, count: commits.length };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
case 'git_add': {
|
|
132
|
+
const cwd = resolveCwd(args, state);
|
|
133
|
+
const paths = Array.isArray(args.paths) ? args.paths : [args.paths || '.'];
|
|
134
|
+
const resolvedPaths = paths.map(p => state.projectRootPath ? guardPath(p, state.projectRootPath) : pathResolve(p));
|
|
135
|
+
runGit(['add', '--', ...resolvedPaths], cwd);
|
|
136
|
+
const status = runGit(['status', '--porcelain'], cwd);
|
|
137
|
+
const staged = status
|
|
138
|
+
? status.split('\n').filter(l => l[0] !== ' ' && l[0] !== '?' && l.trim()).map(l => l.slice(3))
|
|
139
|
+
: [];
|
|
140
|
+
return { success: true, staged, message: `Staged: ${paths.join(', ')}` };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
case 'git_commit': {
|
|
144
|
+
if (!args.message) throw new Error('message is required for git_commit');
|
|
145
|
+
const cwd = resolveCwd(args, state);
|
|
146
|
+
|
|
147
|
+
const nameStatus = runGit(['diff', '--cached', '--name-status'], cwd);
|
|
148
|
+
const stagedFiles = nameStatus
|
|
149
|
+
? nameStatus.split('\n').filter(Boolean).map(l => {
|
|
150
|
+
const [s, ...parts] = l.split('\t');
|
|
151
|
+
return { path: parts.join('\t'), action: s === 'A' ? 'created' : s === 'D' ? 'deleted' : 'modified' };
|
|
152
|
+
})
|
|
153
|
+
: [];
|
|
154
|
+
|
|
155
|
+
if (args.all) runGit(['add', '-u'], cwd);
|
|
156
|
+
|
|
157
|
+
runGit(['commit', '-m', args.message], cwd);
|
|
158
|
+
|
|
159
|
+
const hash = runGit(['rev-parse', '--short', 'HEAD'], cwd);
|
|
160
|
+
const branch = runGit(['rev-parse', '--abbrev-ref', 'HEAD'], cwd);
|
|
161
|
+
|
|
162
|
+
saveAutoContext({
|
|
163
|
+
title: `git commit: ${args.message.slice(0, 57)}${args.message.length > 57 ? '...' : ''}`,
|
|
164
|
+
content: `hash: ${hash} | branch: ${branch}\nmessage: ${args.message}\nfiles: ${stagedFiles.map(f => f.path).join(', ')}`,
|
|
165
|
+
type: 'decision',
|
|
166
|
+
files: stagedFiles,
|
|
167
|
+
tags: ['git', 'commit', branch],
|
|
168
|
+
state,
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
return { success: true, hash, branch, message: args.message, files: stagedFiles };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
case 'git_push': {
|
|
175
|
+
const cwd = resolveCwd(args, state);
|
|
176
|
+
const remote = args.remote || 'origin';
|
|
177
|
+
const branch = args.branch || runGit(['rev-parse', '--abbrev-ref', 'HEAD'], cwd);
|
|
178
|
+
const output = runGit(['push', remote, branch], cwd);
|
|
179
|
+
return { success: true, remote, branch, output: output || 'Pushed successfully.' };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
case 'git_pull': {
|
|
183
|
+
const cwd = resolveCwd(args, state);
|
|
184
|
+
const remote = args.remote || 'origin';
|
|
185
|
+
const argv = ['pull', remote];
|
|
186
|
+
if (args.branch) argv.push(args.branch);
|
|
187
|
+
const output = runGit(argv, cwd);
|
|
188
|
+
return { success: true, remote, output: output || 'Already up to date.' };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
case 'git_branch': {
|
|
192
|
+
const cwd = resolveCwd(args, state);
|
|
193
|
+
const action = args.action || 'list';
|
|
194
|
+
if (action === 'list') {
|
|
195
|
+
const raw = runGit(['branch', '-a'], cwd);
|
|
196
|
+
const branches = raw ? raw.split('\n').map(b => b.trim()).filter(Boolean) : [];
|
|
197
|
+
const current = branches.find(b => b.startsWith('* '))?.slice(2) || '';
|
|
198
|
+
return { branches: branches.map(b => b.replace(/^\* /, '')), current };
|
|
199
|
+
} else if (action === 'create') {
|
|
200
|
+
if (!args.name) throw new Error('name is required for branch create');
|
|
201
|
+
runGit(['checkout', '-b', args.name], cwd);
|
|
202
|
+
return { success: true, branch: args.name, message: `Created and switched to "${args.name}"` };
|
|
203
|
+
} else if (action === 'checkout') {
|
|
204
|
+
if (!args.name) throw new Error('name is required for branch checkout');
|
|
205
|
+
runGit(['checkout', args.name], cwd);
|
|
206
|
+
return { success: true, branch: args.name, message: `Switched to "${args.name}"` };
|
|
207
|
+
}
|
|
208
|
+
throw new Error(`Unknown branch action: ${action}. Use: list, create, checkout`);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
case 'git_stash': {
|
|
212
|
+
const cwd = resolveCwd(args, state);
|
|
213
|
+
const action = args.action || 'save';
|
|
214
|
+
if (action === 'save') {
|
|
215
|
+
const argv = ['stash', 'push'];
|
|
216
|
+
if (args.message) { argv.push('-m'); argv.push(args.message); }
|
|
217
|
+
runGit(argv, cwd);
|
|
218
|
+
return { success: true, message: `Stashed changes${args.message ? `: ${args.message}` : '.'}` };
|
|
219
|
+
} else if (action === 'pop') {
|
|
220
|
+
const out = runGit(['stash', 'pop'], cwd);
|
|
221
|
+
return { success: true, output: out };
|
|
222
|
+
} else if (action === 'list') {
|
|
223
|
+
const raw = runGit(['stash', 'list'], cwd);
|
|
224
|
+
return { stashes: raw ? raw.split('\n').filter(Boolean) : [] };
|
|
225
|
+
} else if (action === 'drop') {
|
|
226
|
+
const ref = args.ref || 'stash@{0}';
|
|
227
|
+
runGit(['stash', 'drop', ref], cwd);
|
|
228
|
+
return { success: true, message: `Dropped ${ref}` };
|
|
229
|
+
}
|
|
230
|
+
throw new Error(`Unknown stash action: ${action}. Use: save, pop, list, drop`);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
case 'git_reset': {
|
|
234
|
+
const cwd = resolveCwd(args, state);
|
|
235
|
+
const mode = args.mode || 'mixed';
|
|
236
|
+
if (mode === 'file') {
|
|
237
|
+
if (!args.path) throw new Error('path is required for file mode reset');
|
|
238
|
+
const filePath = state.projectRootPath ? guardPath(args.path, state.projectRootPath) : pathResolve(args.path);
|
|
239
|
+
runGit(['checkout', '--', filePath], cwd);
|
|
240
|
+
return { success: true, message: `Restored "${args.path}" to last committed state.` };
|
|
241
|
+
}
|
|
242
|
+
const ref = args.ref || 'HEAD';
|
|
243
|
+
runGit(['reset', `--${mode}`, ref], cwd);
|
|
244
|
+
return { success: true, mode, ref, message: `Reset --${mode} to ${ref}` };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
case 'git_show': {
|
|
248
|
+
const cwd = resolveCwd(args, state);
|
|
249
|
+
const ref = args.ref || 'HEAD';
|
|
250
|
+
const info = runGit(['show', '--stat', '--format=%H%n%an%n%ad%n%s', ref], cwd);
|
|
251
|
+
let diff = runGit(['show', ref], cwd);
|
|
252
|
+
if (diff.length > MAX_DIFF_LENGTH) diff = diff.slice(0, MAX_DIFF_LENGTH) + '\n…(truncated)';
|
|
253
|
+
return { ref, info, diff };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
default:
|
|
257
|
+
throw new Error(`Unknown git tool: ${name}`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { search as unifiedSearch } from '../search.js';
|
|
2
|
+
|
|
3
|
+
export const definition = {
|
|
4
|
+
name: 'search',
|
|
5
|
+
description:
|
|
6
|
+
`Search across all saved context. Three modes:\n` +
|
|
7
|
+
`• "semantic" (default) — TF-IDF similarity. Best for natural language queries.\n` +
|
|
8
|
+
`• "keyword" — Exact keyword matching. Best for specific terms.\n` +
|
|
9
|
+
`• "related" — Find entries similar to a given entry ID.`,
|
|
10
|
+
inputSchema: {
|
|
11
|
+
type: 'object',
|
|
12
|
+
properties: {
|
|
13
|
+
query: { type: 'string' },
|
|
14
|
+
mode: { type: 'string', enum: ['keyword', 'semantic', 'related'] },
|
|
15
|
+
project: { type: 'string' },
|
|
16
|
+
limit: { type: 'number' },
|
|
17
|
+
id: { type: 'string', description: '[related mode] entry ID' },
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
outputSchema: {
|
|
21
|
+
type: 'object',
|
|
22
|
+
properties: {
|
|
23
|
+
matches: { type: 'array' },
|
|
24
|
+
count: { type: 'number' },
|
|
25
|
+
mode: { type: 'string' },
|
|
26
|
+
message: { type: 'string' },
|
|
27
|
+
source: { type: 'string' },
|
|
28
|
+
related: { type: 'array' },
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export async function handle(args, _state) {
|
|
34
|
+
const mode = args.mode || 'semantic';
|
|
35
|
+
const limit = args.limit || (mode === 'related' ? 5 : 10);
|
|
36
|
+
|
|
37
|
+
if (mode === 'related') {
|
|
38
|
+
const { target, results } = unifiedSearch({ mode, id: args.id, limit });
|
|
39
|
+
return { source: target.title, related: results, count: results.length, mode };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const raw = unifiedSearch({ mode, query: args.query, project: args.project, limit });
|
|
43
|
+
const matches = raw.map(e => ({
|
|
44
|
+
id: e.id, project: e.project, title: e.title || '',
|
|
45
|
+
tags: e.tags, createdAt: e.createdAt,
|
|
46
|
+
similarity: e.similarity,
|
|
47
|
+
preview: (e.content || '').slice(0, 200),
|
|
48
|
+
}));
|
|
49
|
+
return {
|
|
50
|
+
matches, count: matches.length, mode,
|
|
51
|
+
message: matches.length
|
|
52
|
+
? `${matches.length} ${mode} result(s) for "${args.query}".`
|
|
53
|
+
: `No results for "${args.query}".`,
|
|
54
|
+
};
|
|
55
|
+
}
|
package/src/vector.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* vector.js — TF-IDF cosine similarity search (cached)
|
|
3
|
+
*
|
|
4
|
+
* Optimizations:
|
|
5
|
+
* 1. IDF cache — reuses computed IDF when the corpus hasn't changed
|
|
6
|
+
* 2. Pre-computed corpus strings — avoids re-concatenating on every search
|
|
7
|
+
* 3. Early exit — skips entries with zero query term overlap
|
|
8
|
+
* 4. Sparse dot product — only iterates non-zero dimensions
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { getGeneration } from './db.js';
|
|
12
|
+
|
|
13
|
+
// ── Text utilities ────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
const STOP_WORDS = new Set([
|
|
16
|
+
'a','an','the','and','or','but','in','on','at','to','for','of','with',
|
|
17
|
+
'by','from','is','it','its','as','be','was','are','were','this','that',
|
|
18
|
+
'i','we','you','he','she','they','have','has','had','do','did','will',
|
|
19
|
+
'would','could','should','not','no','so','if','then','than','just','my',
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
function stemLight(w) {
|
|
23
|
+
return w
|
|
24
|
+
.replace(/ication$/, 'ic').replace(/ations?$/, 'ate').replace(/ments?$/, '')
|
|
25
|
+
.replace(/ings?$/, '').replace(/tion$/, 't').replace(/ness$/, '')
|
|
26
|
+
.replace(/ity$/, '').replace(/ies$/, 'y').replace(/s$/, '');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function tokenize(text) {
|
|
30
|
+
return text
|
|
31
|
+
.toLowerCase()
|
|
32
|
+
.replace(/[^a-z0-9\s_-]/g, ' ')
|
|
33
|
+
.split(/\s+/)
|
|
34
|
+
.filter(w => w.length > 2 && !STOP_WORDS.has(w))
|
|
35
|
+
.map(stemLight);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function termFreq(tokens) {
|
|
39
|
+
const tf = {};
|
|
40
|
+
for (const t of tokens) tf[t] = (tf[t] || 0) + 1;
|
|
41
|
+
// FIX: avoid Math.max(...array) spread which can hit the JS argument limit
|
|
42
|
+
// on very large texts. Use a reduce loop instead.
|
|
43
|
+
let max = 1;
|
|
44
|
+
for (const v of Object.values(tf)) if (v > max) max = v;
|
|
45
|
+
for (const t in tf) tf[t] /= max;
|
|
46
|
+
return tf;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── IDF cache ─────────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
let _idfCache = null;
|
|
52
|
+
let _idfGeneration = -1;
|
|
53
|
+
let _idfCorpusLen = -1;
|
|
54
|
+
|
|
55
|
+
function buildIDF(docs) {
|
|
56
|
+
const df = {};
|
|
57
|
+
for (const doc of docs) {
|
|
58
|
+
const seen = new Set(tokenize(doc));
|
|
59
|
+
for (const t of seen) df[t] = (df[t] || 0) + 1;
|
|
60
|
+
}
|
|
61
|
+
const N = docs.length || 1;
|
|
62
|
+
const idf = {};
|
|
63
|
+
for (const t in df) idf[t] = Math.log((N + 1) / (df[t] + 1)) + 1;
|
|
64
|
+
return idf;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function getCachedIDF(corpus) {
|
|
68
|
+
const gen = getGeneration();
|
|
69
|
+
// Cache hit: same generation (no mutations) and same corpus size
|
|
70
|
+
if (_idfCache && _idfGeneration === gen && _idfCorpusLen === corpus.length) {
|
|
71
|
+
return _idfCache;
|
|
72
|
+
}
|
|
73
|
+
_idfCache = buildIDF(corpus);
|
|
74
|
+
_idfGeneration = gen;
|
|
75
|
+
_idfCorpusLen = corpus.length;
|
|
76
|
+
return _idfCache;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── TF-IDF vector ─────────────────────────────────────────────────────────────
|
|
80
|
+
|
|
81
|
+
function tfidfVector(text, idf) {
|
|
82
|
+
const tokens = tokenize(text);
|
|
83
|
+
const tf = termFreq(tokens);
|
|
84
|
+
const vec = {};
|
|
85
|
+
for (const t in tf) vec[t] = tf[t] * (idf[t] || Math.log(2));
|
|
86
|
+
return vec;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── Cosine similarity (sparse) ────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
function cosine(a, b) {
|
|
92
|
+
let dot = 0, magA = 0, magB = 0;
|
|
93
|
+
for (const t in a) {
|
|
94
|
+
magA += a[t] ** 2;
|
|
95
|
+
if (t in b) dot += a[t] * b[t];
|
|
96
|
+
}
|
|
97
|
+
for (const t in b) magB += b[t] ** 2;
|
|
98
|
+
if (!magA || !magB) return 0;
|
|
99
|
+
return dot / (Math.sqrt(magA) * Math.sqrt(magB));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ── Quick overlap check (skip zero-similarity entries early) ──────────────────
|
|
103
|
+
|
|
104
|
+
function hasOverlap(queryTokens, text) {
|
|
105
|
+
const tokens = new Set(tokenize(text));
|
|
106
|
+
return queryTokens.some(t => tokens.has(t));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ── Public API ────────────────────────────────────────────────────────────────
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Rank entries by semantic similarity to query.
|
|
113
|
+
* Returns compact results by default (preview, not full content).
|
|
114
|
+
*/
|
|
115
|
+
export function vectorSearch(query, entries, limit = 10) {
|
|
116
|
+
if (!entries.length) return [];
|
|
117
|
+
|
|
118
|
+
const queryTokens = tokenize(query);
|
|
119
|
+
if (!queryTokens.length) return [];
|
|
120
|
+
|
|
121
|
+
// Build corpus strings (once per call)
|
|
122
|
+
const corpus = entries.map(e => {
|
|
123
|
+
const tags = Array.isArray(e.tags) ? e.tags : [];
|
|
124
|
+
return `${e.title || ''} ${e.content || ''} ${tags.join(' ')}`;
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// Use cached IDF when corpus hasn't changed
|
|
128
|
+
const idf = getCachedIDF(corpus);
|
|
129
|
+
const queryVec = tfidfVector(query, idf);
|
|
130
|
+
|
|
131
|
+
const scored = [];
|
|
132
|
+
for (let i = 0; i < entries.length; i++) {
|
|
133
|
+
// Early exit: skip entries with zero token overlap
|
|
134
|
+
if (!hasOverlap(queryTokens, corpus[i])) continue;
|
|
135
|
+
|
|
136
|
+
const sim = cosine(queryVec, tfidfVector(corpus[i], idf));
|
|
137
|
+
if (sim > 0) {
|
|
138
|
+
scored.push({ ...entries[i], similarity: Math.round(sim * 100) / 100 });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
scored.sort((a, b) => b.similarity - a.similarity);
|
|
143
|
+
return scored.slice(0, limit);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Find entries similar to a given entry.
|
|
148
|
+
*/
|
|
149
|
+
export function findRelated(targetEntry, allEntries, limit = 5) {
|
|
150
|
+
const others = allEntries.filter(e => e.id !== targetEntry.id);
|
|
151
|
+
const query = `${targetEntry.title || ''} ${targetEntry.content || ''}`;
|
|
152
|
+
return vectorSearch(query, others, limit);
|
|
153
|
+
}
|