fossel 1.0.9 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +110 -43
  2. package/dist/cli.js +1704 -275
  3. package/dist/index.js +1300 -54
  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,8 +215,599 @@ var init_client = __esm({
159
215
  }
160
216
  });
161
217
 
162
- // src/tools/delete.ts
218
+ // src/lib/repo.ts
219
+ import { spawnSync } from "child_process";
220
+ import { basename } from "path";
221
+ function normalizeGitRemote(remoteUrl) {
222
+ const trimmed = remoteUrl.trim();
223
+ if (!trimmed) {
224
+ return null;
225
+ }
226
+ for (const pattern of REMOTE_PATTERNS) {
227
+ const match = pattern.exec(trimmed);
228
+ if (!match) {
229
+ continue;
230
+ }
231
+ const path = match[2]?.replace(/^\/+/, "").replace(/\\/g, "/").replace(/\.git$/i, "").replace(/\/+$/, "");
232
+ if (!path) {
233
+ continue;
234
+ }
235
+ return path;
236
+ }
237
+ return null;
238
+ }
239
+ function readGitRemote(cwd) {
240
+ const result = spawnSync("git", ["remote", "get-url", "origin"], {
241
+ cwd,
242
+ encoding: "utf8"
243
+ });
244
+ if (result.status !== 0) {
245
+ return null;
246
+ }
247
+ const value = result.stdout.trim();
248
+ return value.length > 0 ? value : null;
249
+ }
250
+ function detectFolderName(cwd) {
251
+ const name = basename(cwd);
252
+ return name.length > 0 ? name : cwd;
253
+ }
254
+ function fetchAliases(db, canonical) {
255
+ const rows = db.prepare("SELECT alias FROM repo_aliases WHERE canonical = ? ORDER BY alias").all(canonical);
256
+ return rows.map((row) => row.alias);
257
+ }
258
+ function upsertAlias(db, alias, canonical) {
259
+ const trimmed = alias.trim();
260
+ const target = canonical.trim();
261
+ if (!trimmed || !target) {
262
+ return;
263
+ }
264
+ const now = Math.floor(Date.now() / 1e3);
265
+ db.prepare(
266
+ `
267
+ INSERT INTO repo_aliases (alias, canonical, created_at)
268
+ VALUES (?, ?, ?)
269
+ ON CONFLICT(alias) DO UPDATE SET canonical = excluded.canonical
270
+ `
271
+ ).run(trimmed, target, now);
272
+ }
273
+ function lookupAlias(db, alias) {
274
+ const row = db.prepare("SELECT alias, canonical FROM repo_aliases WHERE alias = ?").get(alias);
275
+ return row ?? null;
276
+ }
277
+ function resolveRepo(cwd, db) {
278
+ const gitRemote = readGitRemote(cwd);
279
+ const fromRemote = gitRemote ? normalizeGitRemote(gitRemote) : null;
280
+ const folder = detectFolderName(cwd);
281
+ let canonical;
282
+ let source;
283
+ if (fromRemote) {
284
+ canonical = fromRemote;
285
+ source = "git-remote";
286
+ } else {
287
+ canonical = folder;
288
+ source = "folder";
289
+ }
290
+ upsertAlias(db, canonical, canonical);
291
+ if (folder && folder !== canonical) {
292
+ const existing = lookupAlias(db, folder);
293
+ if (!existing) {
294
+ upsertAlias(db, folder, canonical);
295
+ }
296
+ }
297
+ return {
298
+ canonical,
299
+ cwd,
300
+ gitRemote,
301
+ source,
302
+ aliases: fetchAliases(db, canonical)
303
+ };
304
+ }
305
+ function resolveRepoArg(input, cwd, db) {
306
+ const trimmed = input?.trim();
307
+ if (!trimmed) {
308
+ return resolveRepo(cwd, db);
309
+ }
310
+ const aliasRow = lookupAlias(db, trimmed);
311
+ if (aliasRow) {
312
+ return {
313
+ canonical: aliasRow.canonical,
314
+ cwd,
315
+ gitRemote: null,
316
+ source: "alias",
317
+ aliases: fetchAliases(db, aliasRow.canonical)
318
+ };
319
+ }
320
+ const workspace = resolveRepo(cwd, db);
321
+ if (workspace.canonical && workspace.canonical !== trimmed) {
322
+ const tail = workspace.canonical.split("/").at(-1) ?? workspace.canonical;
323
+ const inputTail = trimmed.split("/").at(-1) ?? trimmed;
324
+ if (tail === inputTail || tail === trimmed || inputTail === workspace.canonical) {
325
+ upsertAlias(db, trimmed, workspace.canonical);
326
+ return {
327
+ ...workspace,
328
+ source: "alias",
329
+ aliases: fetchAliases(db, workspace.canonical)
330
+ };
331
+ }
332
+ }
333
+ upsertAlias(db, trimmed, trimmed);
334
+ return {
335
+ canonical: trimmed,
336
+ cwd,
337
+ gitRemote: null,
338
+ source: "input",
339
+ aliases: fetchAliases(db, trimmed)
340
+ };
341
+ }
342
+ function mergeRepoKeys(db, from, to) {
343
+ if (from === to) {
344
+ return { movedAliases: 0, movedMemories: 0 };
345
+ }
346
+ const tx = db.transaction(() => {
347
+ const aliasResult = db.prepare("UPDATE repo_aliases SET canonical = ? WHERE canonical = ?").run(to, from);
348
+ const aliasesToReassign = db.prepare("SELECT alias FROM repo_aliases WHERE canonical = ?").all(to);
349
+ let movedMemories = 0;
350
+ const updateMemories = db.prepare(
351
+ "UPDATE memories SET repo = ? WHERE repo = ?"
352
+ );
353
+ for (const { alias } of aliasesToReassign) {
354
+ if (alias === to) {
355
+ continue;
356
+ }
357
+ const result = updateMemories.run(to, alias);
358
+ movedMemories += result.changes;
359
+ }
360
+ movedMemories += updateMemories.run(to, from).changes;
361
+ upsertAlias(db, from, to);
362
+ upsertAlias(db, to, to);
363
+ return {
364
+ movedAliases: aliasResult.changes,
365
+ movedMemories
366
+ };
367
+ });
368
+ return tx();
369
+ }
370
+ var REMOTE_PATTERNS;
371
+ var init_repo = __esm({
372
+ "src/lib/repo.ts"() {
373
+ "use strict";
374
+ REMOTE_PATTERNS = [
375
+ // git@github.com:owner/repo.git, git@gitlab.com:group/sub/repo.git
376
+ /^[^@\s]+@([^:]+):([^\s]+?)(?:\.git)?$/,
377
+ // ssh://git@github.com/owner/repo.git
378
+ /^ssh:\/\/[^@/]+@([^/]+)\/([^\s]+?)(?:\.git)?$/,
379
+ // https://github.com/owner/repo.git, http://gitlab.com/group/sub/repo
380
+ /^https?:\/\/(?:[^@/]+@)?([^/]+)\/([^\s]+?)(?:\.git)?$/,
381
+ // git://github.com/owner/repo.git
382
+ /^git:\/\/([^/]+)\/([^\s]+?)(?:\.git)?$/
383
+ ];
384
+ }
385
+ });
386
+
387
+ // src/lib/context.ts
388
+ function parseTags(raw) {
389
+ try {
390
+ const parsed = JSON.parse(raw);
391
+ return Array.isArray(parsed) ? parsed.filter((value) => typeof value === "string") : [];
392
+ } catch {
393
+ return [];
394
+ }
395
+ }
396
+ function buildFtsQuery(query) {
397
+ const terms = query.trim().split(/\s+/).map((term) => term.replace(/"/g, '""')).filter((term) => term.length > 0);
398
+ if (terms.length === 0) {
399
+ return null;
400
+ }
401
+ return terms.map((term) => `"${term}"`).join(" AND ");
402
+ }
403
+ function fetchRepoContext(db, repo, limit, query) {
404
+ const rows = [];
405
+ const seen = /* @__PURE__ */ new Set();
406
+ const push = (memory, source, rank) => {
407
+ if (seen.has(memory.row_id)) {
408
+ return;
409
+ }
410
+ seen.add(memory.row_id);
411
+ rows.push({ ...memory, source, rank });
412
+ };
413
+ const pinned = db.prepare(
414
+ `
415
+ SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned
416
+ FROM memories
417
+ WHERE repo = ? AND pinned = 1
418
+ ORDER BY updated_at DESC
419
+ LIMIT ?
420
+ `
421
+ ).all(repo, limit);
422
+ for (const row of pinned) {
423
+ push(row, "pinned");
424
+ }
425
+ if (rows.length < limit) {
426
+ const recent = db.prepare(
427
+ `
428
+ SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned
429
+ FROM memories
430
+ WHERE repo = ? AND pinned = 0
431
+ ORDER BY updated_at DESC
432
+ LIMIT ?
433
+ `
434
+ ).all(repo, limit - rows.length);
435
+ for (const row of recent) {
436
+ push(row, "recent");
437
+ }
438
+ }
439
+ if (query && rows.length < limit) {
440
+ const ftsQuery = buildFtsQuery(query);
441
+ if (ftsQuery) {
442
+ try {
443
+ const matches = db.prepare(
444
+ `
445
+ SELECT m.rowid AS row_id, m.id, m.repo, m.type, m.note, m.tags,
446
+ m.created_at, m.updated_at, m.pinned, bm25(memories_fts) AS rank
447
+ FROM memories_fts
448
+ JOIN memories AS m ON m.rowid = memories_fts.rowid
449
+ WHERE memories_fts MATCH ? AND m.repo = ?
450
+ ORDER BY rank
451
+ LIMIT ?
452
+ `
453
+ ).all(ftsQuery, repo, limit);
454
+ for (const row of matches) {
455
+ push(row, "search", row.rank);
456
+ if (rows.length >= limit) {
457
+ break;
458
+ }
459
+ }
460
+ } catch {
461
+ }
462
+ }
463
+ }
464
+ return rows.slice(0, limit);
465
+ }
466
+ function formatContext(rows, options) {
467
+ const { repo, query, format = "text" } = options;
468
+ if (rows.length === 0) {
469
+ if (format === "markdown") {
470
+ return `# Fossel context: ${repo}
471
+
472
+ No memories found${query ? ` for "${query}"` : ""}.`;
473
+ }
474
+ return `No memories found for ${repo}${query ? ` matching "${query}"` : ""}.`;
475
+ }
476
+ if (format === "markdown") {
477
+ return formatMarkdown(rows, repo, query);
478
+ }
479
+ return formatText(rows, repo, query);
480
+ }
481
+ function formatMarkdown(rows, repo, query) {
482
+ const sections = [`# Fossel context: ${repo}`];
483
+ if (query) {
484
+ sections.push(`Query: \`${query}\``);
485
+ }
486
+ const pinned = rows.filter((row) => row.pinned === 1);
487
+ if (pinned.length > 0) {
488
+ sections.push(["## \u{1F4CC} Pinned", ...pinned.map(renderMarkdownRow)].join("\n"));
489
+ }
490
+ for (const type of MEMORY_TYPES) {
491
+ const entries = rows.filter((row) => row.pinned !== 1 && row.type === type);
492
+ if (entries.length === 0) {
493
+ continue;
494
+ }
495
+ sections.push(
496
+ [`## ${SECTION_TITLES[type]}`, ...entries.map(renderMarkdownRow)].join("\n")
497
+ );
498
+ }
499
+ return sections.join("\n\n");
500
+ }
501
+ function renderMarkdownRow(row) {
502
+ const tags = parseTags(row.tags);
503
+ const tagSuffix = tags.length > 0 ? ` _(${tags.join(", ")})_` : "";
504
+ return `- (${row.row_id}) ${row.note}${tagSuffix}`;
505
+ }
506
+ function formatText(rows, repo, query) {
507
+ const header = query ? `Repository context for ${repo} (query: "${query}")` : `Repository context for ${repo}`;
508
+ const lines = [header, `Total: ${rows.length}`, ""];
509
+ for (const row of rows) {
510
+ const tags = parseTags(row.tags);
511
+ const tagSuffix = tags.length > 0 ? ` [tags: ${tags.join(", ")}]` : "";
512
+ const pinPrefix = row.pinned ? "\u{1F4CC} " : "";
513
+ const sourceLabel = row.source === "search" ? " [match]" : "";
514
+ lines.push(
515
+ `- (${row.row_id} | ${row.type})${sourceLabel} ${pinPrefix}${row.note}${tagSuffix}`
516
+ );
517
+ }
518
+ return lines.join("\n");
519
+ }
520
+ var SECTION_TITLES;
521
+ var init_context = __esm({
522
+ "src/lib/context.ts"() {
523
+ "use strict";
524
+ init_client();
525
+ SECTION_TITLES = {
526
+ convention: "Conventions",
527
+ bug_fix: "Bug Fixes",
528
+ reviewer_pattern: "Reviewer Patterns",
529
+ decision: "Decisions",
530
+ issue: "Issues",
531
+ general: "General"
532
+ };
533
+ }
534
+ });
535
+
536
+ // src/lib/dedupe.ts
537
+ function normalizeText(text) {
538
+ return text.toLowerCase().replace(/[^a-z0-9\s]/g, " ").replace(/\s+/g, " ").trim();
539
+ }
540
+ function tokenize(text) {
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
+ }
614
+ }
615
+ return best;
616
+ }
617
+ var DEFAULT_THRESHOLD, DEFAULT_CANDIDATE_LIMIT;
618
+ var init_dedupe = __esm({
619
+ "src/lib/dedupe.ts"() {
620
+ "use strict";
621
+ DEFAULT_THRESHOLD = 0.82;
622
+ DEFAULT_CANDIDATE_LIMIT = 200;
623
+ }
624
+ });
625
+
626
+ // src/tools/dedupe-repo.ts
163
627
  import { z } from "zod";
628
+ function parseTags2(raw) {
629
+ try {
630
+ const parsed = JSON.parse(raw);
631
+ return Array.isArray(parsed) ? parsed.filter((value) => typeof value === "string") : [];
632
+ } catch {
633
+ return [];
634
+ }
635
+ }
636
+ function parseMetadata(raw) {
637
+ try {
638
+ const parsed = JSON.parse(raw);
639
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
640
+ return parsed;
641
+ }
642
+ } catch {
643
+ }
644
+ return {};
645
+ }
646
+ function mergeTagLists(...lists) {
647
+ const seen = /* @__PURE__ */ new Set();
648
+ const out = [];
649
+ for (const list of lists) {
650
+ for (const value of list) {
651
+ const trimmed = value.trim().toLowerCase();
652
+ if (!trimmed || seen.has(trimmed)) continue;
653
+ seen.add(trimmed);
654
+ out.push(trimmed);
655
+ }
656
+ }
657
+ return out;
658
+ }
659
+ function registerDedupeRepoTool(server) {
660
+ server.registerTool(
661
+ "dedupe_repo",
662
+ {
663
+ 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.",
664
+ inputSchema: dedupeRepoInputSchema
665
+ },
666
+ async ({ repo, threshold, apply }) => {
667
+ try {
668
+ const db = getDb();
669
+ const resolved = resolveRepoArg(repo, process.cwd(), db);
670
+ const rows = db.prepare(
671
+ `
672
+ SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned, metadata_json
673
+ FROM memories
674
+ WHERE repo = ?
675
+ ORDER BY updated_at DESC
676
+ `
677
+ ).all(resolved.canonical);
678
+ if (rows.length < 2) {
679
+ return {
680
+ content: [
681
+ {
682
+ type: "text",
683
+ text: `No duplicates possible: only ${rows.length} memory in ${resolved.canonical}.`
684
+ }
685
+ ]
686
+ };
687
+ }
688
+ const consumed = /* @__PURE__ */ new Set();
689
+ const plan = [];
690
+ for (let i = 0; i < rows.length; i += 1) {
691
+ const keep = rows[i];
692
+ if (!keep || consumed.has(keep.row_id)) continue;
693
+ for (let j = i + 1; j < rows.length; j += 1) {
694
+ const other = rows[j];
695
+ if (!other || consumed.has(other.row_id)) continue;
696
+ if (other.type !== keep.type) continue;
697
+ const score = similarity(keep.note, other.note);
698
+ if (score >= threshold) {
699
+ plan.push({ keep: keep.row_id, drop: other.row_id, similarity: score });
700
+ consumed.add(other.row_id);
701
+ }
702
+ }
703
+ }
704
+ if (plan.length === 0) {
705
+ return {
706
+ content: [
707
+ {
708
+ type: "text",
709
+ text: `No duplicates \u2265 ${threshold} found in ${resolved.canonical} (${rows.length} memories scanned).`
710
+ }
711
+ ]
712
+ };
713
+ }
714
+ if (!apply) {
715
+ const lines = plan.map(
716
+ (entry) => `- keep ${entry.keep}, drop ${entry.drop} (similarity ${entry.similarity.toFixed(2)})`
717
+ );
718
+ return {
719
+ content: [
720
+ {
721
+ type: "text",
722
+ text: `Dry run for ${resolved.canonical}. Found ${plan.length} duplicate pair(s):
723
+ ${lines.join("\n")}
724
+
725
+ Re-run with apply=true to merge.`
726
+ }
727
+ ]
728
+ };
729
+ }
730
+ const byId = new Map(rows.map((row) => [row.row_id, row]));
731
+ const now = Math.floor(Date.now() / 1e3);
732
+ let merged = 0;
733
+ const tx = db.transaction((entries) => {
734
+ for (const entry of entries) {
735
+ const keep = byId.get(entry.keep);
736
+ const drop = byId.get(entry.drop);
737
+ if (!keep || !drop) continue;
738
+ const longerNote = keep.note.length >= drop.note.length ? keep.note : drop.note;
739
+ const mergedTags = mergeTagLists(parseTags2(keep.tags), parseTags2(drop.tags));
740
+ const metadata = parseMetadata(keep.metadata_json);
741
+ const changelog = metadata.changelog ?? [];
742
+ changelog.push({
743
+ at: now,
744
+ action: "deduped",
745
+ similarity: Number(entry.similarity.toFixed(3)),
746
+ merged_from: drop.row_id,
747
+ previous_note: drop.note
748
+ });
749
+ metadata.changelog = changelog;
750
+ db.prepare(
751
+ `
752
+ UPDATE memories
753
+ SET note = ?, note_normalized = ?, tags = ?, metadata_json = ?, updated_at = ?,
754
+ pinned = CASE WHEN pinned = 1 OR ? = 1 THEN 1 ELSE pinned END
755
+ WHERE rowid = ?
756
+ `
757
+ ).run(
758
+ longerNote,
759
+ normalizeText(longerNote),
760
+ JSON.stringify(mergedTags),
761
+ JSON.stringify(metadata),
762
+ now,
763
+ drop.pinned,
764
+ keep.row_id
765
+ );
766
+ db.prepare("DELETE FROM memories WHERE rowid = ?").run(drop.row_id);
767
+ merged += 1;
768
+ }
769
+ });
770
+ tx(plan);
771
+ return {
772
+ content: [
773
+ {
774
+ type: "text",
775
+ text: `Merged ${merged} duplicate pair(s) in ${resolved.canonical}.`
776
+ }
777
+ ]
778
+ };
779
+ } catch (error) {
780
+ const message = error instanceof Error ? error.message : "Unknown error while deduping repo.";
781
+ return {
782
+ isError: true,
783
+ content: [
784
+ {
785
+ type: "text",
786
+ text: `Failed to dedupe repo: ${message}`
787
+ }
788
+ ]
789
+ };
790
+ }
791
+ }
792
+ );
793
+ }
794
+ var dedupeRepoInputSchema;
795
+ var init_dedupe_repo = __esm({
796
+ "src/tools/dedupe-repo.ts"() {
797
+ "use strict";
798
+ init_client();
799
+ init_dedupe();
800
+ init_repo();
801
+ dedupeRepoInputSchema = {
802
+ repo: z.string().trim().min(1).optional(),
803
+ threshold: z.number().min(0.5).max(1).default(0.85),
804
+ apply: z.boolean().default(false)
805
+ };
806
+ }
807
+ });
808
+
809
+ // src/tools/delete.ts
810
+ import { z as z2 } from "zod";
164
811
  function registerDeleteMemoryTool(server) {
165
812
  server.registerTool(
166
813
  "delete_memory",
@@ -216,14 +863,644 @@ var init_delete = __esm({
216
863
  "use strict";
217
864
  init_client();
218
865
  deleteMemoryInputSchema = {
219
- id: z.string().trim().min(1, "id is required")
866
+ id: z2.string().trim().min(1, "id is required")
867
+ };
868
+ }
869
+ });
870
+
871
+ // src/tools/get-context.ts
872
+ import { z as z3 } from "zod";
873
+ function registerGetContextTool(server) {
874
+ server.registerTool(
875
+ "get_context",
876
+ {
877
+ 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.",
878
+ inputSchema: getContextInputSchema
879
+ },
880
+ async ({ repo, query, limit, format }) => {
881
+ try {
882
+ const db = getDb();
883
+ const resolved = resolveRepoArg(repo, process.cwd(), db);
884
+ const rows = fetchRepoContext(db, resolved.canonical, limit, query);
885
+ const text = formatContext(rows, {
886
+ repo: resolved.canonical,
887
+ query,
888
+ format
889
+ });
890
+ return {
891
+ content: [
892
+ {
893
+ type: "text",
894
+ text
895
+ }
896
+ ]
897
+ };
898
+ } catch (error) {
899
+ const message = error instanceof Error ? error.message : "Unknown error while fetching context.";
900
+ return {
901
+ isError: true,
902
+ content: [
903
+ {
904
+ type: "text",
905
+ text: `Failed to fetch context: ${message}`
906
+ }
907
+ ]
908
+ };
909
+ }
910
+ }
911
+ );
912
+ }
913
+ var getContextInputSchema;
914
+ var init_get_context = __esm({
915
+ "src/tools/get-context.ts"() {
916
+ "use strict";
917
+ init_client();
918
+ init_context();
919
+ init_repo();
920
+ getContextInputSchema = {
921
+ repo: z3.string().trim().min(1).optional(),
922
+ query: z3.string().trim().min(1).optional(),
923
+ limit: z3.number().int().positive().max(50).default(8),
924
+ format: z3.enum(["text", "markdown"]).default("text")
220
925
  };
221
926
  }
222
927
  });
223
928
 
224
929
  // src/tools/get-repo.ts
225
- import { z as z2 } from "zod";
226
- function parseTags(raw) {
930
+ import { z as z4 } from "zod";
931
+ function parseTags3(raw) {
932
+ try {
933
+ const parsed = JSON.parse(raw);
934
+ return Array.isArray(parsed) ? parsed.filter((value) => typeof value === "string") : [];
935
+ } catch {
936
+ return [];
937
+ }
938
+ }
939
+ function formatTypeHeading(type) {
940
+ return type.split("_").map((part) => part[0]?.toUpperCase() + part.slice(1)).join(" ");
941
+ }
942
+ function registerGetRepoContextTool(server) {
943
+ server.registerTool(
944
+ "get_repo_context",
945
+ {
946
+ 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.",
947
+ inputSchema: getRepoContextInputSchema
948
+ },
949
+ async ({ repo, limit }) => {
950
+ try {
951
+ const db = getDb();
952
+ const resolved = resolveRepoArg(repo, process.cwd(), db);
953
+ const rows = db.prepare(
954
+ `
955
+ SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned
956
+ FROM memories
957
+ WHERE repo = ?
958
+ ORDER BY pinned DESC, updated_at DESC
959
+ LIMIT ?
960
+ `
961
+ ).all(resolved.canonical, limit);
962
+ if (rows.length === 0) {
963
+ return {
964
+ content: [
965
+ {
966
+ type: "text",
967
+ text: `No memories found for ${resolved.canonical}.`
968
+ }
969
+ ]
970
+ };
971
+ }
972
+ const grouped = /* @__PURE__ */ new Map();
973
+ for (const memory of rows) {
974
+ const tags = parseTags3(memory.tags);
975
+ const tagSuffix = tags.length > 0 ? ` [tags: ${tags.join(", ")}]` : "";
976
+ const pinPrefix = memory.pinned ? "\u{1F4CC} Pinned " : "";
977
+ const item = `- (${memory.row_id} | legacy: ${memory.id}) ${pinPrefix}${memory.note}${tagSuffix}`;
978
+ const existing = grouped.get(memory.type) ?? [];
979
+ existing.push(item);
980
+ grouped.set(memory.type, existing);
981
+ }
982
+ const sections = [];
983
+ for (const type of MEMORY_TYPES) {
984
+ const entries = grouped.get(type);
985
+ if (!entries || entries.length === 0) {
986
+ continue;
987
+ }
988
+ sections.push(`${formatTypeHeading(type)}
989
+ ${entries.join("\n")}`);
990
+ }
991
+ return {
992
+ content: [
993
+ {
994
+ type: "text",
995
+ text: `Repository context for ${resolved.canonical}
996
+ Total memories: ${rows.length}
997
+
998
+ ${sections.join("\n\n")}`
999
+ }
1000
+ ]
1001
+ };
1002
+ } catch (error) {
1003
+ const message = error instanceof Error ? error.message : "Unknown error while retrieving repository context.";
1004
+ return {
1005
+ isError: true,
1006
+ content: [
1007
+ {
1008
+ type: "text",
1009
+ text: `Failed to fetch repository context: ${message}`
1010
+ }
1011
+ ]
1012
+ };
1013
+ }
1014
+ }
1015
+ );
1016
+ }
1017
+ var getRepoContextInputSchema;
1018
+ var init_get_repo = __esm({
1019
+ "src/tools/get-repo.ts"() {
1020
+ "use strict";
1021
+ init_client();
1022
+ init_repo();
1023
+ getRepoContextInputSchema = {
1024
+ repo: z4.string().trim().min(1).optional(),
1025
+ limit: z4.number().int().positive().max(100).default(10)
1026
+ };
1027
+ }
1028
+ });
1029
+
1030
+ // src/tools/pin.ts
1031
+ import { z as z5 } from "zod";
1032
+ function setPinnedState(memoryId, pinned) {
1033
+ const db = getDb();
1034
+ const now = Math.floor(Date.now() / 1e3);
1035
+ const updateResult = db.prepare(
1036
+ `
1037
+ UPDATE memories
1038
+ SET pinned = ?, updated_at = ?
1039
+ WHERE rowid = ?
1040
+ `
1041
+ ).run(pinned, now, memoryId);
1042
+ if (updateResult.changes === 0) {
1043
+ return null;
1044
+ }
1045
+ return db.prepare(
1046
+ `
1047
+ SELECT rowid AS row_id, note, pinned
1048
+ FROM memories
1049
+ WHERE rowid = ?
1050
+ `
1051
+ ).get(memoryId);
1052
+ }
1053
+ function registerPinMemoryTool(server) {
1054
+ server.registerTool(
1055
+ "pin_memory",
1056
+ {
1057
+ description: "Pin a memory to keep it at the top of repository context.",
1058
+ inputSchema: pinInputSchema
1059
+ },
1060
+ async ({ id }) => {
1061
+ try {
1062
+ const memory = setPinnedState(id, 1);
1063
+ if (!memory) {
1064
+ return {
1065
+ isError: true,
1066
+ content: [
1067
+ {
1068
+ type: "text",
1069
+ text: `Memory ${id} not found.`
1070
+ }
1071
+ ]
1072
+ };
1073
+ }
1074
+ return {
1075
+ content: [
1076
+ {
1077
+ type: "text",
1078
+ text: `Pinned memory ${memory.row_id}: ${memory.note}`
1079
+ }
1080
+ ]
1081
+ };
1082
+ } catch (error) {
1083
+ const message = error instanceof Error ? error.message : "Unknown error while pinning memory.";
1084
+ return {
1085
+ isError: true,
1086
+ content: [
1087
+ {
1088
+ type: "text",
1089
+ text: `Failed to pin memory: ${message}`
1090
+ }
1091
+ ]
1092
+ };
1093
+ }
1094
+ }
1095
+ );
1096
+ }
1097
+ function registerUnpinMemoryTool(server) {
1098
+ server.registerTool(
1099
+ "unpin_memory",
1100
+ {
1101
+ description: "Unpin a previously pinned memory.",
1102
+ inputSchema: pinInputSchema
1103
+ },
1104
+ async ({ id }) => {
1105
+ try {
1106
+ const memory = setPinnedState(id, 0);
1107
+ if (!memory) {
1108
+ return {
1109
+ isError: true,
1110
+ content: [
1111
+ {
1112
+ type: "text",
1113
+ text: `Memory ${id} not found.`
1114
+ }
1115
+ ]
1116
+ };
1117
+ }
1118
+ return {
1119
+ content: [
1120
+ {
1121
+ type: "text",
1122
+ text: `Unpinned memory ${memory.row_id}.`
1123
+ }
1124
+ ]
1125
+ };
1126
+ } catch (error) {
1127
+ const message = error instanceof Error ? error.message : "Unknown error while unpinning memory.";
1128
+ return {
1129
+ isError: true,
1130
+ content: [
1131
+ {
1132
+ type: "text",
1133
+ text: `Failed to unpin memory: ${message}`
1134
+ }
1135
+ ]
1136
+ };
1137
+ }
1138
+ }
1139
+ );
1140
+ }
1141
+ var pinInputSchema;
1142
+ var init_pin = __esm({
1143
+ "src/tools/pin.ts"() {
1144
+ "use strict";
1145
+ init_client();
1146
+ pinInputSchema = {
1147
+ id: z5.number().int().positive()
1148
+ };
1149
+ }
1150
+ });
1151
+
1152
+ // src/lib/inference.ts
1153
+ function inferMemoryType(text) {
1154
+ const scores = /* @__PURE__ */ new Map();
1155
+ for (const rule of TYPE_RULES) {
1156
+ let score = 0;
1157
+ for (const { pattern, weight } of rule.patterns) {
1158
+ if (pattern.test(text)) {
1159
+ score += weight;
1160
+ }
1161
+ }
1162
+ if (score > 0) {
1163
+ scores.set(rule.type, (scores.get(rule.type) ?? 0) + score);
1164
+ }
1165
+ }
1166
+ if (AUTH_KEYWORDS.test(text)) {
1167
+ if (CHOICE_KEYWORDS.test(text)) {
1168
+ scores.set("decision", (scores.get("decision") ?? 0) + 3);
1169
+ } else {
1170
+ scores.set("convention", (scores.get("convention") ?? 0) + 2);
1171
+ }
1172
+ }
1173
+ if (scores.size === 0) {
1174
+ return "convention";
1175
+ }
1176
+ let bestType = "convention";
1177
+ let bestScore = -1;
1178
+ for (const [type, score] of scores) {
1179
+ if (score > bestScore) {
1180
+ bestType = type;
1181
+ bestScore = score;
1182
+ }
1183
+ }
1184
+ return bestType;
1185
+ }
1186
+ function extractKeywordTags(text) {
1187
+ const found = [];
1188
+ for (const { tag, pattern } of TAG_KEYWORDS) {
1189
+ if (pattern.test(text)) {
1190
+ found.push(tag);
1191
+ }
1192
+ }
1193
+ return found;
1194
+ }
1195
+ function extractIdentifierTags(text) {
1196
+ const tokens = /* @__PURE__ */ new Set();
1197
+ const pathLike = text.match(/\/(?:[a-z0-9_-]+\/?){1,4}/gi);
1198
+ if (pathLike) {
1199
+ for (const segment of pathLike) {
1200
+ for (const part of segment.split("/")) {
1201
+ if (part.length >= 3 && /^[a-z0-9_-]+$/i.test(part)) {
1202
+ tokens.add(part.toLowerCase());
1203
+ }
1204
+ }
1205
+ }
1206
+ }
1207
+ 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);
1208
+ if (fileLike) {
1209
+ for (const file of fileLike) {
1210
+ const base = file.split(".").slice(0, -1).join(".");
1211
+ if (base.length >= 3) {
1212
+ tokens.add(base.toLowerCase());
1213
+ }
1214
+ }
1215
+ }
1216
+ return Array.from(tokens);
1217
+ }
1218
+ function extractSalientWords(text, limit) {
1219
+ const words = text.toLowerCase().replace(/[^a-z0-9\s/_-]/g, " ").split(/\s+/).filter((word) => word.length >= 4 && !STOP_WORDS.has(word));
1220
+ const counts = /* @__PURE__ */ new Map();
1221
+ for (const word of words) {
1222
+ counts.set(word, (counts.get(word) ?? 0) + 1);
1223
+ }
1224
+ return Array.from(counts.entries()).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, limit).map(([word]) => word);
1225
+ }
1226
+ function inferTags(text) {
1227
+ const ordered = [];
1228
+ const seen = /* @__PURE__ */ new Set();
1229
+ const push = (value) => {
1230
+ const normalized = value.trim().toLowerCase();
1231
+ if (!normalized || seen.has(normalized)) {
1232
+ return;
1233
+ }
1234
+ seen.add(normalized);
1235
+ ordered.push(normalized);
1236
+ };
1237
+ for (const tag of extractKeywordTags(text)) {
1238
+ push(tag);
1239
+ }
1240
+ for (const tag of extractIdentifierTags(text)) {
1241
+ push(tag);
1242
+ }
1243
+ if (ordered.length < 5) {
1244
+ for (const word of extractSalientWords(text, 8)) {
1245
+ push(word);
1246
+ if (ordered.length >= 5) {
1247
+ break;
1248
+ }
1249
+ }
1250
+ }
1251
+ return ordered.slice(0, 5);
1252
+ }
1253
+ function inferMemoryFromNote(text) {
1254
+ return {
1255
+ type: inferMemoryType(text),
1256
+ tags: inferTags(text)
1257
+ };
1258
+ }
1259
+ var TYPE_RULES, AUTH_KEYWORDS, CHOICE_KEYWORDS, TAG_KEYWORDS, STOP_WORDS;
1260
+ var init_inference = __esm({
1261
+ "src/lib/inference.ts"() {
1262
+ "use strict";
1263
+ TYPE_RULES = [
1264
+ {
1265
+ type: "bug_fix",
1266
+ patterns: [
1267
+ { pattern: /\broot cause\b/i, weight: 4 },
1268
+ { pattern: /\bregression\b/i, weight: 4 },
1269
+ { pattern: /\bhotfix\b/i, weight: 4 },
1270
+ { pattern: /\bfix(?:ed|es|ing)?\b/i, weight: 3 },
1271
+ { pattern: /\bbugs?\b/i, weight: 2 },
1272
+ { pattern: /\bcrash(?:ed|es|ing)?\b/i, weight: 2 },
1273
+ { pattern: /\bbroken\b/i, weight: 2 },
1274
+ { pattern: /\bworkaround\b/i, weight: 2 }
1275
+ ]
1276
+ },
1277
+ {
1278
+ type: "issue",
1279
+ patterns: [
1280
+ { pattern: /\bissue\s*#\d+/i, weight: 5 },
1281
+ { pattern: /\bticket\s*#?\w+/i, weight: 4 },
1282
+ { pattern: /\bjira[-\s]?\w+/i, weight: 4 },
1283
+ { pattern: /\bgh[-\s]?\d+/i, weight: 3 },
1284
+ { pattern: /#\d{2,}/i, weight: 2 }
1285
+ ]
1286
+ },
1287
+ {
1288
+ type: "decision",
1289
+ patterns: [
1290
+ { pattern: /\bdecided not to\b/i, weight: 5 },
1291
+ { pattern: /\bdecided to\b/i, weight: 4 },
1292
+ { pattern: /\bwe chose\b/i, weight: 4 },
1293
+ { pattern: /\bchose\s+\w+\s+over\b/i, weight: 4 },
1294
+ { pattern: /\barchitecture\b/i, weight: 3 },
1295
+ { pattern: /\bdecision\b/i, weight: 3 },
1296
+ { pattern: /\btrade[- ]?off\b/i, weight: 2 },
1297
+ { pattern: /\brfc\b/i, weight: 2 },
1298
+ { pattern: /\b(?:adopted|migrated to)\b/i, weight: 2 }
1299
+ ]
1300
+ },
1301
+ {
1302
+ type: "reviewer_pattern",
1303
+ patterns: [
1304
+ { pattern: /\breviewer(?:s)?\s+(?:prefer|want|expect|require)/i, weight: 5 },
1305
+ { pattern: /\bpr\s+style\b/i, weight: 4 },
1306
+ { pattern: /\bcode review\b/i, weight: 3 },
1307
+ { pattern: /\bprefer(?:s|red)?\b/i, weight: 2 },
1308
+ { pattern: /\breview comment\b/i, weight: 2 }
1309
+ ]
1310
+ },
1311
+ {
1312
+ type: "convention",
1313
+ patterns: [
1314
+ { pattern: /\bconvention\b/i, weight: 4 },
1315
+ { pattern: /\balways\b/i, weight: 2 },
1316
+ { pattern: /\bnever\b/i, weight: 2 },
1317
+ { pattern: /\bstandard\b/i, weight: 2 },
1318
+ { pattern: /\bstyle guide\b/i, weight: 3 },
1319
+ { pattern: /\buse\b\s+\w+\s+\bfor\b/i, weight: 1 }
1320
+ ]
1321
+ }
1322
+ ];
1323
+ AUTH_KEYWORDS = /\b(?:auth|jwt|oauth|token|login|logout|session|sso|saml)\b/i;
1324
+ CHOICE_KEYWORDS = /\b(?:chose|choose|decided|prefer|switched|migrated|adopted|over|instead of)\b/i;
1325
+ TAG_KEYWORDS = [
1326
+ { tag: "auth", pattern: /\b(?:auth|authentication|authorization)\b/i },
1327
+ { tag: "jwt", pattern: /\bjwt\b/i },
1328
+ { tag: "oauth", pattern: /\boauth\b/i },
1329
+ { tag: "session", pattern: /\bsession(?:s)?\b/i },
1330
+ { tag: "api", pattern: /\bapi\b/i },
1331
+ { tag: "rest", pattern: /\brest(?:ful)?\b/i },
1332
+ { tag: "graphql", pattern: /\bgraphql\b/i },
1333
+ { tag: "websocket", pattern: /\bweb[- ]?socket(?:s)?\b/i },
1334
+ { tag: "database", pattern: /\b(?:database|db|sqlite|postgres|mysql|mongo)\b/i },
1335
+ { tag: "migration", pattern: /\bmigration(?:s)?\b/i },
1336
+ { tag: "schema", pattern: /\bschema\b/i },
1337
+ { tag: "frontend", pattern: /\b(?:frontend|ui|react|vue|svelte|next\.js|nextjs)\b/i },
1338
+ { tag: "backend", pattern: /\b(?:backend|server|node\.js|nodejs|express|fastify)\b/i },
1339
+ { tag: "testing", pattern: /\b(?:test|tests|testing|jest|vitest|pytest|rspec)\b/i },
1340
+ { tag: "ci", pattern: /\b(?:ci|cd|pipeline|github actions|gitlab ci)\b/i },
1341
+ { tag: "deployment", pattern: /\b(?:deploy|deployment|release|rollout)\b/i },
1342
+ { tag: "performance", pattern: /\b(?:performance|perf|latency|throughput)\b/i },
1343
+ { tag: "security", pattern: /\b(?:security|vuln|cve|xss|csrf|injection)\b/i },
1344
+ { tag: "logging", pattern: /\b(?:log|logging|telemetry|tracing)\b/i },
1345
+ { tag: "config", pattern: /\b(?:config|configuration|env|environment)\b/i },
1346
+ { tag: "routing", pattern: /\b(?:route|routing|router|endpoint)\b/i },
1347
+ { tag: "build", pattern: /\b(?:build|webpack|vite|tsup|rollup|esbuild)\b/i },
1348
+ { tag: "docs", pattern: /\b(?:docs|documentation|readme)\b/i }
1349
+ ];
1350
+ STOP_WORDS = /* @__PURE__ */ new Set([
1351
+ "the",
1352
+ "a",
1353
+ "an",
1354
+ "and",
1355
+ "or",
1356
+ "but",
1357
+ "is",
1358
+ "are",
1359
+ "was",
1360
+ "were",
1361
+ "be",
1362
+ "been",
1363
+ "being",
1364
+ "to",
1365
+ "of",
1366
+ "in",
1367
+ "on",
1368
+ "for",
1369
+ "with",
1370
+ "by",
1371
+ "at",
1372
+ "from",
1373
+ "as",
1374
+ "that",
1375
+ "this",
1376
+ "it",
1377
+ "we",
1378
+ "our",
1379
+ "you",
1380
+ "your",
1381
+ "i",
1382
+ "my",
1383
+ "they",
1384
+ "their",
1385
+ "them",
1386
+ "he",
1387
+ "she",
1388
+ "his",
1389
+ "her",
1390
+ "if",
1391
+ "then",
1392
+ "than",
1393
+ "so",
1394
+ "do",
1395
+ "does",
1396
+ "did",
1397
+ "done",
1398
+ "not",
1399
+ "no",
1400
+ "yes",
1401
+ "can",
1402
+ "will",
1403
+ "would",
1404
+ "should",
1405
+ "could",
1406
+ "may",
1407
+ "might",
1408
+ "must",
1409
+ "have",
1410
+ "has",
1411
+ "had",
1412
+ "just",
1413
+ "also",
1414
+ "use",
1415
+ "used",
1416
+ "using",
1417
+ "want",
1418
+ "wants",
1419
+ "wanted",
1420
+ "need",
1421
+ "needs",
1422
+ "needed",
1423
+ "like",
1424
+ "now",
1425
+ "new",
1426
+ "old",
1427
+ "good",
1428
+ "bad",
1429
+ "make",
1430
+ "makes",
1431
+ "made",
1432
+ "get",
1433
+ "gets",
1434
+ "got",
1435
+ "set",
1436
+ "sets",
1437
+ "go",
1438
+ "going",
1439
+ "into",
1440
+ "over",
1441
+ "under",
1442
+ "through",
1443
+ "because",
1444
+ "when",
1445
+ "where",
1446
+ "while",
1447
+ "there",
1448
+ "here",
1449
+ "what",
1450
+ "which",
1451
+ "who",
1452
+ "why",
1453
+ "how",
1454
+ "live",
1455
+ "lives",
1456
+ "living",
1457
+ "keep",
1458
+ "kept",
1459
+ "keeps",
1460
+ "take",
1461
+ "takes",
1462
+ "took",
1463
+ "taken",
1464
+ "say",
1465
+ "says",
1466
+ "said",
1467
+ "tell",
1468
+ "tells",
1469
+ "told",
1470
+ "know",
1471
+ "knows",
1472
+ "known",
1473
+ "knew",
1474
+ "redirect",
1475
+ "redirects",
1476
+ "redirected",
1477
+ "redirecting",
1478
+ "user",
1479
+ "users",
1480
+ "page",
1481
+ "pages"
1482
+ ]);
1483
+ }
1484
+ });
1485
+
1486
+ // src/tools/remember.ts
1487
+ import { nanoid } from "nanoid";
1488
+ import { z as z6 } from "zod";
1489
+ function mergeTagLists2(...lists) {
1490
+ const seen = /* @__PURE__ */ new Set();
1491
+ const out = [];
1492
+ for (const list of lists) {
1493
+ if (!list) continue;
1494
+ for (const raw of list) {
1495
+ const value = raw.trim().toLowerCase();
1496
+ if (!value || seen.has(value)) continue;
1497
+ seen.add(value);
1498
+ out.push(value);
1499
+ }
1500
+ }
1501
+ return out;
1502
+ }
1503
+ function parseStoredTags(raw) {
227
1504
  try {
228
1505
  const parsed = JSON.parse(raw);
229
1506
  return Array.isArray(parsed) ? parsed.filter((value) => typeof value === "string") : [];
@@ -231,76 +1508,120 @@ function parseTags(raw) {
231
1508
  return [];
232
1509
  }
233
1510
  }
234
- function formatTypeHeading(type) {
235
- return type.split("_").map((part) => part[0]?.toUpperCase() + part.slice(1)).join(" ");
1511
+ function parseStoredMetadata(raw) {
1512
+ try {
1513
+ const parsed = JSON.parse(raw);
1514
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
1515
+ return parsed;
1516
+ }
1517
+ } catch {
1518
+ }
1519
+ return {};
236
1520
  }
237
- function registerGetRepoContextTool(server) {
1521
+ function registerRememberTool(server) {
238
1522
  server.registerTool(
239
- "get_repo_context",
1523
+ "remember",
240
1524
  {
241
- description: "Get recent memories for a repository grouped by memory type.",
242
- inputSchema: getRepoContextInputSchema
1525
+ 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.",
1526
+ inputSchema: rememberInputSchema
243
1527
  },
244
- async ({ repo, limit }) => {
1528
+ async ({ note, repo, type, tags }) => {
245
1529
  try {
246
1530
  const db = getDb();
247
- const rows = db.prepare(
248
- `
249
- SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned
250
- FROM memories
251
- WHERE repo = ?
252
- ORDER BY pinned DESC, updated_at DESC
253
- LIMIT ?
1531
+ const resolved = resolveRepoArg(repo, process.cwd(), db);
1532
+ const inferred = inferMemoryFromNote(note);
1533
+ const finalType = type ?? inferred.type;
1534
+ const finalTags = mergeTagLists2(tags, inferred.tags).slice(0, 5);
1535
+ const now = Math.floor(Date.now() / 1e3);
1536
+ const duplicate = findDuplicate(db, resolved.canonical, note);
1537
+ if (duplicate) {
1538
+ const existing = duplicate.memory;
1539
+ const existingTags = parseStoredTags(existing.tags);
1540
+ const mergedTags = mergeTagLists2(existingTags, finalTags);
1541
+ const metadata2 = parseStoredMetadata(
1542
+ existing.metadata_json ?? "{}"
1543
+ );
1544
+ const changelog = metadata2.changelog ?? [];
1545
+ changelog.push({
1546
+ at: now,
1547
+ action: "merged",
1548
+ similarity: Number(duplicate.similarity.toFixed(3)),
1549
+ previous_note: existing.note
1550
+ });
1551
+ metadata2.changelog = changelog;
1552
+ const longerNote = note.length > existing.note.length ? note : existing.note;
1553
+ const nextType = type ?? existing.type;
1554
+ db.prepare(
254
1555
  `
255
- ).all(repo, limit);
256
- if (rows.length === 0) {
1556
+ UPDATE memories
1557
+ SET note = ?, note_normalized = ?, tags = ?, type = ?, metadata_json = ?, updated_at = ?
1558
+ WHERE rowid = ?
1559
+ `
1560
+ ).run(
1561
+ longerNote,
1562
+ normalizeText(longerNote),
1563
+ JSON.stringify(mergedTags),
1564
+ nextType,
1565
+ JSON.stringify(metadata2),
1566
+ now,
1567
+ existing.row_id
1568
+ );
257
1569
  return {
258
1570
  content: [
259
1571
  {
260
1572
  type: "text",
261
- text: `No memories found for ${repo}.`
1573
+ text: `Merged into memory ${existing.row_id} for ${resolved.canonical} (similarity ${duplicate.similarity.toFixed(2)}, type ${nextType}, tags: ${mergedTags.join(", ") || "none"}).`
262
1574
  }
263
1575
  ]
264
1576
  };
265
1577
  }
266
- const grouped = /* @__PURE__ */ new Map();
267
- for (const memory of rows) {
268
- const tags = parseTags(memory.tags);
269
- const tagSuffix = tags.length > 0 ? ` [tags: ${tags.join(", ")}]` : "";
270
- const pinPrefix = memory.pinned ? "\u{1F4CC} Pinned " : "";
271
- const item = `- (${memory.row_id} | legacy: ${memory.id}) ${pinPrefix}${memory.note}${tagSuffix}`;
272
- const existing = grouped.get(memory.type) ?? [];
273
- existing.push(item);
274
- grouped.set(memory.type, existing);
275
- }
276
- const sections = [];
277
- for (const type of MEMORY_TYPES) {
278
- const entries = grouped.get(type);
279
- if (!entries || entries.length === 0) {
280
- continue;
1578
+ const id = nanoid();
1579
+ const metadata = {
1580
+ changelog: [
1581
+ {
1582
+ at: now,
1583
+ action: "created"
1584
+ }
1585
+ ],
1586
+ inferred: {
1587
+ type: inferred.type,
1588
+ tags: inferred.tags,
1589
+ type_overridden: type !== void 0
281
1590
  }
282
- sections.push(`${formatTypeHeading(type)}
283
- ${entries.join("\n")}`);
284
- }
1591
+ };
1592
+ db.prepare(
1593
+ `
1594
+ INSERT INTO memories (id, repo, type, note, tags, created_at, updated_at, pinned, metadata_json, note_normalized)
1595
+ VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?)
1596
+ `
1597
+ ).run(
1598
+ id,
1599
+ resolved.canonical,
1600
+ finalType,
1601
+ note,
1602
+ JSON.stringify(finalTags),
1603
+ now,
1604
+ now,
1605
+ JSON.stringify(metadata),
1606
+ normalizeText(note)
1607
+ );
1608
+ const inserted = db.prepare("SELECT rowid AS row_id FROM memories WHERE id = ?").get(id);
285
1609
  return {
286
1610
  content: [
287
1611
  {
288
1612
  type: "text",
289
- text: `Repository context for ${repo}
290
- Total memories: ${rows.length}
291
-
292
- ${sections.join("\n\n")}`
1613
+ text: `Stored memory ${inserted?.row_id ?? "?"} for ${resolved.canonical} (type ${finalType}, tags: ${finalTags.join(", ") || "none"}).`
293
1614
  }
294
1615
  ]
295
1616
  };
296
1617
  } catch (error) {
297
- const message = error instanceof Error ? error.message : "Unknown error while retrieving repository context.";
1618
+ const message = error instanceof Error ? error.message : "Unknown error while remembering note.";
298
1619
  return {
299
1620
  isError: true,
300
1621
  content: [
301
1622
  {
302
1623
  type: "text",
303
- text: `Failed to fetch repository context: ${message}`
1624
+ text: `Failed to remember note: ${message}`
304
1625
  }
305
1626
  ]
306
1627
  };
@@ -308,122 +1629,60 @@ ${sections.join("\n\n")}`
308
1629
  }
309
1630
  );
310
1631
  }
311
- var getRepoContextInputSchema;
312
- var init_get_repo = __esm({
313
- "src/tools/get-repo.ts"() {
1632
+ var rememberInputSchema;
1633
+ var init_remember = __esm({
1634
+ "src/tools/remember.ts"() {
314
1635
  "use strict";
315
1636
  init_client();
316
- getRepoContextInputSchema = {
317
- repo: z2.string().trim().min(1, "repo is required"),
318
- limit: z2.number().int().positive().max(100).default(10)
1637
+ init_dedupe();
1638
+ init_inference();
1639
+ init_repo();
1640
+ rememberInputSchema = {
1641
+ note: z6.string().trim().min(1, "note is required"),
1642
+ repo: z6.string().trim().min(1).optional(),
1643
+ type: z6.enum(MEMORY_TYPES).optional(),
1644
+ tags: z6.array(z6.string().trim().min(1)).optional()
319
1645
  };
320
1646
  }
321
1647
  });
322
1648
 
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;
337
- }
338
- return db.prepare(
339
- `
340
- SELECT rowid AS row_id, note, pinned
341
- FROM memories
342
- WHERE rowid = ?
343
- `
344
- ).get(memoryId);
345
- }
346
- function registerPinMemoryTool(server) {
1649
+ // src/tools/resolve-repo.ts
1650
+ import { z as z7 } from "zod";
1651
+ function registerResolveRepoTool(server) {
347
1652
  server.registerTool(
348
- "pin_memory",
1653
+ "resolve_repo",
349
1654
  {
350
- description: "Pin a memory to keep it at the top of repository context.",
351
- inputSchema: pinInputSchema
1655
+ 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.",
1656
+ inputSchema: resolveRepoInputSchema
352
1657
  },
353
- async ({ id }) => {
1658
+ async ({ cwd }) => {
354
1659
  try {
355
- const memory = setPinnedState(id, 1);
356
- if (!memory) {
357
- return {
358
- isError: true,
359
- content: [
360
- {
361
- type: "text",
362
- text: `Memory ${id} not found.`
363
- }
364
- ]
365
- };
366
- }
367
- return {
368
- content: [
369
- {
370
- type: "text",
371
- text: `Pinned memory ${memory.row_id}: ${memory.note}`
372
- }
373
- ]
374
- };
375
- } catch (error) {
376
- const message = error instanceof Error ? error.message : "Unknown error while pinning memory.";
377
- return {
378
- isError: true,
379
- content: [
380
- {
381
- type: "text",
382
- text: `Failed to pin memory: ${message}`
383
- }
384
- ]
1660
+ const db = getDb();
1661
+ const target = cwd?.trim() || process.cwd();
1662
+ const resolved = resolveRepo(target, db);
1663
+ const payload = {
1664
+ canonical: resolved.canonical,
1665
+ aliases: resolved.aliases,
1666
+ cwd: resolved.cwd,
1667
+ gitRemote: resolved.gitRemote,
1668
+ source: resolved.source
385
1669
  };
386
- }
387
- }
388
- );
389
- }
390
- function registerUnpinMemoryTool(server) {
391
- server.registerTool(
392
- "unpin_memory",
393
- {
394
- description: "Unpin a previously pinned memory.",
395
- inputSchema: pinInputSchema
396
- },
397
- async ({ id }) => {
398
- 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
- }
411
1670
  return {
412
1671
  content: [
413
1672
  {
414
1673
  type: "text",
415
- text: `Unpinned memory ${memory.row_id}.`
1674
+ text: JSON.stringify(payload, null, 2)
416
1675
  }
417
1676
  ]
418
1677
  };
419
1678
  } catch (error) {
420
- const message = error instanceof Error ? error.message : "Unknown error while unpinning memory.";
1679
+ const message = error instanceof Error ? error.message : "Unknown error while resolving repo.";
421
1680
  return {
422
1681
  isError: true,
423
1682
  content: [
424
1683
  {
425
1684
  type: "text",
426
- text: `Failed to unpin memory: ${message}`
1685
+ text: `Failed to resolve repo: ${message}`
427
1686
  }
428
1687
  ]
429
1688
  };
@@ -431,19 +1690,20 @@ function registerUnpinMemoryTool(server) {
431
1690
  }
432
1691
  );
433
1692
  }
434
- var pinInputSchema;
435
- var init_pin = __esm({
436
- "src/tools/pin.ts"() {
1693
+ var resolveRepoInputSchema;
1694
+ var init_resolve_repo = __esm({
1695
+ "src/tools/resolve-repo.ts"() {
437
1696
  "use strict";
438
1697
  init_client();
439
- pinInputSchema = {
440
- id: z3.number().int().positive()
1698
+ init_repo();
1699
+ resolveRepoInputSchema = {
1700
+ cwd: z7.string().trim().min(1).optional()
441
1701
  };
442
1702
  }
443
1703
  });
444
1704
 
445
1705
  // src/tools/search.ts
446
- import { z as z4 } from "zod";
1706
+ import { z as z8 } from "zod";
447
1707
  function normalizeFtsQuery(query) {
448
1708
  const terms = query.trim().split(/\s+/).map((term) => term.replaceAll('"', '""')).filter(Boolean);
449
1709
  if (terms.length === 0) {
@@ -451,7 +1711,7 @@ function normalizeFtsQuery(query) {
451
1711
  }
452
1712
  return terms.map((term) => `"${term}"`).join(" AND ");
453
1713
  }
454
- function parseTags2(raw) {
1714
+ function parseTags4(raw) {
455
1715
  try {
456
1716
  const parsed = JSON.parse(raw);
457
1717
  return Array.isArray(parsed) ? parsed.filter((value) => typeof value === "string") : [];
@@ -470,7 +1730,8 @@ function registerSearchMemoryTool(server) {
470
1730
  try {
471
1731
  const db = getDb();
472
1732
  const ftsQuery = normalizeFtsQuery(query);
473
- const rows = repo ? db.prepare(
1733
+ const resolvedRepo = repo ? resolveRepoArg(repo, process.cwd(), db).canonical : void 0;
1734
+ const rows = resolvedRepo ? db.prepare(
474
1735
  `
475
1736
  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
1737
  FROM memories_fts
@@ -479,7 +1740,7 @@ function registerSearchMemoryTool(server) {
479
1740
  ORDER BY rank
480
1741
  LIMIT ?
481
1742
  `
482
- ).all(ftsQuery, repo, limit) : db.prepare(
1743
+ ).all(ftsQuery, resolvedRepo, limit) : db.prepare(
483
1744
  `
484
1745
  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
1746
  FROM memories_fts
@@ -494,13 +1755,13 @@ function registerSearchMemoryTool(server) {
494
1755
  content: [
495
1756
  {
496
1757
  type: "text",
497
- text: repo ? `No memories matched "${query}" in ${repo}.` : `No memories matched "${query}".`
1758
+ text: resolvedRepo ? `No memories matched "${query}" in ${resolvedRepo}.` : `No memories matched "${query}".`
498
1759
  }
499
1760
  ]
500
1761
  };
501
1762
  }
502
1763
  const formatted = rows.map((row, index) => {
503
- const tags = parseTags2(row.tags);
1764
+ const tags = parseTags4(row.tags);
504
1765
  const tagsText = tags.length > 0 ? ` | tags: ${tags.join(", ")}` : "";
505
1766
  const pinPrefix = row.pinned ? "\u{1F4CC} Pinned " : "";
506
1767
  return `${index + 1}. [${row.repo}] ${row.type} (${row.row_id} | legacy: ${row.id})
@@ -510,7 +1771,7 @@ ${pinPrefix}${row.note}${tagsText}`;
510
1771
  content: [
511
1772
  {
512
1773
  type: "text",
513
- text: `Search results for "${query}"${repo ? ` in ${repo}` : ""}:
1774
+ text: `Search results for "${query}"${resolvedRepo ? ` in ${resolvedRepo}` : ""}:
514
1775
 
515
1776
  ${formatted}`
516
1777
  }
@@ -536,38 +1797,49 @@ var init_search = __esm({
536
1797
  "src/tools/search.ts"() {
537
1798
  "use strict";
538
1799
  init_client();
1800
+ init_repo();
539
1801
  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)
1802
+ query: z8.string().trim().min(1, "query is required"),
1803
+ repo: z8.string().trim().min(1).optional(),
1804
+ limit: z8.number().int().positive().max(50).default(5)
543
1805
  };
544
1806
  }
545
1807
  });
546
1808
 
547
1809
  // src/tools/store.ts
548
- import { nanoid } from "nanoid";
549
- import { z as z5 } from "zod";
1810
+ import { nanoid as nanoid2 } from "nanoid";
1811
+ import { z as z9 } from "zod";
550
1812
  function registerStoreContextTool(server) {
551
1813
  server.registerTool(
552
1814
  "store_context",
553
1815
  {
554
- description: "Store repository-specific contributor context such as bug fixes, conventions, and decisions.",
1816
+ 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
1817
  inputSchema: storeContextInputSchema
556
1818
  },
557
1819
  async ({ repo, type, note, tags }) => {
558
1820
  try {
559
1821
  const db = getDb();
1822
+ const resolved = resolveRepoArg(repo, process.cwd(), db);
560
1823
  const now = Math.floor(Date.now() / 1e3);
561
- const id = nanoid();
1824
+ const id = nanoid2();
562
1825
  const normalizedTags = Array.from(
563
1826
  new Set((tags ?? []).map((tag) => tag.trim()).filter(Boolean))
564
1827
  );
565
1828
  db.prepare(
566
1829
  `
567
- INSERT INTO memories (id, repo, type, note, tags, created_at, updated_at)
568
- VALUES (?, ?, ?, ?, ?, ?, ?)
1830
+ INSERT INTO memories (id, repo, type, note, tags, created_at, updated_at, pinned, metadata_json, note_normalized)
1831
+ VALUES (?, ?, ?, ?, ?, ?, ?, 0, '{}', ?)
569
1832
  `
570
- ).run(id, repo, type, note, JSON.stringify(normalizedTags), now, now);
1833
+ ).run(
1834
+ id,
1835
+ resolved.canonical,
1836
+ type,
1837
+ note,
1838
+ JSON.stringify(normalizedTags),
1839
+ now,
1840
+ now,
1841
+ normalizeText(note)
1842
+ );
571
1843
  const stored = db.prepare(
572
1844
  `
573
1845
  SELECT rowid AS row_id, id
@@ -579,7 +1851,7 @@ function registerStoreContextTool(server) {
579
1851
  content: [
580
1852
  {
581
1853
  type: "text",
582
- text: `Stored memory ${id} (numeric id: ${stored?.row_id ?? "unknown"}) for ${repo} (${type}).`
1854
+ text: `Stored memory ${id} (numeric id: ${stored?.row_id ?? "unknown"}) for ${resolved.canonical} (${type}).`
583
1855
  }
584
1856
  ]
585
1857
  };
@@ -603,17 +1875,19 @@ var init_store = __esm({
603
1875
  "src/tools/store.ts"() {
604
1876
  "use strict";
605
1877
  init_client();
1878
+ init_dedupe();
1879
+ init_repo();
606
1880
  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()
1881
+ repo: z9.string().trim().min(1).optional(),
1882
+ type: z9.enum(MEMORY_TYPES),
1883
+ note: z9.string().trim().min(1, "note is required"),
1884
+ tags: z9.array(z9.string().trim().min(1)).optional()
611
1885
  };
612
1886
  }
613
1887
  });
614
1888
 
615
1889
  // src/tools/summarize.ts
616
- import { z as z6 } from "zod";
1890
+ import { z as z10 } from "zod";
617
1891
  function registerSummarizeRepoContextTool(server) {
618
1892
  server.registerTool(
619
1893
  "summarize_repo_context",
@@ -624,6 +1898,7 @@ function registerSummarizeRepoContextTool(server) {
624
1898
  async ({ repo }) => {
625
1899
  try {
626
1900
  const db = getDb();
1901
+ const resolved = resolveRepoArg(repo, process.cwd(), db);
627
1902
  const rows = db.prepare(
628
1903
  `
629
1904
  SELECT rowid AS row_id, type, note, pinned
@@ -631,13 +1906,13 @@ function registerSummarizeRepoContextTool(server) {
631
1906
  WHERE repo = ?
632
1907
  ORDER BY pinned DESC, updated_at DESC
633
1908
  `
634
- ).all(repo);
1909
+ ).all(resolved.canonical);
635
1910
  if (rows.length === 0) {
636
1911
  return {
637
1912
  content: [
638
1913
  {
639
1914
  type: "text",
640
- text: `Fossel Context Summary: ${repo}
1915
+ text: `Fossel Context Summary: ${resolved.canonical}
641
1916
 
642
1917
  No memories found.`
643
1918
  }
@@ -645,7 +1920,7 @@ No memories found.`
645
1920
  };
646
1921
  }
647
1922
  const pinnedLines = rows.filter((row) => row.pinned === 1).map((row) => `- (${row.row_id}) ${row.note}`);
648
- const sections = [`Fossel Context Summary: ${repo}`];
1923
+ const sections = [`Fossel Context Summary: ${resolved.canonical}`];
649
1924
  if (pinnedLines.length > 0) {
650
1925
  sections.push(`\u{1F4CC} Pinned
651
1926
  ${pinnedLines.join("\n")}`);
@@ -686,8 +1961,9 @@ var init_summarize = __esm({
686
1961
  "src/tools/summarize.ts"() {
687
1962
  "use strict";
688
1963
  init_client();
1964
+ init_repo();
689
1965
  summarizeRepoContextInputSchema = {
690
- repo: z6.string().trim().min(1, "repo is required")
1966
+ repo: z10.string().trim().min(1).optional()
691
1967
  };
692
1968
  sectionTitleByType = {
693
1969
  convention: "Conventions",
@@ -701,8 +1977,8 @@ var init_summarize = __esm({
701
1977
  });
702
1978
 
703
1979
  // src/tools/update.ts
704
- import { z as z7 } from "zod";
705
- function parseTags3(raw) {
1980
+ import { z as z11 } from "zod";
1981
+ function parseTags5(raw) {
706
1982
  try {
707
1983
  const parsed = JSON.parse(raw);
708
1984
  return Array.isArray(parsed) ? parsed.filter((value) => typeof value === "string") : [];
@@ -711,7 +1987,7 @@ function parseTags3(raw) {
711
1987
  }
712
1988
  }
713
1989
  function formatMemory(memory) {
714
- const tags = parseTags3(memory.tags);
1990
+ const tags = parseTags5(memory.tags);
715
1991
  const tagsLine = tags.length > 0 ? tags.join(", ") : "(none)";
716
1992
  return [
717
1993
  `Memory ${memory.row_id} updated successfully.`,
@@ -768,13 +2044,24 @@ function registerUpdateMemoryTool(server) {
768
2044
  const now = Math.floor(Date.now() / 1e3);
769
2045
  const nextType = memory_type ?? existing.type;
770
2046
  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);
2047
+ const nextNormalized = content ? normalizeText(content) : null;
2048
+ if (nextNormalized !== null) {
2049
+ db.prepare(
2050
+ `
2051
+ UPDATE memories
2052
+ SET type = ?, note = ?, note_normalized = ?, updated_at = ?
2053
+ WHERE rowid = ?
2054
+ `
2055
+ ).run(nextType, nextNote, nextNormalized, now, id);
2056
+ } else {
2057
+ db.prepare(
2058
+ `
2059
+ UPDATE memories
2060
+ SET type = ?, note = ?, updated_at = ?
2061
+ WHERE rowid = ?
2062
+ `
2063
+ ).run(nextType, nextNote, now, id);
2064
+ }
778
2065
  const updated = db.prepare(
779
2066
  `
780
2067
  SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned
@@ -821,10 +2108,11 @@ var init_update = __esm({
821
2108
  "src/tools/update.ts"() {
822
2109
  "use strict";
823
2110
  init_client();
2111
+ init_dedupe();
824
2112
  updateMemoryInputSchema = {
825
- id: z7.number().int().positive(),
826
- content: z7.string().trim().min(1).optional(),
827
- memory_type: z7.enum(MEMORY_TYPES).optional()
2113
+ id: z11.number().int().positive(),
2114
+ content: z11.string().trim().min(1).optional(),
2115
+ memory_type: z11.enum(MEMORY_TYPES).optional()
828
2116
  };
829
2117
  }
830
2118
  });
@@ -843,13 +2131,61 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
843
2131
  function resolveDbPath() {
844
2132
  return process.env.FOSSEL_DB_PATH?.trim() || join(homedir(), ".fossel", "memory.db");
845
2133
  }
2134
+ function registerStartupContextResource(server) {
2135
+ server.registerResource(
2136
+ "fossel-startup-context",
2137
+ "fossel://context/current-repo",
2138
+ {
2139
+ title: "Fossel: current repo context",
2140
+ 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.",
2141
+ mimeType: "text/markdown"
2142
+ },
2143
+ async (uri) => {
2144
+ try {
2145
+ const db = getDb();
2146
+ const resolved = resolveRepo(process.cwd(), db);
2147
+ const rows = fetchRepoContext(db, resolved.canonical, 5);
2148
+ const text = formatContext(rows, {
2149
+ repo: resolved.canonical,
2150
+ format: "markdown"
2151
+ });
2152
+ return {
2153
+ contents: [
2154
+ {
2155
+ uri: uri.href,
2156
+ mimeType: "text/markdown",
2157
+ text
2158
+ }
2159
+ ]
2160
+ };
2161
+ } catch (error) {
2162
+ const message = error instanceof Error ? error.message : String(error);
2163
+ return {
2164
+ contents: [
2165
+ {
2166
+ uri: uri.href,
2167
+ mimeType: "text/markdown",
2168
+ text: `# Fossel context unavailable
2169
+
2170
+ ${message}`
2171
+ }
2172
+ ]
2173
+ };
2174
+ }
2175
+ }
2176
+ );
2177
+ }
846
2178
  async function startServer() {
847
2179
  const dbPath = resolveDbPath();
848
2180
  initDb(dbPath);
849
2181
  const server = new McpServer({
850
2182
  name: "fossel",
851
- version: "1.0.0"
2183
+ version: "1.1.0"
852
2184
  });
2185
+ registerRememberTool(server);
2186
+ registerGetContextTool(server);
2187
+ registerResolveRepoTool(server);
2188
+ registerDedupeRepoTool(server);
853
2189
  registerStoreContextTool(server);
854
2190
  registerGetRepoContextTool(server);
855
2191
  registerSearchMemoryTool(server);
@@ -858,6 +2194,7 @@ async function startServer() {
858
2194
  registerPinMemoryTool(server);
859
2195
  registerUnpinMemoryTool(server);
860
2196
  registerSummarizeRepoContextTool(server);
2197
+ registerStartupContextResource(server);
861
2198
  const transport = new StdioServerTransport();
862
2199
  await server.connect(transport);
863
2200
  }
@@ -866,9 +2203,15 @@ var init_index = __esm({
866
2203
  "src/index.ts"() {
867
2204
  "use strict";
868
2205
  init_client();
2206
+ init_context();
2207
+ init_repo();
2208
+ init_dedupe_repo();
869
2209
  init_delete();
2210
+ init_get_context();
870
2211
  init_get_repo();
871
2212
  init_pin();
2213
+ init_remember();
2214
+ init_resolve_repo();
872
2215
  init_search();
873
2216
  init_store();
874
2217
  init_summarize();
@@ -887,111 +2230,194 @@ var init_index = __esm({
887
2230
 
888
2231
  // src/cli.ts
889
2232
  init_client();
2233
+ init_repo();
890
2234
  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";
2235
+ import { join as join2 } from "path";
2236
+ import { statSync } from "fs";
2237
+ import { nanoid as nanoid3 } from "nanoid";
894
2238
  var DEFAULT_DB_PATH = join2(homedir2(), ".fossel", "memory.db");
895
2239
  var INIT_MEMORY_TEXT = "Fossel is active for this repo. Use store_context to save context.";
896
2240
  function resolveDbPath2() {
897
2241
  return process.env.FOSSEL_DB_PATH?.trim() || DEFAULT_DB_PATH;
898
2242
  }
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) {
2243
+ function ensureSampleMemoryIfEmpty(repo) {
922
2244
  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;
2245
+ const totalRow = db.prepare("SELECT COUNT(*) AS count FROM memories").get();
2246
+ if (totalRow.count > 0) {
2247
+ return false;
933
2248
  }
934
2249
  const now = Math.floor(Date.now() / 1e3);
935
2250
  db.prepare(
936
2251
  `
937
- INSERT INTO memories (id, repo, type, note, tags, created_at, updated_at, pinned)
938
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
2252
+ INSERT INTO memories (id, repo, type, note, tags, created_at, updated_at, pinned, metadata_json, note_normalized)
2253
+ VALUES (?, ?, ?, ?, ?, ?, ?, 0, '{}', ?)
939
2254
  `
940
- ).run(nanoid2(), repo, "convention", INIT_MEMORY_TEXT, "[]", now, now, 0);
941
- }
942
- function formatCursorConfig() {
943
- return JSON.stringify(
944
- {
945
- mcpServers: {
946
- fossel: {
947
- command: "npx",
948
- args: ["-y", "fossel"]
949
- }
950
- }
951
- },
952
- null,
953
- 2
2255
+ ).run(
2256
+ nanoid3(),
2257
+ repo,
2258
+ "convention",
2259
+ INIT_MEMORY_TEXT,
2260
+ "[]",
2261
+ now,
2262
+ now,
2263
+ INIT_MEMORY_TEXT.toLowerCase().replace(/[^a-z0-9\s]/g, " ").replace(/\s+/g, " ").trim()
954
2264
  );
2265
+ return true;
955
2266
  }
956
- function formatClaudeDesktopConfig() {
957
- return JSON.stringify(
958
- {
959
- mcpServers: {
960
- fossel: {
961
- command: "npx",
962
- args: ["-y", "fossel"]
963
- }
2267
+ var MCP_CONFIG_SNIPPET = JSON.stringify(
2268
+ {
2269
+ mcpServers: {
2270
+ fossel: {
2271
+ command: "npx",
2272
+ args: ["-y", "fossel"]
964
2273
  }
965
- },
966
- null,
967
- 2
968
- );
2274
+ }
2275
+ },
2276
+ null,
2277
+ 2
2278
+ );
2279
+ function findMergeCandidates(canonical) {
2280
+ const db = getDb();
2281
+ const tail = canonical.split("/").at(-1) ?? canonical;
2282
+ const rows = db.prepare(
2283
+ `
2284
+ SELECT repo, COUNT(*) AS count
2285
+ FROM memories
2286
+ WHERE repo != ?
2287
+ GROUP BY repo
2288
+ `
2289
+ ).all(canonical);
2290
+ return rows.filter((row) => {
2291
+ if (!row.repo) return false;
2292
+ const otherTail = row.repo.split("/").at(-1) ?? row.repo;
2293
+ return otherTail === tail || otherTail === canonical || row.repo === tail;
2294
+ });
969
2295
  }
970
- function printInitOutput(repo, dbPath) {
2296
+ function runInit() {
2297
+ const dbPath = resolveDbPath2();
2298
+ initDb(dbPath);
971
2299
  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());
2300
+ const resolved = resolveRepo(process.cwd(), db);
2301
+ const candidates = findMergeCandidates(resolved.canonical);
2302
+ let mergedAliases = 0;
2303
+ let mergedMemories = 0;
2304
+ for (const candidate of candidates) {
2305
+ const result = mergeRepoKeys(db, candidate.repo, resolved.canonical);
2306
+ mergedAliases += result.movedAliases;
2307
+ mergedMemories += result.movedMemories;
2308
+ }
2309
+ const sampleAdded = ensureSampleMemoryIfEmpty(resolved.canonical);
2310
+ const countRow = db.prepare("SELECT COUNT(*) AS count FROM memories WHERE repo = ?").get(resolved.canonical);
2311
+ console.log("Fossel \u2014 local-first MCP memory for your repos.\n");
2312
+ console.log(`Canonical repo key: ${resolved.canonical}`);
2313
+ console.log(` source: ${resolved.source}`);
2314
+ if (resolved.gitRemote) {
2315
+ console.log(` git remote: ${resolved.gitRemote}`);
2316
+ }
2317
+ if (resolved.aliases.length > 0) {
2318
+ console.log(` aliases: ${resolved.aliases.join(", ")}`);
2319
+ }
2320
+ console.log("");
2321
+ if (mergedAliases > 0 || mergedMemories > 0) {
2322
+ console.log(
2323
+ `Merged ${mergedMemories} memory row(s) and ${mergedAliases} alias row(s) into ${resolved.canonical}.`
2324
+ );
2325
+ console.log("");
2326
+ }
2327
+ console.log("MCP config (Cursor: ~/.cursor/mcp.json, Claude Desktop: settings):");
2328
+ console.log(MCP_CONFIG_SNIPPET);
980
2329
  console.log("");
981
- console.log("Claude Desktop MCP config:");
982
- console.log(formatClaudeDesktopConfig());
2330
+ console.log(`DB path: ${dbPath}`);
2331
+ console.log(`Memories for ${resolved.canonical}: ${countRow.count}`);
2332
+ if (sampleAdded) {
2333
+ console.log("Inserted one starter memory because the database was empty.");
2334
+ }
983
2335
  console.log("");
984
- console.log(`DB Path: ${dbPath}`);
985
- console.log(`Total memories: ${countRow.count}`);
2336
+ console.log("Quick usage in chat:");
2337
+ console.log(" remember \u2014 natural-language save (no type/tags needed)");
2338
+ console.log(" get_context \u2014 pinned + recent + matching memories");
2339
+ console.log(" resolve_repo \u2014 show which repo key Fossel will use");
2340
+ console.log(" store_context \u2014 explicit save (advanced)");
2341
+ console.log(" dedupe_repo \u2014 merge near-duplicate memories");
2342
+ closeDb();
2343
+ }
2344
+ function runDoctor() {
2345
+ const dbPath = resolveDbPath2();
2346
+ const lines = [];
2347
+ let ok = true;
2348
+ initDb(dbPath);
2349
+ const db = getDb();
2350
+ lines.push(`DB path: ${dbPath}`);
2351
+ const resolved = resolveRepo(process.cwd(), db);
2352
+ lines.push(`Canonical repo key: ${resolved.canonical} (source: ${resolved.source})`);
2353
+ if (resolved.gitRemote) {
2354
+ lines.push(`Git remote: ${resolved.gitRemote}`);
2355
+ } else {
2356
+ lines.push("Git remote: not detected (using folder name).");
2357
+ }
2358
+ if (resolved.aliases.length > 0) {
2359
+ lines.push(`Aliases: ${resolved.aliases.join(", ")}`);
2360
+ }
2361
+ const siblings = findMergeCandidates(resolved.canonical);
2362
+ if (siblings.length > 0) {
2363
+ ok = false;
2364
+ const summary = siblings.map((row) => `${row.repo} (${row.count})`).join(", ");
2365
+ lines.push(`\u26A0 Sibling repo keys detected: ${summary}. Run \`npx fossel init\` to merge.`);
2366
+ } else {
2367
+ lines.push("No sibling repo keys.");
2368
+ }
2369
+ const duplicateRows = db.prepare(
2370
+ `
2371
+ SELECT note_normalized, COUNT(*) AS count
2372
+ FROM memories
2373
+ WHERE repo = ? AND note_normalized != ''
2374
+ GROUP BY note_normalized
2375
+ HAVING COUNT(*) > 1
2376
+ `
2377
+ ).all(resolved.canonical);
2378
+ if (duplicateRows.length > 0) {
2379
+ ok = false;
2380
+ const total = duplicateRows.reduce((sum, row) => sum + row.count - 1, 0);
2381
+ lines.push(
2382
+ `\u26A0 ${duplicateRows.length} duplicate clusters covering ${total} extra row(s). Run \`dedupe_repo\` with apply=true.`
2383
+ );
2384
+ } else {
2385
+ lines.push("No exact-duplicate memory clusters.");
2386
+ }
2387
+ const mcpConfigCandidates = [
2388
+ join2(homedir2(), ".cursor", "mcp.json"),
2389
+ join2(
2390
+ homedir2(),
2391
+ "AppData",
2392
+ "Roaming",
2393
+ "Claude",
2394
+ "claude_desktop_config.json"
2395
+ ),
2396
+ join2(homedir2(), "Library", "Application Support", "Claude", "claude_desktop_config.json")
2397
+ ];
2398
+ const found = mcpConfigCandidates.filter((path) => {
2399
+ try {
2400
+ return statSync(path).isFile();
2401
+ } catch {
2402
+ return false;
2403
+ }
2404
+ });
2405
+ if (found.length === 0) {
2406
+ lines.push(
2407
+ "\u26A0 Could not find Cursor or Claude Desktop MCP config. Run `npx fossel init` and paste the snippet."
2408
+ );
2409
+ } else {
2410
+ lines.push(`Detected MCP config(s): ${found.join(", ")}`);
2411
+ }
2412
+ const totalRow = db.prepare("SELECT COUNT(*) AS count FROM memories").get();
2413
+ lines.push(`Total memories across all repos: ${totalRow.count}`);
2414
+ closeDb();
2415
+ console.log(lines.join("\n"));
986
2416
  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");
2417
+ console.log(ok ? "Status: OK" : "Status: needs attention (see \u26A0 lines above)");
2418
+ if (!ok) {
2419
+ process.exitCode = 1;
2420
+ }
995
2421
  }
996
2422
  async function main() {
997
2423
  const command = process.argv[2];
@@ -1001,16 +2427,15 @@ async function main() {
1001
2427
  return;
1002
2428
  }
1003
2429
  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();
2430
+ runInit();
2431
+ return;
2432
+ }
2433
+ if (command === "doctor") {
2434
+ runDoctor();
1010
2435
  return;
1011
2436
  }
1012
2437
  console.error(`Unknown command: ${command}`);
1013
- console.error("Usage: fossel [init]");
2438
+ console.error("Usage: fossel [init | doctor]");
1014
2439
  process.exit(1);
1015
2440
  }
1016
2441
  main().catch((error) => {
@@ -1018,3 +2443,7 @@ main().catch((error) => {
1018
2443
  console.error(`Fossel command failed: ${message}`);
1019
2444
  process.exit(1);
1020
2445
  });
2446
+ export {
2447
+ runDoctor,
2448
+ runInit
2449
+ };