fossel 1.0.9 → 1.1.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.
Files changed (4) hide show
  1. package/README.md +125 -45
  2. package/dist/cli.js +2002 -257
  3. package/dist/index.js +1471 -102
  4. package/package.json +3 -2
package/dist/cli.js CHANGED
@@ -14,6 +14,9 @@ function hasColumn(db, tableName, columnName) {
14
14
  const columns = db.prepare(`PRAGMA table_info(${tableName})`).all();
15
15
  return columns.some((column) => column.name === columnName);
16
16
  }
17
+ function normalizeNoteForMigration(text) {
18
+ return text.toLowerCase().replace(/[^a-z0-9\s]/g, " ").replace(/\s+/g, " ").trim();
19
+ }
17
20
  function runMigrations(db) {
18
21
  db.exec(`
19
22
  CREATE TABLE IF NOT EXISTS migrations (
@@ -108,6 +111,59 @@ var init_migrate = __esm({
108
111
  `);
109
112
  }
110
113
  }
114
+ },
115
+ {
116
+ name: "004_add_repo_aliases",
117
+ apply: (db) => {
118
+ db.exec(`
119
+ CREATE TABLE IF NOT EXISTS repo_aliases (
120
+ alias TEXT PRIMARY KEY,
121
+ canonical TEXT NOT NULL,
122
+ created_at INTEGER NOT NULL
123
+ );
124
+
125
+ CREATE INDEX IF NOT EXISTS idx_repo_aliases_canonical
126
+ ON repo_aliases (canonical);
127
+ `);
128
+ }
129
+ },
130
+ {
131
+ name: "005_add_memories_metadata_json",
132
+ apply: (db) => {
133
+ if (!hasColumn(db, "memories", "metadata_json")) {
134
+ db.exec(`
135
+ ALTER TABLE memories
136
+ ADD COLUMN metadata_json TEXT NOT NULL DEFAULT '{}';
137
+ `);
138
+ }
139
+ }
140
+ },
141
+ {
142
+ name: "006_add_memories_note_normalized",
143
+ apply: (db) => {
144
+ if (!hasColumn(db, "memories", "note_normalized")) {
145
+ db.exec(`
146
+ ALTER TABLE memories
147
+ ADD COLUMN note_normalized TEXT NOT NULL DEFAULT '';
148
+ `);
149
+ }
150
+ db.exec(`
151
+ CREATE INDEX IF NOT EXISTS idx_memories_note_normalized
152
+ ON memories (repo, note_normalized);
153
+ `);
154
+ const rows = db.prepare("SELECT rowid AS row_id, note FROM memories WHERE note_normalized = ''").all();
155
+ if (rows.length > 0) {
156
+ const update = db.prepare(
157
+ "UPDATE memories SET note_normalized = ? WHERE rowid = ?"
158
+ );
159
+ const tx = db.transaction((batch) => {
160
+ for (const row of batch) {
161
+ update.run(normalizeNoteForMigration(row.note), row.row_id);
162
+ }
163
+ });
164
+ tx(rows);
165
+ }
166
+ }
111
167
  }
112
168
  ];
113
169
  }
@@ -159,50 +215,664 @@ var init_client = __esm({
159
215
  }
160
216
  });
161
217
 
162
- // src/tools/delete.ts
218
+ // src/lib/dedupe.ts
219
+ function normalizeText(text) {
220
+ return text.toLowerCase().replace(/[^a-z0-9\s]/g, " ").replace(/\s+/g, " ").trim();
221
+ }
222
+ function tokenize(text) {
223
+ return normalizeText(text).split(" ").filter((token) => token.length >= 2);
224
+ }
225
+ function trigrams(text) {
226
+ const padded = ` ${text} `;
227
+ const grams = /* @__PURE__ */ new Set();
228
+ for (let i = 0; i < padded.length - 2; i += 1) {
229
+ grams.add(padded.slice(i, i + 3));
230
+ }
231
+ return grams;
232
+ }
233
+ function jaccard(a, b) {
234
+ if (a.size === 0 && b.size === 0) {
235
+ return 1;
236
+ }
237
+ let intersection = 0;
238
+ for (const value of a) {
239
+ if (b.has(value)) {
240
+ intersection += 1;
241
+ }
242
+ }
243
+ const union = a.size + b.size - intersection;
244
+ return union === 0 ? 0 : intersection / union;
245
+ }
246
+ function similarity(a, b) {
247
+ const normalizedA = normalizeText(a);
248
+ const normalizedB = normalizeText(b);
249
+ if (!normalizedA && !normalizedB) {
250
+ return 1;
251
+ }
252
+ if (!normalizedA || !normalizedB) {
253
+ return 0;
254
+ }
255
+ if (normalizedA === normalizedB) {
256
+ return 1;
257
+ }
258
+ const wordScore = jaccard(new Set(tokenize(normalizedA)), new Set(tokenize(normalizedB)));
259
+ const triScore = jaccard(trigrams(normalizedA), trigrams(normalizedB));
260
+ return wordScore * 0.55 + triScore * 0.45;
261
+ }
262
+ function findDuplicate(db, repo, note, options = {}) {
263
+ const threshold = options.threshold ?? DEFAULT_THRESHOLD;
264
+ const limit = options.candidateLimit ?? DEFAULT_CANDIDATE_LIMIT;
265
+ const normalized = normalizeText(note);
266
+ if (!normalized) {
267
+ return null;
268
+ }
269
+ const exact = db.prepare(
270
+ `
271
+ SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned
272
+ FROM memories
273
+ WHERE repo = ? AND note_normalized = ?
274
+ ORDER BY updated_at DESC
275
+ LIMIT 1
276
+ `
277
+ ).get(repo, normalized);
278
+ if (exact) {
279
+ return { memory: exact, similarity: 1 };
280
+ }
281
+ const candidates = db.prepare(
282
+ `
283
+ SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned
284
+ FROM memories
285
+ WHERE repo = ?
286
+ ORDER BY updated_at DESC
287
+ LIMIT ?
288
+ `
289
+ ).all(repo, limit);
290
+ let best = null;
291
+ for (const candidate of candidates) {
292
+ const score = similarity(note, candidate.note);
293
+ if (score >= threshold && (!best || score > best.similarity)) {
294
+ best = { memory: candidate, similarity: score };
295
+ }
296
+ }
297
+ return best;
298
+ }
299
+ var DEFAULT_THRESHOLD, DEFAULT_CANDIDATE_LIMIT;
300
+ var init_dedupe = __esm({
301
+ "src/lib/dedupe.ts"() {
302
+ "use strict";
303
+ DEFAULT_THRESHOLD = 0.82;
304
+ DEFAULT_CANDIDATE_LIMIT = 200;
305
+ }
306
+ });
307
+
308
+ // src/lib/repo.ts
309
+ import { spawnSync } from "child_process";
310
+ import { basename } from "path";
311
+ function normalizeGitRemote(remoteUrl) {
312
+ const trimmed = remoteUrl.trim();
313
+ if (!trimmed) {
314
+ return null;
315
+ }
316
+ for (const pattern of REMOTE_PATTERNS) {
317
+ const match = pattern.exec(trimmed);
318
+ if (!match) {
319
+ continue;
320
+ }
321
+ const path = match[2]?.replace(/^\/+/, "").replace(/\\/g, "/").replace(/\.git$/i, "").replace(/\/+$/, "");
322
+ if (!path) {
323
+ continue;
324
+ }
325
+ return path;
326
+ }
327
+ return null;
328
+ }
329
+ function readGitRemote(cwd) {
330
+ const result = spawnSync("git", ["remote", "get-url", "origin"], {
331
+ cwd,
332
+ encoding: "utf8"
333
+ });
334
+ if (result.status !== 0) {
335
+ return null;
336
+ }
337
+ const value = result.stdout.trim();
338
+ return value.length > 0 ? value : null;
339
+ }
340
+ function detectFolderName(cwd) {
341
+ const name = basename(cwd);
342
+ return name.length > 0 ? name : cwd;
343
+ }
344
+ function fetchAliases(db, canonical) {
345
+ const rows = db.prepare("SELECT alias FROM repo_aliases WHERE canonical = ? ORDER BY alias").all(canonical);
346
+ return rows.map((row) => row.alias);
347
+ }
348
+ function upsertAlias(db, alias, canonical) {
349
+ const trimmed = alias.trim();
350
+ const target = canonical.trim();
351
+ if (!trimmed || !target) {
352
+ return;
353
+ }
354
+ const now = Math.floor(Date.now() / 1e3);
355
+ db.prepare(
356
+ `
357
+ INSERT INTO repo_aliases (alias, canonical, created_at)
358
+ VALUES (?, ?, ?)
359
+ ON CONFLICT(alias) DO UPDATE SET canonical = excluded.canonical
360
+ `
361
+ ).run(trimmed, target, now);
362
+ }
363
+ function lookupAlias(db, alias) {
364
+ const row = db.prepare("SELECT alias, canonical FROM repo_aliases WHERE alias = ?").get(alias);
365
+ return row ?? null;
366
+ }
367
+ function resolveRepo(cwd, db) {
368
+ const gitRemote = readGitRemote(cwd);
369
+ const fromRemote = gitRemote ? normalizeGitRemote(gitRemote) : null;
370
+ const folder = detectFolderName(cwd);
371
+ let canonical;
372
+ let source;
373
+ if (fromRemote) {
374
+ canonical = fromRemote;
375
+ source = "git-remote";
376
+ } else {
377
+ canonical = folder;
378
+ source = "folder";
379
+ }
380
+ upsertAlias(db, canonical, canonical);
381
+ if (folder && folder !== canonical) {
382
+ const existing = lookupAlias(db, folder);
383
+ if (!existing) {
384
+ upsertAlias(db, folder, canonical);
385
+ }
386
+ }
387
+ return {
388
+ canonical,
389
+ cwd,
390
+ gitRemote,
391
+ source,
392
+ aliases: fetchAliases(db, canonical)
393
+ };
394
+ }
395
+ function resolveRepoArg(input, cwd, db) {
396
+ const trimmed = input?.trim();
397
+ if (!trimmed) {
398
+ return resolveRepo(cwd, db);
399
+ }
400
+ const aliasRow = lookupAlias(db, trimmed);
401
+ if (aliasRow) {
402
+ return {
403
+ canonical: aliasRow.canonical,
404
+ cwd,
405
+ gitRemote: null,
406
+ source: "alias",
407
+ aliases: fetchAliases(db, aliasRow.canonical)
408
+ };
409
+ }
410
+ const workspace = resolveRepo(cwd, db);
411
+ if (workspace.canonical && workspace.canonical !== trimmed) {
412
+ const tail = workspace.canonical.split("/").at(-1) ?? workspace.canonical;
413
+ const inputTail = trimmed.split("/").at(-1) ?? trimmed;
414
+ if (tail === inputTail || tail === trimmed || inputTail === workspace.canonical) {
415
+ upsertAlias(db, trimmed, workspace.canonical);
416
+ return {
417
+ ...workspace,
418
+ source: "alias",
419
+ aliases: fetchAliases(db, workspace.canonical)
420
+ };
421
+ }
422
+ }
423
+ upsertAlias(db, trimmed, trimmed);
424
+ return {
425
+ canonical: trimmed,
426
+ cwd,
427
+ gitRemote: null,
428
+ source: "input",
429
+ aliases: fetchAliases(db, trimmed)
430
+ };
431
+ }
432
+ function mergeRepoKeys(db, from, to) {
433
+ if (from === to) {
434
+ return { movedAliases: 0, movedMemories: 0, rewrittenNotes: 0 };
435
+ }
436
+ const tx = db.transaction(() => {
437
+ const aliasResult = db.prepare("UPDATE repo_aliases SET canonical = ? WHERE canonical = ?").run(to, from);
438
+ const aliasesToReassign = db.prepare("SELECT alias FROM repo_aliases WHERE canonical = ?").all(to);
439
+ let movedMemories = 0;
440
+ const updateMemories = db.prepare(
441
+ "UPDATE memories SET repo = ? WHERE repo = ?"
442
+ );
443
+ for (const { alias } of aliasesToReassign) {
444
+ if (alias === to) {
445
+ continue;
446
+ }
447
+ const result = updateMemories.run(to, alias);
448
+ movedMemories += result.changes;
449
+ }
450
+ movedMemories += updateMemories.run(to, from).changes;
451
+ const rewrittenNotes = rewriteStaleRepoMentions(db, from, to);
452
+ upsertAlias(db, from, to);
453
+ upsertAlias(db, to, to);
454
+ return {
455
+ movedAliases: aliasResult.changes,
456
+ movedMemories,
457
+ rewrittenNotes
458
+ };
459
+ });
460
+ return tx();
461
+ }
462
+ function tokenBoundaryReplace(text, from, to) {
463
+ const escaped = from.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
464
+ const pattern = new RegExp(`(^|[^\\w/-])(${escaped})(?=$|[^\\w/-])`, "g");
465
+ return text.replace(pattern, (_match, prefix) => `${prefix}${to}`);
466
+ }
467
+ function rewriteStaleRepoMentions(db, from, to) {
468
+ const candidates = db.prepare(
469
+ `
470
+ SELECT rowid AS row_id, note, metadata_json
471
+ FROM memories
472
+ WHERE note LIKE ?
473
+ `
474
+ ).all(`%${from}%`);
475
+ if (candidates.length === 0) {
476
+ return 0;
477
+ }
478
+ const update = db.prepare(
479
+ `
480
+ UPDATE memories
481
+ SET note = ?, note_normalized = ?, metadata_json = ?, updated_at = ?
482
+ WHERE rowid = ?
483
+ `
484
+ );
485
+ const now = Math.floor(Date.now() / 1e3);
486
+ let rewritten = 0;
487
+ for (const row of candidates) {
488
+ const next = tokenBoundaryReplace(row.note, from, to);
489
+ if (next === row.note) {
490
+ continue;
491
+ }
492
+ const normalized = next.toLowerCase().replace(/[^a-z0-9\s]/g, " ").replace(/\s+/g, " ").trim();
493
+ let metadata;
494
+ try {
495
+ const parsed = JSON.parse(row.metadata_json);
496
+ metadata = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
497
+ } catch {
498
+ metadata = {};
499
+ }
500
+ metadata.changelog = metadata.changelog ?? [];
501
+ metadata.changelog.push({
502
+ at: now,
503
+ action: "alias_rewrite",
504
+ previous_note: row.note,
505
+ rewrote_alias: from
506
+ });
507
+ update.run(next, normalized, JSON.stringify(metadata), now, row.row_id);
508
+ rewritten += 1;
509
+ }
510
+ return rewritten;
511
+ }
512
+ function findMemoriesMentioningAlias(db, alias, canonical) {
513
+ const rows = db.prepare(
514
+ `
515
+ SELECT rowid AS row_id, repo, note
516
+ FROM memories
517
+ WHERE repo = ? AND note LIKE ?
518
+ `
519
+ ).all(canonical, `%${alias}%`);
520
+ const escaped = alias.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
521
+ const pattern = new RegExp(`(^|[^\\w/-])${escaped}(?=$|[^\\w/-])`);
522
+ return rows.filter((row) => pattern.test(row.note));
523
+ }
524
+ var REMOTE_PATTERNS;
525
+ var init_repo = __esm({
526
+ "src/lib/repo.ts"() {
527
+ "use strict";
528
+ REMOTE_PATTERNS = [
529
+ // git@github.com:owner/repo.git, git@gitlab.com:group/sub/repo.git
530
+ /^[^@\s]+@([^:]+):([^\s]+?)(?:\.git)?$/,
531
+ // ssh://git@github.com/owner/repo.git
532
+ /^ssh:\/\/[^@/]+@([^/]+)\/([^\s]+?)(?:\.git)?$/,
533
+ // https://github.com/owner/repo.git, http://gitlab.com/group/sub/repo
534
+ /^https?:\/\/(?:[^@/]+@)?([^/]+)\/([^\s]+?)(?:\.git)?$/,
535
+ // git://github.com/owner/repo.git
536
+ /^git:\/\/([^/]+)\/([^\s]+?)(?:\.git)?$/
537
+ ];
538
+ }
539
+ });
540
+
541
+ // src/lib/context.ts
542
+ function parseTags(raw) {
543
+ try {
544
+ const parsed = JSON.parse(raw);
545
+ return Array.isArray(parsed) ? parsed.filter((value) => typeof value === "string") : [];
546
+ } catch {
547
+ return [];
548
+ }
549
+ }
550
+ function normalizeNoteForReadDedupe(text) {
551
+ return text.toLowerCase().replace(/[^a-z0-9\s]/g, " ").replace(/\s+/g, " ").trim();
552
+ }
553
+ function buildFtsQuery(query) {
554
+ const terms = query.trim().split(/\s+/).map((term) => term.replace(/"/g, '""')).filter((term) => term.length > 0);
555
+ if (terms.length === 0) {
556
+ return null;
557
+ }
558
+ return terms.map((term) => `"${term}"`).join(" AND ");
559
+ }
560
+ function fetchRepoContext(db, repo, limit, query) {
561
+ const rows = [];
562
+ const seen = /* @__PURE__ */ new Set();
563
+ const seenNormalized = /* @__PURE__ */ new Set();
564
+ const push = (memory, source, rank) => {
565
+ if (seen.has(memory.row_id)) {
566
+ return;
567
+ }
568
+ const normalized = normalizeNoteForReadDedupe(memory.note);
569
+ if (normalized && seenNormalized.has(normalized)) {
570
+ return;
571
+ }
572
+ seen.add(memory.row_id);
573
+ if (normalized) {
574
+ seenNormalized.add(normalized);
575
+ }
576
+ rows.push({ ...memory, source, rank });
577
+ };
578
+ const pinned = db.prepare(
579
+ `
580
+ SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned
581
+ FROM memories
582
+ WHERE repo = ? AND pinned = 1
583
+ ORDER BY updated_at DESC
584
+ LIMIT ?
585
+ `
586
+ ).all(repo, limit);
587
+ for (const row of pinned) {
588
+ push(row, "pinned");
589
+ }
590
+ if (rows.length < limit) {
591
+ const recent = db.prepare(
592
+ `
593
+ SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned
594
+ FROM memories
595
+ WHERE repo = ? AND pinned = 0
596
+ ORDER BY updated_at DESC
597
+ LIMIT ?
598
+ `
599
+ ).all(repo, limit - rows.length);
600
+ for (const row of recent) {
601
+ push(row, "recent");
602
+ }
603
+ }
604
+ if (query && rows.length < limit) {
605
+ const ftsQuery = buildFtsQuery(query);
606
+ if (ftsQuery) {
607
+ try {
608
+ const matches = db.prepare(
609
+ `
610
+ SELECT m.rowid AS row_id, m.id, m.repo, m.type, m.note, m.tags,
611
+ m.created_at, m.updated_at, m.pinned, bm25(memories_fts) AS rank
612
+ FROM memories_fts
613
+ JOIN memories AS m ON m.rowid = memories_fts.rowid
614
+ WHERE memories_fts MATCH ? AND m.repo = ?
615
+ ORDER BY rank
616
+ LIMIT ?
617
+ `
618
+ ).all(ftsQuery, repo, limit);
619
+ for (const row of matches) {
620
+ push(row, "search", row.rank);
621
+ if (rows.length >= limit) {
622
+ break;
623
+ }
624
+ }
625
+ } catch {
626
+ }
627
+ }
628
+ }
629
+ return rows.slice(0, limit);
630
+ }
631
+ function formatContext(rows, options) {
632
+ const { repo, query, format = "text" } = options;
633
+ if (rows.length === 0) {
634
+ if (format === "markdown") {
635
+ return `# Fossel context: ${repo}
636
+
637
+ No memories found${query ? ` for "${query}"` : ""}.`;
638
+ }
639
+ return `No memories found for ${repo}${query ? ` matching "${query}"` : ""}.`;
640
+ }
641
+ if (format === "markdown") {
642
+ return formatMarkdown(rows, repo, query);
643
+ }
644
+ return formatText(rows, repo, query);
645
+ }
646
+ function formatMarkdown(rows, repo, query) {
647
+ const sections = [`# Fossel context: ${repo}`];
648
+ if (query) {
649
+ sections.push(`Query: \`${query}\``);
650
+ }
651
+ const pinned = rows.filter((row) => row.pinned === 1);
652
+ if (pinned.length > 0) {
653
+ sections.push(["## \u{1F4CC} Pinned", ...pinned.map(renderMarkdownRow)].join("\n"));
654
+ }
655
+ for (const type of MEMORY_TYPES) {
656
+ const entries = rows.filter((row) => row.pinned !== 1 && row.type === type);
657
+ if (entries.length === 0) {
658
+ continue;
659
+ }
660
+ sections.push(
661
+ [`## ${SECTION_TITLES[type]}`, ...entries.map(renderMarkdownRow)].join("\n")
662
+ );
663
+ }
664
+ return sections.join("\n\n");
665
+ }
666
+ function renderMarkdownRow(row) {
667
+ const tags = parseTags(row.tags);
668
+ const tagSuffix = tags.length > 0 ? ` _(${tags.join(", ")})_` : "";
669
+ return `- (${row.row_id}) ${row.note}${tagSuffix}`;
670
+ }
671
+ function formatText(rows, repo, query) {
672
+ const header = query ? `Repository context for ${repo} (query: "${query}")` : `Repository context for ${repo}`;
673
+ const lines = [header, `Total: ${rows.length}`, ""];
674
+ for (const row of rows) {
675
+ const tags = parseTags(row.tags);
676
+ const tagSuffix = tags.length > 0 ? ` [tags: ${tags.join(", ")}]` : "";
677
+ const pinPrefix = row.pinned ? "\u{1F4CC} " : "";
678
+ const sourceLabel = row.source === "search" ? " [match]" : "";
679
+ lines.push(
680
+ `- (${row.row_id} | ${row.type})${sourceLabel} ${pinPrefix}${row.note}${tagSuffix}`
681
+ );
682
+ }
683
+ return lines.join("\n");
684
+ }
685
+ var SECTION_TITLES;
686
+ var init_context = __esm({
687
+ "src/lib/context.ts"() {
688
+ "use strict";
689
+ init_client();
690
+ SECTION_TITLES = {
691
+ convention: "Conventions",
692
+ bug_fix: "Bug Fixes",
693
+ reviewer_pattern: "Reviewer Patterns",
694
+ decision: "Decisions",
695
+ issue: "Issues",
696
+ general: "General"
697
+ };
698
+ }
699
+ });
700
+
701
+ // src/lib/workspace.ts
702
+ function getWorkspaceRoot() {
703
+ const fromEnv = process.env.FOSSEL_WORKSPACE?.trim();
704
+ if (fromEnv) {
705
+ return fromEnv;
706
+ }
707
+ return process.cwd();
708
+ }
709
+ var init_workspace = __esm({
710
+ "src/lib/workspace.ts"() {
711
+ "use strict";
712
+ }
713
+ });
714
+
715
+ // src/tools/dedupe-repo.ts
163
716
  import { z } from "zod";
164
- function registerDeleteMemoryTool(server) {
717
+ function parseTags2(raw) {
718
+ try {
719
+ const parsed = JSON.parse(raw);
720
+ return Array.isArray(parsed) ? parsed.filter((value) => typeof value === "string") : [];
721
+ } catch {
722
+ return [];
723
+ }
724
+ }
725
+ function parseMetadata(raw) {
726
+ try {
727
+ const parsed = JSON.parse(raw);
728
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
729
+ return parsed;
730
+ }
731
+ } catch {
732
+ }
733
+ return {};
734
+ }
735
+ function mergeTagLists(...lists) {
736
+ const seen = /* @__PURE__ */ new Set();
737
+ const out = [];
738
+ for (const list of lists) {
739
+ for (const value of list) {
740
+ const trimmed = value.trim().toLowerCase();
741
+ if (!trimmed || seen.has(trimmed)) continue;
742
+ seen.add(trimmed);
743
+ out.push(trimmed);
744
+ }
745
+ }
746
+ return out;
747
+ }
748
+ function registerDedupeRepoTool(server) {
165
749
  server.registerTool(
166
- "delete_memory",
750
+ "dedupe_repo",
167
751
  {
168
- description: "Delete a memory from storage by id.",
169
- inputSchema: deleteMemoryInputSchema
752
+ description: "Scan a repository for near-duplicate memories. Returns a plan by default; pass apply=true to merge duplicates into the most recently updated row, appending a changelog entry to metadata_json.",
753
+ inputSchema: dedupeRepoInputSchema
170
754
  },
171
- async ({ id }) => {
755
+ async ({ repo, threshold, apply }) => {
172
756
  try {
173
757
  const db = getDb();
174
- const row = db.prepare("SELECT id FROM memories WHERE id = ?").get(id);
175
- if (!row) {
758
+ const resolved = resolveRepoArg(repo, getWorkspaceRoot(), db);
759
+ const rows = db.prepare(
760
+ `
761
+ SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned, metadata_json
762
+ FROM memories
763
+ WHERE repo = ?
764
+ ORDER BY updated_at DESC
765
+ `
766
+ ).all(resolved.canonical);
767
+ if (rows.length < 2) {
176
768
  return {
177
- isError: true,
178
769
  content: [
179
770
  {
180
771
  type: "text",
181
- text: `Memory ${id} not found.`
772
+ text: `No duplicates possible: only ${rows.length} memory in ${resolved.canonical}.`
773
+ }
774
+ ]
775
+ };
776
+ }
777
+ const consumed = /* @__PURE__ */ new Set();
778
+ const plan = [];
779
+ for (let i = 0; i < rows.length; i += 1) {
780
+ const keep = rows[i];
781
+ if (!keep || consumed.has(keep.row_id)) continue;
782
+ for (let j = i + 1; j < rows.length; j += 1) {
783
+ const other = rows[j];
784
+ if (!other || consumed.has(other.row_id)) continue;
785
+ if (other.type !== keep.type) continue;
786
+ const score = similarity(keep.note, other.note);
787
+ if (score >= threshold) {
788
+ plan.push({ keep: keep.row_id, drop: other.row_id, similarity: score });
789
+ consumed.add(other.row_id);
790
+ }
791
+ }
792
+ }
793
+ if (plan.length === 0) {
794
+ return {
795
+ content: [
796
+ {
797
+ type: "text",
798
+ text: `No duplicates \u2265 ${threshold} found in ${resolved.canonical} (${rows.length} memories scanned).`
799
+ }
800
+ ]
801
+ };
802
+ }
803
+ if (!apply) {
804
+ const lines = plan.map(
805
+ (entry) => `- keep ${entry.keep}, drop ${entry.drop} (similarity ${entry.similarity.toFixed(2)})`
806
+ );
807
+ return {
808
+ content: [
809
+ {
810
+ type: "text",
811
+ text: `Dry run for ${resolved.canonical}. Found ${plan.length} duplicate pair(s):
812
+ ${lines.join("\n")}
813
+
814
+ Re-run with apply=true to merge.`
182
815
  }
183
816
  ]
184
817
  };
185
818
  }
186
- const deleteTx = db.transaction((memoryId) => {
187
- db.prepare("DELETE FROM memories WHERE id = ?").run(memoryId);
819
+ const byId = new Map(rows.map((row) => [row.row_id, row]));
820
+ const now = Math.floor(Date.now() / 1e3);
821
+ let merged = 0;
822
+ const tx = db.transaction((entries) => {
823
+ for (const entry of entries) {
824
+ const keep = byId.get(entry.keep);
825
+ const drop = byId.get(entry.drop);
826
+ if (!keep || !drop) continue;
827
+ const longerNote = keep.note.length >= drop.note.length ? keep.note : drop.note;
828
+ const mergedTags = mergeTagLists(parseTags2(keep.tags), parseTags2(drop.tags));
829
+ const metadata = parseMetadata(keep.metadata_json);
830
+ const changelog = metadata.changelog ?? [];
831
+ changelog.push({
832
+ at: now,
833
+ action: "deduped",
834
+ similarity: Number(entry.similarity.toFixed(3)),
835
+ merged_from: drop.row_id,
836
+ previous_note: drop.note
837
+ });
838
+ metadata.changelog = changelog;
839
+ db.prepare(
840
+ `
841
+ UPDATE memories
842
+ SET note = ?, note_normalized = ?, tags = ?, metadata_json = ?, updated_at = ?,
843
+ pinned = CASE WHEN pinned = 1 OR ? = 1 THEN 1 ELSE pinned END
844
+ WHERE rowid = ?
845
+ `
846
+ ).run(
847
+ longerNote,
848
+ normalizeText(longerNote),
849
+ JSON.stringify(mergedTags),
850
+ JSON.stringify(metadata),
851
+ now,
852
+ drop.pinned,
853
+ keep.row_id
854
+ );
855
+ db.prepare("DELETE FROM memories WHERE rowid = ?").run(drop.row_id);
856
+ merged += 1;
857
+ }
188
858
  });
189
- deleteTx(id);
859
+ tx(plan);
190
860
  return {
191
861
  content: [
192
862
  {
193
863
  type: "text",
194
- text: `Deleted memory ${id}.`
864
+ text: `Merged ${merged} duplicate pair(s) in ${resolved.canonical}.`
195
865
  }
196
866
  ]
197
867
  };
198
868
  } catch (error) {
199
- const message = error instanceof Error ? error.message : "Unknown error while deleting memory.";
869
+ const message = error instanceof Error ? error.message : "Unknown error while deduping repo.";
200
870
  return {
201
871
  isError: true,
202
872
  content: [
203
873
  {
204
874
  type: "text",
205
- text: `Failed to delete memory: ${message}`
875
+ text: `Failed to dedupe repo: ${message}`
206
876
  }
207
877
  ]
208
878
  };
@@ -210,20 +880,185 @@ function registerDeleteMemoryTool(server) {
210
880
  }
211
881
  );
212
882
  }
213
- var deleteMemoryInputSchema;
214
- var init_delete = __esm({
215
- "src/tools/delete.ts"() {
883
+ var dedupeRepoInputSchema;
884
+ var init_dedupe_repo = __esm({
885
+ "src/tools/dedupe-repo.ts"() {
216
886
  "use strict";
217
887
  init_client();
218
- deleteMemoryInputSchema = {
219
- id: z.string().trim().min(1, "id is required")
888
+ init_dedupe();
889
+ init_repo();
890
+ init_workspace();
891
+ dedupeRepoInputSchema = {
892
+ repo: z.string().trim().min(1).optional(),
893
+ threshold: z.number().min(0.5).max(1).default(0.85),
894
+ apply: z.boolean().default(false)
220
895
  };
221
896
  }
222
897
  });
223
898
 
224
- // src/tools/get-repo.ts
225
- import { z as z2 } from "zod";
226
- function parseTags(raw) {
899
+ // src/lib/memory.ts
900
+ function findMemoryByAnyId(db, input) {
901
+ const numeric = typeof input === "number" ? input : Number(input);
902
+ const isNumericId = Number.isInteger(numeric) && numeric > 0 && String(numeric) === String(input).trim();
903
+ if (isNumericId) {
904
+ const row = db.prepare(
905
+ `
906
+ SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned
907
+ FROM memories
908
+ WHERE rowid = ?
909
+ `
910
+ ).get(numeric);
911
+ if (row) {
912
+ return row;
913
+ }
914
+ }
915
+ const stringInput = String(input).trim();
916
+ if (stringInput.length === 0) {
917
+ return null;
918
+ }
919
+ const stringRow = db.prepare(
920
+ `
921
+ SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned
922
+ FROM memories
923
+ WHERE id = ?
924
+ `
925
+ ).get(stringInput);
926
+ return stringRow ?? null;
927
+ }
928
+ var init_memory = __esm({
929
+ "src/lib/memory.ts"() {
930
+ "use strict";
931
+ }
932
+ });
933
+
934
+ // src/tools/delete.ts
935
+ import { z as z2 } from "zod";
936
+ function registerDeleteMemoryTool(server) {
937
+ server.registerTool(
938
+ "delete_memory",
939
+ {
940
+ description: "Delete a memory from storage by id. Accepts either the numeric row id or the legacy string id.",
941
+ inputSchema: deleteMemoryInputSchema
942
+ },
943
+ async ({ id }) => {
944
+ try {
945
+ const db = getDb();
946
+ const memory = findMemoryByAnyId(db, id);
947
+ if (!memory) {
948
+ return {
949
+ isError: true,
950
+ content: [
951
+ {
952
+ type: "text",
953
+ text: `Memory ${id} not found.`
954
+ }
955
+ ]
956
+ };
957
+ }
958
+ const deleteTx = db.transaction((rowId) => {
959
+ db.prepare("DELETE FROM memories WHERE rowid = ?").run(rowId);
960
+ });
961
+ deleteTx(memory.row_id);
962
+ return {
963
+ content: [
964
+ {
965
+ type: "text",
966
+ text: `Deleted memory ${memory.row_id} (legacy: ${memory.id}).`
967
+ }
968
+ ]
969
+ };
970
+ } catch (error) {
971
+ const message = error instanceof Error ? error.message : "Unknown error while deleting memory.";
972
+ return {
973
+ isError: true,
974
+ content: [
975
+ {
976
+ type: "text",
977
+ text: `Failed to delete memory: ${message}`
978
+ }
979
+ ]
980
+ };
981
+ }
982
+ }
983
+ );
984
+ }
985
+ var deleteMemoryInputSchema;
986
+ var init_delete = __esm({
987
+ "src/tools/delete.ts"() {
988
+ "use strict";
989
+ init_client();
990
+ init_memory();
991
+ deleteMemoryInputSchema = {
992
+ // Accept either the numeric row_id or the legacy nanoid string. Tools used
993
+ // to disagree about which form to take; this unifies them so callers can
994
+ // paste whichever id they have in front of them.
995
+ id: z2.union([z2.number().int().positive(), z2.string().trim().min(1)])
996
+ };
997
+ }
998
+ });
999
+
1000
+ // src/tools/get-context.ts
1001
+ import { z as z3 } from "zod";
1002
+ function registerGetContextTool(server) {
1003
+ server.registerTool(
1004
+ "get_context",
1005
+ {
1006
+ description: "Unified retrieval tool. Returns pinned memories first, then recent ones, then FTS matches when a query is provided. Default limit is tuned for direct injection into an LLM system prompt. Use format='markdown' for a PR-ready brief.",
1007
+ inputSchema: getContextInputSchema
1008
+ },
1009
+ async ({ repo, query, limit, format }) => {
1010
+ try {
1011
+ const db = getDb();
1012
+ const resolved = resolveRepoArg(repo, getWorkspaceRoot(), db);
1013
+ const rows = fetchRepoContext(db, resolved.canonical, limit, query);
1014
+ const text = formatContext(rows, {
1015
+ repo: resolved.canonical,
1016
+ query,
1017
+ format
1018
+ });
1019
+ return {
1020
+ content: [
1021
+ {
1022
+ type: "text",
1023
+ text
1024
+ }
1025
+ ]
1026
+ };
1027
+ } catch (error) {
1028
+ const message = error instanceof Error ? error.message : "Unknown error while fetching context.";
1029
+ return {
1030
+ isError: true,
1031
+ content: [
1032
+ {
1033
+ type: "text",
1034
+ text: `Failed to fetch context: ${message}`
1035
+ }
1036
+ ]
1037
+ };
1038
+ }
1039
+ }
1040
+ );
1041
+ }
1042
+ var getContextInputSchema;
1043
+ var init_get_context = __esm({
1044
+ "src/tools/get-context.ts"() {
1045
+ "use strict";
1046
+ init_client();
1047
+ init_context();
1048
+ init_repo();
1049
+ init_workspace();
1050
+ getContextInputSchema = {
1051
+ repo: z3.string().trim().min(1).optional(),
1052
+ query: z3.string().trim().min(1).optional(),
1053
+ limit: z3.number().int().positive().max(50).default(8),
1054
+ format: z3.enum(["text", "markdown"]).default("text")
1055
+ };
1056
+ }
1057
+ });
1058
+
1059
+ // src/tools/get-repo.ts
1060
+ import { z as z4 } from "zod";
1061
+ function parseTags3(raw) {
227
1062
  try {
228
1063
  const parsed = JSON.parse(raw);
229
1064
  return Array.isArray(parsed) ? parsed.filter((value) => typeof value === "string") : [];
@@ -238,12 +1073,13 @@ function registerGetRepoContextTool(server) {
238
1073
  server.registerTool(
239
1074
  "get_repo_context",
240
1075
  {
241
- description: "Get recent memories for a repository grouped by memory type.",
1076
+ description: "Get recent memories for a repository grouped by memory type. The repo argument is resolved to a canonical key automatically; omit it to use the current workspace.",
242
1077
  inputSchema: getRepoContextInputSchema
243
1078
  },
244
1079
  async ({ repo, limit }) => {
245
1080
  try {
246
1081
  const db = getDb();
1082
+ const resolved = resolveRepoArg(repo, getWorkspaceRoot(), db);
247
1083
  const rows = db.prepare(
248
1084
  `
249
1085
  SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned
@@ -252,20 +1088,20 @@ function registerGetRepoContextTool(server) {
252
1088
  ORDER BY pinned DESC, updated_at DESC
253
1089
  LIMIT ?
254
1090
  `
255
- ).all(repo, limit);
1091
+ ).all(resolved.canonical, limit);
256
1092
  if (rows.length === 0) {
257
1093
  return {
258
1094
  content: [
259
1095
  {
260
1096
  type: "text",
261
- text: `No memories found for ${repo}.`
1097
+ text: `No memories found for ${resolved.canonical}.`
262
1098
  }
263
1099
  ]
264
1100
  };
265
1101
  }
266
1102
  const grouped = /* @__PURE__ */ new Map();
267
1103
  for (const memory of rows) {
268
- const tags = parseTags(memory.tags);
1104
+ const tags = parseTags3(memory.tags);
269
1105
  const tagSuffix = tags.length > 0 ? ` [tags: ${tags.join(", ")}]` : "";
270
1106
  const pinPrefix = memory.pinned ? "\u{1F4CC} Pinned " : "";
271
1107
  const item = `- (${memory.row_id} | legacy: ${memory.id}) ${pinPrefix}${memory.note}${tagSuffix}`;
@@ -286,7 +1122,7 @@ ${entries.join("\n")}`);
286
1122
  content: [
287
1123
  {
288
1124
  type: "text",
289
- text: `Repository context for ${repo}
1125
+ text: `Repository context for ${resolved.canonical}
290
1126
  Total memories: ${rows.length}
291
1127
 
292
1128
  ${sections.join("\n\n")}`
@@ -313,73 +1149,638 @@ var init_get_repo = __esm({
313
1149
  "src/tools/get-repo.ts"() {
314
1150
  "use strict";
315
1151
  init_client();
1152
+ init_repo();
1153
+ init_workspace();
316
1154
  getRepoContextInputSchema = {
317
- repo: z2.string().trim().min(1, "repo is required"),
318
- limit: z2.number().int().positive().max(100).default(10)
1155
+ repo: z4.string().trim().min(1).optional(),
1156
+ limit: z4.number().int().positive().max(100).default(10)
1157
+ };
1158
+ }
1159
+ });
1160
+
1161
+ // src/tools/pin.ts
1162
+ import { z as z5 } from "zod";
1163
+ function setPinnedState(rowId, pinned) {
1164
+ const db = getDb();
1165
+ const now = Math.floor(Date.now() / 1e3);
1166
+ const updateResult = db.prepare(
1167
+ `
1168
+ UPDATE memories
1169
+ SET pinned = ?, updated_at = ?
1170
+ WHERE rowid = ?
1171
+ `
1172
+ ).run(pinned, now, rowId);
1173
+ if (updateResult.changes === 0) {
1174
+ return null;
1175
+ }
1176
+ return db.prepare(
1177
+ `
1178
+ SELECT rowid AS row_id, note, pinned
1179
+ FROM memories
1180
+ WHERE rowid = ?
1181
+ `
1182
+ ).get(rowId);
1183
+ }
1184
+ function registerPinMemoryTool(server) {
1185
+ server.registerTool(
1186
+ "pin_memory",
1187
+ {
1188
+ description: "Pin a memory to keep it at the top of repository context.",
1189
+ inputSchema: pinInputSchema
1190
+ },
1191
+ async ({ id }) => {
1192
+ try {
1193
+ const db = getDb();
1194
+ const target = findMemoryByAnyId(db, id);
1195
+ if (!target) {
1196
+ return {
1197
+ isError: true,
1198
+ content: [
1199
+ {
1200
+ type: "text",
1201
+ text: `Memory ${id} not found.`
1202
+ }
1203
+ ]
1204
+ };
1205
+ }
1206
+ const memory = setPinnedState(target.row_id, 1);
1207
+ if (!memory) {
1208
+ return {
1209
+ isError: true,
1210
+ content: [
1211
+ {
1212
+ type: "text",
1213
+ text: `Memory ${id} not found.`
1214
+ }
1215
+ ]
1216
+ };
1217
+ }
1218
+ return {
1219
+ content: [
1220
+ {
1221
+ type: "text",
1222
+ text: `Pinned memory ${memory.row_id}: ${memory.note}`
1223
+ }
1224
+ ]
1225
+ };
1226
+ } catch (error) {
1227
+ const message = error instanceof Error ? error.message : "Unknown error while pinning memory.";
1228
+ return {
1229
+ isError: true,
1230
+ content: [
1231
+ {
1232
+ type: "text",
1233
+ text: `Failed to pin memory: ${message}`
1234
+ }
1235
+ ]
1236
+ };
1237
+ }
1238
+ }
1239
+ );
1240
+ }
1241
+ function registerUnpinMemoryTool(server) {
1242
+ server.registerTool(
1243
+ "unpin_memory",
1244
+ {
1245
+ description: "Unpin a previously pinned memory.",
1246
+ inputSchema: pinInputSchema
1247
+ },
1248
+ async ({ id }) => {
1249
+ try {
1250
+ const db = getDb();
1251
+ const target = findMemoryByAnyId(db, id);
1252
+ if (!target) {
1253
+ return {
1254
+ isError: true,
1255
+ content: [
1256
+ {
1257
+ type: "text",
1258
+ text: `Memory ${id} not found.`
1259
+ }
1260
+ ]
1261
+ };
1262
+ }
1263
+ const memory = setPinnedState(target.row_id, 0);
1264
+ if (!memory) {
1265
+ return {
1266
+ isError: true,
1267
+ content: [
1268
+ {
1269
+ type: "text",
1270
+ text: `Memory ${id} not found.`
1271
+ }
1272
+ ]
1273
+ };
1274
+ }
1275
+ return {
1276
+ content: [
1277
+ {
1278
+ type: "text",
1279
+ text: `Unpinned memory ${memory.row_id}.`
1280
+ }
1281
+ ]
1282
+ };
1283
+ } catch (error) {
1284
+ const message = error instanceof Error ? error.message : "Unknown error while unpinning memory.";
1285
+ return {
1286
+ isError: true,
1287
+ content: [
1288
+ {
1289
+ type: "text",
1290
+ text: `Failed to unpin memory: ${message}`
1291
+ }
1292
+ ]
1293
+ };
1294
+ }
1295
+ }
1296
+ );
1297
+ }
1298
+ var pinInputSchema;
1299
+ var init_pin = __esm({
1300
+ "src/tools/pin.ts"() {
1301
+ "use strict";
1302
+ init_client();
1303
+ init_memory();
1304
+ pinInputSchema = {
1305
+ // Accept numeric row_id or legacy string id for parity with the other tools.
1306
+ id: z5.union([z5.number().int().positive(), z5.string().trim().min(1)])
319
1307
  };
320
1308
  }
321
- });
322
-
323
- // src/tools/pin.ts
324
- import { z as z3 } from "zod";
325
- function setPinnedState(memoryId, pinned) {
326
- const db = getDb();
327
- const now = Math.floor(Date.now() / 1e3);
328
- const updateResult = db.prepare(
329
- `
330
- UPDATE memories
331
- SET pinned = ?, updated_at = ?
332
- WHERE rowid = ?
333
- `
334
- ).run(pinned, now, memoryId);
335
- if (updateResult.changes === 0) {
336
- return null;
1309
+ });
1310
+
1311
+ // src/lib/inference.ts
1312
+ function inferMemoryType(text) {
1313
+ const scores = /* @__PURE__ */ new Map();
1314
+ for (const rule of TYPE_RULES) {
1315
+ let score = 0;
1316
+ for (const { pattern, weight } of rule.patterns) {
1317
+ if (pattern.test(text)) {
1318
+ score += weight;
1319
+ }
1320
+ }
1321
+ if (score > 0) {
1322
+ scores.set(rule.type, (scores.get(rule.type) ?? 0) + score);
1323
+ }
1324
+ }
1325
+ if (AUTH_KEYWORDS.test(text)) {
1326
+ if (CHOICE_KEYWORDS.test(text)) {
1327
+ scores.set("decision", (scores.get("decision") ?? 0) + 3);
1328
+ } else {
1329
+ scores.set("convention", (scores.get("convention") ?? 0) + 2);
1330
+ }
1331
+ }
1332
+ if (scores.size === 0) {
1333
+ return "convention";
1334
+ }
1335
+ let bestType = "convention";
1336
+ let bestScore = -1;
1337
+ for (const [type, score] of scores) {
1338
+ if (score > bestScore) {
1339
+ bestType = type;
1340
+ bestScore = score;
1341
+ }
1342
+ }
1343
+ return bestType;
1344
+ }
1345
+ function extractKeywordTags(text) {
1346
+ const found = [];
1347
+ for (const { tag, pattern } of TAG_KEYWORDS) {
1348
+ if (pattern.test(text)) {
1349
+ found.push(tag);
1350
+ }
1351
+ }
1352
+ return found;
1353
+ }
1354
+ function extractIdentifierTags(text) {
1355
+ const tokens = /* @__PURE__ */ new Set();
1356
+ const pathLike = text.match(/\/(?:[a-z0-9_-]+\/?){1,4}/gi);
1357
+ if (pathLike) {
1358
+ for (const segment of pathLike) {
1359
+ for (const part of segment.split("/")) {
1360
+ if (part.length >= 3 && /^[a-z0-9_-]+$/i.test(part)) {
1361
+ tokens.add(part.toLowerCase());
1362
+ }
1363
+ }
1364
+ }
1365
+ }
1366
+ const fileLike = text.match(/\b[a-z0-9_.-]+\.(?:ts|tsx|js|jsx|py|go|rb|rs|java|kt|sql|md|json|yml|yaml)\b/gi);
1367
+ if (fileLike) {
1368
+ for (const file of fileLike) {
1369
+ const base = file.split(".").slice(0, -1).join(".");
1370
+ if (base.length >= 3) {
1371
+ tokens.add(base.toLowerCase());
1372
+ }
1373
+ }
1374
+ }
1375
+ return Array.from(tokens);
1376
+ }
1377
+ function extractSalientWords(text, limit) {
1378
+ const words = text.toLowerCase().replace(/[^a-z0-9\s/_-]/g, " ").split(/\s+/).filter((word) => word.length >= 4 && !STOP_WORDS.has(word));
1379
+ const counts = /* @__PURE__ */ new Map();
1380
+ for (const word of words) {
1381
+ counts.set(word, (counts.get(word) ?? 0) + 1);
1382
+ }
1383
+ return Array.from(counts.entries()).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, limit).map(([word]) => word);
1384
+ }
1385
+ function inferTags(text) {
1386
+ const ordered = [];
1387
+ const seen = /* @__PURE__ */ new Set();
1388
+ const push = (value) => {
1389
+ const normalized = value.trim().toLowerCase();
1390
+ if (!normalized || seen.has(normalized)) {
1391
+ return;
1392
+ }
1393
+ seen.add(normalized);
1394
+ ordered.push(normalized);
1395
+ };
1396
+ for (const tag of extractKeywordTags(text)) {
1397
+ push(tag);
1398
+ }
1399
+ for (const tag of extractIdentifierTags(text)) {
1400
+ push(tag);
1401
+ }
1402
+ if (ordered.length < 5) {
1403
+ for (const word of extractSalientWords(text, 8)) {
1404
+ push(word);
1405
+ if (ordered.length >= 5) {
1406
+ break;
1407
+ }
1408
+ }
1409
+ }
1410
+ return ordered.slice(0, 5);
1411
+ }
1412
+ function inferMemoryFromNote(text) {
1413
+ return {
1414
+ type: inferMemoryType(text),
1415
+ tags: inferTags(text)
1416
+ };
1417
+ }
1418
+ var TYPE_RULES, AUTH_KEYWORDS, CHOICE_KEYWORDS, TAG_KEYWORDS, STOP_WORDS;
1419
+ var init_inference = __esm({
1420
+ "src/lib/inference.ts"() {
1421
+ "use strict";
1422
+ TYPE_RULES = [
1423
+ {
1424
+ type: "bug_fix",
1425
+ patterns: [
1426
+ { pattern: /\broot cause\b/i, weight: 4 },
1427
+ { pattern: /\bregression\b/i, weight: 4 },
1428
+ { pattern: /\bhotfix\b/i, weight: 4 },
1429
+ { pattern: /\bfix(?:ed|es|ing)?\b/i, weight: 3 },
1430
+ { pattern: /\bbugs?\b/i, weight: 2 },
1431
+ { pattern: /\bcrash(?:ed|es|ing)?\b/i, weight: 2 },
1432
+ { pattern: /\bbroken\b/i, weight: 2 },
1433
+ { pattern: /\bworkaround\b/i, weight: 2 }
1434
+ ]
1435
+ },
1436
+ {
1437
+ type: "issue",
1438
+ patterns: [
1439
+ { pattern: /\bissue\s*#\d+/i, weight: 5 },
1440
+ { pattern: /\bticket\s*#?\w+/i, weight: 4 },
1441
+ { pattern: /\bjira[-\s]?\w+/i, weight: 4 },
1442
+ { pattern: /\bgh[-\s]?\d+/i, weight: 3 },
1443
+ { pattern: /#\d{2,}/i, weight: 2 }
1444
+ ]
1445
+ },
1446
+ {
1447
+ type: "decision",
1448
+ patterns: [
1449
+ { pattern: /\bdecided not to\b/i, weight: 5 },
1450
+ { pattern: /\bdecided to\b/i, weight: 4 },
1451
+ { pattern: /\bwe chose\b/i, weight: 4 },
1452
+ { pattern: /\bchose\s+\w+\s+over\b/i, weight: 4 },
1453
+ { pattern: /\barchitecture\b/i, weight: 3 },
1454
+ { pattern: /\bdecision\b/i, weight: 3 },
1455
+ { pattern: /\btrade[- ]?off\b/i, weight: 2 },
1456
+ { pattern: /\brfc\b/i, weight: 2 },
1457
+ { pattern: /\b(?:adopted|migrated to)\b/i, weight: 2 }
1458
+ ]
1459
+ },
1460
+ {
1461
+ type: "reviewer_pattern",
1462
+ patterns: [
1463
+ { pattern: /\breviewer(?:s)?\s+(?:prefer|want|expect|require)/i, weight: 5 },
1464
+ { pattern: /\bpr\s+style\b/i, weight: 4 },
1465
+ { pattern: /\bcode review\b/i, weight: 3 },
1466
+ { pattern: /\bprefer(?:s|red)?\b/i, weight: 2 },
1467
+ { pattern: /\breview comment\b/i, weight: 2 }
1468
+ ]
1469
+ },
1470
+ {
1471
+ type: "convention",
1472
+ patterns: [
1473
+ { pattern: /\bconvention\b/i, weight: 4 },
1474
+ { pattern: /\balways\b/i, weight: 2 },
1475
+ { pattern: /\bnever\b/i, weight: 2 },
1476
+ { pattern: /\bstandard\b/i, weight: 2 },
1477
+ { pattern: /\bstyle guide\b/i, weight: 3 },
1478
+ { pattern: /\buse\b\s+\w+\s+\bfor\b/i, weight: 1 }
1479
+ ]
1480
+ }
1481
+ ];
1482
+ AUTH_KEYWORDS = /\b(?:auth|jwt|oauth|token|login|logout|session|sso|saml)\b/i;
1483
+ CHOICE_KEYWORDS = /\b(?:chose|choose|decided|prefer|switched|migrated|adopted|over|instead of)\b/i;
1484
+ TAG_KEYWORDS = [
1485
+ { tag: "auth", pattern: /\b(?:auth|authentication|authorization)\b/i },
1486
+ { tag: "jwt", pattern: /\bjwt\b/i },
1487
+ { tag: "oauth", pattern: /\boauth\b/i },
1488
+ { tag: "session", pattern: /\bsession(?:s)?\b/i },
1489
+ { tag: "api", pattern: /\bapi\b/i },
1490
+ { tag: "rest", pattern: /\brest(?:ful)?\b/i },
1491
+ { tag: "graphql", pattern: /\bgraphql\b/i },
1492
+ { tag: "websocket", pattern: /\bweb[- ]?socket(?:s)?\b/i },
1493
+ { tag: "database", pattern: /\b(?:database|db|sqlite|postgres|mysql|mongo)\b/i },
1494
+ { tag: "migration", pattern: /\bmigration(?:s)?\b/i },
1495
+ { tag: "schema", pattern: /\bschema\b/i },
1496
+ { tag: "frontend", pattern: /\b(?:frontend|ui|react|vue|svelte|next\.js|nextjs)\b/i },
1497
+ { tag: "backend", pattern: /\b(?:backend|server|node\.js|nodejs|express|fastify)\b/i },
1498
+ { tag: "testing", pattern: /\b(?:test|tests|testing|jest|vitest|pytest|rspec)\b/i },
1499
+ { tag: "ci", pattern: /\b(?:ci|cd|pipeline|github actions|gitlab ci)\b/i },
1500
+ { tag: "deployment", pattern: /\b(?:deploy|deployment|release|rollout)\b/i },
1501
+ { tag: "performance", pattern: /\b(?:performance|perf|latency|throughput)\b/i },
1502
+ { tag: "security", pattern: /\b(?:security|vuln|cve|xss|csrf|injection)\b/i },
1503
+ { tag: "logging", pattern: /\b(?:log|logging|telemetry|tracing)\b/i },
1504
+ { tag: "config", pattern: /\b(?:config|configuration|env|environment)\b/i },
1505
+ { tag: "routing", pattern: /\b(?:route|routing|router|endpoint)\b/i },
1506
+ { tag: "build", pattern: /\b(?:build|webpack|vite|tsup|rollup|esbuild)\b/i },
1507
+ { tag: "docs", pattern: /\b(?:docs|documentation|readme)\b/i }
1508
+ ];
1509
+ STOP_WORDS = /* @__PURE__ */ new Set([
1510
+ "the",
1511
+ "a",
1512
+ "an",
1513
+ "and",
1514
+ "or",
1515
+ "but",
1516
+ "is",
1517
+ "are",
1518
+ "was",
1519
+ "were",
1520
+ "be",
1521
+ "been",
1522
+ "being",
1523
+ "to",
1524
+ "of",
1525
+ "in",
1526
+ "on",
1527
+ "for",
1528
+ "with",
1529
+ "by",
1530
+ "at",
1531
+ "from",
1532
+ "as",
1533
+ "that",
1534
+ "this",
1535
+ "it",
1536
+ "we",
1537
+ "our",
1538
+ "you",
1539
+ "your",
1540
+ "i",
1541
+ "my",
1542
+ "they",
1543
+ "their",
1544
+ "them",
1545
+ "he",
1546
+ "she",
1547
+ "his",
1548
+ "her",
1549
+ "if",
1550
+ "then",
1551
+ "than",
1552
+ "so",
1553
+ "do",
1554
+ "does",
1555
+ "did",
1556
+ "done",
1557
+ "not",
1558
+ "no",
1559
+ "yes",
1560
+ "can",
1561
+ "will",
1562
+ "would",
1563
+ "should",
1564
+ "could",
1565
+ "may",
1566
+ "might",
1567
+ "must",
1568
+ "have",
1569
+ "has",
1570
+ "had",
1571
+ "just",
1572
+ "also",
1573
+ "use",
1574
+ "used",
1575
+ "using",
1576
+ "want",
1577
+ "wants",
1578
+ "wanted",
1579
+ "need",
1580
+ "needs",
1581
+ "needed",
1582
+ "like",
1583
+ "now",
1584
+ "new",
1585
+ "old",
1586
+ "good",
1587
+ "bad",
1588
+ "make",
1589
+ "makes",
1590
+ "made",
1591
+ "get",
1592
+ "gets",
1593
+ "got",
1594
+ "set",
1595
+ "sets",
1596
+ "go",
1597
+ "going",
1598
+ "into",
1599
+ "over",
1600
+ "under",
1601
+ "through",
1602
+ "because",
1603
+ "when",
1604
+ "where",
1605
+ "while",
1606
+ "there",
1607
+ "here",
1608
+ "what",
1609
+ "which",
1610
+ "who",
1611
+ "why",
1612
+ "how",
1613
+ "live",
1614
+ "lives",
1615
+ "living",
1616
+ "keep",
1617
+ "kept",
1618
+ "keeps",
1619
+ "take",
1620
+ "takes",
1621
+ "took",
1622
+ "taken",
1623
+ "say",
1624
+ "says",
1625
+ "said",
1626
+ "tell",
1627
+ "tells",
1628
+ "told",
1629
+ "know",
1630
+ "knows",
1631
+ "known",
1632
+ "knew",
1633
+ "redirect",
1634
+ "redirects",
1635
+ "redirected",
1636
+ "redirecting",
1637
+ "user",
1638
+ "users",
1639
+ "page",
1640
+ "pages"
1641
+ ]);
1642
+ }
1643
+ });
1644
+
1645
+ // src/tools/remember.ts
1646
+ import { nanoid } from "nanoid";
1647
+ import { z as z6 } from "zod";
1648
+ function mergeTagLists2(...lists) {
1649
+ const seen = /* @__PURE__ */ new Set();
1650
+ const out = [];
1651
+ for (const list of lists) {
1652
+ if (!list) continue;
1653
+ for (const raw of list) {
1654
+ const value = raw.trim().toLowerCase();
1655
+ if (!value || seen.has(value)) continue;
1656
+ seen.add(value);
1657
+ out.push(value);
1658
+ }
1659
+ }
1660
+ return out;
1661
+ }
1662
+ function parseStoredTags(raw) {
1663
+ try {
1664
+ const parsed = JSON.parse(raw);
1665
+ return Array.isArray(parsed) ? parsed.filter((value) => typeof value === "string") : [];
1666
+ } catch {
1667
+ return [];
337
1668
  }
338
- return db.prepare(
339
- `
340
- SELECT rowid AS row_id, note, pinned
341
- FROM memories
342
- WHERE rowid = ?
343
- `
344
- ).get(memoryId);
345
1669
  }
346
- function registerPinMemoryTool(server) {
1670
+ function parseStoredMetadata(raw) {
1671
+ try {
1672
+ const parsed = JSON.parse(raw);
1673
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
1674
+ return parsed;
1675
+ }
1676
+ } catch {
1677
+ }
1678
+ return {};
1679
+ }
1680
+ function registerRememberTool(server) {
347
1681
  server.registerTool(
348
- "pin_memory",
1682
+ "remember",
349
1683
  {
350
- description: "Pin a memory to keep it at the top of repository context.",
351
- inputSchema: pinInputSchema
1684
+ description: "Save a memory using only a natural-language note. Fossel infers memory_type, generates tags, resolves the repo, and merges into an existing memory when the note is a near-duplicate. Prefer this tool over store_context for everyday use.",
1685
+ inputSchema: rememberInputSchema
352
1686
  },
353
- async ({ id }) => {
1687
+ async ({ note, repo, type, tags }) => {
354
1688
  try {
355
- const memory = setPinnedState(id, 1);
356
- if (!memory) {
1689
+ const db = getDb();
1690
+ const resolved = resolveRepoArg(repo, getWorkspaceRoot(), db);
1691
+ const inferred = inferMemoryFromNote(note);
1692
+ const finalType = type ?? inferred.type;
1693
+ const finalTags = mergeTagLists2(tags, inferred.tags).slice(0, 5);
1694
+ const now = Math.floor(Date.now() / 1e3);
1695
+ const duplicate = findDuplicate(db, resolved.canonical, note);
1696
+ if (duplicate) {
1697
+ const existing = duplicate.memory;
1698
+ const existingTags = parseStoredTags(existing.tags);
1699
+ const mergedTags = mergeTagLists2(existingTags, finalTags);
1700
+ const metadata2 = parseStoredMetadata(
1701
+ existing.metadata_json ?? "{}"
1702
+ );
1703
+ const changelog = metadata2.changelog ?? [];
1704
+ changelog.push({
1705
+ at: now,
1706
+ action: "merged",
1707
+ similarity: Number(duplicate.similarity.toFixed(3)),
1708
+ previous_note: existing.note
1709
+ });
1710
+ metadata2.changelog = changelog;
1711
+ const longerNote = note.length > existing.note.length ? note : existing.note;
1712
+ const nextType = type ?? existing.type;
1713
+ db.prepare(
1714
+ `
1715
+ UPDATE memories
1716
+ SET note = ?, note_normalized = ?, tags = ?, type = ?, metadata_json = ?, updated_at = ?
1717
+ WHERE rowid = ?
1718
+ `
1719
+ ).run(
1720
+ longerNote,
1721
+ normalizeText(longerNote),
1722
+ JSON.stringify(mergedTags),
1723
+ nextType,
1724
+ JSON.stringify(metadata2),
1725
+ now,
1726
+ existing.row_id
1727
+ );
357
1728
  return {
358
- isError: true,
359
1729
  content: [
360
1730
  {
361
1731
  type: "text",
362
- text: `Memory ${id} not found.`
1732
+ text: `Merged into memory ${existing.row_id} for ${resolved.canonical} (similarity ${duplicate.similarity.toFixed(2)}, type ${nextType}, tags: ${mergedTags.join(", ") || "none"}).`
363
1733
  }
364
1734
  ]
365
1735
  };
366
1736
  }
1737
+ const id = nanoid();
1738
+ const metadata = {
1739
+ changelog: [
1740
+ {
1741
+ at: now,
1742
+ action: "created"
1743
+ }
1744
+ ],
1745
+ inferred: {
1746
+ type: inferred.type,
1747
+ tags: inferred.tags,
1748
+ type_overridden: type !== void 0
1749
+ }
1750
+ };
1751
+ db.prepare(
1752
+ `
1753
+ INSERT INTO memories (id, repo, type, note, tags, created_at, updated_at, pinned, metadata_json, note_normalized)
1754
+ VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?)
1755
+ `
1756
+ ).run(
1757
+ id,
1758
+ resolved.canonical,
1759
+ finalType,
1760
+ note,
1761
+ JSON.stringify(finalTags),
1762
+ now,
1763
+ now,
1764
+ JSON.stringify(metadata),
1765
+ normalizeText(note)
1766
+ );
1767
+ const inserted = db.prepare("SELECT rowid AS row_id FROM memories WHERE id = ?").get(id);
367
1768
  return {
368
1769
  content: [
369
1770
  {
370
1771
  type: "text",
371
- text: `Pinned memory ${memory.row_id}: ${memory.note}`
1772
+ text: `Stored memory ${inserted?.row_id ?? "?"} for ${resolved.canonical} (type ${finalType}, tags: ${finalTags.join(", ") || "none"}).`
372
1773
  }
373
1774
  ]
374
1775
  };
375
1776
  } catch (error) {
376
- const message = error instanceof Error ? error.message : "Unknown error while pinning memory.";
1777
+ const message = error instanceof Error ? error.message : "Unknown error while remembering note.";
377
1778
  return {
378
1779
  isError: true,
379
1780
  content: [
380
1781
  {
381
1782
  type: "text",
382
- text: `Failed to pin memory: ${message}`
1783
+ text: `Failed to remember note: ${message}`
383
1784
  }
384
1785
  ]
385
1786
  };
@@ -387,43 +1788,61 @@ function registerPinMemoryTool(server) {
387
1788
  }
388
1789
  );
389
1790
  }
390
- function registerUnpinMemoryTool(server) {
1791
+ var rememberInputSchema;
1792
+ var init_remember = __esm({
1793
+ "src/tools/remember.ts"() {
1794
+ "use strict";
1795
+ init_client();
1796
+ init_dedupe();
1797
+ init_inference();
1798
+ init_repo();
1799
+ init_workspace();
1800
+ rememberInputSchema = {
1801
+ note: z6.string().trim().min(1, "note is required"),
1802
+ repo: z6.string().trim().min(1).optional(),
1803
+ type: z6.enum(MEMORY_TYPES).optional(),
1804
+ tags: z6.array(z6.string().trim().min(1)).optional()
1805
+ };
1806
+ }
1807
+ });
1808
+
1809
+ // src/tools/resolve-repo.ts
1810
+ import { z as z7 } from "zod";
1811
+ function registerResolveRepoTool(server) {
391
1812
  server.registerTool(
392
- "unpin_memory",
1813
+ "resolve_repo",
393
1814
  {
394
- description: "Unpin a previously pinned memory.",
395
- inputSchema: pinInputSchema
1815
+ description: "Return the canonical repo key for a working directory along with any aliases and the detected git remote. Useful for clients that want to display which repo Fossel is targeting before making other tool calls.",
1816
+ inputSchema: resolveRepoInputSchema
396
1817
  },
397
- async ({ id }) => {
1818
+ async ({ cwd }) => {
398
1819
  try {
399
- const memory = setPinnedState(id, 0);
400
- if (!memory) {
401
- return {
402
- isError: true,
403
- content: [
404
- {
405
- type: "text",
406
- text: `Memory ${id} not found.`
407
- }
408
- ]
409
- };
410
- }
1820
+ const db = getDb();
1821
+ const target = cwd?.trim() || getWorkspaceRoot();
1822
+ const resolved = resolveRepo(target, db);
1823
+ const payload = {
1824
+ canonical: resolved.canonical,
1825
+ aliases: resolved.aliases,
1826
+ cwd: resolved.cwd,
1827
+ gitRemote: resolved.gitRemote,
1828
+ source: resolved.source
1829
+ };
411
1830
  return {
412
1831
  content: [
413
1832
  {
414
1833
  type: "text",
415
- text: `Unpinned memory ${memory.row_id}.`
1834
+ text: JSON.stringify(payload, null, 2)
416
1835
  }
417
1836
  ]
418
1837
  };
419
1838
  } catch (error) {
420
- const message = error instanceof Error ? error.message : "Unknown error while unpinning memory.";
1839
+ const message = error instanceof Error ? error.message : "Unknown error while resolving repo.";
421
1840
  return {
422
1841
  isError: true,
423
1842
  content: [
424
1843
  {
425
1844
  type: "text",
426
- text: `Failed to unpin memory: ${message}`
1845
+ text: `Failed to resolve repo: ${message}`
427
1846
  }
428
1847
  ]
429
1848
  };
@@ -431,27 +1850,37 @@ function registerUnpinMemoryTool(server) {
431
1850
  }
432
1851
  );
433
1852
  }
434
- var pinInputSchema;
435
- var init_pin = __esm({
436
- "src/tools/pin.ts"() {
1853
+ var resolveRepoInputSchema;
1854
+ var init_resolve_repo = __esm({
1855
+ "src/tools/resolve-repo.ts"() {
437
1856
  "use strict";
438
1857
  init_client();
439
- pinInputSchema = {
440
- id: z3.number().int().positive()
1858
+ init_repo();
1859
+ init_workspace();
1860
+ resolveRepoInputSchema = {
1861
+ cwd: z7.string().trim().min(1).optional()
441
1862
  };
442
1863
  }
443
1864
  });
444
1865
 
445
1866
  // src/tools/search.ts
446
- import { z as z4 } from "zod";
447
- function normalizeFtsQuery(query) {
448
- const terms = query.trim().split(/\s+/).map((term) => term.replaceAll('"', '""')).filter(Boolean);
449
- if (terms.length === 0) {
450
- throw new Error("query must contain searchable text");
1867
+ import { z as z8 } from "zod";
1868
+ function tokenizeQuery(query) {
1869
+ return query.toLowerCase().replace(/["()]/g, " ").split(/[\s/_\-.,;:!?]+/).map((token) => token.replace(/[^a-z0-9*]/g, "")).filter((token) => token.length >= 2);
1870
+ }
1871
+ function buildFtsQuery2(tokens) {
1872
+ if (tokens.length === 0) {
1873
+ return null;
451
1874
  }
452
- return terms.map((term) => `"${term}"`).join(" AND ");
1875
+ return tokens.map((token) => `"${token.replace(/"/g, '""')}"`).join(" AND ");
453
1876
  }
454
- function parseTags2(raw) {
1877
+ function buildFtsQueryOr(tokens) {
1878
+ if (tokens.length === 0) {
1879
+ return null;
1880
+ }
1881
+ return tokens.map((token) => `"${token.replace(/"/g, '""')}"`).join(" OR ");
1882
+ }
1883
+ function parseTags4(raw) {
455
1884
  try {
456
1885
  const parsed = JSON.parse(raw);
457
1886
  return Array.isArray(parsed) ? parsed.filter((value) => typeof value === "string") : [];
@@ -459,58 +1888,89 @@ function parseTags2(raw) {
459
1888
  return [];
460
1889
  }
461
1890
  }
1891
+ function runFts(ftsQuery, resolvedRepo, limit) {
1892
+ const db = getDb();
1893
+ try {
1894
+ if (resolvedRepo) {
1895
+ return db.prepare(
1896
+ `
1897
+ SELECT m.rowid AS row_id, m.id, m.repo, m.type, m.note, m.tags,
1898
+ m.created_at, m.updated_at, m.pinned, bm25(memories_fts) AS rank
1899
+ FROM memories_fts
1900
+ JOIN memories AS m ON m.rowid = memories_fts.rowid
1901
+ WHERE memories_fts MATCH ? AND m.repo = ?
1902
+ ORDER BY rank
1903
+ LIMIT ?
1904
+ `
1905
+ ).all(ftsQuery, resolvedRepo, limit);
1906
+ }
1907
+ return db.prepare(
1908
+ `
1909
+ SELECT m.rowid AS row_id, m.id, m.repo, m.type, m.note, m.tags,
1910
+ m.created_at, m.updated_at, m.pinned, bm25(memories_fts) AS rank
1911
+ FROM memories_fts
1912
+ JOIN memories AS m ON m.rowid = memories_fts.rowid
1913
+ WHERE memories_fts MATCH ?
1914
+ ORDER BY rank
1915
+ LIMIT ?
1916
+ `
1917
+ ).all(ftsQuery, limit);
1918
+ } catch {
1919
+ return [];
1920
+ }
1921
+ }
462
1922
  function registerSearchMemoryTool(server) {
463
1923
  server.registerTool(
464
1924
  "search_memory",
465
1925
  {
466
- description: "Search memories using full-text search with optional repository filtering.",
1926
+ description: "Search memories using full-text search with optional repository filtering. Falls back to recent + pinned context when the query has no exact matches.",
467
1927
  inputSchema: searchMemoryInputSchema
468
1928
  },
469
1929
  async ({ query, repo, limit }) => {
470
1930
  try {
471
1931
  const db = getDb();
472
- const ftsQuery = normalizeFtsQuery(query);
473
- const rows = repo ? db.prepare(
474
- `
475
- SELECT m.rowid AS row_id, m.id, m.repo, m.type, m.note, m.tags, m.created_at, m.updated_at, m.pinned, bm25(memories_fts) AS rank
476
- FROM memories_fts
477
- JOIN memories AS m ON m.rowid = memories_fts.rowid
478
- WHERE memories_fts MATCH ? AND m.repo = ?
479
- ORDER BY rank
480
- LIMIT ?
481
- `
482
- ).all(ftsQuery, repo, limit) : db.prepare(
483
- `
484
- SELECT m.rowid AS row_id, m.id, m.repo, m.type, m.note, m.tags, m.created_at, m.updated_at, m.pinned, bm25(memories_fts) AS rank
485
- FROM memories_fts
486
- JOIN memories AS m ON m.rowid = memories_fts.rowid
487
- WHERE memories_fts MATCH ?
488
- ORDER BY rank
489
- LIMIT ?
490
- `
491
- ).all(ftsQuery, limit);
1932
+ const tokens = tokenizeQuery(query);
1933
+ const resolvedRepo = repo ? resolveRepoArg(repo, getWorkspaceRoot(), db).canonical : void 0;
1934
+ const andQuery = buildFtsQuery2(tokens);
1935
+ let rows = [];
1936
+ if (andQuery) {
1937
+ rows = runFts(andQuery, resolvedRepo, limit);
1938
+ }
1939
+ if (rows.length === 0 && tokens.length > 1) {
1940
+ const orQuery = buildFtsQueryOr(tokens);
1941
+ if (orQuery) {
1942
+ rows = runFts(orQuery, resolvedRepo, limit);
1943
+ }
1944
+ }
1945
+ let usedFallback = false;
1946
+ if (rows.length === 0 && resolvedRepo) {
1947
+ const fallback = fetchRepoContext(db, resolvedRepo, limit);
1948
+ rows = fallback.map((row) => ({ ...row, rank: 0 }));
1949
+ usedFallback = fallback.length > 0;
1950
+ }
492
1951
  if (rows.length === 0) {
493
1952
  return {
494
1953
  content: [
495
1954
  {
496
1955
  type: "text",
497
- text: repo ? `No memories matched "${query}" in ${repo}.` : `No memories matched "${query}".`
1956
+ text: resolvedRepo ? `No memories matched "${query}" in ${resolvedRepo}.` : `No memories matched "${query}".`
498
1957
  }
499
1958
  ]
500
1959
  };
501
1960
  }
502
1961
  const formatted = rows.map((row, index) => {
503
- const tags = parseTags2(row.tags);
1962
+ const tags = parseTags4(row.tags);
504
1963
  const tagsText = tags.length > 0 ? ` | tags: ${tags.join(", ")}` : "";
505
1964
  const pinPrefix = row.pinned ? "\u{1F4CC} Pinned " : "";
506
1965
  return `${index + 1}. [${row.repo}] ${row.type} (${row.row_id} | legacy: ${row.id})
507
1966
  ${pinPrefix}${row.note}${tagsText}`;
508
1967
  }).join("\n\n");
1968
+ const header = usedFallback ? `No exact match for "${query}"${resolvedRepo ? ` in ${resolvedRepo}` : ""}; showing recent + pinned context:` : `Search results for "${query}"${resolvedRepo ? ` in ${resolvedRepo}` : ""}:`;
509
1969
  return {
510
1970
  content: [
511
1971
  {
512
1972
  type: "text",
513
- text: `Search results for "${query}"${repo ? ` in ${repo}` : ""}:
1973
+ text: `${header}
514
1974
 
515
1975
  ${formatted}`
516
1976
  }
@@ -536,38 +1996,51 @@ var init_search = __esm({
536
1996
  "src/tools/search.ts"() {
537
1997
  "use strict";
538
1998
  init_client();
1999
+ init_context();
2000
+ init_repo();
2001
+ init_workspace();
539
2002
  searchMemoryInputSchema = {
540
- query: z4.string().trim().min(1, "query is required"),
541
- repo: z4.string().trim().min(1).optional(),
542
- limit: z4.number().int().positive().max(50).default(5)
2003
+ query: z8.string().trim().min(1, "query is required"),
2004
+ repo: z8.string().trim().min(1).optional(),
2005
+ limit: z8.number().int().positive().max(50).default(5)
543
2006
  };
544
2007
  }
545
2008
  });
546
2009
 
547
2010
  // src/tools/store.ts
548
- import { nanoid } from "nanoid";
549
- import { z as z5 } from "zod";
2011
+ import { nanoid as nanoid2 } from "nanoid";
2012
+ import { z as z9 } from "zod";
550
2013
  function registerStoreContextTool(server) {
551
2014
  server.registerTool(
552
2015
  "store_context",
553
2016
  {
554
- description: "Store repository-specific contributor context such as bug fixes, conventions, and decisions.",
2017
+ description: "Store repository-specific contributor context such as bug fixes, conventions, and decisions. The repo argument is resolved to a canonical key automatically; pass it explicitly only when targeting a different repo than the current workspace.",
555
2018
  inputSchema: storeContextInputSchema
556
2019
  },
557
2020
  async ({ repo, type, note, tags }) => {
558
2021
  try {
559
2022
  const db = getDb();
2023
+ const resolved = resolveRepoArg(repo, getWorkspaceRoot(), db);
560
2024
  const now = Math.floor(Date.now() / 1e3);
561
- const id = nanoid();
2025
+ const id = nanoid2();
562
2026
  const normalizedTags = Array.from(
563
2027
  new Set((tags ?? []).map((tag) => tag.trim()).filter(Boolean))
564
2028
  );
565
2029
  db.prepare(
566
2030
  `
567
- INSERT INTO memories (id, repo, type, note, tags, created_at, updated_at)
568
- VALUES (?, ?, ?, ?, ?, ?, ?)
2031
+ INSERT INTO memories (id, repo, type, note, tags, created_at, updated_at, pinned, metadata_json, note_normalized)
2032
+ VALUES (?, ?, ?, ?, ?, ?, ?, 0, '{}', ?)
569
2033
  `
570
- ).run(id, repo, type, note, JSON.stringify(normalizedTags), now, now);
2034
+ ).run(
2035
+ id,
2036
+ resolved.canonical,
2037
+ type,
2038
+ note,
2039
+ JSON.stringify(normalizedTags),
2040
+ now,
2041
+ now,
2042
+ normalizeText(note)
2043
+ );
571
2044
  const stored = db.prepare(
572
2045
  `
573
2046
  SELECT rowid AS row_id, id
@@ -579,7 +2052,7 @@ function registerStoreContextTool(server) {
579
2052
  content: [
580
2053
  {
581
2054
  type: "text",
582
- text: `Stored memory ${id} (numeric id: ${stored?.row_id ?? "unknown"}) for ${repo} (${type}).`
2055
+ text: `Stored memory ${id} (numeric id: ${stored?.row_id ?? "unknown"}) for ${resolved.canonical} (${type}).`
583
2056
  }
584
2057
  ]
585
2058
  };
@@ -603,17 +2076,20 @@ var init_store = __esm({
603
2076
  "src/tools/store.ts"() {
604
2077
  "use strict";
605
2078
  init_client();
2079
+ init_dedupe();
2080
+ init_repo();
2081
+ init_workspace();
606
2082
  storeContextInputSchema = {
607
- repo: z5.string().trim().min(1, "repo is required"),
608
- type: z5.enum(MEMORY_TYPES),
609
- note: z5.string().trim().min(1, "note is required"),
610
- tags: z5.array(z5.string().trim().min(1)).optional()
2083
+ repo: z9.string().trim().min(1).optional(),
2084
+ type: z9.enum(MEMORY_TYPES),
2085
+ note: z9.string().trim().min(1, "note is required"),
2086
+ tags: z9.array(z9.string().trim().min(1)).optional()
611
2087
  };
612
2088
  }
613
2089
  });
614
2090
 
615
2091
  // src/tools/summarize.ts
616
- import { z as z6 } from "zod";
2092
+ import { z as z10 } from "zod";
617
2093
  function registerSummarizeRepoContextTool(server) {
618
2094
  server.registerTool(
619
2095
  "summarize_repo_context",
@@ -624,6 +2100,7 @@ function registerSummarizeRepoContextTool(server) {
624
2100
  async ({ repo }) => {
625
2101
  try {
626
2102
  const db = getDb();
2103
+ const resolved = resolveRepoArg(repo, getWorkspaceRoot(), db);
627
2104
  const rows = db.prepare(
628
2105
  `
629
2106
  SELECT rowid AS row_id, type, note, pinned
@@ -631,13 +2108,13 @@ function registerSummarizeRepoContextTool(server) {
631
2108
  WHERE repo = ?
632
2109
  ORDER BY pinned DESC, updated_at DESC
633
2110
  `
634
- ).all(repo);
2111
+ ).all(resolved.canonical);
635
2112
  if (rows.length === 0) {
636
2113
  return {
637
2114
  content: [
638
2115
  {
639
2116
  type: "text",
640
- text: `Fossel Context Summary: ${repo}
2117
+ text: `Fossel Context Summary: ${resolved.canonical}
641
2118
 
642
2119
  No memories found.`
643
2120
  }
@@ -645,7 +2122,7 @@ No memories found.`
645
2122
  };
646
2123
  }
647
2124
  const pinnedLines = rows.filter((row) => row.pinned === 1).map((row) => `- (${row.row_id}) ${row.note}`);
648
- const sections = [`Fossel Context Summary: ${repo}`];
2125
+ const sections = [`Fossel Context Summary: ${resolved.canonical}`];
649
2126
  if (pinnedLines.length > 0) {
650
2127
  sections.push(`\u{1F4CC} Pinned
651
2128
  ${pinnedLines.join("\n")}`);
@@ -686,8 +2163,10 @@ var init_summarize = __esm({
686
2163
  "src/tools/summarize.ts"() {
687
2164
  "use strict";
688
2165
  init_client();
2166
+ init_repo();
2167
+ init_workspace();
689
2168
  summarizeRepoContextInputSchema = {
690
- repo: z6.string().trim().min(1, "repo is required")
2169
+ repo: z10.string().trim().min(1).optional()
691
2170
  };
692
2171
  sectionTitleByType = {
693
2172
  convention: "Conventions",
@@ -701,8 +2180,8 @@ var init_summarize = __esm({
701
2180
  });
702
2181
 
703
2182
  // src/tools/update.ts
704
- import { z as z7 } from "zod";
705
- function parseTags3(raw) {
2183
+ import { z as z11 } from "zod";
2184
+ function parseTags5(raw) {
706
2185
  try {
707
2186
  const parsed = JSON.parse(raw);
708
2187
  return Array.isArray(parsed) ? parsed.filter((value) => typeof value === "string") : [];
@@ -711,7 +2190,7 @@ function parseTags3(raw) {
711
2190
  }
712
2191
  }
713
2192
  function formatMemory(memory) {
714
- const tags = parseTags3(memory.tags);
2193
+ const tags = parseTags5(memory.tags);
715
2194
  const tagsLine = tags.length > 0 ? tags.join(", ") : "(none)";
716
2195
  return [
717
2196
  `Memory ${memory.row_id} updated successfully.`,
@@ -730,7 +2209,7 @@ function registerUpdateMemoryTool(server) {
730
2209
  server.registerTool(
731
2210
  "update_memory",
732
2211
  {
733
- description: "Update an existing memory by numeric id with partial fields.",
2212
+ description: "Update an existing memory by id (numeric or legacy string) with partial fields.",
734
2213
  inputSchema: updateMemoryInputSchema
735
2214
  },
736
2215
  async ({ id, content, memory_type }) => {
@@ -747,14 +2226,8 @@ function registerUpdateMemoryTool(server) {
747
2226
  };
748
2227
  }
749
2228
  const db = getDb();
750
- const existing = db.prepare(
751
- `
752
- SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned
753
- FROM memories
754
- WHERE rowid = ?
755
- `
756
- ).get(id);
757
- if (!existing) {
2229
+ const target = findMemoryByAnyId(db, id);
2230
+ if (!target) {
758
2231
  return {
759
2232
  isError: true,
760
2233
  content: [
@@ -765,23 +2238,41 @@ function registerUpdateMemoryTool(server) {
765
2238
  ]
766
2239
  };
767
2240
  }
2241
+ const existing = db.prepare(
2242
+ `
2243
+ SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned
2244
+ FROM memories
2245
+ WHERE rowid = ?
2246
+ `
2247
+ ).get(target.row_id);
768
2248
  const now = Math.floor(Date.now() / 1e3);
769
2249
  const nextType = memory_type ?? existing.type;
770
2250
  const nextNote = content ?? existing.note;
771
- db.prepare(
772
- `
773
- UPDATE memories
774
- SET type = ?, note = ?, updated_at = ?
775
- WHERE rowid = ?
776
- `
777
- ).run(nextType, nextNote, now, id);
2251
+ const nextNormalized = content ? normalizeText(content) : null;
2252
+ if (nextNormalized !== null) {
2253
+ db.prepare(
2254
+ `
2255
+ UPDATE memories
2256
+ SET type = ?, note = ?, note_normalized = ?, updated_at = ?
2257
+ WHERE rowid = ?
2258
+ `
2259
+ ).run(nextType, nextNote, nextNormalized, now, existing.row_id);
2260
+ } else {
2261
+ db.prepare(
2262
+ `
2263
+ UPDATE memories
2264
+ SET type = ?, note = ?, updated_at = ?
2265
+ WHERE rowid = ?
2266
+ `
2267
+ ).run(nextType, nextNote, now, existing.row_id);
2268
+ }
778
2269
  const updated = db.prepare(
779
2270
  `
780
2271
  SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned
781
2272
  FROM memories
782
2273
  WHERE rowid = ?
783
2274
  `
784
- ).get(id);
2275
+ ).get(existing.row_id);
785
2276
  if (!updated) {
786
2277
  return {
787
2278
  isError: true,
@@ -821,10 +2312,14 @@ var init_update = __esm({
821
2312
  "src/tools/update.ts"() {
822
2313
  "use strict";
823
2314
  init_client();
2315
+ init_dedupe();
2316
+ init_memory();
824
2317
  updateMemoryInputSchema = {
825
- id: z7.number().int().positive(),
826
- content: z7.string().trim().min(1).optional(),
827
- memory_type: z7.enum(MEMORY_TYPES).optional()
2318
+ // Accept numeric row_id or legacy string id so callers can paste whichever
2319
+ // form they have.
2320
+ id: z11.union([z11.number().int().positive(), z11.string().trim().min(1)]),
2321
+ content: z11.string().trim().min(1).optional(),
2322
+ memory_type: z11.enum(MEMORY_TYPES).optional()
828
2323
  };
829
2324
  }
830
2325
  });
@@ -843,13 +2338,61 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
843
2338
  function resolveDbPath() {
844
2339
  return process.env.FOSSEL_DB_PATH?.trim() || join(homedir(), ".fossel", "memory.db");
845
2340
  }
2341
+ function registerStartupContextResource(server) {
2342
+ server.registerResource(
2343
+ "fossel-startup-context",
2344
+ "fossel://context/current-repo",
2345
+ {
2346
+ title: "Fossel: current repo context",
2347
+ description: "Top pinned and recent memories for the current workspace. Read this at the start of a session to ground the conversation in prior context.",
2348
+ mimeType: "text/markdown"
2349
+ },
2350
+ async (uri) => {
2351
+ try {
2352
+ const db = getDb();
2353
+ const resolved = resolveRepo(getWorkspaceRoot(), db);
2354
+ const rows = fetchRepoContext(db, resolved.canonical, 5);
2355
+ const text = formatContext(rows, {
2356
+ repo: resolved.canonical,
2357
+ format: "markdown"
2358
+ });
2359
+ return {
2360
+ contents: [
2361
+ {
2362
+ uri: uri.href,
2363
+ mimeType: "text/markdown",
2364
+ text
2365
+ }
2366
+ ]
2367
+ };
2368
+ } catch (error) {
2369
+ const message = error instanceof Error ? error.message : String(error);
2370
+ return {
2371
+ contents: [
2372
+ {
2373
+ uri: uri.href,
2374
+ mimeType: "text/markdown",
2375
+ text: `# Fossel context unavailable
2376
+
2377
+ ${message}`
2378
+ }
2379
+ ]
2380
+ };
2381
+ }
2382
+ }
2383
+ );
2384
+ }
846
2385
  async function startServer() {
847
2386
  const dbPath = resolveDbPath();
848
2387
  initDb(dbPath);
849
2388
  const server = new McpServer({
850
2389
  name: "fossel",
851
- version: "1.0.0"
2390
+ version: "1.1.1"
852
2391
  });
2392
+ registerRememberTool(server);
2393
+ registerGetContextTool(server);
2394
+ registerResolveRepoTool(server);
2395
+ registerDedupeRepoTool(server);
853
2396
  registerStoreContextTool(server);
854
2397
  registerGetRepoContextTool(server);
855
2398
  registerSearchMemoryTool(server);
@@ -858,6 +2401,7 @@ async function startServer() {
858
2401
  registerPinMemoryTool(server);
859
2402
  registerUnpinMemoryTool(server);
860
2403
  registerSummarizeRepoContextTool(server);
2404
+ registerStartupContextResource(server);
861
2405
  const transport = new StdioServerTransport();
862
2406
  await server.connect(transport);
863
2407
  }
@@ -866,9 +2410,16 @@ var init_index = __esm({
866
2410
  "src/index.ts"() {
867
2411
  "use strict";
868
2412
  init_client();
2413
+ init_context();
2414
+ init_repo();
2415
+ init_workspace();
2416
+ init_dedupe_repo();
869
2417
  init_delete();
2418
+ init_get_context();
870
2419
  init_get_repo();
871
2420
  init_pin();
2421
+ init_remember();
2422
+ init_resolve_repo();
872
2423
  init_search();
873
2424
  init_store();
874
2425
  init_summarize();
@@ -887,111 +2438,299 @@ var init_index = __esm({
887
2438
 
888
2439
  // src/cli.ts
889
2440
  init_client();
2441
+ init_dedupe();
2442
+ init_repo();
890
2443
  import { homedir as homedir2 } from "os";
891
- import { basename, join as join2 } from "path";
892
- import { spawnSync } from "child_process";
893
- import { nanoid as nanoid2 } from "nanoid";
2444
+ import { join as join2 } from "path";
2445
+ import { statSync } from "fs";
2446
+ import { nanoid as nanoid3 } from "nanoid";
894
2447
  var DEFAULT_DB_PATH = join2(homedir2(), ".fossel", "memory.db");
895
- var INIT_MEMORY_TEXT = "Fossel is active for this repo. Use store_context to save context.";
2448
+ var INIT_MEMORY_TEXT = "Fossel is active for this repo. Say 'remember this' or call get_context to retrieve repo memories.";
896
2449
  function resolveDbPath2() {
897
2450
  return process.env.FOSSEL_DB_PATH?.trim() || DEFAULT_DB_PATH;
898
2451
  }
899
- function detectRepoFromRemote(cwd) {
900
- const result = spawnSync("git", ["remote", "get-url", "origin"], {
901
- cwd,
902
- encoding: "utf8"
903
- });
904
- if (result.status !== 0) {
905
- return null;
906
- }
907
- const remote = result.stdout.trim();
908
- if (!remote) {
909
- return null;
910
- }
911
- const normalized = remote.replace(/\\/g, "/").replace(/\/+$/, "");
912
- const lastSegment = normalized.split("/").at(-1);
913
- if (!lastSegment) {
914
- return null;
915
- }
916
- return lastSegment.replace(/\.git$/i, "");
917
- }
918
- function detectRepoName(cwd) {
919
- return detectRepoFromRemote(cwd) ?? basename(cwd);
920
- }
921
- function ensureSampleMemory(repo) {
2452
+ function ensureSampleMemoryIfEmpty(repo) {
922
2453
  const db = getDb();
923
- const existing = db.prepare(
924
- `
925
- SELECT rowid AS row_id
926
- FROM memories
927
- WHERE repo = ? AND type = 'convention' AND note = ?
928
- LIMIT 1
929
- `
930
- ).get(repo, INIT_MEMORY_TEXT);
931
- if (existing) {
932
- return;
2454
+ const totalRow = db.prepare("SELECT COUNT(*) AS count FROM memories").get();
2455
+ if (totalRow.count > 0) {
2456
+ return false;
933
2457
  }
934
2458
  const now = Math.floor(Date.now() / 1e3);
935
2459
  db.prepare(
936
2460
  `
937
- INSERT INTO memories (id, repo, type, note, tags, created_at, updated_at, pinned)
938
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
2461
+ INSERT INTO memories (id, repo, type, note, tags, created_at, updated_at, pinned, metadata_json, note_normalized)
2462
+ VALUES (?, ?, ?, ?, ?, ?, ?, 0, '{}', ?)
939
2463
  `
940
- ).run(nanoid2(), repo, "convention", INIT_MEMORY_TEXT, "[]", now, now, 0);
2464
+ ).run(
2465
+ nanoid3(),
2466
+ repo,
2467
+ "convention",
2468
+ INIT_MEMORY_TEXT,
2469
+ "[]",
2470
+ now,
2471
+ now,
2472
+ normalizeText(INIT_MEMORY_TEXT)
2473
+ );
2474
+ return true;
941
2475
  }
942
- function formatCursorConfig() {
943
- return JSON.stringify(
944
- {
945
- mcpServers: {
946
- fossel: {
947
- command: "npx",
948
- args: ["-y", "fossel"]
2476
+ var MCP_CONFIG_SNIPPET = JSON.stringify(
2477
+ {
2478
+ mcpServers: {
2479
+ fossel: {
2480
+ command: "npx",
2481
+ args: ["-y", "fossel"],
2482
+ // FOSSEL_WORKSPACE pins the workspace root so the server detects the
2483
+ // right repo even when the IDE launches MCP servers from another cwd.
2484
+ env: {
2485
+ FOSSEL_WORKSPACE: "${workspaceFolder}"
949
2486
  }
950
2487
  }
951
- },
952
- null,
953
- 2
954
- );
2488
+ }
2489
+ },
2490
+ null,
2491
+ 2
2492
+ );
2493
+ function findMergeCandidates(canonical) {
2494
+ const db = getDb();
2495
+ const tail = canonical.split("/").at(-1) ?? canonical;
2496
+ const rows = db.prepare(
2497
+ `
2498
+ SELECT repo, COUNT(*) AS count
2499
+ FROM memories
2500
+ WHERE repo != ?
2501
+ GROUP BY repo
2502
+ `
2503
+ ).all(canonical);
2504
+ return rows.filter((row) => {
2505
+ if (!row.repo) return false;
2506
+ const otherTail = row.repo.split("/").at(-1) ?? row.repo;
2507
+ return otherTail === tail || otherTail === canonical || row.repo === tail;
2508
+ });
955
2509
  }
956
- function formatClaudeDesktopConfig() {
957
- return JSON.stringify(
958
- {
959
- mcpServers: {
960
- fossel: {
961
- command: "npx",
962
- args: ["-y", "fossel"]
963
- }
2510
+ function autoDedupeExact(repo) {
2511
+ const db = getDb();
2512
+ const groups = db.prepare(
2513
+ `
2514
+ SELECT note_normalized, type, COUNT(*) AS count
2515
+ FROM memories
2516
+ WHERE repo = ? AND note_normalized != ''
2517
+ GROUP BY note_normalized, type
2518
+ HAVING COUNT(*) > 1
2519
+ `
2520
+ ).all(repo);
2521
+ if (groups.length === 0) {
2522
+ return 0;
2523
+ }
2524
+ let removed = 0;
2525
+ const tx = db.transaction(() => {
2526
+ for (const group of groups) {
2527
+ const rows = db.prepare(
2528
+ `
2529
+ SELECT rowid AS row_id, pinned, updated_at
2530
+ FROM memories
2531
+ WHERE repo = ? AND note_normalized = ? AND type = ?
2532
+ ORDER BY pinned DESC, updated_at DESC, rowid DESC
2533
+ `
2534
+ ).all(repo, group.note_normalized, group.type);
2535
+ const [keep, ...rest] = rows;
2536
+ if (!keep) continue;
2537
+ const drop = db.prepare("DELETE FROM memories WHERE rowid = ?");
2538
+ for (const row of rest) {
2539
+ drop.run(row.row_id);
2540
+ removed += 1;
964
2541
  }
965
- },
966
- null,
967
- 2
968
- );
2542
+ }
2543
+ });
2544
+ tx();
2545
+ return removed;
969
2546
  }
970
- function printInitOutput(repo, dbPath) {
2547
+ function runInit(options) {
2548
+ const dbPath = resolveDbPath2();
2549
+ initDb(dbPath);
971
2550
  const db = getDb();
972
- const countRow = db.prepare("SELECT COUNT(*) AS count FROM memories").get();
973
- console.log("Fossel remembers project context locally for each repository.");
974
- console.log("Store conventions, fixes, and decisions once; retrieve them when needed.");
975
- console.log("Everything stays in your local SQLite database.\n");
976
- console.log(`Detected repository: ${repo}
977
- `);
978
- console.log("Cursor MCP config (~/.cursor/mcp.json):");
979
- console.log(formatCursorConfig());
2551
+ const resolved = resolveRepo(process.cwd(), db);
2552
+ const candidates = findMergeCandidates(resolved.canonical);
2553
+ let mergedAliases = 0;
2554
+ let mergedMemories = 0;
2555
+ let rewrittenNotes = 0;
2556
+ for (const candidate of candidates) {
2557
+ const result = mergeRepoKeys(db, candidate.repo, resolved.canonical);
2558
+ mergedAliases += result.movedAliases;
2559
+ mergedMemories += result.movedMemories;
2560
+ rewrittenNotes += result.rewrittenNotes;
2561
+ }
2562
+ const sampleAdded = ensureSampleMemoryIfEmpty(resolved.canonical);
2563
+ let autoMerged = 0;
2564
+ if (options.autoDedupe) {
2565
+ autoMerged = autoDedupeExact(resolved.canonical);
2566
+ }
2567
+ const countRow = db.prepare("SELECT COUNT(*) AS count FROM memories WHERE repo = ?").get(resolved.canonical);
2568
+ console.log("Fossel \u2014 local-first MCP memory for your repos.\n");
2569
+ console.log(`Canonical repo key: ${resolved.canonical}`);
2570
+ console.log(` source: ${resolved.source}`);
2571
+ if (resolved.gitRemote) {
2572
+ console.log(` git remote: ${resolved.gitRemote}`);
2573
+ }
2574
+ if (resolved.aliases.length > 0) {
2575
+ console.log(` aliases: ${resolved.aliases.join(", ")}`);
2576
+ }
2577
+ console.log("");
2578
+ if (mergedAliases > 0 || mergedMemories > 0 || rewrittenNotes > 0) {
2579
+ console.log(
2580
+ `Merged ${mergedMemories} memory row(s), ${mergedAliases} alias row(s), and rewrote ${rewrittenNotes} stale mention(s) into ${resolved.canonical}.`
2581
+ );
2582
+ console.log("");
2583
+ }
2584
+ if (autoMerged > 0) {
2585
+ console.log(`Auto-deduped ${autoMerged} exact duplicate row(s).`);
2586
+ console.log("");
2587
+ }
2588
+ console.log("MCP config (Cursor: ~/.cursor/mcp.json, Claude Desktop: settings):");
2589
+ console.log(MCP_CONFIG_SNIPPET);
2590
+ console.log("");
2591
+ console.log(`DB path: ${dbPath}`);
2592
+ console.log(`Memories for ${resolved.canonical}: ${countRow.count}`);
2593
+ if (sampleAdded) {
2594
+ console.log("Inserted one starter memory because the database was empty.");
2595
+ }
980
2596
  console.log("");
981
- console.log("Claude Desktop MCP config:");
982
- console.log(formatClaudeDesktopConfig());
2597
+ console.log("Quick usage in chat:");
2598
+ console.log(" remember \u2014 natural-language save (no type/tags needed)");
2599
+ console.log(" get_context \u2014 pinned + recent + matching memories");
2600
+ console.log(" resolve_repo \u2014 show which repo key Fossel will use");
2601
+ console.log(" store_context \u2014 explicit save (advanced)");
2602
+ console.log(" dedupe_repo \u2014 merge near-duplicate memories");
983
2603
  console.log("");
984
- console.log(`DB Path: ${dbPath}`);
985
- console.log(`Total memories: ${countRow.count}`);
2604
+ console.log("Set FOSSEL_WORKSPACE in your MCP config to your project root if Fossel detects the wrong repo.");
2605
+ closeDb();
2606
+ }
2607
+ function gatherDoctorReport() {
2608
+ const dbPath = resolveDbPath2();
2609
+ const lines = [];
2610
+ let ok = true;
2611
+ initDb(dbPath);
2612
+ const db = getDb();
2613
+ lines.push(`DB path: ${dbPath}`);
2614
+ const resolved = resolveRepo(process.cwd(), db);
2615
+ lines.push(`Canonical repo key: ${resolved.canonical} (source: ${resolved.source})`);
2616
+ if (resolved.gitRemote) {
2617
+ lines.push(`Git remote: ${resolved.gitRemote}`);
2618
+ } else {
2619
+ lines.push("Git remote: not detected (using folder name).");
2620
+ }
2621
+ if (resolved.aliases.length > 0) {
2622
+ lines.push(`Aliases: ${resolved.aliases.join(", ")}`);
2623
+ }
2624
+ const siblings = findMergeCandidates(resolved.canonical);
2625
+ if (siblings.length > 0) {
2626
+ ok = false;
2627
+ const summary = siblings.map((row) => `${row.repo} (${row.count})`).join(", ");
2628
+ lines.push(`\u26A0 Sibling repo keys detected: ${summary}. Run \`npx fossel init\` to merge.`);
2629
+ } else {
2630
+ lines.push("No sibling repo keys.");
2631
+ }
2632
+ const staleMentions = [];
2633
+ for (const alias of resolved.aliases) {
2634
+ if (alias === resolved.canonical) continue;
2635
+ const found2 = findMemoriesMentioningAlias(db, alias, resolved.canonical);
2636
+ for (const row of found2) {
2637
+ staleMentions.push({ alias, row_id: row.row_id, note: row.note });
2638
+ }
2639
+ }
2640
+ if (staleMentions.length > 0) {
2641
+ ok = false;
2642
+ lines.push(
2643
+ `\u26A0 ${staleMentions.length} memory note(s) still mention a deprecated repo key. Run \`fossel doctor --fix\` to rewrite them.`
2644
+ );
2645
+ } else {
2646
+ lines.push("No memory notes reference deprecated repo keys.");
2647
+ }
2648
+ const duplicateRows = db.prepare(
2649
+ `
2650
+ SELECT note_normalized, COUNT(*) AS count
2651
+ FROM memories
2652
+ WHERE repo = ? AND note_normalized != ''
2653
+ GROUP BY note_normalized
2654
+ HAVING COUNT(*) > 1
2655
+ `
2656
+ ).all(resolved.canonical);
2657
+ if (duplicateRows.length > 0) {
2658
+ ok = false;
2659
+ const total = duplicateRows.reduce((sum, row) => sum + row.count - 1, 0);
2660
+ lines.push(
2661
+ `\u26A0 ${duplicateRows.length} duplicate clusters covering ${total} extra row(s). Run \`fossel doctor --fix\` (or \`dedupe_repo\` with apply=true) to merge.`
2662
+ );
2663
+ } else {
2664
+ lines.push("No exact-duplicate memory clusters.");
2665
+ }
2666
+ const mcpConfigCandidates = [
2667
+ join2(homedir2(), ".cursor", "mcp.json"),
2668
+ join2(
2669
+ homedir2(),
2670
+ "AppData",
2671
+ "Roaming",
2672
+ "Claude",
2673
+ "claude_desktop_config.json"
2674
+ ),
2675
+ join2(
2676
+ homedir2(),
2677
+ "Library",
2678
+ "Application Support",
2679
+ "Claude",
2680
+ "claude_desktop_config.json"
2681
+ )
2682
+ ];
2683
+ const found = mcpConfigCandidates.filter((path) => {
2684
+ try {
2685
+ return statSync(path).isFile();
2686
+ } catch {
2687
+ return false;
2688
+ }
2689
+ });
2690
+ if (found.length === 0) {
2691
+ lines.push(
2692
+ "\u26A0 Could not find Cursor or Claude Desktop MCP config. Run `npx fossel init` and paste the snippet."
2693
+ );
2694
+ } else {
2695
+ lines.push(`Detected MCP config(s): ${found.join(", ")}`);
2696
+ }
2697
+ const totalRow = db.prepare("SELECT COUNT(*) AS count FROM memories").get();
2698
+ lines.push(`Total memories across all repos: ${totalRow.count}`);
2699
+ return { ok, lines, duplicateClusters: duplicateRows.length, staleMentions };
2700
+ }
2701
+ function runDoctor(options) {
2702
+ const report = gatherDoctorReport();
2703
+ console.log(report.lines.join("\n"));
2704
+ console.log("");
2705
+ if (!options.fix) {
2706
+ console.log(report.ok ? "Status: OK" : "Status: needs attention (see \u26A0 lines above)");
2707
+ if (!report.ok) {
2708
+ process.exitCode = 1;
2709
+ }
2710
+ closeDb();
2711
+ return;
2712
+ }
2713
+ const db = getDb();
2714
+ const resolved = resolveRepo(process.cwd(), db);
2715
+ const candidates = findMergeCandidates(resolved.canonical);
2716
+ let movedMemories = 0;
2717
+ let rewrittenNotes = 0;
2718
+ for (const candidate of candidates) {
2719
+ const result = mergeRepoKeys(db, candidate.repo, resolved.canonical);
2720
+ movedMemories += result.movedMemories;
2721
+ rewrittenNotes += result.rewrittenNotes;
2722
+ }
2723
+ const removed = autoDedupeExact(resolved.canonical);
2724
+ console.log("Applied fixes:");
2725
+ console.log(` merged repo memory rows: ${movedMemories}`);
2726
+ console.log(` rewrote stale mentions: ${rewrittenNotes}`);
2727
+ console.log(` removed exact duplicates: ${removed}`);
986
2728
  console.log("");
987
- console.log("Quick commands");
988
- console.log("Command Description");
989
- console.log("----------------------- --------------------------------------------");
990
- console.log("npx -y fossel Start Fossel MCP server over stdio");
991
- console.log("npx -y fossel init Initialize Fossel for current repository");
992
- console.log("store_context Save context memory");
993
- console.log("get_repo_context Retrieve recent repo memories");
994
- console.log("summarize_repo_context Generate markdown context summary");
2729
+ console.log("Re-run `fossel doctor` to verify.");
2730
+ closeDb();
2731
+ }
2732
+ function parseFlag(args, name) {
2733
+ return args.includes(`--${name}`);
995
2734
  }
996
2735
  async function main() {
997
2736
  const command = process.argv[2];
@@ -1001,16 +2740,18 @@ async function main() {
1001
2740
  return;
1002
2741
  }
1003
2742
  if (command === "init") {
1004
- const dbPath = resolveDbPath2();
1005
- initDb(dbPath);
1006
- const repo = detectRepoName(process.cwd());
1007
- ensureSampleMemory(repo);
1008
- printInitOutput(repo, dbPath);
1009
- closeDb();
2743
+ const args = process.argv.slice(3);
2744
+ const autoDedupe = !parseFlag(args, "no-dedupe");
2745
+ runInit({ autoDedupe });
2746
+ return;
2747
+ }
2748
+ if (command === "doctor") {
2749
+ const args = process.argv.slice(3);
2750
+ runDoctor({ fix: parseFlag(args, "fix") });
1010
2751
  return;
1011
2752
  }
1012
2753
  console.error(`Unknown command: ${command}`);
1013
- console.error("Usage: fossel [init]");
2754
+ console.error("Usage: fossel [init [--no-dedupe] | doctor [--fix]]");
1014
2755
  process.exit(1);
1015
2756
  }
1016
2757
  main().catch((error) => {
@@ -1018,3 +2759,7 @@ main().catch((error) => {
1018
2759
  console.error(`Fossel command failed: ${message}`);
1019
2760
  process.exit(1);
1020
2761
  });
2762
+ export {
2763
+ runDoctor,
2764
+ runInit
2765
+ };