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/index.js CHANGED
@@ -17,6 +17,9 @@ function hasColumn(db, tableName, columnName) {
17
17
  const columns = db.prepare(`PRAGMA table_info(${tableName})`).all();
18
18
  return columns.some((column) => column.name === columnName);
19
19
  }
20
+ function normalizeNoteForMigration(text) {
21
+ return text.toLowerCase().replace(/[^a-z0-9\s]/g, " ").replace(/\s+/g, " ").trim();
22
+ }
20
23
  var migrations = [
21
24
  {
22
25
  name: "001_init_memories_schema",
@@ -82,6 +85,59 @@ var migrations = [
82
85
  `);
83
86
  }
84
87
  }
88
+ },
89
+ {
90
+ name: "004_add_repo_aliases",
91
+ apply: (db) => {
92
+ db.exec(`
93
+ CREATE TABLE IF NOT EXISTS repo_aliases (
94
+ alias TEXT PRIMARY KEY,
95
+ canonical TEXT NOT NULL,
96
+ created_at INTEGER NOT NULL
97
+ );
98
+
99
+ CREATE INDEX IF NOT EXISTS idx_repo_aliases_canonical
100
+ ON repo_aliases (canonical);
101
+ `);
102
+ }
103
+ },
104
+ {
105
+ name: "005_add_memories_metadata_json",
106
+ apply: (db) => {
107
+ if (!hasColumn(db, "memories", "metadata_json")) {
108
+ db.exec(`
109
+ ALTER TABLE memories
110
+ ADD COLUMN metadata_json TEXT NOT NULL DEFAULT '{}';
111
+ `);
112
+ }
113
+ }
114
+ },
115
+ {
116
+ name: "006_add_memories_note_normalized",
117
+ apply: (db) => {
118
+ if (!hasColumn(db, "memories", "note_normalized")) {
119
+ db.exec(`
120
+ ALTER TABLE memories
121
+ ADD COLUMN note_normalized TEXT NOT NULL DEFAULT '';
122
+ `);
123
+ }
124
+ db.exec(`
125
+ CREATE INDEX IF NOT EXISTS idx_memories_note_normalized
126
+ ON memories (repo, note_normalized);
127
+ `);
128
+ const rows = db.prepare("SELECT rowid AS row_id, note FROM memories WHERE note_normalized = ''").all();
129
+ if (rows.length > 0) {
130
+ const update = db.prepare(
131
+ "UPDATE memories SET note_normalized = ? WHERE rowid = ?"
132
+ );
133
+ const tx = db.transaction((batch) => {
134
+ for (const row of batch) {
135
+ update.run(normalizeNoteForMigration(row.note), row.row_id);
136
+ }
137
+ });
138
+ tx(rows);
139
+ }
140
+ }
85
141
  }
86
142
  ];
87
143
  function runMigrations(db) {
@@ -139,10 +195,547 @@ function getDb() {
139
195
  return dbInstance;
140
196
  }
141
197
 
142
- // src/tools/delete.ts
198
+ // src/lib/context.ts
199
+ var SECTION_TITLES = {
200
+ convention: "Conventions",
201
+ bug_fix: "Bug Fixes",
202
+ reviewer_pattern: "Reviewer Patterns",
203
+ decision: "Decisions",
204
+ issue: "Issues",
205
+ general: "General"
206
+ };
207
+ function parseTags(raw) {
208
+ try {
209
+ const parsed = JSON.parse(raw);
210
+ return Array.isArray(parsed) ? parsed.filter((value) => typeof value === "string") : [];
211
+ } catch {
212
+ return [];
213
+ }
214
+ }
215
+ function buildFtsQuery(query) {
216
+ const terms = query.trim().split(/\s+/).map((term) => term.replace(/"/g, '""')).filter((term) => term.length > 0);
217
+ if (terms.length === 0) {
218
+ return null;
219
+ }
220
+ return terms.map((term) => `"${term}"`).join(" AND ");
221
+ }
222
+ function fetchRepoContext(db, repo, limit, query) {
223
+ const rows = [];
224
+ const seen = /* @__PURE__ */ new Set();
225
+ const push = (memory, source, rank) => {
226
+ if (seen.has(memory.row_id)) {
227
+ return;
228
+ }
229
+ seen.add(memory.row_id);
230
+ rows.push({ ...memory, source, rank });
231
+ };
232
+ const pinned = db.prepare(
233
+ `
234
+ SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned
235
+ FROM memories
236
+ WHERE repo = ? AND pinned = 1
237
+ ORDER BY updated_at DESC
238
+ LIMIT ?
239
+ `
240
+ ).all(repo, limit);
241
+ for (const row of pinned) {
242
+ push(row, "pinned");
243
+ }
244
+ if (rows.length < limit) {
245
+ const recent = db.prepare(
246
+ `
247
+ SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned
248
+ FROM memories
249
+ WHERE repo = ? AND pinned = 0
250
+ ORDER BY updated_at DESC
251
+ LIMIT ?
252
+ `
253
+ ).all(repo, limit - rows.length);
254
+ for (const row of recent) {
255
+ push(row, "recent");
256
+ }
257
+ }
258
+ if (query && rows.length < limit) {
259
+ const ftsQuery = buildFtsQuery(query);
260
+ if (ftsQuery) {
261
+ try {
262
+ const matches = db.prepare(
263
+ `
264
+ SELECT m.rowid AS row_id, m.id, m.repo, m.type, m.note, m.tags,
265
+ m.created_at, m.updated_at, m.pinned, bm25(memories_fts) AS rank
266
+ FROM memories_fts
267
+ JOIN memories AS m ON m.rowid = memories_fts.rowid
268
+ WHERE memories_fts MATCH ? AND m.repo = ?
269
+ ORDER BY rank
270
+ LIMIT ?
271
+ `
272
+ ).all(ftsQuery, repo, limit);
273
+ for (const row of matches) {
274
+ push(row, "search", row.rank);
275
+ if (rows.length >= limit) {
276
+ break;
277
+ }
278
+ }
279
+ } catch {
280
+ }
281
+ }
282
+ }
283
+ return rows.slice(0, limit);
284
+ }
285
+ function formatContext(rows, options) {
286
+ const { repo, query, format = "text" } = options;
287
+ if (rows.length === 0) {
288
+ if (format === "markdown") {
289
+ return `# Fossel context: ${repo}
290
+
291
+ No memories found${query ? ` for "${query}"` : ""}.`;
292
+ }
293
+ return `No memories found for ${repo}${query ? ` matching "${query}"` : ""}.`;
294
+ }
295
+ if (format === "markdown") {
296
+ return formatMarkdown(rows, repo, query);
297
+ }
298
+ return formatText(rows, repo, query);
299
+ }
300
+ function formatMarkdown(rows, repo, query) {
301
+ const sections = [`# Fossel context: ${repo}`];
302
+ if (query) {
303
+ sections.push(`Query: \`${query}\``);
304
+ }
305
+ const pinned = rows.filter((row) => row.pinned === 1);
306
+ if (pinned.length > 0) {
307
+ sections.push(["## \u{1F4CC} Pinned", ...pinned.map(renderMarkdownRow)].join("\n"));
308
+ }
309
+ for (const type of MEMORY_TYPES) {
310
+ const entries = rows.filter((row) => row.pinned !== 1 && row.type === type);
311
+ if (entries.length === 0) {
312
+ continue;
313
+ }
314
+ sections.push(
315
+ [`## ${SECTION_TITLES[type]}`, ...entries.map(renderMarkdownRow)].join("\n")
316
+ );
317
+ }
318
+ return sections.join("\n\n");
319
+ }
320
+ function renderMarkdownRow(row) {
321
+ const tags = parseTags(row.tags);
322
+ const tagSuffix = tags.length > 0 ? ` _(${tags.join(", ")})_` : "";
323
+ return `- (${row.row_id}) ${row.note}${tagSuffix}`;
324
+ }
325
+ function formatText(rows, repo, query) {
326
+ const header = query ? `Repository context for ${repo} (query: "${query}")` : `Repository context for ${repo}`;
327
+ const lines = [header, `Total: ${rows.length}`, ""];
328
+ for (const row of rows) {
329
+ const tags = parseTags(row.tags);
330
+ const tagSuffix = tags.length > 0 ? ` [tags: ${tags.join(", ")}]` : "";
331
+ const pinPrefix = row.pinned ? "\u{1F4CC} " : "";
332
+ const sourceLabel = row.source === "search" ? " [match]" : "";
333
+ lines.push(
334
+ `- (${row.row_id} | ${row.type})${sourceLabel} ${pinPrefix}${row.note}${tagSuffix}`
335
+ );
336
+ }
337
+ return lines.join("\n");
338
+ }
339
+
340
+ // src/lib/repo.ts
341
+ import { spawnSync } from "child_process";
342
+ import { basename } from "path";
343
+ var REMOTE_PATTERNS = [
344
+ // git@github.com:owner/repo.git, git@gitlab.com:group/sub/repo.git
345
+ /^[^@\s]+@([^:]+):([^\s]+?)(?:\.git)?$/,
346
+ // ssh://git@github.com/owner/repo.git
347
+ /^ssh:\/\/[^@/]+@([^/]+)\/([^\s]+?)(?:\.git)?$/,
348
+ // https://github.com/owner/repo.git, http://gitlab.com/group/sub/repo
349
+ /^https?:\/\/(?:[^@/]+@)?([^/]+)\/([^\s]+?)(?:\.git)?$/,
350
+ // git://github.com/owner/repo.git
351
+ /^git:\/\/([^/]+)\/([^\s]+?)(?:\.git)?$/
352
+ ];
353
+ function normalizeGitRemote(remoteUrl) {
354
+ const trimmed = remoteUrl.trim();
355
+ if (!trimmed) {
356
+ return null;
357
+ }
358
+ for (const pattern of REMOTE_PATTERNS) {
359
+ const match = pattern.exec(trimmed);
360
+ if (!match) {
361
+ continue;
362
+ }
363
+ const path = match[2]?.replace(/^\/+/, "").replace(/\\/g, "/").replace(/\.git$/i, "").replace(/\/+$/, "");
364
+ if (!path) {
365
+ continue;
366
+ }
367
+ return path;
368
+ }
369
+ return null;
370
+ }
371
+ function readGitRemote(cwd) {
372
+ const result = spawnSync("git", ["remote", "get-url", "origin"], {
373
+ cwd,
374
+ encoding: "utf8"
375
+ });
376
+ if (result.status !== 0) {
377
+ return null;
378
+ }
379
+ const value = result.stdout.trim();
380
+ return value.length > 0 ? value : null;
381
+ }
382
+ function detectFolderName(cwd) {
383
+ const name = basename(cwd);
384
+ return name.length > 0 ? name : cwd;
385
+ }
386
+ function fetchAliases(db, canonical) {
387
+ const rows = db.prepare("SELECT alias FROM repo_aliases WHERE canonical = ? ORDER BY alias").all(canonical);
388
+ return rows.map((row) => row.alias);
389
+ }
390
+ function upsertAlias(db, alias, canonical) {
391
+ const trimmed = alias.trim();
392
+ const target = canonical.trim();
393
+ if (!trimmed || !target) {
394
+ return;
395
+ }
396
+ const now = Math.floor(Date.now() / 1e3);
397
+ db.prepare(
398
+ `
399
+ INSERT INTO repo_aliases (alias, canonical, created_at)
400
+ VALUES (?, ?, ?)
401
+ ON CONFLICT(alias) DO UPDATE SET canonical = excluded.canonical
402
+ `
403
+ ).run(trimmed, target, now);
404
+ }
405
+ function lookupAlias(db, alias) {
406
+ const row = db.prepare("SELECT alias, canonical FROM repo_aliases WHERE alias = ?").get(alias);
407
+ return row ?? null;
408
+ }
409
+ function resolveRepo(cwd, db) {
410
+ const gitRemote = readGitRemote(cwd);
411
+ const fromRemote = gitRemote ? normalizeGitRemote(gitRemote) : null;
412
+ const folder = detectFolderName(cwd);
413
+ let canonical;
414
+ let source;
415
+ if (fromRemote) {
416
+ canonical = fromRemote;
417
+ source = "git-remote";
418
+ } else {
419
+ canonical = folder;
420
+ source = "folder";
421
+ }
422
+ upsertAlias(db, canonical, canonical);
423
+ if (folder && folder !== canonical) {
424
+ const existing = lookupAlias(db, folder);
425
+ if (!existing) {
426
+ upsertAlias(db, folder, canonical);
427
+ }
428
+ }
429
+ return {
430
+ canonical,
431
+ cwd,
432
+ gitRemote,
433
+ source,
434
+ aliases: fetchAliases(db, canonical)
435
+ };
436
+ }
437
+ function resolveRepoArg(input, cwd, db) {
438
+ const trimmed = input?.trim();
439
+ if (!trimmed) {
440
+ return resolveRepo(cwd, db);
441
+ }
442
+ const aliasRow = lookupAlias(db, trimmed);
443
+ if (aliasRow) {
444
+ return {
445
+ canonical: aliasRow.canonical,
446
+ cwd,
447
+ gitRemote: null,
448
+ source: "alias",
449
+ aliases: fetchAliases(db, aliasRow.canonical)
450
+ };
451
+ }
452
+ const workspace = resolveRepo(cwd, db);
453
+ if (workspace.canonical && workspace.canonical !== trimmed) {
454
+ const tail = workspace.canonical.split("/").at(-1) ?? workspace.canonical;
455
+ const inputTail = trimmed.split("/").at(-1) ?? trimmed;
456
+ if (tail === inputTail || tail === trimmed || inputTail === workspace.canonical) {
457
+ upsertAlias(db, trimmed, workspace.canonical);
458
+ return {
459
+ ...workspace,
460
+ source: "alias",
461
+ aliases: fetchAliases(db, workspace.canonical)
462
+ };
463
+ }
464
+ }
465
+ upsertAlias(db, trimmed, trimmed);
466
+ return {
467
+ canonical: trimmed,
468
+ cwd,
469
+ gitRemote: null,
470
+ source: "input",
471
+ aliases: fetchAliases(db, trimmed)
472
+ };
473
+ }
474
+
475
+ // src/tools/dedupe-repo.ts
143
476
  import { z } from "zod";
477
+
478
+ // src/lib/dedupe.ts
479
+ var DEFAULT_THRESHOLD = 0.82;
480
+ var DEFAULT_CANDIDATE_LIMIT = 200;
481
+ function normalizeText(text) {
482
+ return text.toLowerCase().replace(/[^a-z0-9\s]/g, " ").replace(/\s+/g, " ").trim();
483
+ }
484
+ function tokenize(text) {
485
+ return normalizeText(text).split(" ").filter((token) => token.length >= 2);
486
+ }
487
+ function trigrams(text) {
488
+ const padded = ` ${text} `;
489
+ const grams = /* @__PURE__ */ new Set();
490
+ for (let i = 0; i < padded.length - 2; i += 1) {
491
+ grams.add(padded.slice(i, i + 3));
492
+ }
493
+ return grams;
494
+ }
495
+ function jaccard(a, b) {
496
+ if (a.size === 0 && b.size === 0) {
497
+ return 1;
498
+ }
499
+ let intersection = 0;
500
+ for (const value of a) {
501
+ if (b.has(value)) {
502
+ intersection += 1;
503
+ }
504
+ }
505
+ const union = a.size + b.size - intersection;
506
+ return union === 0 ? 0 : intersection / union;
507
+ }
508
+ function similarity(a, b) {
509
+ const normalizedA = normalizeText(a);
510
+ const normalizedB = normalizeText(b);
511
+ if (!normalizedA && !normalizedB) {
512
+ return 1;
513
+ }
514
+ if (!normalizedA || !normalizedB) {
515
+ return 0;
516
+ }
517
+ if (normalizedA === normalizedB) {
518
+ return 1;
519
+ }
520
+ const wordScore = jaccard(new Set(tokenize(normalizedA)), new Set(tokenize(normalizedB)));
521
+ const triScore = jaccard(trigrams(normalizedA), trigrams(normalizedB));
522
+ return wordScore * 0.55 + triScore * 0.45;
523
+ }
524
+ function findDuplicate(db, repo, note, options = {}) {
525
+ const threshold = options.threshold ?? DEFAULT_THRESHOLD;
526
+ const limit = options.candidateLimit ?? DEFAULT_CANDIDATE_LIMIT;
527
+ const normalized = normalizeText(note);
528
+ if (!normalized) {
529
+ return null;
530
+ }
531
+ const exact = db.prepare(
532
+ `
533
+ SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned
534
+ FROM memories
535
+ WHERE repo = ? AND note_normalized = ?
536
+ ORDER BY updated_at DESC
537
+ LIMIT 1
538
+ `
539
+ ).get(repo, normalized);
540
+ if (exact) {
541
+ return { memory: exact, similarity: 1 };
542
+ }
543
+ const candidates = db.prepare(
544
+ `
545
+ SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned
546
+ FROM memories
547
+ WHERE repo = ?
548
+ ORDER BY updated_at DESC
549
+ LIMIT ?
550
+ `
551
+ ).all(repo, limit);
552
+ let best = null;
553
+ for (const candidate of candidates) {
554
+ const score = similarity(note, candidate.note);
555
+ if (score >= threshold && (!best || score > best.similarity)) {
556
+ best = { memory: candidate, similarity: score };
557
+ }
558
+ }
559
+ return best;
560
+ }
561
+
562
+ // src/tools/dedupe-repo.ts
563
+ var dedupeRepoInputSchema = {
564
+ repo: z.string().trim().min(1).optional(),
565
+ threshold: z.number().min(0.5).max(1).default(0.85),
566
+ apply: z.boolean().default(false)
567
+ };
568
+ function parseTags2(raw) {
569
+ try {
570
+ const parsed = JSON.parse(raw);
571
+ return Array.isArray(parsed) ? parsed.filter((value) => typeof value === "string") : [];
572
+ } catch {
573
+ return [];
574
+ }
575
+ }
576
+ function parseMetadata(raw) {
577
+ try {
578
+ const parsed = JSON.parse(raw);
579
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
580
+ return parsed;
581
+ }
582
+ } catch {
583
+ }
584
+ return {};
585
+ }
586
+ function mergeTagLists(...lists) {
587
+ const seen = /* @__PURE__ */ new Set();
588
+ const out = [];
589
+ for (const list of lists) {
590
+ for (const value of list) {
591
+ const trimmed = value.trim().toLowerCase();
592
+ if (!trimmed || seen.has(trimmed)) continue;
593
+ seen.add(trimmed);
594
+ out.push(trimmed);
595
+ }
596
+ }
597
+ return out;
598
+ }
599
+ function registerDedupeRepoTool(server) {
600
+ server.registerTool(
601
+ "dedupe_repo",
602
+ {
603
+ 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.",
604
+ inputSchema: dedupeRepoInputSchema
605
+ },
606
+ async ({ repo, threshold, apply }) => {
607
+ try {
608
+ const db = getDb();
609
+ const resolved = resolveRepoArg(repo, process.cwd(), db);
610
+ const rows = db.prepare(
611
+ `
612
+ SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned, metadata_json
613
+ FROM memories
614
+ WHERE repo = ?
615
+ ORDER BY updated_at DESC
616
+ `
617
+ ).all(resolved.canonical);
618
+ if (rows.length < 2) {
619
+ return {
620
+ content: [
621
+ {
622
+ type: "text",
623
+ text: `No duplicates possible: only ${rows.length} memory in ${resolved.canonical}.`
624
+ }
625
+ ]
626
+ };
627
+ }
628
+ const consumed = /* @__PURE__ */ new Set();
629
+ const plan = [];
630
+ for (let i = 0; i < rows.length; i += 1) {
631
+ const keep = rows[i];
632
+ if (!keep || consumed.has(keep.row_id)) continue;
633
+ for (let j = i + 1; j < rows.length; j += 1) {
634
+ const other = rows[j];
635
+ if (!other || consumed.has(other.row_id)) continue;
636
+ if (other.type !== keep.type) continue;
637
+ const score = similarity(keep.note, other.note);
638
+ if (score >= threshold) {
639
+ plan.push({ keep: keep.row_id, drop: other.row_id, similarity: score });
640
+ consumed.add(other.row_id);
641
+ }
642
+ }
643
+ }
644
+ if (plan.length === 0) {
645
+ return {
646
+ content: [
647
+ {
648
+ type: "text",
649
+ text: `No duplicates \u2265 ${threshold} found in ${resolved.canonical} (${rows.length} memories scanned).`
650
+ }
651
+ ]
652
+ };
653
+ }
654
+ if (!apply) {
655
+ const lines = plan.map(
656
+ (entry) => `- keep ${entry.keep}, drop ${entry.drop} (similarity ${entry.similarity.toFixed(2)})`
657
+ );
658
+ return {
659
+ content: [
660
+ {
661
+ type: "text",
662
+ text: `Dry run for ${resolved.canonical}. Found ${plan.length} duplicate pair(s):
663
+ ${lines.join("\n")}
664
+
665
+ Re-run with apply=true to merge.`
666
+ }
667
+ ]
668
+ };
669
+ }
670
+ const byId = new Map(rows.map((row) => [row.row_id, row]));
671
+ const now = Math.floor(Date.now() / 1e3);
672
+ let merged = 0;
673
+ const tx = db.transaction((entries) => {
674
+ for (const entry of entries) {
675
+ const keep = byId.get(entry.keep);
676
+ const drop = byId.get(entry.drop);
677
+ if (!keep || !drop) continue;
678
+ const longerNote = keep.note.length >= drop.note.length ? keep.note : drop.note;
679
+ const mergedTags = mergeTagLists(parseTags2(keep.tags), parseTags2(drop.tags));
680
+ const metadata = parseMetadata(keep.metadata_json);
681
+ const changelog = metadata.changelog ?? [];
682
+ changelog.push({
683
+ at: now,
684
+ action: "deduped",
685
+ similarity: Number(entry.similarity.toFixed(3)),
686
+ merged_from: drop.row_id,
687
+ previous_note: drop.note
688
+ });
689
+ metadata.changelog = changelog;
690
+ db.prepare(
691
+ `
692
+ UPDATE memories
693
+ SET note = ?, note_normalized = ?, tags = ?, metadata_json = ?, updated_at = ?,
694
+ pinned = CASE WHEN pinned = 1 OR ? = 1 THEN 1 ELSE pinned END
695
+ WHERE rowid = ?
696
+ `
697
+ ).run(
698
+ longerNote,
699
+ normalizeText(longerNote),
700
+ JSON.stringify(mergedTags),
701
+ JSON.stringify(metadata),
702
+ now,
703
+ drop.pinned,
704
+ keep.row_id
705
+ );
706
+ db.prepare("DELETE FROM memories WHERE rowid = ?").run(drop.row_id);
707
+ merged += 1;
708
+ }
709
+ });
710
+ tx(plan);
711
+ return {
712
+ content: [
713
+ {
714
+ type: "text",
715
+ text: `Merged ${merged} duplicate pair(s) in ${resolved.canonical}.`
716
+ }
717
+ ]
718
+ };
719
+ } catch (error) {
720
+ const message = error instanceof Error ? error.message : "Unknown error while deduping repo.";
721
+ return {
722
+ isError: true,
723
+ content: [
724
+ {
725
+ type: "text",
726
+ text: `Failed to dedupe repo: ${message}`
727
+ }
728
+ ]
729
+ };
730
+ }
731
+ }
732
+ );
733
+ }
734
+
735
+ // src/tools/delete.ts
736
+ import { z as z2 } from "zod";
144
737
  var deleteMemoryInputSchema = {
145
- id: z.string().trim().min(1, "id is required")
738
+ id: z2.string().trim().min(1, "id is required")
146
739
  };
147
740
  function registerDeleteMemoryTool(server) {
148
741
  server.registerTool(
@@ -194,13 +787,62 @@ function registerDeleteMemoryTool(server) {
194
787
  );
195
788
  }
196
789
 
790
+ // src/tools/get-context.ts
791
+ import { z as z3 } from "zod";
792
+ var getContextInputSchema = {
793
+ repo: z3.string().trim().min(1).optional(),
794
+ query: z3.string().trim().min(1).optional(),
795
+ limit: z3.number().int().positive().max(50).default(8),
796
+ format: z3.enum(["text", "markdown"]).default("text")
797
+ };
798
+ function registerGetContextTool(server) {
799
+ server.registerTool(
800
+ "get_context",
801
+ {
802
+ 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.",
803
+ inputSchema: getContextInputSchema
804
+ },
805
+ async ({ repo, query, limit, format }) => {
806
+ try {
807
+ const db = getDb();
808
+ const resolved = resolveRepoArg(repo, process.cwd(), db);
809
+ const rows = fetchRepoContext(db, resolved.canonical, limit, query);
810
+ const text = formatContext(rows, {
811
+ repo: resolved.canonical,
812
+ query,
813
+ format
814
+ });
815
+ return {
816
+ content: [
817
+ {
818
+ type: "text",
819
+ text
820
+ }
821
+ ]
822
+ };
823
+ } catch (error) {
824
+ const message = error instanceof Error ? error.message : "Unknown error while fetching context.";
825
+ return {
826
+ isError: true,
827
+ content: [
828
+ {
829
+ type: "text",
830
+ text: `Failed to fetch context: ${message}`
831
+ }
832
+ ]
833
+ };
834
+ }
835
+ }
836
+ );
837
+ }
838
+
197
839
  // src/tools/get-repo.ts
198
- import { z as z2 } from "zod";
840
+ import { z as z4 } from "zod";
199
841
  var getRepoContextInputSchema = {
200
- repo: z2.string().trim().min(1, "repo is required"),
201
- limit: z2.number().int().positive().max(100).default(10)
842
+ repo: z4.string().trim().min(1).optional(),
843
+ limit: z4.number().int().positive().max(100).default(10)
202
844
  };
203
- function parseTags(raw) {
845
+ function parseTags3(raw) {
204
846
  try {
205
847
  const parsed = JSON.parse(raw);
206
848
  return Array.isArray(parsed) ? parsed.filter((value) => typeof value === "string") : [];
@@ -215,12 +857,13 @@ function registerGetRepoContextTool(server) {
215
857
  server.registerTool(
216
858
  "get_repo_context",
217
859
  {
218
- description: "Get recent memories for a repository grouped by memory type.",
860
+ 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.",
219
861
  inputSchema: getRepoContextInputSchema
220
862
  },
221
863
  async ({ repo, limit }) => {
222
864
  try {
223
865
  const db = getDb();
866
+ const resolved = resolveRepoArg(repo, process.cwd(), db);
224
867
  const rows = db.prepare(
225
868
  `
226
869
  SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned
@@ -229,20 +872,20 @@ function registerGetRepoContextTool(server) {
229
872
  ORDER BY pinned DESC, updated_at DESC
230
873
  LIMIT ?
231
874
  `
232
- ).all(repo, limit);
875
+ ).all(resolved.canonical, limit);
233
876
  if (rows.length === 0) {
234
877
  return {
235
878
  content: [
236
879
  {
237
880
  type: "text",
238
- text: `No memories found for ${repo}.`
881
+ text: `No memories found for ${resolved.canonical}.`
239
882
  }
240
883
  ]
241
884
  };
242
885
  }
243
886
  const grouped = /* @__PURE__ */ new Map();
244
887
  for (const memory of rows) {
245
- const tags = parseTags(memory.tags);
888
+ const tags = parseTags3(memory.tags);
246
889
  const tagSuffix = tags.length > 0 ? ` [tags: ${tags.join(", ")}]` : "";
247
890
  const pinPrefix = memory.pinned ? "\u{1F4CC} Pinned " : "";
248
891
  const item = `- (${memory.row_id} | legacy: ${memory.id}) ${pinPrefix}${memory.note}${tagSuffix}`;
@@ -263,7 +906,7 @@ ${entries.join("\n")}`);
263
906
  content: [
264
907
  {
265
908
  type: "text",
266
- text: `Repository context for ${repo}
909
+ text: `Repository context for ${resolved.canonical}
267
910
  Total memories: ${rows.length}
268
911
 
269
912
  ${sections.join("\n\n")}`
@@ -287,9 +930,9 @@ ${sections.join("\n\n")}`
287
930
  }
288
931
 
289
932
  // src/tools/pin.ts
290
- import { z as z3 } from "zod";
933
+ import { z as z5 } from "zod";
291
934
  var pinInputSchema = {
292
- id: z3.number().int().positive()
935
+ id: z5.number().int().positive()
293
936
  };
294
937
  function setPinnedState(memoryId, pinned) {
295
938
  const db = getDb();
@@ -401,12 +1044,543 @@ function registerUnpinMemoryTool(server) {
401
1044
  );
402
1045
  }
403
1046
 
1047
+ // src/tools/remember.ts
1048
+ import { nanoid } from "nanoid";
1049
+ import { z as z6 } from "zod";
1050
+
1051
+ // src/lib/inference.ts
1052
+ var TYPE_RULES = [
1053
+ {
1054
+ type: "bug_fix",
1055
+ patterns: [
1056
+ { pattern: /\broot cause\b/i, weight: 4 },
1057
+ { pattern: /\bregression\b/i, weight: 4 },
1058
+ { pattern: /\bhotfix\b/i, weight: 4 },
1059
+ { pattern: /\bfix(?:ed|es|ing)?\b/i, weight: 3 },
1060
+ { pattern: /\bbugs?\b/i, weight: 2 },
1061
+ { pattern: /\bcrash(?:ed|es|ing)?\b/i, weight: 2 },
1062
+ { pattern: /\bbroken\b/i, weight: 2 },
1063
+ { pattern: /\bworkaround\b/i, weight: 2 }
1064
+ ]
1065
+ },
1066
+ {
1067
+ type: "issue",
1068
+ patterns: [
1069
+ { pattern: /\bissue\s*#\d+/i, weight: 5 },
1070
+ { pattern: /\bticket\s*#?\w+/i, weight: 4 },
1071
+ { pattern: /\bjira[-\s]?\w+/i, weight: 4 },
1072
+ { pattern: /\bgh[-\s]?\d+/i, weight: 3 },
1073
+ { pattern: /#\d{2,}/i, weight: 2 }
1074
+ ]
1075
+ },
1076
+ {
1077
+ type: "decision",
1078
+ patterns: [
1079
+ { pattern: /\bdecided not to\b/i, weight: 5 },
1080
+ { pattern: /\bdecided to\b/i, weight: 4 },
1081
+ { pattern: /\bwe chose\b/i, weight: 4 },
1082
+ { pattern: /\bchose\s+\w+\s+over\b/i, weight: 4 },
1083
+ { pattern: /\barchitecture\b/i, weight: 3 },
1084
+ { pattern: /\bdecision\b/i, weight: 3 },
1085
+ { pattern: /\btrade[- ]?off\b/i, weight: 2 },
1086
+ { pattern: /\brfc\b/i, weight: 2 },
1087
+ { pattern: /\b(?:adopted|migrated to)\b/i, weight: 2 }
1088
+ ]
1089
+ },
1090
+ {
1091
+ type: "reviewer_pattern",
1092
+ patterns: [
1093
+ { pattern: /\breviewer(?:s)?\s+(?:prefer|want|expect|require)/i, weight: 5 },
1094
+ { pattern: /\bpr\s+style\b/i, weight: 4 },
1095
+ { pattern: /\bcode review\b/i, weight: 3 },
1096
+ { pattern: /\bprefer(?:s|red)?\b/i, weight: 2 },
1097
+ { pattern: /\breview comment\b/i, weight: 2 }
1098
+ ]
1099
+ },
1100
+ {
1101
+ type: "convention",
1102
+ patterns: [
1103
+ { pattern: /\bconvention\b/i, weight: 4 },
1104
+ { pattern: /\balways\b/i, weight: 2 },
1105
+ { pattern: /\bnever\b/i, weight: 2 },
1106
+ { pattern: /\bstandard\b/i, weight: 2 },
1107
+ { pattern: /\bstyle guide\b/i, weight: 3 },
1108
+ { pattern: /\buse\b\s+\w+\s+\bfor\b/i, weight: 1 }
1109
+ ]
1110
+ }
1111
+ ];
1112
+ var AUTH_KEYWORDS = /\b(?:auth|jwt|oauth|token|login|logout|session|sso|saml)\b/i;
1113
+ var CHOICE_KEYWORDS = /\b(?:chose|choose|decided|prefer|switched|migrated|adopted|over|instead of)\b/i;
1114
+ var TAG_KEYWORDS = [
1115
+ { tag: "auth", pattern: /\b(?:auth|authentication|authorization)\b/i },
1116
+ { tag: "jwt", pattern: /\bjwt\b/i },
1117
+ { tag: "oauth", pattern: /\boauth\b/i },
1118
+ { tag: "session", pattern: /\bsession(?:s)?\b/i },
1119
+ { tag: "api", pattern: /\bapi\b/i },
1120
+ { tag: "rest", pattern: /\brest(?:ful)?\b/i },
1121
+ { tag: "graphql", pattern: /\bgraphql\b/i },
1122
+ { tag: "websocket", pattern: /\bweb[- ]?socket(?:s)?\b/i },
1123
+ { tag: "database", pattern: /\b(?:database|db|sqlite|postgres|mysql|mongo)\b/i },
1124
+ { tag: "migration", pattern: /\bmigration(?:s)?\b/i },
1125
+ { tag: "schema", pattern: /\bschema\b/i },
1126
+ { tag: "frontend", pattern: /\b(?:frontend|ui|react|vue|svelte|next\.js|nextjs)\b/i },
1127
+ { tag: "backend", pattern: /\b(?:backend|server|node\.js|nodejs|express|fastify)\b/i },
1128
+ { tag: "testing", pattern: /\b(?:test|tests|testing|jest|vitest|pytest|rspec)\b/i },
1129
+ { tag: "ci", pattern: /\b(?:ci|cd|pipeline|github actions|gitlab ci)\b/i },
1130
+ { tag: "deployment", pattern: /\b(?:deploy|deployment|release|rollout)\b/i },
1131
+ { tag: "performance", pattern: /\b(?:performance|perf|latency|throughput)\b/i },
1132
+ { tag: "security", pattern: /\b(?:security|vuln|cve|xss|csrf|injection)\b/i },
1133
+ { tag: "logging", pattern: /\b(?:log|logging|telemetry|tracing)\b/i },
1134
+ { tag: "config", pattern: /\b(?:config|configuration|env|environment)\b/i },
1135
+ { tag: "routing", pattern: /\b(?:route|routing|router|endpoint)\b/i },
1136
+ { tag: "build", pattern: /\b(?:build|webpack|vite|tsup|rollup|esbuild)\b/i },
1137
+ { tag: "docs", pattern: /\b(?:docs|documentation|readme)\b/i }
1138
+ ];
1139
+ var STOP_WORDS = /* @__PURE__ */ new Set([
1140
+ "the",
1141
+ "a",
1142
+ "an",
1143
+ "and",
1144
+ "or",
1145
+ "but",
1146
+ "is",
1147
+ "are",
1148
+ "was",
1149
+ "were",
1150
+ "be",
1151
+ "been",
1152
+ "being",
1153
+ "to",
1154
+ "of",
1155
+ "in",
1156
+ "on",
1157
+ "for",
1158
+ "with",
1159
+ "by",
1160
+ "at",
1161
+ "from",
1162
+ "as",
1163
+ "that",
1164
+ "this",
1165
+ "it",
1166
+ "we",
1167
+ "our",
1168
+ "you",
1169
+ "your",
1170
+ "i",
1171
+ "my",
1172
+ "they",
1173
+ "their",
1174
+ "them",
1175
+ "he",
1176
+ "she",
1177
+ "his",
1178
+ "her",
1179
+ "if",
1180
+ "then",
1181
+ "than",
1182
+ "so",
1183
+ "do",
1184
+ "does",
1185
+ "did",
1186
+ "done",
1187
+ "not",
1188
+ "no",
1189
+ "yes",
1190
+ "can",
1191
+ "will",
1192
+ "would",
1193
+ "should",
1194
+ "could",
1195
+ "may",
1196
+ "might",
1197
+ "must",
1198
+ "have",
1199
+ "has",
1200
+ "had",
1201
+ "just",
1202
+ "also",
1203
+ "use",
1204
+ "used",
1205
+ "using",
1206
+ "want",
1207
+ "wants",
1208
+ "wanted",
1209
+ "need",
1210
+ "needs",
1211
+ "needed",
1212
+ "like",
1213
+ "now",
1214
+ "new",
1215
+ "old",
1216
+ "good",
1217
+ "bad",
1218
+ "make",
1219
+ "makes",
1220
+ "made",
1221
+ "get",
1222
+ "gets",
1223
+ "got",
1224
+ "set",
1225
+ "sets",
1226
+ "go",
1227
+ "going",
1228
+ "into",
1229
+ "over",
1230
+ "under",
1231
+ "through",
1232
+ "because",
1233
+ "when",
1234
+ "where",
1235
+ "while",
1236
+ "there",
1237
+ "here",
1238
+ "what",
1239
+ "which",
1240
+ "who",
1241
+ "why",
1242
+ "how",
1243
+ "live",
1244
+ "lives",
1245
+ "living",
1246
+ "keep",
1247
+ "kept",
1248
+ "keeps",
1249
+ "take",
1250
+ "takes",
1251
+ "took",
1252
+ "taken",
1253
+ "say",
1254
+ "says",
1255
+ "said",
1256
+ "tell",
1257
+ "tells",
1258
+ "told",
1259
+ "know",
1260
+ "knows",
1261
+ "known",
1262
+ "knew",
1263
+ "redirect",
1264
+ "redirects",
1265
+ "redirected",
1266
+ "redirecting",
1267
+ "user",
1268
+ "users",
1269
+ "page",
1270
+ "pages"
1271
+ ]);
1272
+ function inferMemoryType(text) {
1273
+ const scores = /* @__PURE__ */ new Map();
1274
+ for (const rule of TYPE_RULES) {
1275
+ let score = 0;
1276
+ for (const { pattern, weight } of rule.patterns) {
1277
+ if (pattern.test(text)) {
1278
+ score += weight;
1279
+ }
1280
+ }
1281
+ if (score > 0) {
1282
+ scores.set(rule.type, (scores.get(rule.type) ?? 0) + score);
1283
+ }
1284
+ }
1285
+ if (AUTH_KEYWORDS.test(text)) {
1286
+ if (CHOICE_KEYWORDS.test(text)) {
1287
+ scores.set("decision", (scores.get("decision") ?? 0) + 3);
1288
+ } else {
1289
+ scores.set("convention", (scores.get("convention") ?? 0) + 2);
1290
+ }
1291
+ }
1292
+ if (scores.size === 0) {
1293
+ return "convention";
1294
+ }
1295
+ let bestType = "convention";
1296
+ let bestScore = -1;
1297
+ for (const [type, score] of scores) {
1298
+ if (score > bestScore) {
1299
+ bestType = type;
1300
+ bestScore = score;
1301
+ }
1302
+ }
1303
+ return bestType;
1304
+ }
1305
+ function extractKeywordTags(text) {
1306
+ const found = [];
1307
+ for (const { tag, pattern } of TAG_KEYWORDS) {
1308
+ if (pattern.test(text)) {
1309
+ found.push(tag);
1310
+ }
1311
+ }
1312
+ return found;
1313
+ }
1314
+ function extractIdentifierTags(text) {
1315
+ const tokens = /* @__PURE__ */ new Set();
1316
+ const pathLike = text.match(/\/(?:[a-z0-9_-]+\/?){1,4}/gi);
1317
+ if (pathLike) {
1318
+ for (const segment of pathLike) {
1319
+ for (const part of segment.split("/")) {
1320
+ if (part.length >= 3 && /^[a-z0-9_-]+$/i.test(part)) {
1321
+ tokens.add(part.toLowerCase());
1322
+ }
1323
+ }
1324
+ }
1325
+ }
1326
+ 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);
1327
+ if (fileLike) {
1328
+ for (const file of fileLike) {
1329
+ const base = file.split(".").slice(0, -1).join(".");
1330
+ if (base.length >= 3) {
1331
+ tokens.add(base.toLowerCase());
1332
+ }
1333
+ }
1334
+ }
1335
+ return Array.from(tokens);
1336
+ }
1337
+ function extractSalientWords(text, limit) {
1338
+ const words = text.toLowerCase().replace(/[^a-z0-9\s/_-]/g, " ").split(/\s+/).filter((word) => word.length >= 4 && !STOP_WORDS.has(word));
1339
+ const counts = /* @__PURE__ */ new Map();
1340
+ for (const word of words) {
1341
+ counts.set(word, (counts.get(word) ?? 0) + 1);
1342
+ }
1343
+ return Array.from(counts.entries()).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, limit).map(([word]) => word);
1344
+ }
1345
+ function inferTags(text) {
1346
+ const ordered = [];
1347
+ const seen = /* @__PURE__ */ new Set();
1348
+ const push = (value) => {
1349
+ const normalized = value.trim().toLowerCase();
1350
+ if (!normalized || seen.has(normalized)) {
1351
+ return;
1352
+ }
1353
+ seen.add(normalized);
1354
+ ordered.push(normalized);
1355
+ };
1356
+ for (const tag of extractKeywordTags(text)) {
1357
+ push(tag);
1358
+ }
1359
+ for (const tag of extractIdentifierTags(text)) {
1360
+ push(tag);
1361
+ }
1362
+ if (ordered.length < 5) {
1363
+ for (const word of extractSalientWords(text, 8)) {
1364
+ push(word);
1365
+ if (ordered.length >= 5) {
1366
+ break;
1367
+ }
1368
+ }
1369
+ }
1370
+ return ordered.slice(0, 5);
1371
+ }
1372
+ function inferMemoryFromNote(text) {
1373
+ return {
1374
+ type: inferMemoryType(text),
1375
+ tags: inferTags(text)
1376
+ };
1377
+ }
1378
+
1379
+ // src/tools/remember.ts
1380
+ var rememberInputSchema = {
1381
+ note: z6.string().trim().min(1, "note is required"),
1382
+ repo: z6.string().trim().min(1).optional(),
1383
+ type: z6.enum(MEMORY_TYPES).optional(),
1384
+ tags: z6.array(z6.string().trim().min(1)).optional()
1385
+ };
1386
+ function mergeTagLists2(...lists) {
1387
+ const seen = /* @__PURE__ */ new Set();
1388
+ const out = [];
1389
+ for (const list of lists) {
1390
+ if (!list) continue;
1391
+ for (const raw of list) {
1392
+ const value = raw.trim().toLowerCase();
1393
+ if (!value || seen.has(value)) continue;
1394
+ seen.add(value);
1395
+ out.push(value);
1396
+ }
1397
+ }
1398
+ return out;
1399
+ }
1400
+ function parseStoredTags(raw) {
1401
+ try {
1402
+ const parsed = JSON.parse(raw);
1403
+ return Array.isArray(parsed) ? parsed.filter((value) => typeof value === "string") : [];
1404
+ } catch {
1405
+ return [];
1406
+ }
1407
+ }
1408
+ function parseStoredMetadata(raw) {
1409
+ try {
1410
+ const parsed = JSON.parse(raw);
1411
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
1412
+ return parsed;
1413
+ }
1414
+ } catch {
1415
+ }
1416
+ return {};
1417
+ }
1418
+ function registerRememberTool(server) {
1419
+ server.registerTool(
1420
+ "remember",
1421
+ {
1422
+ 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.",
1423
+ inputSchema: rememberInputSchema
1424
+ },
1425
+ async ({ note, repo, type, tags }) => {
1426
+ try {
1427
+ const db = getDb();
1428
+ const resolved = resolveRepoArg(repo, process.cwd(), db);
1429
+ const inferred = inferMemoryFromNote(note);
1430
+ const finalType = type ?? inferred.type;
1431
+ const finalTags = mergeTagLists2(tags, inferred.tags).slice(0, 5);
1432
+ const now = Math.floor(Date.now() / 1e3);
1433
+ const duplicate = findDuplicate(db, resolved.canonical, note);
1434
+ if (duplicate) {
1435
+ const existing = duplicate.memory;
1436
+ const existingTags = parseStoredTags(existing.tags);
1437
+ const mergedTags = mergeTagLists2(existingTags, finalTags);
1438
+ const metadata2 = parseStoredMetadata(
1439
+ existing.metadata_json ?? "{}"
1440
+ );
1441
+ const changelog = metadata2.changelog ?? [];
1442
+ changelog.push({
1443
+ at: now,
1444
+ action: "merged",
1445
+ similarity: Number(duplicate.similarity.toFixed(3)),
1446
+ previous_note: existing.note
1447
+ });
1448
+ metadata2.changelog = changelog;
1449
+ const longerNote = note.length > existing.note.length ? note : existing.note;
1450
+ const nextType = type ?? existing.type;
1451
+ db.prepare(
1452
+ `
1453
+ UPDATE memories
1454
+ SET note = ?, note_normalized = ?, tags = ?, type = ?, metadata_json = ?, updated_at = ?
1455
+ WHERE rowid = ?
1456
+ `
1457
+ ).run(
1458
+ longerNote,
1459
+ normalizeText(longerNote),
1460
+ JSON.stringify(mergedTags),
1461
+ nextType,
1462
+ JSON.stringify(metadata2),
1463
+ now,
1464
+ existing.row_id
1465
+ );
1466
+ return {
1467
+ content: [
1468
+ {
1469
+ type: "text",
1470
+ text: `Merged into memory ${existing.row_id} for ${resolved.canonical} (similarity ${duplicate.similarity.toFixed(2)}, type ${nextType}, tags: ${mergedTags.join(", ") || "none"}).`
1471
+ }
1472
+ ]
1473
+ };
1474
+ }
1475
+ const id = nanoid();
1476
+ const metadata = {
1477
+ changelog: [
1478
+ {
1479
+ at: now,
1480
+ action: "created"
1481
+ }
1482
+ ],
1483
+ inferred: {
1484
+ type: inferred.type,
1485
+ tags: inferred.tags,
1486
+ type_overridden: type !== void 0
1487
+ }
1488
+ };
1489
+ db.prepare(
1490
+ `
1491
+ INSERT INTO memories (id, repo, type, note, tags, created_at, updated_at, pinned, metadata_json, note_normalized)
1492
+ VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?)
1493
+ `
1494
+ ).run(
1495
+ id,
1496
+ resolved.canonical,
1497
+ finalType,
1498
+ note,
1499
+ JSON.stringify(finalTags),
1500
+ now,
1501
+ now,
1502
+ JSON.stringify(metadata),
1503
+ normalizeText(note)
1504
+ );
1505
+ const inserted = db.prepare("SELECT rowid AS row_id FROM memories WHERE id = ?").get(id);
1506
+ return {
1507
+ content: [
1508
+ {
1509
+ type: "text",
1510
+ text: `Stored memory ${inserted?.row_id ?? "?"} for ${resolved.canonical} (type ${finalType}, tags: ${finalTags.join(", ") || "none"}).`
1511
+ }
1512
+ ]
1513
+ };
1514
+ } catch (error) {
1515
+ const message = error instanceof Error ? error.message : "Unknown error while remembering note.";
1516
+ return {
1517
+ isError: true,
1518
+ content: [
1519
+ {
1520
+ type: "text",
1521
+ text: `Failed to remember note: ${message}`
1522
+ }
1523
+ ]
1524
+ };
1525
+ }
1526
+ }
1527
+ );
1528
+ }
1529
+
1530
+ // src/tools/resolve-repo.ts
1531
+ import { z as z7 } from "zod";
1532
+ var resolveRepoInputSchema = {
1533
+ cwd: z7.string().trim().min(1).optional()
1534
+ };
1535
+ function registerResolveRepoTool(server) {
1536
+ server.registerTool(
1537
+ "resolve_repo",
1538
+ {
1539
+ 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.",
1540
+ inputSchema: resolveRepoInputSchema
1541
+ },
1542
+ async ({ cwd }) => {
1543
+ try {
1544
+ const db = getDb();
1545
+ const target = cwd?.trim() || process.cwd();
1546
+ const resolved = resolveRepo(target, db);
1547
+ const payload = {
1548
+ canonical: resolved.canonical,
1549
+ aliases: resolved.aliases,
1550
+ cwd: resolved.cwd,
1551
+ gitRemote: resolved.gitRemote,
1552
+ source: resolved.source
1553
+ };
1554
+ return {
1555
+ content: [
1556
+ {
1557
+ type: "text",
1558
+ text: JSON.stringify(payload, null, 2)
1559
+ }
1560
+ ]
1561
+ };
1562
+ } catch (error) {
1563
+ const message = error instanceof Error ? error.message : "Unknown error while resolving repo.";
1564
+ return {
1565
+ isError: true,
1566
+ content: [
1567
+ {
1568
+ type: "text",
1569
+ text: `Failed to resolve repo: ${message}`
1570
+ }
1571
+ ]
1572
+ };
1573
+ }
1574
+ }
1575
+ );
1576
+ }
1577
+
404
1578
  // src/tools/search.ts
405
- import { z as z4 } from "zod";
1579
+ import { z as z8 } from "zod";
406
1580
  var searchMemoryInputSchema = {
407
- query: z4.string().trim().min(1, "query is required"),
408
- repo: z4.string().trim().min(1).optional(),
409
- limit: z4.number().int().positive().max(50).default(5)
1581
+ query: z8.string().trim().min(1, "query is required"),
1582
+ repo: z8.string().trim().min(1).optional(),
1583
+ limit: z8.number().int().positive().max(50).default(5)
410
1584
  };
411
1585
  function normalizeFtsQuery(query) {
412
1586
  const terms = query.trim().split(/\s+/).map((term) => term.replaceAll('"', '""')).filter(Boolean);
@@ -415,7 +1589,7 @@ function normalizeFtsQuery(query) {
415
1589
  }
416
1590
  return terms.map((term) => `"${term}"`).join(" AND ");
417
1591
  }
418
- function parseTags2(raw) {
1592
+ function parseTags4(raw) {
419
1593
  try {
420
1594
  const parsed = JSON.parse(raw);
421
1595
  return Array.isArray(parsed) ? parsed.filter((value) => typeof value === "string") : [];
@@ -434,7 +1608,8 @@ function registerSearchMemoryTool(server) {
434
1608
  try {
435
1609
  const db = getDb();
436
1610
  const ftsQuery = normalizeFtsQuery(query);
437
- const rows = repo ? db.prepare(
1611
+ const resolvedRepo = repo ? resolveRepoArg(repo, process.cwd(), db).canonical : void 0;
1612
+ const rows = resolvedRepo ? db.prepare(
438
1613
  `
439
1614
  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
440
1615
  FROM memories_fts
@@ -443,7 +1618,7 @@ function registerSearchMemoryTool(server) {
443
1618
  ORDER BY rank
444
1619
  LIMIT ?
445
1620
  `
446
- ).all(ftsQuery, repo, limit) : db.prepare(
1621
+ ).all(ftsQuery, resolvedRepo, limit) : db.prepare(
447
1622
  `
448
1623
  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
449
1624
  FROM memories_fts
@@ -458,13 +1633,13 @@ function registerSearchMemoryTool(server) {
458
1633
  content: [
459
1634
  {
460
1635
  type: "text",
461
- text: repo ? `No memories matched "${query}" in ${repo}.` : `No memories matched "${query}".`
1636
+ text: resolvedRepo ? `No memories matched "${query}" in ${resolvedRepo}.` : `No memories matched "${query}".`
462
1637
  }
463
1638
  ]
464
1639
  };
465
1640
  }
466
1641
  const formatted = rows.map((row, index) => {
467
- const tags = parseTags2(row.tags);
1642
+ const tags = parseTags4(row.tags);
468
1643
  const tagsText = tags.length > 0 ? ` | tags: ${tags.join(", ")}` : "";
469
1644
  const pinPrefix = row.pinned ? "\u{1F4CC} Pinned " : "";
470
1645
  return `${index + 1}. [${row.repo}] ${row.type} (${row.row_id} | legacy: ${row.id})
@@ -474,7 +1649,7 @@ ${pinPrefix}${row.note}${tagsText}`;
474
1649
  content: [
475
1650
  {
476
1651
  type: "text",
477
- text: `Search results for "${query}"${repo ? ` in ${repo}` : ""}:
1652
+ text: `Search results for "${query}"${resolvedRepo ? ` in ${resolvedRepo}` : ""}:
478
1653
 
479
1654
  ${formatted}`
480
1655
  }
@@ -497,35 +1672,45 @@ ${formatted}`
497
1672
  }
498
1673
 
499
1674
  // src/tools/store.ts
500
- import { nanoid } from "nanoid";
501
- import { z as z5 } from "zod";
1675
+ import { nanoid as nanoid2 } from "nanoid";
1676
+ import { z as z9 } from "zod";
502
1677
  var storeContextInputSchema = {
503
- repo: z5.string().trim().min(1, "repo is required"),
504
- type: z5.enum(MEMORY_TYPES),
505
- note: z5.string().trim().min(1, "note is required"),
506
- tags: z5.array(z5.string().trim().min(1)).optional()
1678
+ repo: z9.string().trim().min(1).optional(),
1679
+ type: z9.enum(MEMORY_TYPES),
1680
+ note: z9.string().trim().min(1, "note is required"),
1681
+ tags: z9.array(z9.string().trim().min(1)).optional()
507
1682
  };
508
1683
  function registerStoreContextTool(server) {
509
1684
  server.registerTool(
510
1685
  "store_context",
511
1686
  {
512
- description: "Store repository-specific contributor context such as bug fixes, conventions, and decisions.",
1687
+ 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.",
513
1688
  inputSchema: storeContextInputSchema
514
1689
  },
515
1690
  async ({ repo, type, note, tags }) => {
516
1691
  try {
517
1692
  const db = getDb();
1693
+ const resolved = resolveRepoArg(repo, process.cwd(), db);
518
1694
  const now = Math.floor(Date.now() / 1e3);
519
- const id = nanoid();
1695
+ const id = nanoid2();
520
1696
  const normalizedTags = Array.from(
521
1697
  new Set((tags ?? []).map((tag) => tag.trim()).filter(Boolean))
522
1698
  );
523
1699
  db.prepare(
524
1700
  `
525
- INSERT INTO memories (id, repo, type, note, tags, created_at, updated_at)
526
- VALUES (?, ?, ?, ?, ?, ?, ?)
1701
+ INSERT INTO memories (id, repo, type, note, tags, created_at, updated_at, pinned, metadata_json, note_normalized)
1702
+ VALUES (?, ?, ?, ?, ?, ?, ?, 0, '{}', ?)
527
1703
  `
528
- ).run(id, repo, type, note, JSON.stringify(normalizedTags), now, now);
1704
+ ).run(
1705
+ id,
1706
+ resolved.canonical,
1707
+ type,
1708
+ note,
1709
+ JSON.stringify(normalizedTags),
1710
+ now,
1711
+ now,
1712
+ normalizeText(note)
1713
+ );
529
1714
  const stored = db.prepare(
530
1715
  `
531
1716
  SELECT rowid AS row_id, id
@@ -537,7 +1722,7 @@ function registerStoreContextTool(server) {
537
1722
  content: [
538
1723
  {
539
1724
  type: "text",
540
- text: `Stored memory ${id} (numeric id: ${stored?.row_id ?? "unknown"}) for ${repo} (${type}).`
1725
+ text: `Stored memory ${id} (numeric id: ${stored?.row_id ?? "unknown"}) for ${resolved.canonical} (${type}).`
541
1726
  }
542
1727
  ]
543
1728
  };
@@ -558,9 +1743,9 @@ function registerStoreContextTool(server) {
558
1743
  }
559
1744
 
560
1745
  // src/tools/summarize.ts
561
- import { z as z6 } from "zod";
1746
+ import { z as z10 } from "zod";
562
1747
  var summarizeRepoContextInputSchema = {
563
- repo: z6.string().trim().min(1, "repo is required")
1748
+ repo: z10.string().trim().min(1).optional()
564
1749
  };
565
1750
  var sectionTitleByType = {
566
1751
  convention: "Conventions",
@@ -580,6 +1765,7 @@ function registerSummarizeRepoContextTool(server) {
580
1765
  async ({ repo }) => {
581
1766
  try {
582
1767
  const db = getDb();
1768
+ const resolved = resolveRepoArg(repo, process.cwd(), db);
583
1769
  const rows = db.prepare(
584
1770
  `
585
1771
  SELECT rowid AS row_id, type, note, pinned
@@ -587,13 +1773,13 @@ function registerSummarizeRepoContextTool(server) {
587
1773
  WHERE repo = ?
588
1774
  ORDER BY pinned DESC, updated_at DESC
589
1775
  `
590
- ).all(repo);
1776
+ ).all(resolved.canonical);
591
1777
  if (rows.length === 0) {
592
1778
  return {
593
1779
  content: [
594
1780
  {
595
1781
  type: "text",
596
- text: `Fossel Context Summary: ${repo}
1782
+ text: `Fossel Context Summary: ${resolved.canonical}
597
1783
 
598
1784
  No memories found.`
599
1785
  }
@@ -601,7 +1787,7 @@ No memories found.`
601
1787
  };
602
1788
  }
603
1789
  const pinnedLines = rows.filter((row) => row.pinned === 1).map((row) => `- (${row.row_id}) ${row.note}`);
604
- const sections = [`Fossel Context Summary: ${repo}`];
1790
+ const sections = [`Fossel Context Summary: ${resolved.canonical}`];
605
1791
  if (pinnedLines.length > 0) {
606
1792
  sections.push(`\u{1F4CC} Pinned
607
1793
  ${pinnedLines.join("\n")}`);
@@ -639,13 +1825,13 @@ ${entries.join("\n")}`);
639
1825
  }
640
1826
 
641
1827
  // src/tools/update.ts
642
- import { z as z7 } from "zod";
1828
+ import { z as z11 } from "zod";
643
1829
  var updateMemoryInputSchema = {
644
- id: z7.number().int().positive(),
645
- content: z7.string().trim().min(1).optional(),
646
- memory_type: z7.enum(MEMORY_TYPES).optional()
1830
+ id: z11.number().int().positive(),
1831
+ content: z11.string().trim().min(1).optional(),
1832
+ memory_type: z11.enum(MEMORY_TYPES).optional()
647
1833
  };
648
- function parseTags3(raw) {
1834
+ function parseTags5(raw) {
649
1835
  try {
650
1836
  const parsed = JSON.parse(raw);
651
1837
  return Array.isArray(parsed) ? parsed.filter((value) => typeof value === "string") : [];
@@ -654,7 +1840,7 @@ function parseTags3(raw) {
654
1840
  }
655
1841
  }
656
1842
  function formatMemory(memory) {
657
- const tags = parseTags3(memory.tags);
1843
+ const tags = parseTags5(memory.tags);
658
1844
  const tagsLine = tags.length > 0 ? tags.join(", ") : "(none)";
659
1845
  return [
660
1846
  `Memory ${memory.row_id} updated successfully.`,
@@ -711,13 +1897,24 @@ function registerUpdateMemoryTool(server) {
711
1897
  const now = Math.floor(Date.now() / 1e3);
712
1898
  const nextType = memory_type ?? existing.type;
713
1899
  const nextNote = content ?? existing.note;
714
- db.prepare(
715
- `
716
- UPDATE memories
717
- SET type = ?, note = ?, updated_at = ?
718
- WHERE rowid = ?
719
- `
720
- ).run(nextType, nextNote, now, id);
1900
+ const nextNormalized = content ? normalizeText(content) : null;
1901
+ if (nextNormalized !== null) {
1902
+ db.prepare(
1903
+ `
1904
+ UPDATE memories
1905
+ SET type = ?, note = ?, note_normalized = ?, updated_at = ?
1906
+ WHERE rowid = ?
1907
+ `
1908
+ ).run(nextType, nextNote, nextNormalized, now, id);
1909
+ } else {
1910
+ db.prepare(
1911
+ `
1912
+ UPDATE memories
1913
+ SET type = ?, note = ?, updated_at = ?
1914
+ WHERE rowid = ?
1915
+ `
1916
+ ).run(nextType, nextNote, now, id);
1917
+ }
721
1918
  const updated = db.prepare(
722
1919
  `
723
1920
  SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned
@@ -764,13 +1961,61 @@ function registerUpdateMemoryTool(server) {
764
1961
  function resolveDbPath() {
765
1962
  return process.env.FOSSEL_DB_PATH?.trim() || join(homedir(), ".fossel", "memory.db");
766
1963
  }
1964
+ function registerStartupContextResource(server) {
1965
+ server.registerResource(
1966
+ "fossel-startup-context",
1967
+ "fossel://context/current-repo",
1968
+ {
1969
+ title: "Fossel: current repo context",
1970
+ 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.",
1971
+ mimeType: "text/markdown"
1972
+ },
1973
+ async (uri) => {
1974
+ try {
1975
+ const db = getDb();
1976
+ const resolved = resolveRepo(process.cwd(), db);
1977
+ const rows = fetchRepoContext(db, resolved.canonical, 5);
1978
+ const text = formatContext(rows, {
1979
+ repo: resolved.canonical,
1980
+ format: "markdown"
1981
+ });
1982
+ return {
1983
+ contents: [
1984
+ {
1985
+ uri: uri.href,
1986
+ mimeType: "text/markdown",
1987
+ text
1988
+ }
1989
+ ]
1990
+ };
1991
+ } catch (error) {
1992
+ const message = error instanceof Error ? error.message : String(error);
1993
+ return {
1994
+ contents: [
1995
+ {
1996
+ uri: uri.href,
1997
+ mimeType: "text/markdown",
1998
+ text: `# Fossel context unavailable
1999
+
2000
+ ${message}`
2001
+ }
2002
+ ]
2003
+ };
2004
+ }
2005
+ }
2006
+ );
2007
+ }
767
2008
  async function startServer() {
768
2009
  const dbPath = resolveDbPath();
769
2010
  initDb(dbPath);
770
2011
  const server = new McpServer({
771
2012
  name: "fossel",
772
- version: "1.0.0"
2013
+ version: "1.1.0"
773
2014
  });
2015
+ registerRememberTool(server);
2016
+ registerGetContextTool(server);
2017
+ registerResolveRepoTool(server);
2018
+ registerDedupeRepoTool(server);
774
2019
  registerStoreContextTool(server);
775
2020
  registerGetRepoContextTool(server);
776
2021
  registerSearchMemoryTool(server);
@@ -779,6 +2024,7 @@ async function startServer() {
779
2024
  registerPinMemoryTool(server);
780
2025
  registerUnpinMemoryTool(server);
781
2026
  registerSummarizeRepoContextTool(server);
2027
+ registerStartupContextResource(server);
782
2028
  const transport = new StdioServerTransport();
783
2029
  await server.connect(transport);
784
2030
  }