@syntesseraai/opencode-feature-factory 0.3.0 → 0.3.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 +27 -0
- package/agents/building.md +0 -1
- package/agents/ff-acceptance.md +0 -2
- package/agents/ff-research.md +0 -1
- package/agents/ff-review.md +0 -2
- package/agents/ff-security.md +0 -2
- package/agents/ff-validate.md +0 -2
- package/agents/ff-well-architected.md +0 -2
- package/agents/planning.md +0 -1
- package/agents/reviewing.md +0 -1
- package/bin/ff-deploy.js +5 -0
- package/bin/ff-local-recall-mcp.js +9 -0
- package/dist/index.js +16 -1
- package/dist/local-recall/daemon-controller.d.ts +51 -0
- package/dist/local-recall/daemon-controller.js +166 -0
- package/dist/local-recall/index-state.d.ts +14 -0
- package/dist/local-recall/index-state.js +76 -0
- package/dist/local-recall/index.d.ts +8 -2
- package/dist/local-recall/index.js +9 -2
- package/dist/local-recall/mcp-server.d.ts +29 -33
- package/dist/local-recall/mcp-server.js +172 -53
- package/dist/local-recall/mcp-stdio-server.d.ts +4 -0
- package/dist/local-recall/mcp-stdio-server.js +225 -0
- package/dist/local-recall/mcp-tools.d.ts +24 -11
- package/dist/local-recall/mcp-tools.js +112 -87
- package/dist/local-recall/vector/embedding-provider.d.ts +37 -0
- package/dist/local-recall/vector/embedding-provider.js +184 -0
- package/dist/local-recall/vector/orama-index.d.ts +37 -0
- package/dist/local-recall/vector/orama-index.js +379 -0
- package/dist/local-recall/vector/types.d.ts +33 -0
- package/dist/local-recall/vector/types.js +1 -0
- package/dist/mcp-config.d.ts +63 -0
- package/dist/mcp-config.js +121 -0
- package/package.json +5 -2
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import { create, insert, search as oramaSearch } from '@orama/orama';
|
|
2
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
const INDEX_VERSION = 1;
|
|
5
|
+
const EMBED_BATCH_SIZE = 16;
|
|
6
|
+
function clamp(value, min, max) {
|
|
7
|
+
return Math.min(Math.max(value, min), max);
|
|
8
|
+
}
|
|
9
|
+
function isVector(value) {
|
|
10
|
+
return Array.isArray(value) && value.every((entry) => typeof entry === 'number');
|
|
11
|
+
}
|
|
12
|
+
function matchesFilters(document, criteria) {
|
|
13
|
+
if (criteria.category && document.category !== criteria.category) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
if (criteria.sessionID && document.sessionID !== criteria.sessionID) {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
if (criteria.minImportance !== undefined && document.importance < criteria.minImportance) {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
if (criteria.tags && criteria.tags.length > 0) {
|
|
23
|
+
const expected = new Set(criteria.tags.map((tag) => tag.toLowerCase()));
|
|
24
|
+
const actual = new Set(document.tags.map((tag) => tag.toLowerCase()));
|
|
25
|
+
const hasAnyTag = Array.from(expected).some((tag) => actual.has(tag));
|
|
26
|
+
if (!hasAnyTag) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
function toSearchResult(document, relevance) {
|
|
33
|
+
return {
|
|
34
|
+
id: document.id,
|
|
35
|
+
sessionID: document.sessionID,
|
|
36
|
+
category: document.category,
|
|
37
|
+
title: document.title,
|
|
38
|
+
tags: document.tags,
|
|
39
|
+
importance: document.importance,
|
|
40
|
+
createdAt: document.createdAt,
|
|
41
|
+
relevance,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function validateDocument(raw) {
|
|
45
|
+
if (!raw || typeof raw !== 'object') {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
const candidate = raw;
|
|
49
|
+
if (typeof candidate.id !== 'string' ||
|
|
50
|
+
typeof candidate.sessionID !== 'string' ||
|
|
51
|
+
typeof candidate.messageID !== 'string' ||
|
|
52
|
+
typeof candidate.category !== 'string' ||
|
|
53
|
+
typeof candidate.title !== 'string' ||
|
|
54
|
+
typeof candidate.body !== 'string' ||
|
|
55
|
+
!Array.isArray(candidate.tags) ||
|
|
56
|
+
!candidate.tags.every((tag) => typeof tag === 'string') ||
|
|
57
|
+
typeof candidate.importance !== 'number' ||
|
|
58
|
+
typeof candidate.createdAt !== 'number' ||
|
|
59
|
+
!isVector(candidate.embedding)) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
return {
|
|
63
|
+
id: candidate.id,
|
|
64
|
+
sessionID: candidate.sessionID,
|
|
65
|
+
messageID: candidate.messageID,
|
|
66
|
+
category: candidate.category,
|
|
67
|
+
title: candidate.title,
|
|
68
|
+
body: candidate.body,
|
|
69
|
+
tags: candidate.tags,
|
|
70
|
+
importance: candidate.importance,
|
|
71
|
+
createdAt: candidate.createdAt,
|
|
72
|
+
embedding: candidate.embedding,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
export class OramaMemoryIndex {
|
|
76
|
+
directory;
|
|
77
|
+
provider;
|
|
78
|
+
db = null;
|
|
79
|
+
documents = new Map();
|
|
80
|
+
dimensions = null;
|
|
81
|
+
updatedAt = null;
|
|
82
|
+
constructor(directory, provider) {
|
|
83
|
+
this.directory = directory;
|
|
84
|
+
this.provider = provider;
|
|
85
|
+
}
|
|
86
|
+
async initialize() {
|
|
87
|
+
await mkdir(this.indexDir, { recursive: true });
|
|
88
|
+
await this.loadSnapshot();
|
|
89
|
+
if (this.documents.size > 0 && this.dimensions) {
|
|
90
|
+
await this.ensureDB(this.dimensions);
|
|
91
|
+
await this.hydrateDB();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
getStatus() {
|
|
95
|
+
return {
|
|
96
|
+
documents: this.documents.size,
|
|
97
|
+
dimensions: this.dimensions,
|
|
98
|
+
provider: this.provider.name,
|
|
99
|
+
model: this.provider.model,
|
|
100
|
+
updatedAt: this.updatedAt,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
async search(criteria) {
|
|
104
|
+
const limit = clamp(criteria.limit ?? 10, 1, 50);
|
|
105
|
+
if (this.documents.size === 0) {
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
const query = criteria.query.trim();
|
|
109
|
+
if (!query) {
|
|
110
|
+
const filtered = Array.from(this.documents.values())
|
|
111
|
+
.filter((document) => matchesFilters(document, criteria))
|
|
112
|
+
.sort((a, b) => b.importance - a.importance || b.createdAt - a.createdAt)
|
|
113
|
+
.slice(0, limit)
|
|
114
|
+
.map((document) => toSearchResult(document, 0));
|
|
115
|
+
return filtered;
|
|
116
|
+
}
|
|
117
|
+
const [queryEmbedding] = await this.provider.embed([query]);
|
|
118
|
+
if (!queryEmbedding) {
|
|
119
|
+
return [];
|
|
120
|
+
}
|
|
121
|
+
await this.ensureDB(queryEmbedding.length);
|
|
122
|
+
if (!this.db) {
|
|
123
|
+
return [];
|
|
124
|
+
}
|
|
125
|
+
const response = (await oramaSearch(this.db, {
|
|
126
|
+
mode: 'hybrid',
|
|
127
|
+
term: query,
|
|
128
|
+
vector: {
|
|
129
|
+
property: 'embedding',
|
|
130
|
+
value: queryEmbedding,
|
|
131
|
+
},
|
|
132
|
+
limit: Math.max(limit * 4, 10),
|
|
133
|
+
}));
|
|
134
|
+
const matches = [];
|
|
135
|
+
for (const hit of response.hits ?? []) {
|
|
136
|
+
const document = hit.document ?? (hit.id ? this.documents.get(hit.id) : undefined);
|
|
137
|
+
if (!document || !matchesFilters(document, criteria)) {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
matches.push(toSearchResult(document, hit.score ?? 0));
|
|
141
|
+
}
|
|
142
|
+
matches.sort((a, b) => {
|
|
143
|
+
const scoreA = a.relevance * a.importance;
|
|
144
|
+
const scoreB = b.relevance * b.importance;
|
|
145
|
+
return scoreB - scoreA;
|
|
146
|
+
});
|
|
147
|
+
return matches.slice(0, limit);
|
|
148
|
+
}
|
|
149
|
+
async upsertMemories(memories) {
|
|
150
|
+
if (memories.length === 0) {
|
|
151
|
+
return 0;
|
|
152
|
+
}
|
|
153
|
+
const inputs = memories.map((memory) => this.memoryToEmbeddingInput(memory));
|
|
154
|
+
const embeddings = await this.embedBatch(inputs);
|
|
155
|
+
const dimensions = this.assertEmbeddingDimensions(embeddings);
|
|
156
|
+
for (let index = 0; index < memories.length; index++) {
|
|
157
|
+
const memory = memories[index];
|
|
158
|
+
const embedding = embeddings[index];
|
|
159
|
+
if (!memory || !embedding) {
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
const document = this.toDocument(memory, embedding);
|
|
163
|
+
this.documents.set(document.id, document);
|
|
164
|
+
}
|
|
165
|
+
await this.rebuildDatabaseFromDocuments(dimensions);
|
|
166
|
+
this.updatedAt = new Date().toISOString();
|
|
167
|
+
await this.persistSnapshot();
|
|
168
|
+
return memories.length;
|
|
169
|
+
}
|
|
170
|
+
async removeMemories(memoryIDs) {
|
|
171
|
+
if (memoryIDs.length === 0) {
|
|
172
|
+
return 0;
|
|
173
|
+
}
|
|
174
|
+
let removedCount = 0;
|
|
175
|
+
for (const memoryID of memoryIDs) {
|
|
176
|
+
if (!this.documents.has(memoryID)) {
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
this.documents.delete(memoryID);
|
|
180
|
+
removedCount++;
|
|
181
|
+
}
|
|
182
|
+
if (removedCount > 0) {
|
|
183
|
+
await this.rebuildDatabaseFromDocuments();
|
|
184
|
+
this.updatedAt = new Date().toISOString();
|
|
185
|
+
await this.persistSnapshot();
|
|
186
|
+
}
|
|
187
|
+
return removedCount;
|
|
188
|
+
}
|
|
189
|
+
async rebuild(memories) {
|
|
190
|
+
this.documents.clear();
|
|
191
|
+
if (memories.length === 0) {
|
|
192
|
+
this.db = null;
|
|
193
|
+
this.dimensions = this.provider.dimensions ?? null;
|
|
194
|
+
this.updatedAt = new Date().toISOString();
|
|
195
|
+
await this.persistSnapshot();
|
|
196
|
+
return 0;
|
|
197
|
+
}
|
|
198
|
+
const inputs = memories.map((memory) => this.memoryToEmbeddingInput(memory));
|
|
199
|
+
const embeddings = await this.embedBatch(inputs);
|
|
200
|
+
const dimensions = this.assertEmbeddingDimensions(embeddings);
|
|
201
|
+
for (let index = 0; index < memories.length; index++) {
|
|
202
|
+
const memory = memories[index];
|
|
203
|
+
const embedding = embeddings[index];
|
|
204
|
+
if (!memory || !embedding) {
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
const document = this.toDocument(memory, embedding);
|
|
208
|
+
this.documents.set(document.id, document);
|
|
209
|
+
}
|
|
210
|
+
await this.rebuildDatabaseFromDocuments(dimensions);
|
|
211
|
+
this.updatedAt = new Date().toISOString();
|
|
212
|
+
await this.persistSnapshot();
|
|
213
|
+
return memories.length;
|
|
214
|
+
}
|
|
215
|
+
get indexDir() {
|
|
216
|
+
return path.join(this.directory, '.feature-factory', 'local-recall', 'index');
|
|
217
|
+
}
|
|
218
|
+
get manifestPath() {
|
|
219
|
+
return path.join(this.indexDir, 'manifest.json');
|
|
220
|
+
}
|
|
221
|
+
get documentsPath() {
|
|
222
|
+
return path.join(this.indexDir, 'documents.json');
|
|
223
|
+
}
|
|
224
|
+
buildSchema(dimensions) {
|
|
225
|
+
return {
|
|
226
|
+
id: 'string',
|
|
227
|
+
sessionID: 'string',
|
|
228
|
+
messageID: 'string',
|
|
229
|
+
category: 'string',
|
|
230
|
+
title: 'string',
|
|
231
|
+
body: 'string',
|
|
232
|
+
tags: 'string[]',
|
|
233
|
+
importance: 'number',
|
|
234
|
+
createdAt: 'number',
|
|
235
|
+
embedding: `vector[${dimensions}]`,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
async ensureDB(dimensions) {
|
|
239
|
+
if (this.db && this.dimensions === dimensions) {
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
this.dimensions = dimensions;
|
|
243
|
+
this.db = await create({
|
|
244
|
+
schema: this.buildSchema(dimensions),
|
|
245
|
+
});
|
|
246
|
+
await this.hydrateDB();
|
|
247
|
+
}
|
|
248
|
+
async rebuildDatabaseFromDocuments(dimensions) {
|
|
249
|
+
if (this.documents.size === 0) {
|
|
250
|
+
this.db = null;
|
|
251
|
+
if (dimensions) {
|
|
252
|
+
this.dimensions = dimensions;
|
|
253
|
+
}
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
const inferredDimensions = dimensions ??
|
|
257
|
+
this.dimensions ??
|
|
258
|
+
Array.from(this.documents.values())[0]?.embedding.length ??
|
|
259
|
+
0;
|
|
260
|
+
if (inferredDimensions <= 0) {
|
|
261
|
+
throw new Error('Cannot rebuild vector index without known embedding dimensions');
|
|
262
|
+
}
|
|
263
|
+
this.dimensions = inferredDimensions;
|
|
264
|
+
this.db = await create({
|
|
265
|
+
schema: this.buildSchema(inferredDimensions),
|
|
266
|
+
});
|
|
267
|
+
for (const document of this.documents.values()) {
|
|
268
|
+
if (document.embedding.length !== inferredDimensions) {
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
await insert(this.db, document);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
async hydrateDB() {
|
|
275
|
+
if (!this.db) {
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
for (const document of this.documents.values()) {
|
|
279
|
+
if (this.dimensions && document.embedding.length !== this.dimensions) {
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
await insert(this.db, document);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
async loadSnapshot() {
|
|
286
|
+
let manifest = null;
|
|
287
|
+
let documents = [];
|
|
288
|
+
try {
|
|
289
|
+
const rawManifest = await readFile(this.manifestPath, 'utf-8');
|
|
290
|
+
manifest = JSON.parse(rawManifest);
|
|
291
|
+
}
|
|
292
|
+
catch {
|
|
293
|
+
manifest = null;
|
|
294
|
+
}
|
|
295
|
+
try {
|
|
296
|
+
const rawDocuments = await readFile(this.documentsPath, 'utf-8');
|
|
297
|
+
documents = JSON.parse(rawDocuments);
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
documents = [];
|
|
301
|
+
}
|
|
302
|
+
for (const raw of documents) {
|
|
303
|
+
const document = validateDocument(raw);
|
|
304
|
+
if (!document) {
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
this.documents.set(document.id, document);
|
|
308
|
+
}
|
|
309
|
+
const inferredDimensions = Array.from(this.documents.values())[0]?.embedding.length ?? null;
|
|
310
|
+
this.dimensions =
|
|
311
|
+
manifest && typeof manifest.dimensions === 'number' && manifest.dimensions > 0
|
|
312
|
+
? manifest.dimensions
|
|
313
|
+
: inferredDimensions;
|
|
314
|
+
this.updatedAt = manifest?.updatedAt ?? null;
|
|
315
|
+
}
|
|
316
|
+
async persistSnapshot() {
|
|
317
|
+
await mkdir(this.indexDir, { recursive: true });
|
|
318
|
+
const manifest = {
|
|
319
|
+
version: INDEX_VERSION,
|
|
320
|
+
provider: this.provider.name,
|
|
321
|
+
model: this.provider.model,
|
|
322
|
+
dimensions: this.dimensions ?? this.provider.dimensions ?? 0,
|
|
323
|
+
documents: this.documents.size,
|
|
324
|
+
updatedAt: this.updatedAt ?? new Date().toISOString(),
|
|
325
|
+
};
|
|
326
|
+
await this.writeAtomic(this.manifestPath, manifest);
|
|
327
|
+
await this.writeAtomic(this.documentsPath, Array.from(this.documents.values()));
|
|
328
|
+
}
|
|
329
|
+
async writeAtomic(filePath, value) {
|
|
330
|
+
const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
|
331
|
+
await writeFile(tmpPath, JSON.stringify(value, null, 2), 'utf-8');
|
|
332
|
+
await rename(tmpPath, filePath);
|
|
333
|
+
}
|
|
334
|
+
memoryToEmbeddingInput(memory) {
|
|
335
|
+
return `${memory.title}\n\n${memory.body}`.trim();
|
|
336
|
+
}
|
|
337
|
+
toDocument(memory, embedding) {
|
|
338
|
+
return {
|
|
339
|
+
id: memory.id,
|
|
340
|
+
sessionID: memory.sessionID,
|
|
341
|
+
messageID: memory.messageID,
|
|
342
|
+
category: memory.category,
|
|
343
|
+
title: memory.title,
|
|
344
|
+
body: memory.body,
|
|
345
|
+
tags: memory.tags,
|
|
346
|
+
importance: memory.importance,
|
|
347
|
+
createdAt: memory.createdAt,
|
|
348
|
+
embedding,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
assertEmbeddingDimensions(vectors) {
|
|
352
|
+
const first = vectors[0];
|
|
353
|
+
if (!first) {
|
|
354
|
+
throw new Error('Embedding provider returned no vectors');
|
|
355
|
+
}
|
|
356
|
+
const dimensions = first.length;
|
|
357
|
+
if (dimensions === 0) {
|
|
358
|
+
throw new Error('Embedding provider returned empty vectors');
|
|
359
|
+
}
|
|
360
|
+
for (const vector of vectors) {
|
|
361
|
+
if (vector.length !== dimensions) {
|
|
362
|
+
throw new Error('Embedding provider returned mixed dimensions');
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
if (this.dimensions && this.dimensions !== dimensions) {
|
|
366
|
+
throw new Error(`Embedding dimensions changed from ${this.dimensions} to ${dimensions}; rebuild required`);
|
|
367
|
+
}
|
|
368
|
+
return dimensions;
|
|
369
|
+
}
|
|
370
|
+
async embedBatch(input) {
|
|
371
|
+
const embeddings = [];
|
|
372
|
+
for (let index = 0; index < input.length; index += EMBED_BATCH_SIZE) {
|
|
373
|
+
const chunk = input.slice(index, index + EMBED_BATCH_SIZE);
|
|
374
|
+
const chunkEmbeddings = await this.provider.embed(chunk);
|
|
375
|
+
embeddings.push(...chunkEmbeddings);
|
|
376
|
+
}
|
|
377
|
+
return embeddings;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { MemoryCategory, MemorySearchResult } from '../types.js';
|
|
2
|
+
export type EmbeddingProviderName = 'ollama' | 'openai';
|
|
3
|
+
export interface EmbeddingProvider {
|
|
4
|
+
readonly name: EmbeddingProviderName;
|
|
5
|
+
readonly model: string;
|
|
6
|
+
readonly dimensions?: number;
|
|
7
|
+
embed(input: string[]): Promise<number[][]>;
|
|
8
|
+
}
|
|
9
|
+
export interface VectorIndexManifest {
|
|
10
|
+
version: number;
|
|
11
|
+
provider: EmbeddingProviderName;
|
|
12
|
+
model: string;
|
|
13
|
+
dimensions: number;
|
|
14
|
+
documents: number;
|
|
15
|
+
updatedAt: string;
|
|
16
|
+
}
|
|
17
|
+
export interface VectorIndexDocument {
|
|
18
|
+
id: string;
|
|
19
|
+
sessionID: string;
|
|
20
|
+
messageID: string;
|
|
21
|
+
category: MemoryCategory;
|
|
22
|
+
title: string;
|
|
23
|
+
body: string;
|
|
24
|
+
tags: string[];
|
|
25
|
+
importance: number;
|
|
26
|
+
createdAt: number;
|
|
27
|
+
embedding: number[];
|
|
28
|
+
}
|
|
29
|
+
export interface VectorSearchResponse {
|
|
30
|
+
backend: 'vector' | 'lexical';
|
|
31
|
+
results: MemorySearchResult[];
|
|
32
|
+
fallbackReason?: string;
|
|
33
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
type BunShell = any;
|
|
2
|
+
/**
|
|
3
|
+
* Default MCP server configuration to be added by the plugin
|
|
4
|
+
* These servers will be merged into the project's opencode.json
|
|
5
|
+
*/
|
|
6
|
+
export declare const DEFAULT_MCP_SERVERS: {
|
|
7
|
+
readonly 'ff-local-recall': {
|
|
8
|
+
readonly type: "local";
|
|
9
|
+
readonly command: "ff-local-recall-mcp";
|
|
10
|
+
readonly enabled: true;
|
|
11
|
+
};
|
|
12
|
+
readonly 'jina-ai': {
|
|
13
|
+
readonly type: "remote";
|
|
14
|
+
readonly url: "https://mcp.jina.ai/v1";
|
|
15
|
+
readonly headers: {
|
|
16
|
+
readonly Authorization: "Bearer {env:JINAAI_API_KEY}";
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
readonly gh_grep: {
|
|
20
|
+
readonly type: "remote";
|
|
21
|
+
readonly url: "https://mcp.grep.app";
|
|
22
|
+
};
|
|
23
|
+
readonly context7: {
|
|
24
|
+
readonly type: "remote";
|
|
25
|
+
readonly url: "https://mcp.context7.com/mcp";
|
|
26
|
+
readonly headers: {
|
|
27
|
+
readonly CONTEXT7_API_KEY: "{env:CONTEXT7_API_KEY}";
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
export interface MCPServerConfig {
|
|
32
|
+
type: 'local' | 'remote';
|
|
33
|
+
command?: string;
|
|
34
|
+
url?: string;
|
|
35
|
+
headers?: Record<string, string>;
|
|
36
|
+
enabled?: boolean;
|
|
37
|
+
}
|
|
38
|
+
export interface MCPServers {
|
|
39
|
+
[serverName: string]: MCPServerConfig;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Merge MCP servers, preserving existing servers and adding new ones.
|
|
43
|
+
* Existing servers take precedence (we never overwrite them).
|
|
44
|
+
*/
|
|
45
|
+
export declare function mergeMCPServers(existing: MCPServers | undefined, defaults: typeof DEFAULT_MCP_SERVERS): MCPServers;
|
|
46
|
+
/**
|
|
47
|
+
* Update the MCP servers configuration in opencode.json files.
|
|
48
|
+
*
|
|
49
|
+
* This function:
|
|
50
|
+
* 1. Reads existing config from both root and .opencode directories
|
|
51
|
+
* 2. Merges MCP servers from both configs (preserving all existing servers)
|
|
52
|
+
* 3. Adds default Feature Factory MCP servers that don't exist
|
|
53
|
+
* 4. Writes updated config to .opencode/opencode.json
|
|
54
|
+
*
|
|
55
|
+
* Note: Writing to .opencode/opencode.json keeps Feature Factory MCP config
|
|
56
|
+
* separate from the user's root opencode.json, following the same pattern
|
|
57
|
+
* as the quality gate configuration.
|
|
58
|
+
*
|
|
59
|
+
* @param $ - Bun shell instance
|
|
60
|
+
* @param directory - The project directory
|
|
61
|
+
*/
|
|
62
|
+
export declare function updateMCPConfig($: BunShell, directory: string): Promise<void>;
|
|
63
|
+
export {};
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { readJsonFile } from './quality-gate-config.js';
|
|
2
|
+
/**
|
|
3
|
+
* Default MCP server configuration to be added by the plugin
|
|
4
|
+
* These servers will be merged into the project's opencode.json
|
|
5
|
+
*/
|
|
6
|
+
export const DEFAULT_MCP_SERVERS = {
|
|
7
|
+
'ff-local-recall': {
|
|
8
|
+
type: 'local',
|
|
9
|
+
command: 'ff-local-recall-mcp',
|
|
10
|
+
enabled: true,
|
|
11
|
+
},
|
|
12
|
+
'jina-ai': {
|
|
13
|
+
type: 'remote',
|
|
14
|
+
url: 'https://mcp.jina.ai/v1',
|
|
15
|
+
headers: {
|
|
16
|
+
Authorization: 'Bearer {env:JINAAI_API_KEY}',
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
gh_grep: {
|
|
20
|
+
type: 'remote',
|
|
21
|
+
url: 'https://mcp.grep.app',
|
|
22
|
+
},
|
|
23
|
+
context7: {
|
|
24
|
+
type: 'remote',
|
|
25
|
+
url: 'https://mcp.context7.com/mcp',
|
|
26
|
+
headers: {
|
|
27
|
+
CONTEXT7_API_KEY: '{env:CONTEXT7_API_KEY}',
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* Merge MCP servers, preserving existing servers and adding new ones.
|
|
33
|
+
* Existing servers take precedence (we never overwrite them).
|
|
34
|
+
*/
|
|
35
|
+
export function mergeMCPServers(existing, defaults) {
|
|
36
|
+
const existingServers = existing ?? {};
|
|
37
|
+
const result = { ...existingServers };
|
|
38
|
+
// Add default servers that don't exist yet
|
|
39
|
+
for (const [serverName, serverConfig] of Object.entries(defaults)) {
|
|
40
|
+
if (!result[serverName]) {
|
|
41
|
+
result[serverName] = serverConfig;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return result;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Update the MCP servers configuration in opencode.json files.
|
|
48
|
+
*
|
|
49
|
+
* This function:
|
|
50
|
+
* 1. Reads existing config from both root and .opencode directories
|
|
51
|
+
* 2. Merges MCP servers from both configs (preserving all existing servers)
|
|
52
|
+
* 3. Adds default Feature Factory MCP servers that don't exist
|
|
53
|
+
* 4. Writes updated config to .opencode/opencode.json
|
|
54
|
+
*
|
|
55
|
+
* Note: Writing to .opencode/opencode.json keeps Feature Factory MCP config
|
|
56
|
+
* separate from the user's root opencode.json, following the same pattern
|
|
57
|
+
* as the quality gate configuration.
|
|
58
|
+
*
|
|
59
|
+
* @param $ - Bun shell instance
|
|
60
|
+
* @param directory - The project directory
|
|
61
|
+
*/
|
|
62
|
+
export async function updateMCPConfig($, directory) {
|
|
63
|
+
const rootConfigPath = `${directory}/opencode.json`;
|
|
64
|
+
const dotOpencodeConfigPath = `${directory}/.opencode/opencode.json`;
|
|
65
|
+
const dotOpencodeDir = `${directory}/.opencode`;
|
|
66
|
+
// Read existing configs
|
|
67
|
+
const [rootJson, dotOpencodeJson] = await Promise.all([
|
|
68
|
+
readJsonFile($, rootConfigPath),
|
|
69
|
+
readJsonFile($, dotOpencodeConfigPath),
|
|
70
|
+
]);
|
|
71
|
+
// Get existing MCP servers from both configs
|
|
72
|
+
const rootMcpServers = (rootJson?.mcp ?? {});
|
|
73
|
+
const dotOpencodeMcpServers = (dotOpencodeJson?.mcp ?? {});
|
|
74
|
+
// Merge existing servers (dotOpencode overrides root)
|
|
75
|
+
const existingMcpServers = {
|
|
76
|
+
...rootMcpServers,
|
|
77
|
+
...dotOpencodeMcpServers,
|
|
78
|
+
};
|
|
79
|
+
// Merge with default MCP servers
|
|
80
|
+
const updatedMcpServers = mergeMCPServers(existingMcpServers, DEFAULT_MCP_SERVERS);
|
|
81
|
+
// Check if any changes are needed
|
|
82
|
+
const hasChanges = Object.keys(DEFAULT_MCP_SERVERS).some((serverName) => !existingMcpServers[serverName]);
|
|
83
|
+
if (!hasChanges) {
|
|
84
|
+
// All default servers already exist, no need to update
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
// Prepare updated config for .opencode/opencode.json
|
|
88
|
+
const updatedDotOpencodeConfig = {
|
|
89
|
+
...(dotOpencodeJson ?? {}),
|
|
90
|
+
mcp: updatedMcpServers,
|
|
91
|
+
};
|
|
92
|
+
// Ensure .opencode directory exists
|
|
93
|
+
try {
|
|
94
|
+
await $ `mkdir -p ${dotOpencodeDir}`.quiet();
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// Directory might already exist, ignore
|
|
98
|
+
}
|
|
99
|
+
// Backup existing .opencode/opencode.json if it exists and has content
|
|
100
|
+
if (dotOpencodeJson && Object.keys(dotOpencodeJson).length > 0) {
|
|
101
|
+
try {
|
|
102
|
+
const timestamp = new Date().toISOString().split('T')[0].replace(/-/g, '');
|
|
103
|
+
const backupPath = `${dotOpencodeConfigPath}.backup.${timestamp}`;
|
|
104
|
+
const backupContent = JSON.stringify(dotOpencodeJson, null, 2);
|
|
105
|
+
await $ `echo ${backupContent} > ${backupPath}`.quiet();
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
// Backup failed, but continue anyway
|
|
109
|
+
console.warn('[feature-factory] Could not create backup:', error);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// Write updated config to .opencode/opencode.json
|
|
113
|
+
const configContent = JSON.stringify(updatedDotOpencodeConfig, null, 2);
|
|
114
|
+
try {
|
|
115
|
+
await $ `echo ${configContent} > ${dotOpencodeConfigPath}`.quiet();
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
// Silently fail - don't block if we can't write the config
|
|
119
|
+
console.warn('[feature-factory] Could not update MCP config:', error);
|
|
120
|
+
}
|
|
121
|
+
}
|
package/package.json
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://json.schemastore.org/package.json",
|
|
3
3
|
"name": "@syntesseraai/opencode-feature-factory",
|
|
4
|
-
"version": "0.3.
|
|
4
|
+
"version": "0.3.1",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "OpenCode plugin for Feature Factory agents - provides sub-agents and skills for validation, review, security, and architecture assessment",
|
|
7
7
|
"license": "MIT",
|
|
8
8
|
"main": "./dist/index.js",
|
|
9
9
|
"types": "./dist/index.d.ts",
|
|
10
10
|
"bin": {
|
|
11
|
-
"ff-deploy": "./bin/ff-deploy.js"
|
|
11
|
+
"ff-deploy": "./bin/ff-deploy.js",
|
|
12
|
+
"ff-local-recall-mcp": "./bin/ff-local-recall-mcp.js"
|
|
12
13
|
},
|
|
13
14
|
"files": [
|
|
14
15
|
"dist",
|
|
@@ -32,7 +33,9 @@
|
|
|
32
33
|
],
|
|
33
34
|
"scripts": {},
|
|
34
35
|
"dependencies": {
|
|
36
|
+
"@modelcontextprotocol/sdk": "^1.0.0",
|
|
35
37
|
"@opencode-ai/plugin": "^1.1.48",
|
|
38
|
+
"@orama/orama": "^3.0.0",
|
|
36
39
|
"glob": "^10.0.0",
|
|
37
40
|
"uuid": "^9.0.0"
|
|
38
41
|
},
|