context-mcp-server 1.0.8 → 1.1.0
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/codegraph/__pycache__/server.cpython-313.pyc +0 -0
- package/codegraph/graph/__pycache__/query.cpython-313.pyc +0 -0
- package/codegraph/graph/query.py +43 -0
- package/codegraph/server.py +20 -17
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/cli.js +94 -183
- package/src/db.js +79 -102
- package/src/search.js +73 -9
- package/src/templates/AGENTS.md +56 -46
- package/src/templates/CLAUDE.md +89 -55
- package/src/templates/GEMINI.md +56 -46
- package/src/templates/commands/save-context.md +6 -3
- package/src/templates/skills/SKILL.md +83 -50
- package/src/templates/windsurf-rules.md +69 -20
- package/src/tools/codegraph.js +46 -43
- package/src/tools/context.js +42 -24
- package/src/tools/plan.js +14 -11
- package/uv.lock +1 -1
- package/src/migrator.js +0 -124
package/src/db.js
CHANGED
|
@@ -15,11 +15,11 @@
|
|
|
15
15
|
import {
|
|
16
16
|
readFileSync, writeFileSync, mkdirSync, existsSync,
|
|
17
17
|
openSync, closeSync, unlinkSync, renameSync, chmodSync, rmdirSync,
|
|
18
|
+
readdirSync,
|
|
18
19
|
} from 'node:fs';
|
|
19
20
|
import { homedir, platform } from 'node:os';
|
|
20
21
|
import { join } from 'node:path';
|
|
21
22
|
import { randomUUID } from 'node:crypto';
|
|
22
|
-
import { runMigration } from './migrator.js';
|
|
23
23
|
|
|
24
24
|
const DATA_DIR = process.env.CONTEXT_MCP_DIR || join(homedir(), '.context-mcp');
|
|
25
25
|
const PROJECTS_DIR = join(DATA_DIR, 'projects');
|
|
@@ -74,7 +74,6 @@ let _dirtyProjects = new Set();
|
|
|
74
74
|
let _dirty = false;
|
|
75
75
|
let _writeTimer = null;
|
|
76
76
|
let _generation = 0;
|
|
77
|
-
let _migrated = false;
|
|
78
77
|
|
|
79
78
|
// ── File I/O helpers ─────────────────────────────────────────────────────────
|
|
80
79
|
|
|
@@ -122,7 +121,18 @@ function _readObj(filePath, defaults) {
|
|
|
122
121
|
|
|
123
122
|
function loadProjectsIndex() {
|
|
124
123
|
if (_projectsIndex) return _projectsIndex;
|
|
125
|
-
if (!existsSync(PROJECTS_PATH)) {
|
|
124
|
+
if (!existsSync(PROJECTS_PATH)) {
|
|
125
|
+
// Fall back to scanning the projects dir so CLI works without projects.json
|
|
126
|
+
_projectsIndex = [];
|
|
127
|
+
if (existsSync(PROJECTS_DIR)) {
|
|
128
|
+
try {
|
|
129
|
+
for (const slug of readdirSync(PROJECTS_DIR, { withFileTypes: true })) {
|
|
130
|
+
if (slug.isDirectory()) _projectsIndex.push({ name: slug.name, slug: slug.name });
|
|
131
|
+
}
|
|
132
|
+
} catch {}
|
|
133
|
+
}
|
|
134
|
+
return _projectsIndex;
|
|
135
|
+
}
|
|
126
136
|
try {
|
|
127
137
|
const d = JSON.parse(readFileSync(PROJECTS_PATH, 'utf8'));
|
|
128
138
|
_projectsIndex = Array.isArray(d.projects) ? d.projects : [];
|
|
@@ -130,21 +140,6 @@ function loadProjectsIndex() {
|
|
|
130
140
|
return _projectsIndex;
|
|
131
141
|
}
|
|
132
142
|
|
|
133
|
-
// ── Migration ─────────────────────────────────────────────────────────────────
|
|
134
|
-
|
|
135
|
-
function migrate() {
|
|
136
|
-
if (_migrated) return;
|
|
137
|
-
_migrated = true;
|
|
138
|
-
runMigration({
|
|
139
|
-
dataDir: DATA_DIR,
|
|
140
|
-
projectsDir: PROJECTS_DIR,
|
|
141
|
-
projectsPath: PROJECTS_PATH,
|
|
142
|
-
slugify,
|
|
143
|
-
flushFile: _flushFile,
|
|
144
|
-
projectsIndex: loadProjectsIndex(),
|
|
145
|
-
});
|
|
146
|
-
}
|
|
147
|
-
|
|
148
143
|
// ── Per-project data loading ─────────────────────────────────────────────────
|
|
149
144
|
|
|
150
145
|
function loadProjectData(name) {
|
|
@@ -166,7 +161,6 @@ function getAllEntries(projectName) {
|
|
|
166
161
|
return [...data.context, ...data.summary];
|
|
167
162
|
}
|
|
168
163
|
|
|
169
|
-
// Find an entry by ID, optionally scoped to a project.
|
|
170
164
|
function findEntryById(id, projectHint) {
|
|
171
165
|
const search = (data) => {
|
|
172
166
|
for (const arr of [data.context, data.summary]) {
|
|
@@ -184,7 +178,6 @@ function findEntryById(id, projectHint) {
|
|
|
184
178
|
const e = search(data);
|
|
185
179
|
if (e) return { entry: e, projectName: name };
|
|
186
180
|
}
|
|
187
|
-
// Load all remaining projects
|
|
188
181
|
const idx = loadProjectsIndex();
|
|
189
182
|
for (const proj of idx) {
|
|
190
183
|
if (_projectData.has(proj.name) || proj.name === projectHint) continue;
|
|
@@ -194,7 +187,6 @@ function findEntryById(id, projectHint) {
|
|
|
194
187
|
return null;
|
|
195
188
|
}
|
|
196
189
|
|
|
197
|
-
// Remove an entry from its array in the project data.
|
|
198
190
|
function removeEntryFromData(data, entry) {
|
|
199
191
|
if (treeFor(entry) === 'summary') {
|
|
200
192
|
data.summary = data.summary.filter(e => e.id !== entry.id);
|
|
@@ -223,7 +215,7 @@ function flushProjectToDisk(name) {
|
|
|
223
215
|
_flushFile(discussFilePath(name), { discussions: data.discussions });
|
|
224
216
|
}
|
|
225
217
|
|
|
226
|
-
function flushToDisk() {
|
|
218
|
+
export function flushToDisk() {
|
|
227
219
|
if (!_dirty) return;
|
|
228
220
|
_writeTimer = null;
|
|
229
221
|
|
|
@@ -244,11 +236,8 @@ process.on('exit', flushToDisk);
|
|
|
244
236
|
process.on('SIGINT', () => { flushToDisk(); process.exit(); });
|
|
245
237
|
process.on('SIGTERM', () => { flushToDisk(); process.exit(); });
|
|
246
238
|
|
|
247
|
-
// ── Initialise: run migration lazily on first access ─────────────────────────
|
|
248
|
-
|
|
249
239
|
function init() {
|
|
250
240
|
loadProjectsIndex();
|
|
251
|
-
migrate();
|
|
252
241
|
}
|
|
253
242
|
|
|
254
243
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
@@ -283,6 +272,8 @@ function compactEntry(e) {
|
|
|
283
272
|
updatedAt: e.updatedAt || null,
|
|
284
273
|
preview: truncate(e.content, PREVIEW_LENGTH),
|
|
285
274
|
};
|
|
275
|
+
if (e.why) compact.why = e.why;
|
|
276
|
+
if (e.outcome) compact.outcome = e.outcome;
|
|
286
277
|
if (e.files && e.files.length) compact.files = e.files;
|
|
287
278
|
if (e.codeRefs && e.codeRefs.length) compact.codeRefs = e.codeRefs;
|
|
288
279
|
if (e.expiresAt) compact.expiresAt = e.expiresAt;
|
|
@@ -291,7 +282,26 @@ function compactEntry(e) {
|
|
|
291
282
|
|
|
292
283
|
// ── Context entries ──────────────────────────────────────────────────────────
|
|
293
284
|
|
|
294
|
-
|
|
285
|
+
const VALID_TYPES = new Set(['note', 'compaction']);
|
|
286
|
+
|
|
287
|
+
function computeImportance({ files = [], why = '', outcome = '', tags = [], type } = {}) {
|
|
288
|
+
if (type === 'compaction') return 5;
|
|
289
|
+
let score = 0;
|
|
290
|
+
if (Array.isArray(files) && files.length > 0) score += 2;
|
|
291
|
+
if (why && why.trim()) score += 1;
|
|
292
|
+
if (outcome && outcome.trim()) score += 1;
|
|
293
|
+
if (Array.isArray(tags) && tags.some(t => t === 'plan' || t === 'decision')) score += 1;
|
|
294
|
+
return score;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const _SECRET_PATTERN = /("?(?:api[-_]?key|password|passwd|pwd|token|secret|authorization|auth_token|access_token|refresh_token|bearer|cookie|signature|private[-_]?key|client[-_]?secret)"?\s*[:=]\s*)("[^"]*"|'[^']*'|\S+)/gi;
|
|
298
|
+
|
|
299
|
+
function redactSecrets(text) {
|
|
300
|
+
if (typeof text !== 'string') return text;
|
|
301
|
+
return text.replace(_SECRET_PATTERN, '$1[REDACTED]');
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export function saveContext({ project, content, why = '', outcome = '', tags = [], source = 'user', title = '',
|
|
295
305
|
type = 'note', status = 'active', files = [], codeRefs = [],
|
|
296
306
|
sessionId = null, parentId = null, expiresAt = null, rootPath = null }) {
|
|
297
307
|
init();
|
|
@@ -299,6 +309,9 @@ export function saveContext({ project, content, tags = [], source = 'user', titl
|
|
|
299
309
|
ensureProject(projectName, rootPath || undefined);
|
|
300
310
|
const data = loadProjectData(projectName);
|
|
301
311
|
const now = new Date().toISOString();
|
|
312
|
+
const validatedType = VALID_TYPES.has(type) ? type : 'note';
|
|
313
|
+
const normalizedFiles = Array.isArray(files) ? files : [];
|
|
314
|
+
const normalizedTags = normalizeTags(tags);
|
|
302
315
|
const entry = {
|
|
303
316
|
id: randomUUID(),
|
|
304
317
|
project: projectName,
|
|
@@ -306,29 +319,31 @@ export function saveContext({ project, content, tags = [], source = 'user', titl
|
|
|
306
319
|
parentId: parentId || sessionId || `project:${projectName}`,
|
|
307
320
|
nodeType: 'entry',
|
|
308
321
|
version: 1,
|
|
309
|
-
title: truncate(title,
|
|
310
|
-
content: truncate(content, MAX_CONTENT_LENGTH),
|
|
311
|
-
|
|
322
|
+
title: truncate(title, 120),
|
|
323
|
+
content: redactSecrets(truncate(content, MAX_CONTENT_LENGTH)),
|
|
324
|
+
why: redactSecrets(truncate(why || '', 300)),
|
|
325
|
+
outcome: redactSecrets(truncate(outcome || '', 300)),
|
|
326
|
+
type: validatedType,
|
|
312
327
|
status,
|
|
313
|
-
tags:
|
|
328
|
+
tags: normalizedTags,
|
|
314
329
|
source: normalizeSource(source),
|
|
315
|
-
files:
|
|
330
|
+
files: normalizedFiles,
|
|
316
331
|
codeRefs: Array.isArray(codeRefs) ? codeRefs : [],
|
|
332
|
+
importance: computeImportance({ files: normalizedFiles, why, outcome, tags: normalizedTags, type: validatedType }),
|
|
317
333
|
discussionId: null,
|
|
318
334
|
createdAt: now,
|
|
319
335
|
updatedAt: null,
|
|
320
336
|
expiresAt: expiresAt || null,
|
|
321
337
|
};
|
|
322
338
|
const tree = treeFor(entry);
|
|
323
|
-
if (tree === '
|
|
324
|
-
else if (tree === 'summary') data.summary.push(entry);
|
|
339
|
+
if (tree === 'summary') data.summary.push(entry);
|
|
325
340
|
else data.context.push(entry);
|
|
326
341
|
_dirtyProjects.add(projectName);
|
|
327
342
|
markDirty();
|
|
328
343
|
return entry;
|
|
329
344
|
}
|
|
330
345
|
|
|
331
|
-
export function updateContext({ id, content, title, tags, type, status, files, codeRefs, sessionId, parentId, expiresAt }) {
|
|
346
|
+
export function updateContext({ id, content, why, outcome, title, tags, type, status, files, codeRefs, sessionId, parentId, expiresAt }) {
|
|
332
347
|
init();
|
|
333
348
|
const found = findEntryById(id);
|
|
334
349
|
if (!found) return null;
|
|
@@ -337,9 +352,11 @@ export function updateContext({ id, content, title, tags, type, status, files, c
|
|
|
337
352
|
|
|
338
353
|
const oldTree = treeFor(entry);
|
|
339
354
|
if (content !== undefined) entry.content = truncate(content, MAX_CONTENT_LENGTH);
|
|
340
|
-
if (
|
|
355
|
+
if (why !== undefined) entry.why = truncate(why || '', 300);
|
|
356
|
+
if (outcome !== undefined) entry.outcome = truncate(outcome || '', 300);
|
|
357
|
+
if (title !== undefined) entry.title = truncate(title, 120);
|
|
341
358
|
if (tags !== undefined) entry.tags = normalizeTags(tags);
|
|
342
|
-
if (type !== undefined) entry.type = type;
|
|
359
|
+
if (type !== undefined) entry.type = VALID_TYPES.has(type) ? type : entry.type;
|
|
343
360
|
if (status !== undefined) entry.status = status;
|
|
344
361
|
if (files !== undefined) entry.files = Array.isArray(files) ? files : [];
|
|
345
362
|
if (codeRefs !== undefined) entry.codeRefs = Array.isArray(codeRefs) ? codeRefs : [];
|
|
@@ -353,11 +370,8 @@ export function updateContext({ id, content, title, tags, type, status, files, c
|
|
|
353
370
|
const newTree = treeFor(entry);
|
|
354
371
|
if (newTree !== oldTree) {
|
|
355
372
|
removeEntryFromData(data, entry);
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
if (newTree === 'graph') data.graph.entries.push(tempEntry);
|
|
359
|
-
else if (newTree === 'summary') data.summary.push(tempEntry);
|
|
360
|
-
else data.context.push(tempEntry);
|
|
373
|
+
if (newTree === 'summary') data.summary.push(entry);
|
|
374
|
+
else data.context.push(entry);
|
|
361
375
|
}
|
|
362
376
|
|
|
363
377
|
_dirtyProjects.add(projectName);
|
|
@@ -478,7 +492,7 @@ export function deleteProject(nameOrId) {
|
|
|
478
492
|
|
|
479
493
|
// Count before removing
|
|
480
494
|
const data = _projectData.get(projectName) || loadProjectData(projectName);
|
|
481
|
-
const ctxCount = data.context.length + data.
|
|
495
|
+
const ctxCount = data.context.length + data.summary.length;
|
|
482
496
|
const discCount = data.discussions.length;
|
|
483
497
|
|
|
484
498
|
// Remove project directory from disk
|
|
@@ -605,7 +619,7 @@ export function saveDiscussion({ name, title, description, content, project, tag
|
|
|
605
619
|
project: project !== undefined ? (project || 'global') : (prev?.project ?? 'global'),
|
|
606
620
|
sessionId: sessionId !== undefined ? (sessionId || null) : (prev?.sessionId ?? null),
|
|
607
621
|
parentId: parentId !== undefined ? (parentId || null) : (prev?.parentId ?? null),
|
|
608
|
-
title: title !== undefined ? truncate(title || name,
|
|
622
|
+
title: title !== undefined ? truncate(title || name, 120) : (prev?.title ?? name),
|
|
609
623
|
description: description !== undefined ? (description || '') : (prev?.description ?? ''),
|
|
610
624
|
content: content !== undefined ? truncate(content || '', MAX_CONTENT_LENGTH) : (prev?.content ?? ''),
|
|
611
625
|
type: type !== undefined ? (VALID_DISCUSSION_TYPES.has(type) ? type : 'plan') : (prev?.type ?? 'plan'),
|
|
@@ -651,7 +665,7 @@ export function updateDiscussion({ id, name, title, description, content, status
|
|
|
651
665
|
if (found) { disc = found; projName = pName; break; }
|
|
652
666
|
}
|
|
653
667
|
if (!disc) return null;
|
|
654
|
-
if (title !== undefined) disc.title = truncate(title || disc.name,
|
|
668
|
+
if (title !== undefined) disc.title = truncate(title || disc.name, 120);
|
|
655
669
|
if (description !== undefined) disc.description = description || '';
|
|
656
670
|
if (content !== undefined) disc.content = truncate(content || '', MAX_CONTENT_LENGTH);
|
|
657
671
|
if (type !== undefined) disc.type = VALID_DISCUSSION_TYPES.has(type) ? type : disc.type;
|
|
@@ -747,28 +761,6 @@ export function linkContextToDiscussion({ discussionId, discussionName, contextI
|
|
|
747
761
|
return { discussionId: disc.id, contextId };
|
|
748
762
|
}
|
|
749
763
|
|
|
750
|
-
export function getContextByDiscussion(discussionId) {
|
|
751
|
-
init();
|
|
752
|
-
const idx = loadProjectsIndex();
|
|
753
|
-
const seen = new Set([..._projectData.keys(), ...idx.map(p => p.name)]);
|
|
754
|
-
const results = [];
|
|
755
|
-
for (const name of seen) {
|
|
756
|
-
results.push(...getAllEntries(name).filter(c => c.discussionId === discussionId));
|
|
757
|
-
}
|
|
758
|
-
return results;
|
|
759
|
-
}
|
|
760
|
-
|
|
761
|
-
export function clearDiscussionLink(contextId) {
|
|
762
|
-
init();
|
|
763
|
-
const found = findEntryById(contextId);
|
|
764
|
-
if (!found) return null;
|
|
765
|
-
found.entry.discussionId = null;
|
|
766
|
-
found.entry.updatedAt = new Date().toISOString();
|
|
767
|
-
_dirtyProjects.add(found.projectName);
|
|
768
|
-
markDirty();
|
|
769
|
-
return found.entry;
|
|
770
|
-
}
|
|
771
|
-
|
|
772
764
|
export function deleteDiscussion({ name, id }) {
|
|
773
765
|
init();
|
|
774
766
|
const idx = loadProjectsIndex();
|
|
@@ -790,38 +782,6 @@ export function deleteDiscussion({ name, id }) {
|
|
|
790
782
|
return { deleted: 0 };
|
|
791
783
|
}
|
|
792
784
|
|
|
793
|
-
export function updateDiscussionStep({ discussionName, discussionId, stepId, status, linkedContextId }) {
|
|
794
|
-
init();
|
|
795
|
-
let disc = null;
|
|
796
|
-
let projName = null;
|
|
797
|
-
const idx = loadProjectsIndex();
|
|
798
|
-
const seen = new Set([..._projectData.keys(), ...idx.map(p => p.name)]);
|
|
799
|
-
for (const pName of seen) {
|
|
800
|
-
const d = loadProjectData(pName);
|
|
801
|
-
const found = discussionId
|
|
802
|
-
? d.discussions.find(x => x.id === discussionId)
|
|
803
|
-
: d.discussions.find(x => x.name === discussionName);
|
|
804
|
-
if (found) { disc = found; projName = pName; break; }
|
|
805
|
-
}
|
|
806
|
-
if (!disc) return null;
|
|
807
|
-
const step = (disc.steps || []).find(s => s.id === stepId);
|
|
808
|
-
if (!step) return null;
|
|
809
|
-
if (status) step.status = status;
|
|
810
|
-
if (status === 'done') step.completedAt = new Date().toISOString();
|
|
811
|
-
if (linkedContextId) {
|
|
812
|
-
if (!Array.isArray(step.linkedContextIds)) step.linkedContextIds = [];
|
|
813
|
-
if (!step.linkedContextIds.includes(linkedContextId)) step.linkedContextIds.push(linkedContextId);
|
|
814
|
-
if (!Array.isArray(disc.linkedContextIds)) disc.linkedContextIds = [];
|
|
815
|
-
if (!disc.linkedContextIds.includes(linkedContextId)) disc.linkedContextIds.push(linkedContextId);
|
|
816
|
-
}
|
|
817
|
-
const allDone = disc.steps.every(s => s.status === 'done' || s.status === 'skipped');
|
|
818
|
-
if (allDone && disc.status !== 'done') disc.status = 'done';
|
|
819
|
-
disc.updatedAt = new Date().toISOString();
|
|
820
|
-
_dirtyProjects.add(projName);
|
|
821
|
-
markDirty();
|
|
822
|
-
return { discussion: disc, step };
|
|
823
|
-
}
|
|
824
|
-
|
|
825
785
|
// ── Auto-operations ───────────────────────────────────────────────────────────
|
|
826
786
|
|
|
827
787
|
export function archiveExpired(project) {
|
|
@@ -862,21 +822,39 @@ const COMPACTION_THRESHOLD = 20;
|
|
|
862
822
|
const COMPACTION_TARGET = 30;
|
|
863
823
|
|
|
864
824
|
export function shouldCompact(project) {
|
|
865
|
-
|
|
825
|
+
init();
|
|
826
|
+
// Only count non-compaction entries — compaction summaries themselves don't trigger further compaction
|
|
827
|
+
const proj = project || 'global';
|
|
828
|
+
const data = loadProjectData(proj);
|
|
829
|
+
const nonCompaction = [...data.context, ...data.summary].filter(e => e.type !== 'compaction');
|
|
830
|
+
return nonCompaction.length > COMPACTION_THRESHOLD;
|
|
866
831
|
}
|
|
867
832
|
|
|
868
|
-
export function compactProject(project, summaryContent) {
|
|
833
|
+
export function compactProject(project, summaryContent, { skipSummaryEntry = false } = {}) {
|
|
869
834
|
init();
|
|
870
835
|
const proj = project || 'global';
|
|
871
836
|
const data = loadProjectData(proj);
|
|
837
|
+
const now = Date.now();
|
|
872
838
|
const entries = data.context
|
|
873
|
-
.
|
|
839
|
+
.filter(e => e.type !== 'compaction') // never remove existing compaction summaries
|
|
840
|
+
.sort((a, b) => {
|
|
841
|
+
// Higher score = more expendable; oldest + least important drops first
|
|
842
|
+
const ageDaysA = (now - new Date(a.createdAt || 0).getTime()) / 86_400_000;
|
|
843
|
+
const ageDaysB = (now - new Date(b.createdAt || 0).getTime()) / 86_400_000;
|
|
844
|
+
const scoreA = ageDaysA * 0.7 + (5 - (a.importance ?? 0)) * 0.3;
|
|
845
|
+
const scoreB = ageDaysB * 0.7 + (5 - (b.importance ?? 0)) * 0.3;
|
|
846
|
+
return scoreB - scoreA;
|
|
847
|
+
});
|
|
874
848
|
if (entries.length < COMPACTION_TARGET) return null;
|
|
875
849
|
const toRemove = new Set(entries.slice(0, COMPACTION_TARGET).map(e => e.id));
|
|
876
850
|
const removed = entries.filter(e => toRemove.has(e.id));
|
|
877
851
|
for (const entry of removed) removeEntryFromData(data, entry);
|
|
878
852
|
_dirtyProjects.add(proj);
|
|
879
853
|
markDirty();
|
|
854
|
+
// If AI already saved a compaction entry this call, don't create a duplicate
|
|
855
|
+
if (skipSummaryEntry) {
|
|
856
|
+
return { removedCount: removed.length, summaryId: null };
|
|
857
|
+
}
|
|
880
858
|
const summary = saveContext({
|
|
881
859
|
project: proj,
|
|
882
860
|
title: `Compacted ${removed.length} entries — ${new Date().toISOString().slice(0, 10)}`,
|
|
@@ -892,7 +870,6 @@ export function compactProject(project, summaryContent) {
|
|
|
892
870
|
|
|
893
871
|
export function saveGraph({ path, nodes, edges, communities, cached, changed, time_ms, summary }) {
|
|
894
872
|
init();
|
|
895
|
-
// Find project by rootPath matching graph path
|
|
896
873
|
const idx = loadProjectsIndex();
|
|
897
874
|
const proj = idx.find(p => normPath(p.rootPath) === normPath(path));
|
|
898
875
|
const projName = proj ? proj.name : 'global';
|
package/src/search.js
CHANGED
|
@@ -1,13 +1,71 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* search.js — unified search entry point
|
|
3
|
-
*
|
|
4
|
-
* Single function replaces direct calls to searchContext / vectorSearch / findRelated
|
|
5
|
-
* across index.js, cli.js, and error_check. db.js + vector.js stay as low-level impls.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
1
|
import { getContext, searchContext } from './db.js';
|
|
9
2
|
import { vectorSearch, findRelated } from './vector.js';
|
|
10
3
|
|
|
4
|
+
// ── Vocabulary cache (lazy singleton per process) ─────────────────────────────
|
|
5
|
+
|
|
6
|
+
let _vocabCache = null;
|
|
7
|
+
|
|
8
|
+
function buildVocab(entries) {
|
|
9
|
+
const vocab = new Set();
|
|
10
|
+
for (const e of entries) {
|
|
11
|
+
const text = `${e.title || ''} ${e.content || ''}`.toLowerCase();
|
|
12
|
+
for (const w of text.split(/\W+/)) {
|
|
13
|
+
if (w.length > 2) vocab.add(w);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return vocab;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// ── Levenshtein fuzzy correction ──────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
function levenshtein(a, b) {
|
|
22
|
+
const prev = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
23
|
+
for (let i = 0; i < a.length; i++) {
|
|
24
|
+
const curr = [i + 1];
|
|
25
|
+
for (let j = 0; j < b.length; j++) {
|
|
26
|
+
curr.push(Math.min(prev[j + 1] + 1, curr[j] + 1, prev[j] + (a[i] === b[j] ? 0 : 1)));
|
|
27
|
+
}
|
|
28
|
+
prev.splice(0, prev.length, ...curr);
|
|
29
|
+
}
|
|
30
|
+
return prev[b.length];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function maxEditDist(len) {
|
|
34
|
+
if (len <= 4) return 1;
|
|
35
|
+
if (len <= 12) return 2;
|
|
36
|
+
return 3;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function fuzzyCorrect(word, vocab) {
|
|
40
|
+
const max = maxEditDist(word.length);
|
|
41
|
+
let bestWord = word;
|
|
42
|
+
let bestDist = max + 1;
|
|
43
|
+
for (const candidate of vocab) {
|
|
44
|
+
if (Math.abs(candidate.length - word.length) > max) continue;
|
|
45
|
+
const dist = levenshtein(word, candidate);
|
|
46
|
+
if (dist < bestDist) { bestDist = dist; bestWord = candidate; }
|
|
47
|
+
}
|
|
48
|
+
return bestDist <= max ? bestWord : word;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── Smart snippet (window centered on first match position) ───────────────────
|
|
52
|
+
|
|
53
|
+
function snippet(text, terms, windowSize = 200) {
|
|
54
|
+
if (!text) return text;
|
|
55
|
+
const lower = text.toLowerCase();
|
|
56
|
+
let bestPos = -1;
|
|
57
|
+
for (const term of terms) {
|
|
58
|
+
const idx = lower.indexOf(term.toLowerCase());
|
|
59
|
+
if (idx !== -1 && (bestPos === -1 || idx < bestPos)) bestPos = idx;
|
|
60
|
+
}
|
|
61
|
+
if (bestPos === -1) return text.slice(0, windowSize);
|
|
62
|
+
const start = Math.max(0, bestPos - Math.floor(windowSize / 3));
|
|
63
|
+
const end = Math.min(text.length, start + windowSize);
|
|
64
|
+
return (start > 0 ? '…' : '') + text.slice(start, end) + (end < text.length ? '…' : '');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── Unified search ────────────────────────────────────────────────────────────
|
|
68
|
+
|
|
11
69
|
/**
|
|
12
70
|
* @param {Object} opts
|
|
13
71
|
* @param {string} opts.query - search query (keyword/semantic)
|
|
@@ -21,7 +79,14 @@ export function search({ query, mode = 'semantic', project, limit = 10, id, comp
|
|
|
21
79
|
switch (mode) {
|
|
22
80
|
case 'keyword': {
|
|
23
81
|
if (!query) throw new Error('query required for keyword search');
|
|
24
|
-
|
|
82
|
+
if (!_vocabCache) {
|
|
83
|
+
_vocabCache = buildVocab(getContext({ project, limit: 500 }));
|
|
84
|
+
}
|
|
85
|
+
const terms = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
86
|
+
const corrected = terms.map(t => fuzzyCorrect(t, _vocabCache));
|
|
87
|
+
const correctedQuery = corrected.join(' ');
|
|
88
|
+
const results = searchContext({ query: correctedQuery, project, limit, compact: false });
|
|
89
|
+
return results.map(e => ({ ...e, content: snippet(e.content, corrected) }));
|
|
25
90
|
}
|
|
26
91
|
case 'semantic': {
|
|
27
92
|
if (!query) throw new Error('query required for semantic search');
|
|
@@ -33,7 +98,6 @@ export function search({ query, mode = 'semantic', project, limit = 10, id, comp
|
|
|
33
98
|
const all = getContext({ limit: 1000 });
|
|
34
99
|
const target = all.find(e => e.id === id || e.id.startsWith(id));
|
|
35
100
|
if (!target) throw new Error(`No entry found with id starting "${id}"`);
|
|
36
|
-
// explicit relations first, semantic enrichment for remainder
|
|
37
101
|
const explicitIds = new Set([
|
|
38
102
|
...(target.relations || []).map(r => r.id),
|
|
39
103
|
...(target.relatedBy || []).map(r => r.id),
|
package/src/templates/AGENTS.md
CHANGED
|
@@ -1,80 +1,90 @@
|
|
|
1
1
|
# Context-MCP — Codex CLI Usage Guide
|
|
2
2
|
|
|
3
3
|
Persistent memory + codebase knowledge graph.
|
|
4
|
-
|
|
4
|
+
`context.resume` starts every session. `codegraph_arch` shows module structure. `codegraph_query` finds specific symbols. Files only for bugs/logic.
|
|
5
5
|
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
## 1. Start of Every Conversation (MANDATORY)
|
|
9
9
|
|
|
10
|
-
Call
|
|
10
|
+
Call `context` MCP tool: `action:"resume"`, `project:"<project>"` before anything else.
|
|
11
11
|
|
|
12
|
-
Returns:
|
|
13
|
-
- `recentEntries` — decisions, bugs, notes from previous conversations
|
|
14
|
-
- `activePlans` — active AI-created plans for this project
|
|
15
|
-
- `codegraph` — `{ built: true/false, nodes, edges, communities }`
|
|
12
|
+
Returns: `recentEntries`, `activePlans`, `codegraph`, `stats.totalEntries`.
|
|
16
13
|
|
|
17
|
-
|
|
18
|
-
- `codegraph.built:
|
|
19
|
-
- `
|
|
14
|
+
- `codegraph.built: true` → use graph tools before reading files
|
|
15
|
+
- `codegraph.built: false` → call `codegraph_build(path)` first
|
|
16
|
+
- `stats.totalEntries ≥ 20` → write compaction summary FIRST (see Rule 4)
|
|
17
|
+
- `activePlans` non-empty → read them before starting new work
|
|
20
18
|
|
|
21
19
|
---
|
|
22
20
|
|
|
23
|
-
## 2.
|
|
21
|
+
## 2. Save Triggers (MANDATORY)
|
|
24
22
|
|
|
25
|
-
|
|
26
|
-
```
|
|
27
|
-
context.save type: "note" title: "ContextGraph built — <project>"
|
|
28
|
-
content: "nodes: X | edges: Y | communities: Z"
|
|
29
|
-
```
|
|
23
|
+
Call `context.save` with `type: "note"` after finishing anything worth keeping:
|
|
30
24
|
|
|
31
|
-
|
|
25
|
+
| Trigger | Required fields |
|
|
26
|
+
|---|---|
|
|
27
|
+
| Task / fix / feature complete | title, why, outcome, files[] |
|
|
28
|
+
| Decision made | title, why, outcome |
|
|
29
|
+
| Discovery / constraint / gotcha | title, content |
|
|
30
|
+
| Config / env / deploy info | title, content |
|
|
31
|
+
| Graph build complete | title, content (nodes/edges count) |
|
|
32
|
+
| User says "save this" | title, content |
|
|
33
|
+
| "compact now" / "compress memory" | `type:"compaction"`, full session summary |
|
|
32
34
|
|
|
33
|
-
**
|
|
35
|
+
**Do NOT save:** routine reads, search results, explanations of existing code.
|
|
36
|
+
|
|
37
|
+
---
|
|
34
38
|
|
|
35
|
-
|
|
36
|
-
|--------------|------|
|
|
37
|
-
| Approach / library / pattern decided | `decision` |
|
|
38
|
-
| Bug found, root cause known, or fixed | `bug` |
|
|
39
|
-
| Gotcha, constraint, discovery, structure understood | `note` |
|
|
40
|
-
| Config / env var / secret / deploy step | `config` |
|
|
39
|
+
## 3. Plans (MANDATORY for multi-file work)
|
|
41
40
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
41
|
+
**Create a plan when:** editing 2+ files, multi-step implementation, refactor, multi-file bug fix.
|
|
42
|
+
|
|
43
|
+
1. Call `plan.save` with name, content, project before starting
|
|
44
|
+
2. Call `plan.update status:"done"` when complete — deletes the plan
|
|
45
|
+
|
|
46
|
+
Check `activePlans` on resume — don't create duplicates.
|
|
45
47
|
|
|
46
48
|
---
|
|
47
49
|
|
|
48
|
-
##
|
|
50
|
+
## 4. Auto-Summary at ≥ 20 Entries (MANDATORY)
|
|
49
51
|
|
|
50
|
-
|
|
52
|
+
When `totalEntries ≥ 20`, call `context.save` BEFORE the user's task:
|
|
51
53
|
|
|
52
|
-
### Step 1 — Build (once, fast, local)
|
|
53
54
|
```
|
|
54
|
-
|
|
55
|
+
type: "compaction" title: "Session summary — <YYYY-MM-DD>"
|
|
56
|
+
content: "<what was built, decided, broke, current state>"
|
|
57
|
+
project: "<project>"
|
|
55
58
|
```
|
|
56
59
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
codegraph_report(path) → full graph analysis
|
|
63
|
-
```
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## 5. Search Before Asking
|
|
63
|
+
|
|
64
|
+
Call `search` before asking user to re-explain past work.
|
|
64
65
|
|
|
65
66
|
---
|
|
66
67
|
|
|
67
|
-
##
|
|
68
|
+
## 6. ContextGraph Tools
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
codegraph_build(path) → build AST graph (run once)
|
|
72
|
+
codegraph_arch(path, limit?) → module map: files, exports, imports
|
|
73
|
+
codegraph_query(path, question?, node?) → find symbol or answer structural question
|
|
74
|
+
codegraph_nodes(path, type) → list all nodes of a type
|
|
75
|
+
codegraph_report(path) → structural analysis
|
|
76
|
+
```
|
|
68
77
|
|
|
69
|
-
|
|
70
|
-
**File** — bugs, logic, tracing unexpected behavior.
|
|
78
|
+
Use `codegraph_arch` first. Never read files for structure questions.
|
|
71
79
|
|
|
72
80
|
---
|
|
73
81
|
|
|
74
|
-
##
|
|
82
|
+
## 7. Rules
|
|
75
83
|
|
|
76
|
-
1.
|
|
77
|
-
2.
|
|
78
|
-
3.
|
|
79
|
-
4.
|
|
80
|
-
5.
|
|
84
|
+
1. `context.resume` first — before any tool or response
|
|
85
|
+
2. Always pass `project`
|
|
86
|
+
3. Save on task complete — `why` + `outcome` + `files`
|
|
87
|
+
4. Compaction at ≥ 20 entries — before starting task
|
|
88
|
+
5. Plan before multi-file work — `status:"done"` deletes it
|
|
89
|
+
6. Search before asking about past work
|
|
90
|
+
7. Graph tools before files
|