fossel 1.1.0 → 1.2.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/README.md +91 -17
- package/dist/cli.js +739 -174
- package/dist/index.js +426 -73
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -164,6 +164,25 @@ var init_migrate = __esm({
|
|
|
164
164
|
tx(rows);
|
|
165
165
|
}
|
|
166
166
|
}
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
name: "007_add_memory_embeddings",
|
|
170
|
+
apply: (db) => {
|
|
171
|
+
db.exec(`
|
|
172
|
+
CREATE TABLE IF NOT EXISTS memory_embeddings (
|
|
173
|
+
memory_rowid INTEGER PRIMARY KEY,
|
|
174
|
+
dim INTEGER NOT NULL,
|
|
175
|
+
version INTEGER NOT NULL,
|
|
176
|
+
vector BLOB NOT NULL,
|
|
177
|
+
updated_at INTEGER NOT NULL
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
CREATE TRIGGER IF NOT EXISTS memories_embeddings_ad
|
|
181
|
+
AFTER DELETE ON memories BEGIN
|
|
182
|
+
DELETE FROM memory_embeddings WHERE memory_rowid = old.rowid;
|
|
183
|
+
END;
|
|
184
|
+
`);
|
|
185
|
+
}
|
|
167
186
|
}
|
|
168
187
|
];
|
|
169
188
|
}
|
|
@@ -215,6 +234,96 @@ var init_client = __esm({
|
|
|
215
234
|
}
|
|
216
235
|
});
|
|
217
236
|
|
|
237
|
+
// src/lib/dedupe.ts
|
|
238
|
+
function normalizeText(text) {
|
|
239
|
+
return text.toLowerCase().replace(/[^a-z0-9\s]/g, " ").replace(/\s+/g, " ").trim();
|
|
240
|
+
}
|
|
241
|
+
function tokenize(text) {
|
|
242
|
+
return normalizeText(text).split(" ").filter((token) => token.length >= 2);
|
|
243
|
+
}
|
|
244
|
+
function trigrams(text) {
|
|
245
|
+
const padded = ` ${text} `;
|
|
246
|
+
const grams = /* @__PURE__ */ new Set();
|
|
247
|
+
for (let i = 0; i < padded.length - 2; i += 1) {
|
|
248
|
+
grams.add(padded.slice(i, i + 3));
|
|
249
|
+
}
|
|
250
|
+
return grams;
|
|
251
|
+
}
|
|
252
|
+
function jaccard(a, b) {
|
|
253
|
+
if (a.size === 0 && b.size === 0) {
|
|
254
|
+
return 1;
|
|
255
|
+
}
|
|
256
|
+
let intersection = 0;
|
|
257
|
+
for (const value of a) {
|
|
258
|
+
if (b.has(value)) {
|
|
259
|
+
intersection += 1;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
const union = a.size + b.size - intersection;
|
|
263
|
+
return union === 0 ? 0 : intersection / union;
|
|
264
|
+
}
|
|
265
|
+
function similarity(a, b) {
|
|
266
|
+
const normalizedA = normalizeText(a);
|
|
267
|
+
const normalizedB = normalizeText(b);
|
|
268
|
+
if (!normalizedA && !normalizedB) {
|
|
269
|
+
return 1;
|
|
270
|
+
}
|
|
271
|
+
if (!normalizedA || !normalizedB) {
|
|
272
|
+
return 0;
|
|
273
|
+
}
|
|
274
|
+
if (normalizedA === normalizedB) {
|
|
275
|
+
return 1;
|
|
276
|
+
}
|
|
277
|
+
const wordScore = jaccard(new Set(tokenize(normalizedA)), new Set(tokenize(normalizedB)));
|
|
278
|
+
const triScore = jaccard(trigrams(normalizedA), trigrams(normalizedB));
|
|
279
|
+
return wordScore * 0.55 + triScore * 0.45;
|
|
280
|
+
}
|
|
281
|
+
function findDuplicate(db, repo, note, options = {}) {
|
|
282
|
+
const threshold = options.threshold ?? DEFAULT_THRESHOLD;
|
|
283
|
+
const limit = options.candidateLimit ?? DEFAULT_CANDIDATE_LIMIT;
|
|
284
|
+
const normalized = normalizeText(note);
|
|
285
|
+
if (!normalized) {
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
const exact = db.prepare(
|
|
289
|
+
`
|
|
290
|
+
SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned
|
|
291
|
+
FROM memories
|
|
292
|
+
WHERE repo = ? AND note_normalized = ?
|
|
293
|
+
ORDER BY updated_at DESC
|
|
294
|
+
LIMIT 1
|
|
295
|
+
`
|
|
296
|
+
).get(repo, normalized);
|
|
297
|
+
if (exact) {
|
|
298
|
+
return { memory: exact, similarity: 1 };
|
|
299
|
+
}
|
|
300
|
+
const candidates = db.prepare(
|
|
301
|
+
`
|
|
302
|
+
SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned
|
|
303
|
+
FROM memories
|
|
304
|
+
WHERE repo = ?
|
|
305
|
+
ORDER BY updated_at DESC
|
|
306
|
+
LIMIT ?
|
|
307
|
+
`
|
|
308
|
+
).all(repo, limit);
|
|
309
|
+
let best = null;
|
|
310
|
+
for (const candidate of candidates) {
|
|
311
|
+
const score = similarity(note, candidate.note);
|
|
312
|
+
if (score >= threshold && (!best || score > best.similarity)) {
|
|
313
|
+
best = { memory: candidate, similarity: score };
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return best;
|
|
317
|
+
}
|
|
318
|
+
var DEFAULT_THRESHOLD, DEFAULT_CANDIDATE_LIMIT;
|
|
319
|
+
var init_dedupe = __esm({
|
|
320
|
+
"src/lib/dedupe.ts"() {
|
|
321
|
+
"use strict";
|
|
322
|
+
DEFAULT_THRESHOLD = 0.82;
|
|
323
|
+
DEFAULT_CANDIDATE_LIMIT = 200;
|
|
324
|
+
}
|
|
325
|
+
});
|
|
326
|
+
|
|
218
327
|
// src/lib/repo.ts
|
|
219
328
|
import { spawnSync } from "child_process";
|
|
220
329
|
import { basename } from "path";
|
|
@@ -341,7 +450,7 @@ function resolveRepoArg(input, cwd, db) {
|
|
|
341
450
|
}
|
|
342
451
|
function mergeRepoKeys(db, from, to) {
|
|
343
452
|
if (from === to) {
|
|
344
|
-
return { movedAliases: 0, movedMemories: 0 };
|
|
453
|
+
return { movedAliases: 0, movedMemories: 0, rewrittenNotes: 0 };
|
|
345
454
|
}
|
|
346
455
|
const tx = db.transaction(() => {
|
|
347
456
|
const aliasResult = db.prepare("UPDATE repo_aliases SET canonical = ? WHERE canonical = ?").run(to, from);
|
|
@@ -358,15 +467,79 @@ function mergeRepoKeys(db, from, to) {
|
|
|
358
467
|
movedMemories += result.changes;
|
|
359
468
|
}
|
|
360
469
|
movedMemories += updateMemories.run(to, from).changes;
|
|
470
|
+
const rewrittenNotes = rewriteStaleRepoMentions(db, from, to);
|
|
361
471
|
upsertAlias(db, from, to);
|
|
362
472
|
upsertAlias(db, to, to);
|
|
363
473
|
return {
|
|
364
474
|
movedAliases: aliasResult.changes,
|
|
365
|
-
movedMemories
|
|
475
|
+
movedMemories,
|
|
476
|
+
rewrittenNotes
|
|
366
477
|
};
|
|
367
478
|
});
|
|
368
479
|
return tx();
|
|
369
480
|
}
|
|
481
|
+
function tokenBoundaryReplace(text, from, to) {
|
|
482
|
+
const escaped = from.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
483
|
+
const pattern = new RegExp(`(^|[^\\w/-])(${escaped})(?=$|[^\\w/-])`, "g");
|
|
484
|
+
return text.replace(pattern, (_match, prefix) => `${prefix}${to}`);
|
|
485
|
+
}
|
|
486
|
+
function rewriteStaleRepoMentions(db, from, to) {
|
|
487
|
+
const candidates = db.prepare(
|
|
488
|
+
`
|
|
489
|
+
SELECT rowid AS row_id, note, metadata_json
|
|
490
|
+
FROM memories
|
|
491
|
+
WHERE note LIKE ?
|
|
492
|
+
`
|
|
493
|
+
).all(`%${from}%`);
|
|
494
|
+
if (candidates.length === 0) {
|
|
495
|
+
return 0;
|
|
496
|
+
}
|
|
497
|
+
const update = db.prepare(
|
|
498
|
+
`
|
|
499
|
+
UPDATE memories
|
|
500
|
+
SET note = ?, note_normalized = ?, metadata_json = ?, updated_at = ?
|
|
501
|
+
WHERE rowid = ?
|
|
502
|
+
`
|
|
503
|
+
);
|
|
504
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
505
|
+
let rewritten = 0;
|
|
506
|
+
for (const row of candidates) {
|
|
507
|
+
const next = tokenBoundaryReplace(row.note, from, to);
|
|
508
|
+
if (next === row.note) {
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
const normalized = next.toLowerCase().replace(/[^a-z0-9\s]/g, " ").replace(/\s+/g, " ").trim();
|
|
512
|
+
let metadata;
|
|
513
|
+
try {
|
|
514
|
+
const parsed = JSON.parse(row.metadata_json);
|
|
515
|
+
metadata = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
|
|
516
|
+
} catch {
|
|
517
|
+
metadata = {};
|
|
518
|
+
}
|
|
519
|
+
metadata.changelog = metadata.changelog ?? [];
|
|
520
|
+
metadata.changelog.push({
|
|
521
|
+
at: now,
|
|
522
|
+
action: "alias_rewrite",
|
|
523
|
+
previous_note: row.note,
|
|
524
|
+
rewrote_alias: from
|
|
525
|
+
});
|
|
526
|
+
update.run(next, normalized, JSON.stringify(metadata), now, row.row_id);
|
|
527
|
+
rewritten += 1;
|
|
528
|
+
}
|
|
529
|
+
return rewritten;
|
|
530
|
+
}
|
|
531
|
+
function findMemoriesMentioningAlias(db, alias, canonical) {
|
|
532
|
+
const rows = db.prepare(
|
|
533
|
+
`
|
|
534
|
+
SELECT rowid AS row_id, repo, note
|
|
535
|
+
FROM memories
|
|
536
|
+
WHERE repo = ? AND note LIKE ?
|
|
537
|
+
`
|
|
538
|
+
).all(canonical, `%${alias}%`);
|
|
539
|
+
const escaped = alias.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
540
|
+
const pattern = new RegExp(`(^|[^\\w/-])${escaped}(?=$|[^\\w/-])`);
|
|
541
|
+
return rows.filter((row) => pattern.test(row.note));
|
|
542
|
+
}
|
|
370
543
|
var REMOTE_PATTERNS;
|
|
371
544
|
var init_repo = __esm({
|
|
372
545
|
"src/lib/repo.ts"() {
|
|
@@ -384,6 +557,170 @@ var init_repo = __esm({
|
|
|
384
557
|
}
|
|
385
558
|
});
|
|
386
559
|
|
|
560
|
+
// src/lib/embeddings.ts
|
|
561
|
+
function embeddingsEnabled() {
|
|
562
|
+
const value = process.env.FOSSEL_EMBEDDINGS?.trim().toLowerCase();
|
|
563
|
+
return value === "1" || value === "true" || value === "on" || value === "yes";
|
|
564
|
+
}
|
|
565
|
+
function fnv1a(str) {
|
|
566
|
+
let hash = 2166136261;
|
|
567
|
+
for (let i = 0; i < str.length; i += 1) {
|
|
568
|
+
hash ^= str.charCodeAt(i);
|
|
569
|
+
hash = Math.imul(hash, 16777619);
|
|
570
|
+
}
|
|
571
|
+
return hash >>> 0;
|
|
572
|
+
}
|
|
573
|
+
function tokenize2(text) {
|
|
574
|
+
return text.toLowerCase().replace(/[^a-z0-9\s]/g, " ").replace(/\s+/g, " ").trim().split(" ").filter((token) => token.length >= 2);
|
|
575
|
+
}
|
|
576
|
+
function embedText(text) {
|
|
577
|
+
const vector = new Float32Array(EMBEDDING_DIM);
|
|
578
|
+
const tokens = tokenize2(text);
|
|
579
|
+
if (tokens.length === 0) {
|
|
580
|
+
return vector;
|
|
581
|
+
}
|
|
582
|
+
const addFeature = (feature, weight) => {
|
|
583
|
+
const h = fnv1a(feature);
|
|
584
|
+
const index = h % EMBEDDING_DIM;
|
|
585
|
+
const sign = (fnv1a(`#${feature}`) & 1) === 0 ? 1 : -1;
|
|
586
|
+
vector[index] += sign * weight;
|
|
587
|
+
};
|
|
588
|
+
for (let i = 0; i < tokens.length; i += 1) {
|
|
589
|
+
addFeature(tokens[i], 1);
|
|
590
|
+
if (i + 1 < tokens.length) {
|
|
591
|
+
addFeature(`${tokens[i]} ${tokens[i + 1]}`, 0.6);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
let norm = 0;
|
|
595
|
+
for (let i = 0; i < EMBEDDING_DIM; i += 1) {
|
|
596
|
+
norm += vector[i] * vector[i];
|
|
597
|
+
}
|
|
598
|
+
norm = Math.sqrt(norm);
|
|
599
|
+
if (norm > 0) {
|
|
600
|
+
for (let i = 0; i < EMBEDDING_DIM; i += 1) {
|
|
601
|
+
vector[i] /= norm;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
return vector;
|
|
605
|
+
}
|
|
606
|
+
function cosineSimilarity(a, b) {
|
|
607
|
+
if (a.length !== b.length) {
|
|
608
|
+
return 0;
|
|
609
|
+
}
|
|
610
|
+
let dot = 0;
|
|
611
|
+
for (let i = 0; i < a.length; i += 1) {
|
|
612
|
+
dot += a[i] * b[i];
|
|
613
|
+
}
|
|
614
|
+
return dot;
|
|
615
|
+
}
|
|
616
|
+
function vectorToBuffer(vector) {
|
|
617
|
+
return Buffer.from(vector.buffer, vector.byteOffset, vector.byteLength);
|
|
618
|
+
}
|
|
619
|
+
function bufferToVector(buffer) {
|
|
620
|
+
const copy = Buffer.from(buffer);
|
|
621
|
+
return new Float32Array(
|
|
622
|
+
copy.buffer,
|
|
623
|
+
copy.byteOffset,
|
|
624
|
+
Math.floor(copy.byteLength / 4)
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
var EMBEDDING_DIM, EMBEDDING_VERSION;
|
|
628
|
+
var init_embeddings = __esm({
|
|
629
|
+
"src/lib/embeddings.ts"() {
|
|
630
|
+
"use strict";
|
|
631
|
+
EMBEDDING_DIM = 256;
|
|
632
|
+
EMBEDDING_VERSION = 1;
|
|
633
|
+
}
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
// src/lib/vector-index.ts
|
|
637
|
+
function indexMemoryEmbedding(db, rowId, note) {
|
|
638
|
+
if (!embeddingsEnabled()) {
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
const vector = embedText(note);
|
|
642
|
+
db.prepare(
|
|
643
|
+
`
|
|
644
|
+
INSERT INTO memory_embeddings (memory_rowid, dim, version, vector, updated_at)
|
|
645
|
+
VALUES (?, ?, ?, ?, ?)
|
|
646
|
+
ON CONFLICT(memory_rowid) DO UPDATE SET
|
|
647
|
+
dim = excluded.dim,
|
|
648
|
+
version = excluded.version,
|
|
649
|
+
vector = excluded.vector,
|
|
650
|
+
updated_at = excluded.updated_at
|
|
651
|
+
`
|
|
652
|
+
).run(
|
|
653
|
+
rowId,
|
|
654
|
+
EMBEDDING_DIM,
|
|
655
|
+
EMBEDDING_VERSION,
|
|
656
|
+
vectorToBuffer(vector),
|
|
657
|
+
Math.floor(Date.now() / 1e3)
|
|
658
|
+
);
|
|
659
|
+
}
|
|
660
|
+
function backfillRepoEmbeddings(db, repo) {
|
|
661
|
+
if (!embeddingsEnabled()) {
|
|
662
|
+
return 0;
|
|
663
|
+
}
|
|
664
|
+
const rows = db.prepare(
|
|
665
|
+
`
|
|
666
|
+
SELECT m.rowid AS row_id, m.note
|
|
667
|
+
FROM memories AS m
|
|
668
|
+
LEFT JOIN memory_embeddings AS e ON e.memory_rowid = m.rowid
|
|
669
|
+
WHERE m.repo = ?
|
|
670
|
+
AND (e.memory_rowid IS NULL OR e.version != ? OR e.dim != ?)
|
|
671
|
+
`
|
|
672
|
+
).all(repo, EMBEDDING_VERSION, EMBEDDING_DIM);
|
|
673
|
+
if (rows.length === 0) {
|
|
674
|
+
return 0;
|
|
675
|
+
}
|
|
676
|
+
const tx = db.transaction((batch) => {
|
|
677
|
+
for (const row of batch) {
|
|
678
|
+
indexMemoryEmbedding(db, row.row_id, row.note);
|
|
679
|
+
}
|
|
680
|
+
});
|
|
681
|
+
tx(rows);
|
|
682
|
+
return rows.length;
|
|
683
|
+
}
|
|
684
|
+
function vectorSearch(db, repo, query, limit) {
|
|
685
|
+
if (!embeddingsEnabled()) {
|
|
686
|
+
return [];
|
|
687
|
+
}
|
|
688
|
+
backfillRepoEmbeddings(db, repo);
|
|
689
|
+
const queryVector = embedText(query);
|
|
690
|
+
let queryNorm = 0;
|
|
691
|
+
for (let i = 0; i < queryVector.length; i += 1) {
|
|
692
|
+
queryNorm += queryVector[i] * queryVector[i];
|
|
693
|
+
}
|
|
694
|
+
if (queryNorm === 0) {
|
|
695
|
+
return [];
|
|
696
|
+
}
|
|
697
|
+
const rows = db.prepare(
|
|
698
|
+
`
|
|
699
|
+
SELECT m.rowid AS row_id, m.id, m.repo, m.type, m.note, m.tags,
|
|
700
|
+
m.created_at, m.updated_at, m.pinned, e.vector AS vector
|
|
701
|
+
FROM memory_embeddings AS e
|
|
702
|
+
JOIN memories AS m ON m.rowid = e.memory_rowid
|
|
703
|
+
WHERE m.repo = ? AND e.dim = ? AND e.version = ?
|
|
704
|
+
`
|
|
705
|
+
).all(repo, EMBEDDING_DIM, EMBEDDING_VERSION);
|
|
706
|
+
const scored = [];
|
|
707
|
+
for (const row of rows) {
|
|
708
|
+
const { vector, ...memory } = row;
|
|
709
|
+
const score = cosineSimilarity(queryVector, bufferToVector(vector));
|
|
710
|
+
if (score > 0) {
|
|
711
|
+
scored.push({ ...memory, score });
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
scored.sort((a, b) => b.score - a.score);
|
|
715
|
+
return scored.slice(0, limit);
|
|
716
|
+
}
|
|
717
|
+
var init_vector_index = __esm({
|
|
718
|
+
"src/lib/vector-index.ts"() {
|
|
719
|
+
"use strict";
|
|
720
|
+
init_embeddings();
|
|
721
|
+
}
|
|
722
|
+
});
|
|
723
|
+
|
|
387
724
|
// src/lib/context.ts
|
|
388
725
|
function parseTags(raw) {
|
|
389
726
|
try {
|
|
@@ -393,6 +730,9 @@ function parseTags(raw) {
|
|
|
393
730
|
return [];
|
|
394
731
|
}
|
|
395
732
|
}
|
|
733
|
+
function normalizeNoteForReadDedupe(text) {
|
|
734
|
+
return text.toLowerCase().replace(/[^a-z0-9\s]/g, " ").replace(/\s+/g, " ").trim();
|
|
735
|
+
}
|
|
396
736
|
function buildFtsQuery(query) {
|
|
397
737
|
const terms = query.trim().split(/\s+/).map((term) => term.replace(/"/g, '""')).filter((term) => term.length > 0);
|
|
398
738
|
if (terms.length === 0) {
|
|
@@ -403,11 +743,19 @@ function buildFtsQuery(query) {
|
|
|
403
743
|
function fetchRepoContext(db, repo, limit, query) {
|
|
404
744
|
const rows = [];
|
|
405
745
|
const seen = /* @__PURE__ */ new Set();
|
|
746
|
+
const seenNormalized = /* @__PURE__ */ new Set();
|
|
406
747
|
const push = (memory, source, rank) => {
|
|
407
748
|
if (seen.has(memory.row_id)) {
|
|
408
749
|
return;
|
|
409
750
|
}
|
|
751
|
+
const normalized = normalizeNoteForReadDedupe(memory.note);
|
|
752
|
+
if (normalized && seenNormalized.has(normalized)) {
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
410
755
|
seen.add(memory.row_id);
|
|
756
|
+
if (normalized) {
|
|
757
|
+
seenNormalized.add(normalized);
|
|
758
|
+
}
|
|
411
759
|
rows.push({ ...memory, source, rank });
|
|
412
760
|
};
|
|
413
761
|
const pinned = db.prepare(
|
|
@@ -422,7 +770,10 @@ function fetchRepoContext(db, repo, limit, query) {
|
|
|
422
770
|
for (const row of pinned) {
|
|
423
771
|
push(row, "pinned");
|
|
424
772
|
}
|
|
425
|
-
|
|
773
|
+
const pushRecent = () => {
|
|
774
|
+
if (rows.length >= limit) {
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
426
777
|
const recent = db.prepare(
|
|
427
778
|
`
|
|
428
779
|
SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned
|
|
@@ -431,13 +782,20 @@ function fetchRepoContext(db, repo, limit, query) {
|
|
|
431
782
|
ORDER BY updated_at DESC
|
|
432
783
|
LIMIT ?
|
|
433
784
|
`
|
|
434
|
-
).all(repo, limit
|
|
785
|
+
).all(repo, limit);
|
|
435
786
|
for (const row of recent) {
|
|
436
787
|
push(row, "recent");
|
|
788
|
+
if (rows.length >= limit) {
|
|
789
|
+
break;
|
|
790
|
+
}
|
|
437
791
|
}
|
|
792
|
+
};
|
|
793
|
+
if (!query) {
|
|
794
|
+
pushRecent();
|
|
438
795
|
}
|
|
439
796
|
if (query && rows.length < limit) {
|
|
440
797
|
const ftsQuery = buildFtsQuery(query);
|
|
798
|
+
const ftsRows = [];
|
|
441
799
|
if (ftsQuery) {
|
|
442
800
|
try {
|
|
443
801
|
const matches = db.prepare(
|
|
@@ -451,15 +809,49 @@ function fetchRepoContext(db, repo, limit, query) {
|
|
|
451
809
|
LIMIT ?
|
|
452
810
|
`
|
|
453
811
|
).all(ftsQuery, repo, limit);
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
812
|
+
ftsRows.push(...matches);
|
|
813
|
+
} catch {
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
const vectorRows = embeddingsEnabled() ? vectorSearch(db, repo, query, limit) : [];
|
|
817
|
+
if (vectorRows.length > 0) {
|
|
818
|
+
const RRF_K = 60;
|
|
819
|
+
const fused = /* @__PURE__ */ new Map();
|
|
820
|
+
const accumulate = (list, rankOf) => {
|
|
821
|
+
list.forEach((memory, index) => {
|
|
822
|
+
const contribution = 1 / (RRF_K + index + 1);
|
|
823
|
+
const prior = fused.get(memory.row_id);
|
|
824
|
+
if (prior) {
|
|
825
|
+
prior.score += contribution;
|
|
826
|
+
} else {
|
|
827
|
+
fused.set(memory.row_id, {
|
|
828
|
+
memory,
|
|
829
|
+
score: contribution,
|
|
830
|
+
rank: rankOf?.(memory, index)
|
|
831
|
+
});
|
|
458
832
|
}
|
|
833
|
+
});
|
|
834
|
+
};
|
|
835
|
+
accumulate(ftsRows, (m) => m.rank);
|
|
836
|
+
accumulate(vectorRows);
|
|
837
|
+
const ordered = Array.from(fused.values()).sort(
|
|
838
|
+
(a, b) => b.score - a.score
|
|
839
|
+
);
|
|
840
|
+
for (const { memory, rank } of ordered) {
|
|
841
|
+
push(memory, "search", rank);
|
|
842
|
+
if (rows.length >= limit) {
|
|
843
|
+
break;
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
} else {
|
|
847
|
+
for (const row of ftsRows) {
|
|
848
|
+
push(row, "search", row.rank);
|
|
849
|
+
if (rows.length >= limit) {
|
|
850
|
+
break;
|
|
459
851
|
}
|
|
460
|
-
} catch {
|
|
461
852
|
}
|
|
462
853
|
}
|
|
854
|
+
pushRecent();
|
|
463
855
|
}
|
|
464
856
|
return rows.slice(0, limit);
|
|
465
857
|
}
|
|
@@ -522,6 +914,8 @@ var init_context = __esm({
|
|
|
522
914
|
"src/lib/context.ts"() {
|
|
523
915
|
"use strict";
|
|
524
916
|
init_client();
|
|
917
|
+
init_embeddings();
|
|
918
|
+
init_vector_index();
|
|
525
919
|
SECTION_TITLES = {
|
|
526
920
|
convention: "Conventions",
|
|
527
921
|
bug_fix: "Bug Fixes",
|
|
@@ -533,93 +927,17 @@ var init_context = __esm({
|
|
|
533
927
|
}
|
|
534
928
|
});
|
|
535
929
|
|
|
536
|
-
// src/lib/
|
|
537
|
-
function
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
return normalizeText(text).split(" ").filter((token) => token.length >= 2);
|
|
542
|
-
}
|
|
543
|
-
function trigrams(text) {
|
|
544
|
-
const padded = ` ${text} `;
|
|
545
|
-
const grams = /* @__PURE__ */ new Set();
|
|
546
|
-
for (let i = 0; i < padded.length - 2; i += 1) {
|
|
547
|
-
grams.add(padded.slice(i, i + 3));
|
|
548
|
-
}
|
|
549
|
-
return grams;
|
|
550
|
-
}
|
|
551
|
-
function jaccard(a, b) {
|
|
552
|
-
if (a.size === 0 && b.size === 0) {
|
|
553
|
-
return 1;
|
|
554
|
-
}
|
|
555
|
-
let intersection = 0;
|
|
556
|
-
for (const value of a) {
|
|
557
|
-
if (b.has(value)) {
|
|
558
|
-
intersection += 1;
|
|
559
|
-
}
|
|
560
|
-
}
|
|
561
|
-
const union = a.size + b.size - intersection;
|
|
562
|
-
return union === 0 ? 0 : intersection / union;
|
|
563
|
-
}
|
|
564
|
-
function similarity(a, b) {
|
|
565
|
-
const normalizedA = normalizeText(a);
|
|
566
|
-
const normalizedB = normalizeText(b);
|
|
567
|
-
if (!normalizedA && !normalizedB) {
|
|
568
|
-
return 1;
|
|
569
|
-
}
|
|
570
|
-
if (!normalizedA || !normalizedB) {
|
|
571
|
-
return 0;
|
|
572
|
-
}
|
|
573
|
-
if (normalizedA === normalizedB) {
|
|
574
|
-
return 1;
|
|
575
|
-
}
|
|
576
|
-
const wordScore = jaccard(new Set(tokenize(normalizedA)), new Set(tokenize(normalizedB)));
|
|
577
|
-
const triScore = jaccard(trigrams(normalizedA), trigrams(normalizedB));
|
|
578
|
-
return wordScore * 0.55 + triScore * 0.45;
|
|
579
|
-
}
|
|
580
|
-
function findDuplicate(db, repo, note, options = {}) {
|
|
581
|
-
const threshold = options.threshold ?? DEFAULT_THRESHOLD;
|
|
582
|
-
const limit = options.candidateLimit ?? DEFAULT_CANDIDATE_LIMIT;
|
|
583
|
-
const normalized = normalizeText(note);
|
|
584
|
-
if (!normalized) {
|
|
585
|
-
return null;
|
|
586
|
-
}
|
|
587
|
-
const exact = db.prepare(
|
|
588
|
-
`
|
|
589
|
-
SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned
|
|
590
|
-
FROM memories
|
|
591
|
-
WHERE repo = ? AND note_normalized = ?
|
|
592
|
-
ORDER BY updated_at DESC
|
|
593
|
-
LIMIT 1
|
|
594
|
-
`
|
|
595
|
-
).get(repo, normalized);
|
|
596
|
-
if (exact) {
|
|
597
|
-
return { memory: exact, similarity: 1 };
|
|
598
|
-
}
|
|
599
|
-
const candidates = db.prepare(
|
|
600
|
-
`
|
|
601
|
-
SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned
|
|
602
|
-
FROM memories
|
|
603
|
-
WHERE repo = ?
|
|
604
|
-
ORDER BY updated_at DESC
|
|
605
|
-
LIMIT ?
|
|
606
|
-
`
|
|
607
|
-
).all(repo, limit);
|
|
608
|
-
let best = null;
|
|
609
|
-
for (const candidate of candidates) {
|
|
610
|
-
const score = similarity(note, candidate.note);
|
|
611
|
-
if (score >= threshold && (!best || score > best.similarity)) {
|
|
612
|
-
best = { memory: candidate, similarity: score };
|
|
613
|
-
}
|
|
930
|
+
// src/lib/workspace.ts
|
|
931
|
+
function getWorkspaceRoot() {
|
|
932
|
+
const fromEnv = process.env.FOSSEL_WORKSPACE?.trim();
|
|
933
|
+
if (fromEnv) {
|
|
934
|
+
return fromEnv;
|
|
614
935
|
}
|
|
615
|
-
return
|
|
936
|
+
return process.cwd();
|
|
616
937
|
}
|
|
617
|
-
var
|
|
618
|
-
|
|
619
|
-
"src/lib/dedupe.ts"() {
|
|
938
|
+
var init_workspace = __esm({
|
|
939
|
+
"src/lib/workspace.ts"() {
|
|
620
940
|
"use strict";
|
|
621
|
-
DEFAULT_THRESHOLD = 0.82;
|
|
622
|
-
DEFAULT_CANDIDATE_LIMIT = 200;
|
|
623
941
|
}
|
|
624
942
|
});
|
|
625
943
|
|
|
@@ -666,7 +984,7 @@ function registerDedupeRepoTool(server) {
|
|
|
666
984
|
async ({ repo, threshold, apply }) => {
|
|
667
985
|
try {
|
|
668
986
|
const db = getDb();
|
|
669
|
-
const resolved = resolveRepoArg(repo,
|
|
987
|
+
const resolved = resolveRepoArg(repo, getWorkspaceRoot(), db);
|
|
670
988
|
const rows = db.prepare(
|
|
671
989
|
`
|
|
672
990
|
SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned, metadata_json
|
|
@@ -798,6 +1116,7 @@ var init_dedupe_repo = __esm({
|
|
|
798
1116
|
init_client();
|
|
799
1117
|
init_dedupe();
|
|
800
1118
|
init_repo();
|
|
1119
|
+
init_workspace();
|
|
801
1120
|
dedupeRepoInputSchema = {
|
|
802
1121
|
repo: z.string().trim().min(1).optional(),
|
|
803
1122
|
threshold: z.number().min(0.5).max(1).default(0.85),
|
|
@@ -806,20 +1125,55 @@ var init_dedupe_repo = __esm({
|
|
|
806
1125
|
}
|
|
807
1126
|
});
|
|
808
1127
|
|
|
1128
|
+
// src/lib/memory.ts
|
|
1129
|
+
function findMemoryByAnyId(db, input) {
|
|
1130
|
+
const numeric = typeof input === "number" ? input : Number(input);
|
|
1131
|
+
const isNumericId = Number.isInteger(numeric) && numeric > 0 && String(numeric) === String(input).trim();
|
|
1132
|
+
if (isNumericId) {
|
|
1133
|
+
const row = db.prepare(
|
|
1134
|
+
`
|
|
1135
|
+
SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned
|
|
1136
|
+
FROM memories
|
|
1137
|
+
WHERE rowid = ?
|
|
1138
|
+
`
|
|
1139
|
+
).get(numeric);
|
|
1140
|
+
if (row) {
|
|
1141
|
+
return row;
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
const stringInput = String(input).trim();
|
|
1145
|
+
if (stringInput.length === 0) {
|
|
1146
|
+
return null;
|
|
1147
|
+
}
|
|
1148
|
+
const stringRow = db.prepare(
|
|
1149
|
+
`
|
|
1150
|
+
SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned
|
|
1151
|
+
FROM memories
|
|
1152
|
+
WHERE id = ?
|
|
1153
|
+
`
|
|
1154
|
+
).get(stringInput);
|
|
1155
|
+
return stringRow ?? null;
|
|
1156
|
+
}
|
|
1157
|
+
var init_memory = __esm({
|
|
1158
|
+
"src/lib/memory.ts"() {
|
|
1159
|
+
"use strict";
|
|
1160
|
+
}
|
|
1161
|
+
});
|
|
1162
|
+
|
|
809
1163
|
// src/tools/delete.ts
|
|
810
1164
|
import { z as z2 } from "zod";
|
|
811
1165
|
function registerDeleteMemoryTool(server) {
|
|
812
1166
|
server.registerTool(
|
|
813
1167
|
"delete_memory",
|
|
814
1168
|
{
|
|
815
|
-
description: "Delete a memory from storage by id.",
|
|
1169
|
+
description: "Delete a memory from storage by id. Accepts either the numeric row id or the legacy string id.",
|
|
816
1170
|
inputSchema: deleteMemoryInputSchema
|
|
817
1171
|
},
|
|
818
1172
|
async ({ id }) => {
|
|
819
1173
|
try {
|
|
820
1174
|
const db = getDb();
|
|
821
|
-
const
|
|
822
|
-
if (!
|
|
1175
|
+
const memory = findMemoryByAnyId(db, id);
|
|
1176
|
+
if (!memory) {
|
|
823
1177
|
return {
|
|
824
1178
|
isError: true,
|
|
825
1179
|
content: [
|
|
@@ -830,15 +1184,15 @@ function registerDeleteMemoryTool(server) {
|
|
|
830
1184
|
]
|
|
831
1185
|
};
|
|
832
1186
|
}
|
|
833
|
-
const deleteTx = db.transaction((
|
|
834
|
-
db.prepare("DELETE FROM memories WHERE
|
|
1187
|
+
const deleteTx = db.transaction((rowId) => {
|
|
1188
|
+
db.prepare("DELETE FROM memories WHERE rowid = ?").run(rowId);
|
|
835
1189
|
});
|
|
836
|
-
deleteTx(
|
|
1190
|
+
deleteTx(memory.row_id);
|
|
837
1191
|
return {
|
|
838
1192
|
content: [
|
|
839
1193
|
{
|
|
840
1194
|
type: "text",
|
|
841
|
-
text: `Deleted memory ${id}.`
|
|
1195
|
+
text: `Deleted memory ${memory.row_id} (legacy: ${memory.id}).`
|
|
842
1196
|
}
|
|
843
1197
|
]
|
|
844
1198
|
};
|
|
@@ -862,8 +1216,12 @@ var init_delete = __esm({
|
|
|
862
1216
|
"src/tools/delete.ts"() {
|
|
863
1217
|
"use strict";
|
|
864
1218
|
init_client();
|
|
1219
|
+
init_memory();
|
|
865
1220
|
deleteMemoryInputSchema = {
|
|
866
|
-
|
|
1221
|
+
// Accept either the numeric row_id or the legacy nanoid string. Tools used
|
|
1222
|
+
// to disagree about which form to take; this unifies them so callers can
|
|
1223
|
+
// paste whichever id they have in front of them.
|
|
1224
|
+
id: z2.union([z2.number().int().positive(), z2.string().trim().min(1)])
|
|
867
1225
|
};
|
|
868
1226
|
}
|
|
869
1227
|
});
|
|
@@ -880,7 +1238,7 @@ function registerGetContextTool(server) {
|
|
|
880
1238
|
async ({ repo, query, limit, format }) => {
|
|
881
1239
|
try {
|
|
882
1240
|
const db = getDb();
|
|
883
|
-
const resolved = resolveRepoArg(repo,
|
|
1241
|
+
const resolved = resolveRepoArg(repo, getWorkspaceRoot(), db);
|
|
884
1242
|
const rows = fetchRepoContext(db, resolved.canonical, limit, query);
|
|
885
1243
|
const text = formatContext(rows, {
|
|
886
1244
|
repo: resolved.canonical,
|
|
@@ -917,6 +1275,7 @@ var init_get_context = __esm({
|
|
|
917
1275
|
init_client();
|
|
918
1276
|
init_context();
|
|
919
1277
|
init_repo();
|
|
1278
|
+
init_workspace();
|
|
920
1279
|
getContextInputSchema = {
|
|
921
1280
|
repo: z3.string().trim().min(1).optional(),
|
|
922
1281
|
query: z3.string().trim().min(1).optional(),
|
|
@@ -949,7 +1308,7 @@ function registerGetRepoContextTool(server) {
|
|
|
949
1308
|
async ({ repo, limit }) => {
|
|
950
1309
|
try {
|
|
951
1310
|
const db = getDb();
|
|
952
|
-
const resolved = resolveRepoArg(repo,
|
|
1311
|
+
const resolved = resolveRepoArg(repo, getWorkspaceRoot(), db);
|
|
953
1312
|
const rows = db.prepare(
|
|
954
1313
|
`
|
|
955
1314
|
SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned
|
|
@@ -1020,6 +1379,7 @@ var init_get_repo = __esm({
|
|
|
1020
1379
|
"use strict";
|
|
1021
1380
|
init_client();
|
|
1022
1381
|
init_repo();
|
|
1382
|
+
init_workspace();
|
|
1023
1383
|
getRepoContextInputSchema = {
|
|
1024
1384
|
repo: z4.string().trim().min(1).optional(),
|
|
1025
1385
|
limit: z4.number().int().positive().max(100).default(10)
|
|
@@ -1029,7 +1389,7 @@ var init_get_repo = __esm({
|
|
|
1029
1389
|
|
|
1030
1390
|
// src/tools/pin.ts
|
|
1031
1391
|
import { z as z5 } from "zod";
|
|
1032
|
-
function setPinnedState(
|
|
1392
|
+
function setPinnedState(rowId, pinned) {
|
|
1033
1393
|
const db = getDb();
|
|
1034
1394
|
const now = Math.floor(Date.now() / 1e3);
|
|
1035
1395
|
const updateResult = db.prepare(
|
|
@@ -1038,7 +1398,7 @@ function setPinnedState(memoryId, pinned) {
|
|
|
1038
1398
|
SET pinned = ?, updated_at = ?
|
|
1039
1399
|
WHERE rowid = ?
|
|
1040
1400
|
`
|
|
1041
|
-
).run(pinned, now,
|
|
1401
|
+
).run(pinned, now, rowId);
|
|
1042
1402
|
if (updateResult.changes === 0) {
|
|
1043
1403
|
return null;
|
|
1044
1404
|
}
|
|
@@ -1048,7 +1408,7 @@ function setPinnedState(memoryId, pinned) {
|
|
|
1048
1408
|
FROM memories
|
|
1049
1409
|
WHERE rowid = ?
|
|
1050
1410
|
`
|
|
1051
|
-
).get(
|
|
1411
|
+
).get(rowId);
|
|
1052
1412
|
}
|
|
1053
1413
|
function registerPinMemoryTool(server) {
|
|
1054
1414
|
server.registerTool(
|
|
@@ -1059,7 +1419,20 @@ function registerPinMemoryTool(server) {
|
|
|
1059
1419
|
},
|
|
1060
1420
|
async ({ id }) => {
|
|
1061
1421
|
try {
|
|
1062
|
-
const
|
|
1422
|
+
const db = getDb();
|
|
1423
|
+
const target = findMemoryByAnyId(db, id);
|
|
1424
|
+
if (!target) {
|
|
1425
|
+
return {
|
|
1426
|
+
isError: true,
|
|
1427
|
+
content: [
|
|
1428
|
+
{
|
|
1429
|
+
type: "text",
|
|
1430
|
+
text: `Memory ${id} not found.`
|
|
1431
|
+
}
|
|
1432
|
+
]
|
|
1433
|
+
};
|
|
1434
|
+
}
|
|
1435
|
+
const memory = setPinnedState(target.row_id, 1);
|
|
1063
1436
|
if (!memory) {
|
|
1064
1437
|
return {
|
|
1065
1438
|
isError: true,
|
|
@@ -1103,7 +1476,20 @@ function registerUnpinMemoryTool(server) {
|
|
|
1103
1476
|
},
|
|
1104
1477
|
async ({ id }) => {
|
|
1105
1478
|
try {
|
|
1106
|
-
const
|
|
1479
|
+
const db = getDb();
|
|
1480
|
+
const target = findMemoryByAnyId(db, id);
|
|
1481
|
+
if (!target) {
|
|
1482
|
+
return {
|
|
1483
|
+
isError: true,
|
|
1484
|
+
content: [
|
|
1485
|
+
{
|
|
1486
|
+
type: "text",
|
|
1487
|
+
text: `Memory ${id} not found.`
|
|
1488
|
+
}
|
|
1489
|
+
]
|
|
1490
|
+
};
|
|
1491
|
+
}
|
|
1492
|
+
const memory = setPinnedState(target.row_id, 0);
|
|
1107
1493
|
if (!memory) {
|
|
1108
1494
|
return {
|
|
1109
1495
|
isError: true,
|
|
@@ -1143,8 +1529,10 @@ var init_pin = __esm({
|
|
|
1143
1529
|
"src/tools/pin.ts"() {
|
|
1144
1530
|
"use strict";
|
|
1145
1531
|
init_client();
|
|
1532
|
+
init_memory();
|
|
1146
1533
|
pinInputSchema = {
|
|
1147
|
-
id
|
|
1534
|
+
// Accept numeric row_id or legacy string id for parity with the other tools.
|
|
1535
|
+
id: z5.union([z5.number().int().positive(), z5.string().trim().min(1)])
|
|
1148
1536
|
};
|
|
1149
1537
|
}
|
|
1150
1538
|
});
|
|
@@ -1528,7 +1916,7 @@ function registerRememberTool(server) {
|
|
|
1528
1916
|
async ({ note, repo, type, tags }) => {
|
|
1529
1917
|
try {
|
|
1530
1918
|
const db = getDb();
|
|
1531
|
-
const resolved = resolveRepoArg(repo,
|
|
1919
|
+
const resolved = resolveRepoArg(repo, getWorkspaceRoot(), db);
|
|
1532
1920
|
const inferred = inferMemoryFromNote(note);
|
|
1533
1921
|
const finalType = type ?? inferred.type;
|
|
1534
1922
|
const finalTags = mergeTagLists2(tags, inferred.tags).slice(0, 5);
|
|
@@ -1566,6 +1954,7 @@ function registerRememberTool(server) {
|
|
|
1566
1954
|
now,
|
|
1567
1955
|
existing.row_id
|
|
1568
1956
|
);
|
|
1957
|
+
indexMemoryEmbedding(db, existing.row_id, longerNote);
|
|
1569
1958
|
return {
|
|
1570
1959
|
content: [
|
|
1571
1960
|
{
|
|
@@ -1606,6 +1995,9 @@ function registerRememberTool(server) {
|
|
|
1606
1995
|
normalizeText(note)
|
|
1607
1996
|
);
|
|
1608
1997
|
const inserted = db.prepare("SELECT rowid AS row_id FROM memories WHERE id = ?").get(id);
|
|
1998
|
+
if (inserted) {
|
|
1999
|
+
indexMemoryEmbedding(db, inserted.row_id, note);
|
|
2000
|
+
}
|
|
1609
2001
|
return {
|
|
1610
2002
|
content: [
|
|
1611
2003
|
{
|
|
@@ -1637,6 +2029,8 @@ var init_remember = __esm({
|
|
|
1637
2029
|
init_dedupe();
|
|
1638
2030
|
init_inference();
|
|
1639
2031
|
init_repo();
|
|
2032
|
+
init_vector_index();
|
|
2033
|
+
init_workspace();
|
|
1640
2034
|
rememberInputSchema = {
|
|
1641
2035
|
note: z6.string().trim().min(1, "note is required"),
|
|
1642
2036
|
repo: z6.string().trim().min(1).optional(),
|
|
@@ -1658,7 +2052,7 @@ function registerResolveRepoTool(server) {
|
|
|
1658
2052
|
async ({ cwd }) => {
|
|
1659
2053
|
try {
|
|
1660
2054
|
const db = getDb();
|
|
1661
|
-
const target = cwd?.trim() ||
|
|
2055
|
+
const target = cwd?.trim() || getWorkspaceRoot();
|
|
1662
2056
|
const resolved = resolveRepo(target, db);
|
|
1663
2057
|
const payload = {
|
|
1664
2058
|
canonical: resolved.canonical,
|
|
@@ -1696,6 +2090,7 @@ var init_resolve_repo = __esm({
|
|
|
1696
2090
|
"use strict";
|
|
1697
2091
|
init_client();
|
|
1698
2092
|
init_repo();
|
|
2093
|
+
init_workspace();
|
|
1699
2094
|
resolveRepoInputSchema = {
|
|
1700
2095
|
cwd: z7.string().trim().min(1).optional()
|
|
1701
2096
|
};
|
|
@@ -1704,12 +2099,20 @@ var init_resolve_repo = __esm({
|
|
|
1704
2099
|
|
|
1705
2100
|
// src/tools/search.ts
|
|
1706
2101
|
import { z as z8 } from "zod";
|
|
1707
|
-
function
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
2102
|
+
function tokenizeQuery(query) {
|
|
2103
|
+
return query.toLowerCase().replace(/["()]/g, " ").split(/[\s/_\-.,;:!?]+/).map((token) => token.replace(/[^a-z0-9*]/g, "")).filter((token) => token.length >= 2);
|
|
2104
|
+
}
|
|
2105
|
+
function buildFtsQuery2(tokens) {
|
|
2106
|
+
if (tokens.length === 0) {
|
|
2107
|
+
return null;
|
|
1711
2108
|
}
|
|
1712
|
-
return
|
|
2109
|
+
return tokens.map((token) => `"${token.replace(/"/g, '""')}"`).join(" AND ");
|
|
2110
|
+
}
|
|
2111
|
+
function buildFtsQueryOr(tokens) {
|
|
2112
|
+
if (tokens.length === 0) {
|
|
2113
|
+
return null;
|
|
2114
|
+
}
|
|
2115
|
+
return tokens.map((token) => `"${token.replace(/"/g, '""')}"`).join(" OR ");
|
|
1713
2116
|
}
|
|
1714
2117
|
function parseTags4(raw) {
|
|
1715
2118
|
try {
|
|
@@ -1719,37 +2122,73 @@ function parseTags4(raw) {
|
|
|
1719
2122
|
return [];
|
|
1720
2123
|
}
|
|
1721
2124
|
}
|
|
2125
|
+
function runFts(ftsQuery, resolvedRepo, limit) {
|
|
2126
|
+
const db = getDb();
|
|
2127
|
+
try {
|
|
2128
|
+
if (resolvedRepo) {
|
|
2129
|
+
return db.prepare(
|
|
2130
|
+
`
|
|
2131
|
+
SELECT m.rowid AS row_id, m.id, m.repo, m.type, m.note, m.tags,
|
|
2132
|
+
m.created_at, m.updated_at, m.pinned, bm25(memories_fts) AS rank
|
|
2133
|
+
FROM memories_fts
|
|
2134
|
+
JOIN memories AS m ON m.rowid = memories_fts.rowid
|
|
2135
|
+
WHERE memories_fts MATCH ? AND m.repo = ?
|
|
2136
|
+
ORDER BY rank
|
|
2137
|
+
LIMIT ?
|
|
2138
|
+
`
|
|
2139
|
+
).all(ftsQuery, resolvedRepo, limit);
|
|
2140
|
+
}
|
|
2141
|
+
return db.prepare(
|
|
2142
|
+
`
|
|
2143
|
+
SELECT m.rowid AS row_id, m.id, m.repo, m.type, m.note, m.tags,
|
|
2144
|
+
m.created_at, m.updated_at, m.pinned, bm25(memories_fts) AS rank
|
|
2145
|
+
FROM memories_fts
|
|
2146
|
+
JOIN memories AS m ON m.rowid = memories_fts.rowid
|
|
2147
|
+
WHERE memories_fts MATCH ?
|
|
2148
|
+
ORDER BY rank
|
|
2149
|
+
LIMIT ?
|
|
2150
|
+
`
|
|
2151
|
+
).all(ftsQuery, limit);
|
|
2152
|
+
} catch {
|
|
2153
|
+
return [];
|
|
2154
|
+
}
|
|
2155
|
+
}
|
|
1722
2156
|
function registerSearchMemoryTool(server) {
|
|
1723
2157
|
server.registerTool(
|
|
1724
2158
|
"search_memory",
|
|
1725
2159
|
{
|
|
1726
|
-
description: "Search memories using full-text search with optional repository filtering.",
|
|
2160
|
+
description: "Search memories using full-text search with optional repository filtering. Falls back to recent + pinned context when the query has no exact matches.",
|
|
1727
2161
|
inputSchema: searchMemoryInputSchema
|
|
1728
2162
|
},
|
|
1729
2163
|
async ({ query, repo, limit }) => {
|
|
1730
2164
|
try {
|
|
1731
2165
|
const db = getDb();
|
|
1732
|
-
const
|
|
1733
|
-
const resolvedRepo = repo ? resolveRepoArg(repo,
|
|
1734
|
-
const
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
2166
|
+
const tokens = tokenizeQuery(query);
|
|
2167
|
+
const resolvedRepo = repo ? resolveRepoArg(repo, getWorkspaceRoot(), db).canonical : void 0;
|
|
2168
|
+
const andQuery = buildFtsQuery2(tokens);
|
|
2169
|
+
let rows = [];
|
|
2170
|
+
if (andQuery) {
|
|
2171
|
+
rows = runFts(andQuery, resolvedRepo, limit);
|
|
2172
|
+
}
|
|
2173
|
+
if (rows.length === 0 && tokens.length > 1) {
|
|
2174
|
+
const orQuery = buildFtsQueryOr(tokens);
|
|
2175
|
+
if (orQuery) {
|
|
2176
|
+
rows = runFts(orQuery, resolvedRepo, limit);
|
|
2177
|
+
}
|
|
2178
|
+
}
|
|
2179
|
+
if (rows.length === 0 && resolvedRepo && embeddingsEnabled()) {
|
|
2180
|
+
const semantic = vectorSearch(db, resolvedRepo, query, limit);
|
|
2181
|
+
rows = semantic.map(({ score, ...row }) => ({
|
|
2182
|
+
...row,
|
|
2183
|
+
rank: score
|
|
2184
|
+
}));
|
|
2185
|
+
}
|
|
2186
|
+
let usedFallback = false;
|
|
2187
|
+
if (rows.length === 0 && resolvedRepo) {
|
|
2188
|
+
const fallback = fetchRepoContext(db, resolvedRepo, limit);
|
|
2189
|
+
rows = fallback.map((row) => ({ ...row, rank: 0 }));
|
|
2190
|
+
usedFallback = fallback.length > 0;
|
|
2191
|
+
}
|
|
1753
2192
|
if (rows.length === 0) {
|
|
1754
2193
|
return {
|
|
1755
2194
|
content: [
|
|
@@ -1767,11 +2206,12 @@ function registerSearchMemoryTool(server) {
|
|
|
1767
2206
|
return `${index + 1}. [${row.repo}] ${row.type} (${row.row_id} | legacy: ${row.id})
|
|
1768
2207
|
${pinPrefix}${row.note}${tagsText}`;
|
|
1769
2208
|
}).join("\n\n");
|
|
2209
|
+
const header = usedFallback ? `No exact match for "${query}"${resolvedRepo ? ` in ${resolvedRepo}` : ""}; showing recent + pinned context:` : `Search results for "${query}"${resolvedRepo ? ` in ${resolvedRepo}` : ""}:`;
|
|
1770
2210
|
return {
|
|
1771
2211
|
content: [
|
|
1772
2212
|
{
|
|
1773
2213
|
type: "text",
|
|
1774
|
-
text:
|
|
2214
|
+
text: `${header}
|
|
1775
2215
|
|
|
1776
2216
|
${formatted}`
|
|
1777
2217
|
}
|
|
@@ -1797,7 +2237,11 @@ var init_search = __esm({
|
|
|
1797
2237
|
"src/tools/search.ts"() {
|
|
1798
2238
|
"use strict";
|
|
1799
2239
|
init_client();
|
|
2240
|
+
init_context();
|
|
2241
|
+
init_embeddings();
|
|
1800
2242
|
init_repo();
|
|
2243
|
+
init_vector_index();
|
|
2244
|
+
init_workspace();
|
|
1801
2245
|
searchMemoryInputSchema = {
|
|
1802
2246
|
query: z8.string().trim().min(1, "query is required"),
|
|
1803
2247
|
repo: z8.string().trim().min(1).optional(),
|
|
@@ -1819,7 +2263,7 @@ function registerStoreContextTool(server) {
|
|
|
1819
2263
|
async ({ repo, type, note, tags }) => {
|
|
1820
2264
|
try {
|
|
1821
2265
|
const db = getDb();
|
|
1822
|
-
const resolved = resolveRepoArg(repo,
|
|
2266
|
+
const resolved = resolveRepoArg(repo, getWorkspaceRoot(), db);
|
|
1823
2267
|
const now = Math.floor(Date.now() / 1e3);
|
|
1824
2268
|
const id = nanoid2();
|
|
1825
2269
|
const normalizedTags = Array.from(
|
|
@@ -1847,6 +2291,9 @@ function registerStoreContextTool(server) {
|
|
|
1847
2291
|
WHERE id = ?
|
|
1848
2292
|
`
|
|
1849
2293
|
).get(id);
|
|
2294
|
+
if (stored) {
|
|
2295
|
+
indexMemoryEmbedding(db, stored.row_id, note);
|
|
2296
|
+
}
|
|
1850
2297
|
return {
|
|
1851
2298
|
content: [
|
|
1852
2299
|
{
|
|
@@ -1877,6 +2324,8 @@ var init_store = __esm({
|
|
|
1877
2324
|
init_client();
|
|
1878
2325
|
init_dedupe();
|
|
1879
2326
|
init_repo();
|
|
2327
|
+
init_vector_index();
|
|
2328
|
+
init_workspace();
|
|
1880
2329
|
storeContextInputSchema = {
|
|
1881
2330
|
repo: z9.string().trim().min(1).optional(),
|
|
1882
2331
|
type: z9.enum(MEMORY_TYPES),
|
|
@@ -1898,7 +2347,7 @@ function registerSummarizeRepoContextTool(server) {
|
|
|
1898
2347
|
async ({ repo }) => {
|
|
1899
2348
|
try {
|
|
1900
2349
|
const db = getDb();
|
|
1901
|
-
const resolved = resolveRepoArg(repo,
|
|
2350
|
+
const resolved = resolveRepoArg(repo, getWorkspaceRoot(), db);
|
|
1902
2351
|
const rows = db.prepare(
|
|
1903
2352
|
`
|
|
1904
2353
|
SELECT rowid AS row_id, type, note, pinned
|
|
@@ -1962,6 +2411,7 @@ var init_summarize = __esm({
|
|
|
1962
2411
|
"use strict";
|
|
1963
2412
|
init_client();
|
|
1964
2413
|
init_repo();
|
|
2414
|
+
init_workspace();
|
|
1965
2415
|
summarizeRepoContextInputSchema = {
|
|
1966
2416
|
repo: z10.string().trim().min(1).optional()
|
|
1967
2417
|
};
|
|
@@ -2006,7 +2456,7 @@ function registerUpdateMemoryTool(server) {
|
|
|
2006
2456
|
server.registerTool(
|
|
2007
2457
|
"update_memory",
|
|
2008
2458
|
{
|
|
2009
|
-
description: "Update an existing memory by numeric
|
|
2459
|
+
description: "Update an existing memory by id (numeric or legacy string) with partial fields.",
|
|
2010
2460
|
inputSchema: updateMemoryInputSchema
|
|
2011
2461
|
},
|
|
2012
2462
|
async ({ id, content, memory_type }) => {
|
|
@@ -2023,14 +2473,8 @@ function registerUpdateMemoryTool(server) {
|
|
|
2023
2473
|
};
|
|
2024
2474
|
}
|
|
2025
2475
|
const db = getDb();
|
|
2026
|
-
const
|
|
2027
|
-
|
|
2028
|
-
SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned
|
|
2029
|
-
FROM memories
|
|
2030
|
-
WHERE rowid = ?
|
|
2031
|
-
`
|
|
2032
|
-
).get(id);
|
|
2033
|
-
if (!existing) {
|
|
2476
|
+
const target = findMemoryByAnyId(db, id);
|
|
2477
|
+
if (!target) {
|
|
2034
2478
|
return {
|
|
2035
2479
|
isError: true,
|
|
2036
2480
|
content: [
|
|
@@ -2041,6 +2485,13 @@ function registerUpdateMemoryTool(server) {
|
|
|
2041
2485
|
]
|
|
2042
2486
|
};
|
|
2043
2487
|
}
|
|
2488
|
+
const existing = db.prepare(
|
|
2489
|
+
`
|
|
2490
|
+
SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned
|
|
2491
|
+
FROM memories
|
|
2492
|
+
WHERE rowid = ?
|
|
2493
|
+
`
|
|
2494
|
+
).get(target.row_id);
|
|
2044
2495
|
const now = Math.floor(Date.now() / 1e3);
|
|
2045
2496
|
const nextType = memory_type ?? existing.type;
|
|
2046
2497
|
const nextNote = content ?? existing.note;
|
|
@@ -2052,7 +2503,8 @@ function registerUpdateMemoryTool(server) {
|
|
|
2052
2503
|
SET type = ?, note = ?, note_normalized = ?, updated_at = ?
|
|
2053
2504
|
WHERE rowid = ?
|
|
2054
2505
|
`
|
|
2055
|
-
).run(nextType, nextNote, nextNormalized, now,
|
|
2506
|
+
).run(nextType, nextNote, nextNormalized, now, existing.row_id);
|
|
2507
|
+
indexMemoryEmbedding(db, existing.row_id, nextNote);
|
|
2056
2508
|
} else {
|
|
2057
2509
|
db.prepare(
|
|
2058
2510
|
`
|
|
@@ -2060,7 +2512,7 @@ function registerUpdateMemoryTool(server) {
|
|
|
2060
2512
|
SET type = ?, note = ?, updated_at = ?
|
|
2061
2513
|
WHERE rowid = ?
|
|
2062
2514
|
`
|
|
2063
|
-
).run(nextType, nextNote, now,
|
|
2515
|
+
).run(nextType, nextNote, now, existing.row_id);
|
|
2064
2516
|
}
|
|
2065
2517
|
const updated = db.prepare(
|
|
2066
2518
|
`
|
|
@@ -2068,7 +2520,7 @@ function registerUpdateMemoryTool(server) {
|
|
|
2068
2520
|
FROM memories
|
|
2069
2521
|
WHERE rowid = ?
|
|
2070
2522
|
`
|
|
2071
|
-
).get(
|
|
2523
|
+
).get(existing.row_id);
|
|
2072
2524
|
if (!updated) {
|
|
2073
2525
|
return {
|
|
2074
2526
|
isError: true,
|
|
@@ -2109,8 +2561,12 @@ var init_update = __esm({
|
|
|
2109
2561
|
"use strict";
|
|
2110
2562
|
init_client();
|
|
2111
2563
|
init_dedupe();
|
|
2564
|
+
init_memory();
|
|
2565
|
+
init_vector_index();
|
|
2112
2566
|
updateMemoryInputSchema = {
|
|
2113
|
-
id
|
|
2567
|
+
// Accept numeric row_id or legacy string id so callers can paste whichever
|
|
2568
|
+
// form they have.
|
|
2569
|
+
id: z11.union([z11.number().int().positive(), z11.string().trim().min(1)]),
|
|
2114
2570
|
content: z11.string().trim().min(1).optional(),
|
|
2115
2571
|
memory_type: z11.enum(MEMORY_TYPES).optional()
|
|
2116
2572
|
};
|
|
@@ -2143,7 +2599,7 @@ function registerStartupContextResource(server) {
|
|
|
2143
2599
|
async (uri) => {
|
|
2144
2600
|
try {
|
|
2145
2601
|
const db = getDb();
|
|
2146
|
-
const resolved = resolveRepo(
|
|
2602
|
+
const resolved = resolveRepo(getWorkspaceRoot(), db);
|
|
2147
2603
|
const rows = fetchRepoContext(db, resolved.canonical, 5);
|
|
2148
2604
|
const text = formatContext(rows, {
|
|
2149
2605
|
repo: resolved.canonical,
|
|
@@ -2180,7 +2636,7 @@ async function startServer() {
|
|
|
2180
2636
|
initDb(dbPath);
|
|
2181
2637
|
const server = new McpServer({
|
|
2182
2638
|
name: "fossel",
|
|
2183
|
-
version: "1.
|
|
2639
|
+
version: "1.2.0"
|
|
2184
2640
|
});
|
|
2185
2641
|
registerRememberTool(server);
|
|
2186
2642
|
registerGetContextTool(server);
|
|
@@ -2205,6 +2661,7 @@ var init_index = __esm({
|
|
|
2205
2661
|
init_client();
|
|
2206
2662
|
init_context();
|
|
2207
2663
|
init_repo();
|
|
2664
|
+
init_workspace();
|
|
2208
2665
|
init_dedupe_repo();
|
|
2209
2666
|
init_delete();
|
|
2210
2667
|
init_get_context();
|
|
@@ -2230,13 +2687,14 @@ var init_index = __esm({
|
|
|
2230
2687
|
|
|
2231
2688
|
// src/cli.ts
|
|
2232
2689
|
init_client();
|
|
2690
|
+
init_dedupe();
|
|
2233
2691
|
init_repo();
|
|
2234
2692
|
import { homedir as homedir2 } from "os";
|
|
2235
2693
|
import { join as join2 } from "path";
|
|
2236
2694
|
import { statSync } from "fs";
|
|
2237
2695
|
import { nanoid as nanoid3 } from "nanoid";
|
|
2238
2696
|
var DEFAULT_DB_PATH = join2(homedir2(), ".fossel", "memory.db");
|
|
2239
|
-
var INIT_MEMORY_TEXT = "Fossel is active for this repo.
|
|
2697
|
+
var INIT_MEMORY_TEXT = "Fossel is active for this repo. Say 'remember this' or call get_context to retrieve repo memories.";
|
|
2240
2698
|
function resolveDbPath2() {
|
|
2241
2699
|
return process.env.FOSSEL_DB_PATH?.trim() || DEFAULT_DB_PATH;
|
|
2242
2700
|
}
|
|
@@ -2260,7 +2718,7 @@ function ensureSampleMemoryIfEmpty(repo) {
|
|
|
2260
2718
|
"[]",
|
|
2261
2719
|
now,
|
|
2262
2720
|
now,
|
|
2263
|
-
INIT_MEMORY_TEXT
|
|
2721
|
+
normalizeText(INIT_MEMORY_TEXT)
|
|
2264
2722
|
);
|
|
2265
2723
|
return true;
|
|
2266
2724
|
}
|
|
@@ -2269,7 +2727,12 @@ var MCP_CONFIG_SNIPPET = JSON.stringify(
|
|
|
2269
2727
|
mcpServers: {
|
|
2270
2728
|
fossel: {
|
|
2271
2729
|
command: "npx",
|
|
2272
|
-
args: ["-y", "fossel"]
|
|
2730
|
+
args: ["-y", "fossel"],
|
|
2731
|
+
// FOSSEL_WORKSPACE pins the workspace root so the server detects the
|
|
2732
|
+
// right repo even when the IDE launches MCP servers from another cwd.
|
|
2733
|
+
env: {
|
|
2734
|
+
FOSSEL_WORKSPACE: "${workspaceFolder}"
|
|
2735
|
+
}
|
|
2273
2736
|
}
|
|
2274
2737
|
}
|
|
2275
2738
|
},
|
|
@@ -2293,7 +2756,44 @@ function findMergeCandidates(canonical) {
|
|
|
2293
2756
|
return otherTail === tail || otherTail === canonical || row.repo === tail;
|
|
2294
2757
|
});
|
|
2295
2758
|
}
|
|
2296
|
-
function
|
|
2759
|
+
function autoDedupeExact(repo) {
|
|
2760
|
+
const db = getDb();
|
|
2761
|
+
const groups = db.prepare(
|
|
2762
|
+
`
|
|
2763
|
+
SELECT note_normalized, type, COUNT(*) AS count
|
|
2764
|
+
FROM memories
|
|
2765
|
+
WHERE repo = ? AND note_normalized != ''
|
|
2766
|
+
GROUP BY note_normalized, type
|
|
2767
|
+
HAVING COUNT(*) > 1
|
|
2768
|
+
`
|
|
2769
|
+
).all(repo);
|
|
2770
|
+
if (groups.length === 0) {
|
|
2771
|
+
return 0;
|
|
2772
|
+
}
|
|
2773
|
+
let removed = 0;
|
|
2774
|
+
const tx = db.transaction(() => {
|
|
2775
|
+
for (const group of groups) {
|
|
2776
|
+
const rows = db.prepare(
|
|
2777
|
+
`
|
|
2778
|
+
SELECT rowid AS row_id, pinned, updated_at
|
|
2779
|
+
FROM memories
|
|
2780
|
+
WHERE repo = ? AND note_normalized = ? AND type = ?
|
|
2781
|
+
ORDER BY pinned DESC, updated_at DESC, rowid DESC
|
|
2782
|
+
`
|
|
2783
|
+
).all(repo, group.note_normalized, group.type);
|
|
2784
|
+
const [keep, ...rest] = rows;
|
|
2785
|
+
if (!keep) continue;
|
|
2786
|
+
const drop = db.prepare("DELETE FROM memories WHERE rowid = ?");
|
|
2787
|
+
for (const row of rest) {
|
|
2788
|
+
drop.run(row.row_id);
|
|
2789
|
+
removed += 1;
|
|
2790
|
+
}
|
|
2791
|
+
}
|
|
2792
|
+
});
|
|
2793
|
+
tx();
|
|
2794
|
+
return removed;
|
|
2795
|
+
}
|
|
2796
|
+
function runInit(options) {
|
|
2297
2797
|
const dbPath = resolveDbPath2();
|
|
2298
2798
|
initDb(dbPath);
|
|
2299
2799
|
const db = getDb();
|
|
@@ -2301,12 +2801,18 @@ function runInit() {
|
|
|
2301
2801
|
const candidates = findMergeCandidates(resolved.canonical);
|
|
2302
2802
|
let mergedAliases = 0;
|
|
2303
2803
|
let mergedMemories = 0;
|
|
2804
|
+
let rewrittenNotes = 0;
|
|
2304
2805
|
for (const candidate of candidates) {
|
|
2305
2806
|
const result = mergeRepoKeys(db, candidate.repo, resolved.canonical);
|
|
2306
2807
|
mergedAliases += result.movedAliases;
|
|
2307
2808
|
mergedMemories += result.movedMemories;
|
|
2809
|
+
rewrittenNotes += result.rewrittenNotes;
|
|
2308
2810
|
}
|
|
2309
2811
|
const sampleAdded = ensureSampleMemoryIfEmpty(resolved.canonical);
|
|
2812
|
+
let autoMerged = 0;
|
|
2813
|
+
if (options.autoDedupe) {
|
|
2814
|
+
autoMerged = autoDedupeExact(resolved.canonical);
|
|
2815
|
+
}
|
|
2310
2816
|
const countRow = db.prepare("SELECT COUNT(*) AS count FROM memories WHERE repo = ?").get(resolved.canonical);
|
|
2311
2817
|
console.log("Fossel \u2014 local-first MCP memory for your repos.\n");
|
|
2312
2818
|
console.log(`Canonical repo key: ${resolved.canonical}`);
|
|
@@ -2318,12 +2824,16 @@ function runInit() {
|
|
|
2318
2824
|
console.log(` aliases: ${resolved.aliases.join(", ")}`);
|
|
2319
2825
|
}
|
|
2320
2826
|
console.log("");
|
|
2321
|
-
if (mergedAliases > 0 || mergedMemories > 0) {
|
|
2827
|
+
if (mergedAliases > 0 || mergedMemories > 0 || rewrittenNotes > 0) {
|
|
2322
2828
|
console.log(
|
|
2323
|
-
`Merged ${mergedMemories} memory row(s)
|
|
2829
|
+
`Merged ${mergedMemories} memory row(s), ${mergedAliases} alias row(s), and rewrote ${rewrittenNotes} stale mention(s) into ${resolved.canonical}.`
|
|
2324
2830
|
);
|
|
2325
2831
|
console.log("");
|
|
2326
2832
|
}
|
|
2833
|
+
if (autoMerged > 0) {
|
|
2834
|
+
console.log(`Auto-deduped ${autoMerged} exact duplicate row(s).`);
|
|
2835
|
+
console.log("");
|
|
2836
|
+
}
|
|
2327
2837
|
console.log("MCP config (Cursor: ~/.cursor/mcp.json, Claude Desktop: settings):");
|
|
2328
2838
|
console.log(MCP_CONFIG_SNIPPET);
|
|
2329
2839
|
console.log("");
|
|
@@ -2339,9 +2849,11 @@ function runInit() {
|
|
|
2339
2849
|
console.log(" resolve_repo \u2014 show which repo key Fossel will use");
|
|
2340
2850
|
console.log(" store_context \u2014 explicit save (advanced)");
|
|
2341
2851
|
console.log(" dedupe_repo \u2014 merge near-duplicate memories");
|
|
2852
|
+
console.log("");
|
|
2853
|
+
console.log("Set FOSSEL_WORKSPACE in your MCP config to your project root if Fossel detects the wrong repo.");
|
|
2342
2854
|
closeDb();
|
|
2343
2855
|
}
|
|
2344
|
-
function
|
|
2856
|
+
function gatherDoctorReport() {
|
|
2345
2857
|
const dbPath = resolveDbPath2();
|
|
2346
2858
|
const lines = [];
|
|
2347
2859
|
let ok = true;
|
|
@@ -2366,6 +2878,22 @@ function runDoctor() {
|
|
|
2366
2878
|
} else {
|
|
2367
2879
|
lines.push("No sibling repo keys.");
|
|
2368
2880
|
}
|
|
2881
|
+
const staleMentions = [];
|
|
2882
|
+
for (const alias of resolved.aliases) {
|
|
2883
|
+
if (alias === resolved.canonical) continue;
|
|
2884
|
+
const found2 = findMemoriesMentioningAlias(db, alias, resolved.canonical);
|
|
2885
|
+
for (const row of found2) {
|
|
2886
|
+
staleMentions.push({ alias, row_id: row.row_id, note: row.note });
|
|
2887
|
+
}
|
|
2888
|
+
}
|
|
2889
|
+
if (staleMentions.length > 0) {
|
|
2890
|
+
ok = false;
|
|
2891
|
+
lines.push(
|
|
2892
|
+
`\u26A0 ${staleMentions.length} memory note(s) still mention a deprecated repo key. Run \`fossel doctor --fix\` to rewrite them.`
|
|
2893
|
+
);
|
|
2894
|
+
} else {
|
|
2895
|
+
lines.push("No memory notes reference deprecated repo keys.");
|
|
2896
|
+
}
|
|
2369
2897
|
const duplicateRows = db.prepare(
|
|
2370
2898
|
`
|
|
2371
2899
|
SELECT note_normalized, COUNT(*) AS count
|
|
@@ -2379,7 +2907,7 @@ function runDoctor() {
|
|
|
2379
2907
|
ok = false;
|
|
2380
2908
|
const total = duplicateRows.reduce((sum, row) => sum + row.count - 1, 0);
|
|
2381
2909
|
lines.push(
|
|
2382
|
-
`\u26A0 ${duplicateRows.length} duplicate clusters covering ${total} extra row(s). Run \`dedupe_repo\` with apply=true.`
|
|
2910
|
+
`\u26A0 ${duplicateRows.length} duplicate clusters covering ${total} extra row(s). Run \`fossel doctor --fix\` (or \`dedupe_repo\` with apply=true) to merge.`
|
|
2383
2911
|
);
|
|
2384
2912
|
} else {
|
|
2385
2913
|
lines.push("No exact-duplicate memory clusters.");
|
|
@@ -2393,7 +2921,13 @@ function runDoctor() {
|
|
|
2393
2921
|
"Claude",
|
|
2394
2922
|
"claude_desktop_config.json"
|
|
2395
2923
|
),
|
|
2396
|
-
join2(
|
|
2924
|
+
join2(
|
|
2925
|
+
homedir2(),
|
|
2926
|
+
"Library",
|
|
2927
|
+
"Application Support",
|
|
2928
|
+
"Claude",
|
|
2929
|
+
"claude_desktop_config.json"
|
|
2930
|
+
)
|
|
2397
2931
|
];
|
|
2398
2932
|
const found = mcpConfigCandidates.filter((path) => {
|
|
2399
2933
|
try {
|
|
@@ -2411,13 +2945,41 @@ function runDoctor() {
|
|
|
2411
2945
|
}
|
|
2412
2946
|
const totalRow = db.prepare("SELECT COUNT(*) AS count FROM memories").get();
|
|
2413
2947
|
lines.push(`Total memories across all repos: ${totalRow.count}`);
|
|
2414
|
-
|
|
2415
|
-
|
|
2948
|
+
return { ok, lines, duplicateClusters: duplicateRows.length, staleMentions };
|
|
2949
|
+
}
|
|
2950
|
+
function runDoctor(options) {
|
|
2951
|
+
const report = gatherDoctorReport();
|
|
2952
|
+
console.log(report.lines.join("\n"));
|
|
2416
2953
|
console.log("");
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2954
|
+
if (!options.fix) {
|
|
2955
|
+
console.log(report.ok ? "Status: OK" : "Status: needs attention (see \u26A0 lines above)");
|
|
2956
|
+
if (!report.ok) {
|
|
2957
|
+
process.exitCode = 1;
|
|
2958
|
+
}
|
|
2959
|
+
closeDb();
|
|
2960
|
+
return;
|
|
2420
2961
|
}
|
|
2962
|
+
const db = getDb();
|
|
2963
|
+
const resolved = resolveRepo(process.cwd(), db);
|
|
2964
|
+
const candidates = findMergeCandidates(resolved.canonical);
|
|
2965
|
+
let movedMemories = 0;
|
|
2966
|
+
let rewrittenNotes = 0;
|
|
2967
|
+
for (const candidate of candidates) {
|
|
2968
|
+
const result = mergeRepoKeys(db, candidate.repo, resolved.canonical);
|
|
2969
|
+
movedMemories += result.movedMemories;
|
|
2970
|
+
rewrittenNotes += result.rewrittenNotes;
|
|
2971
|
+
}
|
|
2972
|
+
const removed = autoDedupeExact(resolved.canonical);
|
|
2973
|
+
console.log("Applied fixes:");
|
|
2974
|
+
console.log(` merged repo memory rows: ${movedMemories}`);
|
|
2975
|
+
console.log(` rewrote stale mentions: ${rewrittenNotes}`);
|
|
2976
|
+
console.log(` removed exact duplicates: ${removed}`);
|
|
2977
|
+
console.log("");
|
|
2978
|
+
console.log("Re-run `fossel doctor` to verify.");
|
|
2979
|
+
closeDb();
|
|
2980
|
+
}
|
|
2981
|
+
function parseFlag(args, name) {
|
|
2982
|
+
return args.includes(`--${name}`);
|
|
2421
2983
|
}
|
|
2422
2984
|
async function main() {
|
|
2423
2985
|
const command = process.argv[2];
|
|
@@ -2427,15 +2989,18 @@ async function main() {
|
|
|
2427
2989
|
return;
|
|
2428
2990
|
}
|
|
2429
2991
|
if (command === "init") {
|
|
2430
|
-
|
|
2992
|
+
const args = process.argv.slice(3);
|
|
2993
|
+
const autoDedupe = !parseFlag(args, "no-dedupe");
|
|
2994
|
+
runInit({ autoDedupe });
|
|
2431
2995
|
return;
|
|
2432
2996
|
}
|
|
2433
2997
|
if (command === "doctor") {
|
|
2434
|
-
|
|
2998
|
+
const args = process.argv.slice(3);
|
|
2999
|
+
runDoctor({ fix: parseFlag(args, "fix") });
|
|
2435
3000
|
return;
|
|
2436
3001
|
}
|
|
2437
3002
|
console.error(`Unknown command: ${command}`);
|
|
2438
|
-
console.error("Usage: fossel [init | doctor]");
|
|
3003
|
+
console.error("Usage: fossel [init [--no-dedupe] | doctor [--fix]]");
|
|
2439
3004
|
process.exit(1);
|
|
2440
3005
|
}
|
|
2441
3006
|
main().catch((error) => {
|