fossel 1.0.9 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +125 -45
  2. package/dist/cli.js +2002 -257
  3. package/dist/index.js +1471 -102
  4. package/package.json +3 -2
package/dist/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,23 +195,615 @@ 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 normalizeNoteForReadDedupe(text) {
216
+ return text.toLowerCase().replace(/[^a-z0-9\s]/g, " ").replace(/\s+/g, " ").trim();
217
+ }
218
+ function buildFtsQuery(query) {
219
+ const terms = query.trim().split(/\s+/).map((term) => term.replace(/"/g, '""')).filter((term) => term.length > 0);
220
+ if (terms.length === 0) {
221
+ return null;
222
+ }
223
+ return terms.map((term) => `"${term}"`).join(" AND ");
224
+ }
225
+ function fetchRepoContext(db, repo, limit, query) {
226
+ const rows = [];
227
+ const seen = /* @__PURE__ */ new Set();
228
+ const seenNormalized = /* @__PURE__ */ new Set();
229
+ const push = (memory, source, rank) => {
230
+ if (seen.has(memory.row_id)) {
231
+ return;
232
+ }
233
+ const normalized = normalizeNoteForReadDedupe(memory.note);
234
+ if (normalized && seenNormalized.has(normalized)) {
235
+ return;
236
+ }
237
+ seen.add(memory.row_id);
238
+ if (normalized) {
239
+ seenNormalized.add(normalized);
240
+ }
241
+ rows.push({ ...memory, source, rank });
242
+ };
243
+ const pinned = db.prepare(
244
+ `
245
+ SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned
246
+ FROM memories
247
+ WHERE repo = ? AND pinned = 1
248
+ ORDER BY updated_at DESC
249
+ LIMIT ?
250
+ `
251
+ ).all(repo, limit);
252
+ for (const row of pinned) {
253
+ push(row, "pinned");
254
+ }
255
+ if (rows.length < limit) {
256
+ const recent = db.prepare(
257
+ `
258
+ SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned
259
+ FROM memories
260
+ WHERE repo = ? AND pinned = 0
261
+ ORDER BY updated_at DESC
262
+ LIMIT ?
263
+ `
264
+ ).all(repo, limit - rows.length);
265
+ for (const row of recent) {
266
+ push(row, "recent");
267
+ }
268
+ }
269
+ if (query && rows.length < limit) {
270
+ const ftsQuery = buildFtsQuery(query);
271
+ if (ftsQuery) {
272
+ try {
273
+ const matches = db.prepare(
274
+ `
275
+ SELECT m.rowid AS row_id, m.id, m.repo, m.type, m.note, m.tags,
276
+ m.created_at, m.updated_at, m.pinned, bm25(memories_fts) AS rank
277
+ FROM memories_fts
278
+ JOIN memories AS m ON m.rowid = memories_fts.rowid
279
+ WHERE memories_fts MATCH ? AND m.repo = ?
280
+ ORDER BY rank
281
+ LIMIT ?
282
+ `
283
+ ).all(ftsQuery, repo, limit);
284
+ for (const row of matches) {
285
+ push(row, "search", row.rank);
286
+ if (rows.length >= limit) {
287
+ break;
288
+ }
289
+ }
290
+ } catch {
291
+ }
292
+ }
293
+ }
294
+ return rows.slice(0, limit);
295
+ }
296
+ function formatContext(rows, options) {
297
+ const { repo, query, format = "text" } = options;
298
+ if (rows.length === 0) {
299
+ if (format === "markdown") {
300
+ return `# Fossel context: ${repo}
301
+
302
+ No memories found${query ? ` for "${query}"` : ""}.`;
303
+ }
304
+ return `No memories found for ${repo}${query ? ` matching "${query}"` : ""}.`;
305
+ }
306
+ if (format === "markdown") {
307
+ return formatMarkdown(rows, repo, query);
308
+ }
309
+ return formatText(rows, repo, query);
310
+ }
311
+ function formatMarkdown(rows, repo, query) {
312
+ const sections = [`# Fossel context: ${repo}`];
313
+ if (query) {
314
+ sections.push(`Query: \`${query}\``);
315
+ }
316
+ const pinned = rows.filter((row) => row.pinned === 1);
317
+ if (pinned.length > 0) {
318
+ sections.push(["## \u{1F4CC} Pinned", ...pinned.map(renderMarkdownRow)].join("\n"));
319
+ }
320
+ for (const type of MEMORY_TYPES) {
321
+ const entries = rows.filter((row) => row.pinned !== 1 && row.type === type);
322
+ if (entries.length === 0) {
323
+ continue;
324
+ }
325
+ sections.push(
326
+ [`## ${SECTION_TITLES[type]}`, ...entries.map(renderMarkdownRow)].join("\n")
327
+ );
328
+ }
329
+ return sections.join("\n\n");
330
+ }
331
+ function renderMarkdownRow(row) {
332
+ const tags = parseTags(row.tags);
333
+ const tagSuffix = tags.length > 0 ? ` _(${tags.join(", ")})_` : "";
334
+ return `- (${row.row_id}) ${row.note}${tagSuffix}`;
335
+ }
336
+ function formatText(rows, repo, query) {
337
+ const header = query ? `Repository context for ${repo} (query: "${query}")` : `Repository context for ${repo}`;
338
+ const lines = [header, `Total: ${rows.length}`, ""];
339
+ for (const row of rows) {
340
+ const tags = parseTags(row.tags);
341
+ const tagSuffix = tags.length > 0 ? ` [tags: ${tags.join(", ")}]` : "";
342
+ const pinPrefix = row.pinned ? "\u{1F4CC} " : "";
343
+ const sourceLabel = row.source === "search" ? " [match]" : "";
344
+ lines.push(
345
+ `- (${row.row_id} | ${row.type})${sourceLabel} ${pinPrefix}${row.note}${tagSuffix}`
346
+ );
347
+ }
348
+ return lines.join("\n");
349
+ }
350
+
351
+ // src/lib/repo.ts
352
+ import { spawnSync } from "child_process";
353
+ import { basename } from "path";
354
+ var REMOTE_PATTERNS = [
355
+ // git@github.com:owner/repo.git, git@gitlab.com:group/sub/repo.git
356
+ /^[^@\s]+@([^:]+):([^\s]+?)(?:\.git)?$/,
357
+ // ssh://git@github.com/owner/repo.git
358
+ /^ssh:\/\/[^@/]+@([^/]+)\/([^\s]+?)(?:\.git)?$/,
359
+ // https://github.com/owner/repo.git, http://gitlab.com/group/sub/repo
360
+ /^https?:\/\/(?:[^@/]+@)?([^/]+)\/([^\s]+?)(?:\.git)?$/,
361
+ // git://github.com/owner/repo.git
362
+ /^git:\/\/([^/]+)\/([^\s]+?)(?:\.git)?$/
363
+ ];
364
+ function normalizeGitRemote(remoteUrl) {
365
+ const trimmed = remoteUrl.trim();
366
+ if (!trimmed) {
367
+ return null;
368
+ }
369
+ for (const pattern of REMOTE_PATTERNS) {
370
+ const match = pattern.exec(trimmed);
371
+ if (!match) {
372
+ continue;
373
+ }
374
+ const path = match[2]?.replace(/^\/+/, "").replace(/\\/g, "/").replace(/\.git$/i, "").replace(/\/+$/, "");
375
+ if (!path) {
376
+ continue;
377
+ }
378
+ return path;
379
+ }
380
+ return null;
381
+ }
382
+ function readGitRemote(cwd) {
383
+ const result = spawnSync("git", ["remote", "get-url", "origin"], {
384
+ cwd,
385
+ encoding: "utf8"
386
+ });
387
+ if (result.status !== 0) {
388
+ return null;
389
+ }
390
+ const value = result.stdout.trim();
391
+ return value.length > 0 ? value : null;
392
+ }
393
+ function detectFolderName(cwd) {
394
+ const name = basename(cwd);
395
+ return name.length > 0 ? name : cwd;
396
+ }
397
+ function fetchAliases(db, canonical) {
398
+ const rows = db.prepare("SELECT alias FROM repo_aliases WHERE canonical = ? ORDER BY alias").all(canonical);
399
+ return rows.map((row) => row.alias);
400
+ }
401
+ function upsertAlias(db, alias, canonical) {
402
+ const trimmed = alias.trim();
403
+ const target = canonical.trim();
404
+ if (!trimmed || !target) {
405
+ return;
406
+ }
407
+ const now = Math.floor(Date.now() / 1e3);
408
+ db.prepare(
409
+ `
410
+ INSERT INTO repo_aliases (alias, canonical, created_at)
411
+ VALUES (?, ?, ?)
412
+ ON CONFLICT(alias) DO UPDATE SET canonical = excluded.canonical
413
+ `
414
+ ).run(trimmed, target, now);
415
+ }
416
+ function lookupAlias(db, alias) {
417
+ const row = db.prepare("SELECT alias, canonical FROM repo_aliases WHERE alias = ?").get(alias);
418
+ return row ?? null;
419
+ }
420
+ function resolveRepo(cwd, db) {
421
+ const gitRemote = readGitRemote(cwd);
422
+ const fromRemote = gitRemote ? normalizeGitRemote(gitRemote) : null;
423
+ const folder = detectFolderName(cwd);
424
+ let canonical;
425
+ let source;
426
+ if (fromRemote) {
427
+ canonical = fromRemote;
428
+ source = "git-remote";
429
+ } else {
430
+ canonical = folder;
431
+ source = "folder";
432
+ }
433
+ upsertAlias(db, canonical, canonical);
434
+ if (folder && folder !== canonical) {
435
+ const existing = lookupAlias(db, folder);
436
+ if (!existing) {
437
+ upsertAlias(db, folder, canonical);
438
+ }
439
+ }
440
+ return {
441
+ canonical,
442
+ cwd,
443
+ gitRemote,
444
+ source,
445
+ aliases: fetchAliases(db, canonical)
446
+ };
447
+ }
448
+ function resolveRepoArg(input, cwd, db) {
449
+ const trimmed = input?.trim();
450
+ if (!trimmed) {
451
+ return resolveRepo(cwd, db);
452
+ }
453
+ const aliasRow = lookupAlias(db, trimmed);
454
+ if (aliasRow) {
455
+ return {
456
+ canonical: aliasRow.canonical,
457
+ cwd,
458
+ gitRemote: null,
459
+ source: "alias",
460
+ aliases: fetchAliases(db, aliasRow.canonical)
461
+ };
462
+ }
463
+ const workspace = resolveRepo(cwd, db);
464
+ if (workspace.canonical && workspace.canonical !== trimmed) {
465
+ const tail = workspace.canonical.split("/").at(-1) ?? workspace.canonical;
466
+ const inputTail = trimmed.split("/").at(-1) ?? trimmed;
467
+ if (tail === inputTail || tail === trimmed || inputTail === workspace.canonical) {
468
+ upsertAlias(db, trimmed, workspace.canonical);
469
+ return {
470
+ ...workspace,
471
+ source: "alias",
472
+ aliases: fetchAliases(db, workspace.canonical)
473
+ };
474
+ }
475
+ }
476
+ upsertAlias(db, trimmed, trimmed);
477
+ return {
478
+ canonical: trimmed,
479
+ cwd,
480
+ gitRemote: null,
481
+ source: "input",
482
+ aliases: fetchAliases(db, trimmed)
483
+ };
484
+ }
485
+
486
+ // src/lib/workspace.ts
487
+ function getWorkspaceRoot() {
488
+ const fromEnv = process.env.FOSSEL_WORKSPACE?.trim();
489
+ if (fromEnv) {
490
+ return fromEnv;
491
+ }
492
+ return process.cwd();
493
+ }
494
+
495
+ // src/tools/dedupe-repo.ts
143
496
  import { z } from "zod";
497
+
498
+ // src/lib/dedupe.ts
499
+ var DEFAULT_THRESHOLD = 0.82;
500
+ var DEFAULT_CANDIDATE_LIMIT = 200;
501
+ function normalizeText(text) {
502
+ return text.toLowerCase().replace(/[^a-z0-9\s]/g, " ").replace(/\s+/g, " ").trim();
503
+ }
504
+ function tokenize(text) {
505
+ return normalizeText(text).split(" ").filter((token) => token.length >= 2);
506
+ }
507
+ function trigrams(text) {
508
+ const padded = ` ${text} `;
509
+ const grams = /* @__PURE__ */ new Set();
510
+ for (let i = 0; i < padded.length - 2; i += 1) {
511
+ grams.add(padded.slice(i, i + 3));
512
+ }
513
+ return grams;
514
+ }
515
+ function jaccard(a, b) {
516
+ if (a.size === 0 && b.size === 0) {
517
+ return 1;
518
+ }
519
+ let intersection = 0;
520
+ for (const value of a) {
521
+ if (b.has(value)) {
522
+ intersection += 1;
523
+ }
524
+ }
525
+ const union = a.size + b.size - intersection;
526
+ return union === 0 ? 0 : intersection / union;
527
+ }
528
+ function similarity(a, b) {
529
+ const normalizedA = normalizeText(a);
530
+ const normalizedB = normalizeText(b);
531
+ if (!normalizedA && !normalizedB) {
532
+ return 1;
533
+ }
534
+ if (!normalizedA || !normalizedB) {
535
+ return 0;
536
+ }
537
+ if (normalizedA === normalizedB) {
538
+ return 1;
539
+ }
540
+ const wordScore = jaccard(new Set(tokenize(normalizedA)), new Set(tokenize(normalizedB)));
541
+ const triScore = jaccard(trigrams(normalizedA), trigrams(normalizedB));
542
+ return wordScore * 0.55 + triScore * 0.45;
543
+ }
544
+ function findDuplicate(db, repo, note, options = {}) {
545
+ const threshold = options.threshold ?? DEFAULT_THRESHOLD;
546
+ const limit = options.candidateLimit ?? DEFAULT_CANDIDATE_LIMIT;
547
+ const normalized = normalizeText(note);
548
+ if (!normalized) {
549
+ return null;
550
+ }
551
+ const exact = db.prepare(
552
+ `
553
+ SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned
554
+ FROM memories
555
+ WHERE repo = ? AND note_normalized = ?
556
+ ORDER BY updated_at DESC
557
+ LIMIT 1
558
+ `
559
+ ).get(repo, normalized);
560
+ if (exact) {
561
+ return { memory: exact, similarity: 1 };
562
+ }
563
+ const candidates = db.prepare(
564
+ `
565
+ SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned
566
+ FROM memories
567
+ WHERE repo = ?
568
+ ORDER BY updated_at DESC
569
+ LIMIT ?
570
+ `
571
+ ).all(repo, limit);
572
+ let best = null;
573
+ for (const candidate of candidates) {
574
+ const score = similarity(note, candidate.note);
575
+ if (score >= threshold && (!best || score > best.similarity)) {
576
+ best = { memory: candidate, similarity: score };
577
+ }
578
+ }
579
+ return best;
580
+ }
581
+
582
+ // src/tools/dedupe-repo.ts
583
+ var dedupeRepoInputSchema = {
584
+ repo: z.string().trim().min(1).optional(),
585
+ threshold: z.number().min(0.5).max(1).default(0.85),
586
+ apply: z.boolean().default(false)
587
+ };
588
+ function parseTags2(raw) {
589
+ try {
590
+ const parsed = JSON.parse(raw);
591
+ return Array.isArray(parsed) ? parsed.filter((value) => typeof value === "string") : [];
592
+ } catch {
593
+ return [];
594
+ }
595
+ }
596
+ function parseMetadata(raw) {
597
+ try {
598
+ const parsed = JSON.parse(raw);
599
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
600
+ return parsed;
601
+ }
602
+ } catch {
603
+ }
604
+ return {};
605
+ }
606
+ function mergeTagLists(...lists) {
607
+ const seen = /* @__PURE__ */ new Set();
608
+ const out = [];
609
+ for (const list of lists) {
610
+ for (const value of list) {
611
+ const trimmed = value.trim().toLowerCase();
612
+ if (!trimmed || seen.has(trimmed)) continue;
613
+ seen.add(trimmed);
614
+ out.push(trimmed);
615
+ }
616
+ }
617
+ return out;
618
+ }
619
+ function registerDedupeRepoTool(server) {
620
+ server.registerTool(
621
+ "dedupe_repo",
622
+ {
623
+ 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.",
624
+ inputSchema: dedupeRepoInputSchema
625
+ },
626
+ async ({ repo, threshold, apply }) => {
627
+ try {
628
+ const db = getDb();
629
+ const resolved = resolveRepoArg(repo, getWorkspaceRoot(), db);
630
+ const rows = db.prepare(
631
+ `
632
+ SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned, metadata_json
633
+ FROM memories
634
+ WHERE repo = ?
635
+ ORDER BY updated_at DESC
636
+ `
637
+ ).all(resolved.canonical);
638
+ if (rows.length < 2) {
639
+ return {
640
+ content: [
641
+ {
642
+ type: "text",
643
+ text: `No duplicates possible: only ${rows.length} memory in ${resolved.canonical}.`
644
+ }
645
+ ]
646
+ };
647
+ }
648
+ const consumed = /* @__PURE__ */ new Set();
649
+ const plan = [];
650
+ for (let i = 0; i < rows.length; i += 1) {
651
+ const keep = rows[i];
652
+ if (!keep || consumed.has(keep.row_id)) continue;
653
+ for (let j = i + 1; j < rows.length; j += 1) {
654
+ const other = rows[j];
655
+ if (!other || consumed.has(other.row_id)) continue;
656
+ if (other.type !== keep.type) continue;
657
+ const score = similarity(keep.note, other.note);
658
+ if (score >= threshold) {
659
+ plan.push({ keep: keep.row_id, drop: other.row_id, similarity: score });
660
+ consumed.add(other.row_id);
661
+ }
662
+ }
663
+ }
664
+ if (plan.length === 0) {
665
+ return {
666
+ content: [
667
+ {
668
+ type: "text",
669
+ text: `No duplicates \u2265 ${threshold} found in ${resolved.canonical} (${rows.length} memories scanned).`
670
+ }
671
+ ]
672
+ };
673
+ }
674
+ if (!apply) {
675
+ const lines = plan.map(
676
+ (entry) => `- keep ${entry.keep}, drop ${entry.drop} (similarity ${entry.similarity.toFixed(2)})`
677
+ );
678
+ return {
679
+ content: [
680
+ {
681
+ type: "text",
682
+ text: `Dry run for ${resolved.canonical}. Found ${plan.length} duplicate pair(s):
683
+ ${lines.join("\n")}
684
+
685
+ Re-run with apply=true to merge.`
686
+ }
687
+ ]
688
+ };
689
+ }
690
+ const byId = new Map(rows.map((row) => [row.row_id, row]));
691
+ const now = Math.floor(Date.now() / 1e3);
692
+ let merged = 0;
693
+ const tx = db.transaction((entries) => {
694
+ for (const entry of entries) {
695
+ const keep = byId.get(entry.keep);
696
+ const drop = byId.get(entry.drop);
697
+ if (!keep || !drop) continue;
698
+ const longerNote = keep.note.length >= drop.note.length ? keep.note : drop.note;
699
+ const mergedTags = mergeTagLists(parseTags2(keep.tags), parseTags2(drop.tags));
700
+ const metadata = parseMetadata(keep.metadata_json);
701
+ const changelog = metadata.changelog ?? [];
702
+ changelog.push({
703
+ at: now,
704
+ action: "deduped",
705
+ similarity: Number(entry.similarity.toFixed(3)),
706
+ merged_from: drop.row_id,
707
+ previous_note: drop.note
708
+ });
709
+ metadata.changelog = changelog;
710
+ db.prepare(
711
+ `
712
+ UPDATE memories
713
+ SET note = ?, note_normalized = ?, tags = ?, metadata_json = ?, updated_at = ?,
714
+ pinned = CASE WHEN pinned = 1 OR ? = 1 THEN 1 ELSE pinned END
715
+ WHERE rowid = ?
716
+ `
717
+ ).run(
718
+ longerNote,
719
+ normalizeText(longerNote),
720
+ JSON.stringify(mergedTags),
721
+ JSON.stringify(metadata),
722
+ now,
723
+ drop.pinned,
724
+ keep.row_id
725
+ );
726
+ db.prepare("DELETE FROM memories WHERE rowid = ?").run(drop.row_id);
727
+ merged += 1;
728
+ }
729
+ });
730
+ tx(plan);
731
+ return {
732
+ content: [
733
+ {
734
+ type: "text",
735
+ text: `Merged ${merged} duplicate pair(s) in ${resolved.canonical}.`
736
+ }
737
+ ]
738
+ };
739
+ } catch (error) {
740
+ const message = error instanceof Error ? error.message : "Unknown error while deduping repo.";
741
+ return {
742
+ isError: true,
743
+ content: [
744
+ {
745
+ type: "text",
746
+ text: `Failed to dedupe repo: ${message}`
747
+ }
748
+ ]
749
+ };
750
+ }
751
+ }
752
+ );
753
+ }
754
+
755
+ // src/tools/delete.ts
756
+ import { z as z2 } from "zod";
757
+
758
+ // src/lib/memory.ts
759
+ function findMemoryByAnyId(db, input) {
760
+ const numeric = typeof input === "number" ? input : Number(input);
761
+ const isNumericId = Number.isInteger(numeric) && numeric > 0 && String(numeric) === String(input).trim();
762
+ if (isNumericId) {
763
+ const row = db.prepare(
764
+ `
765
+ SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned
766
+ FROM memories
767
+ WHERE rowid = ?
768
+ `
769
+ ).get(numeric);
770
+ if (row) {
771
+ return row;
772
+ }
773
+ }
774
+ const stringInput = String(input).trim();
775
+ if (stringInput.length === 0) {
776
+ return null;
777
+ }
778
+ const stringRow = db.prepare(
779
+ `
780
+ SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned
781
+ FROM memories
782
+ WHERE id = ?
783
+ `
784
+ ).get(stringInput);
785
+ return stringRow ?? null;
786
+ }
787
+
788
+ // src/tools/delete.ts
144
789
  var deleteMemoryInputSchema = {
145
- id: z.string().trim().min(1, "id is required")
790
+ // Accept either the numeric row_id or the legacy nanoid string. Tools used
791
+ // to disagree about which form to take; this unifies them so callers can
792
+ // paste whichever id they have in front of them.
793
+ id: z2.union([z2.number().int().positive(), z2.string().trim().min(1)])
146
794
  };
147
795
  function registerDeleteMemoryTool(server) {
148
796
  server.registerTool(
149
797
  "delete_memory",
150
798
  {
151
- description: "Delete a memory from storage by id.",
799
+ description: "Delete a memory from storage by id. Accepts either the numeric row id or the legacy string id.",
152
800
  inputSchema: deleteMemoryInputSchema
153
801
  },
154
802
  async ({ id }) => {
155
803
  try {
156
804
  const db = getDb();
157
- const row = db.prepare("SELECT id FROM memories WHERE id = ?").get(id);
158
- if (!row) {
805
+ const memory = findMemoryByAnyId(db, id);
806
+ if (!memory) {
159
807
  return {
160
808
  isError: true,
161
809
  content: [
@@ -166,26 +814,75 @@ function registerDeleteMemoryTool(server) {
166
814
  ]
167
815
  };
168
816
  }
169
- const deleteTx = db.transaction((memoryId) => {
170
- db.prepare("DELETE FROM memories WHERE id = ?").run(memoryId);
817
+ const deleteTx = db.transaction((rowId) => {
818
+ db.prepare("DELETE FROM memories WHERE rowid = ?").run(rowId);
819
+ });
820
+ deleteTx(memory.row_id);
821
+ return {
822
+ content: [
823
+ {
824
+ type: "text",
825
+ text: `Deleted memory ${memory.row_id} (legacy: ${memory.id}).`
826
+ }
827
+ ]
828
+ };
829
+ } catch (error) {
830
+ const message = error instanceof Error ? error.message : "Unknown error while deleting memory.";
831
+ return {
832
+ isError: true,
833
+ content: [
834
+ {
835
+ type: "text",
836
+ text: `Failed to delete memory: ${message}`
837
+ }
838
+ ]
839
+ };
840
+ }
841
+ }
842
+ );
843
+ }
844
+
845
+ // src/tools/get-context.ts
846
+ import { z as z3 } from "zod";
847
+ var getContextInputSchema = {
848
+ repo: z3.string().trim().min(1).optional(),
849
+ query: z3.string().trim().min(1).optional(),
850
+ limit: z3.number().int().positive().max(50).default(8),
851
+ format: z3.enum(["text", "markdown"]).default("text")
852
+ };
853
+ function registerGetContextTool(server) {
854
+ server.registerTool(
855
+ "get_context",
856
+ {
857
+ 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.",
858
+ inputSchema: getContextInputSchema
859
+ },
860
+ async ({ repo, query, limit, format }) => {
861
+ try {
862
+ const db = getDb();
863
+ const resolved = resolveRepoArg(repo, getWorkspaceRoot(), db);
864
+ const rows = fetchRepoContext(db, resolved.canonical, limit, query);
865
+ const text = formatContext(rows, {
866
+ repo: resolved.canonical,
867
+ query,
868
+ format
171
869
  });
172
- deleteTx(id);
173
870
  return {
174
871
  content: [
175
872
  {
176
873
  type: "text",
177
- text: `Deleted memory ${id}.`
874
+ text
178
875
  }
179
876
  ]
180
877
  };
181
878
  } catch (error) {
182
- const message = error instanceof Error ? error.message : "Unknown error while deleting memory.";
879
+ const message = error instanceof Error ? error.message : "Unknown error while fetching context.";
183
880
  return {
184
881
  isError: true,
185
882
  content: [
186
883
  {
187
884
  type: "text",
188
- text: `Failed to delete memory: ${message}`
885
+ text: `Failed to fetch context: ${message}`
189
886
  }
190
887
  ]
191
888
  };
@@ -195,12 +892,12 @@ function registerDeleteMemoryTool(server) {
195
892
  }
196
893
 
197
894
  // src/tools/get-repo.ts
198
- import { z as z2 } from "zod";
895
+ import { z as z4 } from "zod";
199
896
  var getRepoContextInputSchema = {
200
- repo: z2.string().trim().min(1, "repo is required"),
201
- limit: z2.number().int().positive().max(100).default(10)
897
+ repo: z4.string().trim().min(1).optional(),
898
+ limit: z4.number().int().positive().max(100).default(10)
202
899
  };
203
- function parseTags(raw) {
900
+ function parseTags3(raw) {
204
901
  try {
205
902
  const parsed = JSON.parse(raw);
206
903
  return Array.isArray(parsed) ? parsed.filter((value) => typeof value === "string") : [];
@@ -215,12 +912,13 @@ function registerGetRepoContextTool(server) {
215
912
  server.registerTool(
216
913
  "get_repo_context",
217
914
  {
218
- description: "Get recent memories for a repository grouped by memory type.",
915
+ 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
916
  inputSchema: getRepoContextInputSchema
220
917
  },
221
918
  async ({ repo, limit }) => {
222
919
  try {
223
920
  const db = getDb();
921
+ const resolved = resolveRepoArg(repo, getWorkspaceRoot(), db);
224
922
  const rows = db.prepare(
225
923
  `
226
924
  SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned
@@ -229,20 +927,20 @@ function registerGetRepoContextTool(server) {
229
927
  ORDER BY pinned DESC, updated_at DESC
230
928
  LIMIT ?
231
929
  `
232
- ).all(repo, limit);
930
+ ).all(resolved.canonical, limit);
233
931
  if (rows.length === 0) {
234
932
  return {
235
933
  content: [
236
934
  {
237
935
  type: "text",
238
- text: `No memories found for ${repo}.`
936
+ text: `No memories found for ${resolved.canonical}.`
239
937
  }
240
938
  ]
241
939
  };
242
940
  }
243
941
  const grouped = /* @__PURE__ */ new Map();
244
942
  for (const memory of rows) {
245
- const tags = parseTags(memory.tags);
943
+ const tags = parseTags3(memory.tags);
246
944
  const tagSuffix = tags.length > 0 ? ` [tags: ${tags.join(", ")}]` : "";
247
945
  const pinPrefix = memory.pinned ? "\u{1F4CC} Pinned " : "";
248
946
  const item = `- (${memory.row_id} | legacy: ${memory.id}) ${pinPrefix}${memory.note}${tagSuffix}`;
@@ -263,7 +961,7 @@ ${entries.join("\n")}`);
263
961
  content: [
264
962
  {
265
963
  type: "text",
266
- text: `Repository context for ${repo}
964
+ text: `Repository context for ${resolved.canonical}
267
965
  Total memories: ${rows.length}
268
966
 
269
967
  ${sections.join("\n\n")}`
@@ -287,11 +985,12 @@ ${sections.join("\n\n")}`
287
985
  }
288
986
 
289
987
  // src/tools/pin.ts
290
- import { z as z3 } from "zod";
988
+ import { z as z5 } from "zod";
291
989
  var pinInputSchema = {
292
- id: z3.number().int().positive()
990
+ // Accept numeric row_id or legacy string id for parity with the other tools.
991
+ id: z5.union([z5.number().int().positive(), z5.string().trim().min(1)])
293
992
  };
294
- function setPinnedState(memoryId, pinned) {
993
+ function setPinnedState(rowId, pinned) {
295
994
  const db = getDb();
296
995
  const now = Math.floor(Date.now() / 1e3);
297
996
  const updateResult = db.prepare(
@@ -300,7 +999,7 @@ function setPinnedState(memoryId, pinned) {
300
999
  SET pinned = ?, updated_at = ?
301
1000
  WHERE rowid = ?
302
1001
  `
303
- ).run(pinned, now, memoryId);
1002
+ ).run(pinned, now, rowId);
304
1003
  if (updateResult.changes === 0) {
305
1004
  return null;
306
1005
  }
@@ -310,7 +1009,7 @@ function setPinnedState(memoryId, pinned) {
310
1009
  FROM memories
311
1010
  WHERE rowid = ?
312
1011
  `
313
- ).get(memoryId);
1012
+ ).get(rowId);
314
1013
  }
315
1014
  function registerPinMemoryTool(server) {
316
1015
  server.registerTool(
@@ -321,7 +1020,20 @@ function registerPinMemoryTool(server) {
321
1020
  },
322
1021
  async ({ id }) => {
323
1022
  try {
324
- const memory = setPinnedState(id, 1);
1023
+ const db = getDb();
1024
+ const target = findMemoryByAnyId(db, id);
1025
+ if (!target) {
1026
+ return {
1027
+ isError: true,
1028
+ content: [
1029
+ {
1030
+ type: "text",
1031
+ text: `Memory ${id} not found.`
1032
+ }
1033
+ ]
1034
+ };
1035
+ }
1036
+ const memory = setPinnedState(target.row_id, 1);
325
1037
  if (!memory) {
326
1038
  return {
327
1039
  isError: true,
@@ -365,7 +1077,20 @@ function registerUnpinMemoryTool(server) {
365
1077
  },
366
1078
  async ({ id }) => {
367
1079
  try {
368
- const memory = setPinnedState(id, 0);
1080
+ const db = getDb();
1081
+ const target = findMemoryByAnyId(db, id);
1082
+ if (!target) {
1083
+ return {
1084
+ isError: true,
1085
+ content: [
1086
+ {
1087
+ type: "text",
1088
+ text: `Memory ${id} not found.`
1089
+ }
1090
+ ]
1091
+ };
1092
+ }
1093
+ const memory = setPinnedState(target.row_id, 0);
369
1094
  if (!memory) {
370
1095
  return {
371
1096
  isError: true,
@@ -401,21 +1126,560 @@ function registerUnpinMemoryTool(server) {
401
1126
  );
402
1127
  }
403
1128
 
1129
+ // src/tools/remember.ts
1130
+ import { nanoid } from "nanoid";
1131
+ import { z as z6 } from "zod";
1132
+
1133
+ // src/lib/inference.ts
1134
+ var TYPE_RULES = [
1135
+ {
1136
+ type: "bug_fix",
1137
+ patterns: [
1138
+ { pattern: /\broot cause\b/i, weight: 4 },
1139
+ { pattern: /\bregression\b/i, weight: 4 },
1140
+ { pattern: /\bhotfix\b/i, weight: 4 },
1141
+ { pattern: /\bfix(?:ed|es|ing)?\b/i, weight: 3 },
1142
+ { pattern: /\bbugs?\b/i, weight: 2 },
1143
+ { pattern: /\bcrash(?:ed|es|ing)?\b/i, weight: 2 },
1144
+ { pattern: /\bbroken\b/i, weight: 2 },
1145
+ { pattern: /\bworkaround\b/i, weight: 2 }
1146
+ ]
1147
+ },
1148
+ {
1149
+ type: "issue",
1150
+ patterns: [
1151
+ { pattern: /\bissue\s*#\d+/i, weight: 5 },
1152
+ { pattern: /\bticket\s*#?\w+/i, weight: 4 },
1153
+ { pattern: /\bjira[-\s]?\w+/i, weight: 4 },
1154
+ { pattern: /\bgh[-\s]?\d+/i, weight: 3 },
1155
+ { pattern: /#\d{2,}/i, weight: 2 }
1156
+ ]
1157
+ },
1158
+ {
1159
+ type: "decision",
1160
+ patterns: [
1161
+ { pattern: /\bdecided not to\b/i, weight: 5 },
1162
+ { pattern: /\bdecided to\b/i, weight: 4 },
1163
+ { pattern: /\bwe chose\b/i, weight: 4 },
1164
+ { pattern: /\bchose\s+\w+\s+over\b/i, weight: 4 },
1165
+ { pattern: /\barchitecture\b/i, weight: 3 },
1166
+ { pattern: /\bdecision\b/i, weight: 3 },
1167
+ { pattern: /\btrade[- ]?off\b/i, weight: 2 },
1168
+ { pattern: /\brfc\b/i, weight: 2 },
1169
+ { pattern: /\b(?:adopted|migrated to)\b/i, weight: 2 }
1170
+ ]
1171
+ },
1172
+ {
1173
+ type: "reviewer_pattern",
1174
+ patterns: [
1175
+ { pattern: /\breviewer(?:s)?\s+(?:prefer|want|expect|require)/i, weight: 5 },
1176
+ { pattern: /\bpr\s+style\b/i, weight: 4 },
1177
+ { pattern: /\bcode review\b/i, weight: 3 },
1178
+ { pattern: /\bprefer(?:s|red)?\b/i, weight: 2 },
1179
+ { pattern: /\breview comment\b/i, weight: 2 }
1180
+ ]
1181
+ },
1182
+ {
1183
+ type: "convention",
1184
+ patterns: [
1185
+ { pattern: /\bconvention\b/i, weight: 4 },
1186
+ { pattern: /\balways\b/i, weight: 2 },
1187
+ { pattern: /\bnever\b/i, weight: 2 },
1188
+ { pattern: /\bstandard\b/i, weight: 2 },
1189
+ { pattern: /\bstyle guide\b/i, weight: 3 },
1190
+ { pattern: /\buse\b\s+\w+\s+\bfor\b/i, weight: 1 }
1191
+ ]
1192
+ }
1193
+ ];
1194
+ var AUTH_KEYWORDS = /\b(?:auth|jwt|oauth|token|login|logout|session|sso|saml)\b/i;
1195
+ var CHOICE_KEYWORDS = /\b(?:chose|choose|decided|prefer|switched|migrated|adopted|over|instead of)\b/i;
1196
+ var TAG_KEYWORDS = [
1197
+ { tag: "auth", pattern: /\b(?:auth|authentication|authorization)\b/i },
1198
+ { tag: "jwt", pattern: /\bjwt\b/i },
1199
+ { tag: "oauth", pattern: /\boauth\b/i },
1200
+ { tag: "session", pattern: /\bsession(?:s)?\b/i },
1201
+ { tag: "api", pattern: /\bapi\b/i },
1202
+ { tag: "rest", pattern: /\brest(?:ful)?\b/i },
1203
+ { tag: "graphql", pattern: /\bgraphql\b/i },
1204
+ { tag: "websocket", pattern: /\bweb[- ]?socket(?:s)?\b/i },
1205
+ { tag: "database", pattern: /\b(?:database|db|sqlite|postgres|mysql|mongo)\b/i },
1206
+ { tag: "migration", pattern: /\bmigration(?:s)?\b/i },
1207
+ { tag: "schema", pattern: /\bschema\b/i },
1208
+ { tag: "frontend", pattern: /\b(?:frontend|ui|react|vue|svelte|next\.js|nextjs)\b/i },
1209
+ { tag: "backend", pattern: /\b(?:backend|server|node\.js|nodejs|express|fastify)\b/i },
1210
+ { tag: "testing", pattern: /\b(?:test|tests|testing|jest|vitest|pytest|rspec)\b/i },
1211
+ { tag: "ci", pattern: /\b(?:ci|cd|pipeline|github actions|gitlab ci)\b/i },
1212
+ { tag: "deployment", pattern: /\b(?:deploy|deployment|release|rollout)\b/i },
1213
+ { tag: "performance", pattern: /\b(?:performance|perf|latency|throughput)\b/i },
1214
+ { tag: "security", pattern: /\b(?:security|vuln|cve|xss|csrf|injection)\b/i },
1215
+ { tag: "logging", pattern: /\b(?:log|logging|telemetry|tracing)\b/i },
1216
+ { tag: "config", pattern: /\b(?:config|configuration|env|environment)\b/i },
1217
+ { tag: "routing", pattern: /\b(?:route|routing|router|endpoint)\b/i },
1218
+ { tag: "build", pattern: /\b(?:build|webpack|vite|tsup|rollup|esbuild)\b/i },
1219
+ { tag: "docs", pattern: /\b(?:docs|documentation|readme)\b/i }
1220
+ ];
1221
+ var STOP_WORDS = /* @__PURE__ */ new Set([
1222
+ "the",
1223
+ "a",
1224
+ "an",
1225
+ "and",
1226
+ "or",
1227
+ "but",
1228
+ "is",
1229
+ "are",
1230
+ "was",
1231
+ "were",
1232
+ "be",
1233
+ "been",
1234
+ "being",
1235
+ "to",
1236
+ "of",
1237
+ "in",
1238
+ "on",
1239
+ "for",
1240
+ "with",
1241
+ "by",
1242
+ "at",
1243
+ "from",
1244
+ "as",
1245
+ "that",
1246
+ "this",
1247
+ "it",
1248
+ "we",
1249
+ "our",
1250
+ "you",
1251
+ "your",
1252
+ "i",
1253
+ "my",
1254
+ "they",
1255
+ "their",
1256
+ "them",
1257
+ "he",
1258
+ "she",
1259
+ "his",
1260
+ "her",
1261
+ "if",
1262
+ "then",
1263
+ "than",
1264
+ "so",
1265
+ "do",
1266
+ "does",
1267
+ "did",
1268
+ "done",
1269
+ "not",
1270
+ "no",
1271
+ "yes",
1272
+ "can",
1273
+ "will",
1274
+ "would",
1275
+ "should",
1276
+ "could",
1277
+ "may",
1278
+ "might",
1279
+ "must",
1280
+ "have",
1281
+ "has",
1282
+ "had",
1283
+ "just",
1284
+ "also",
1285
+ "use",
1286
+ "used",
1287
+ "using",
1288
+ "want",
1289
+ "wants",
1290
+ "wanted",
1291
+ "need",
1292
+ "needs",
1293
+ "needed",
1294
+ "like",
1295
+ "now",
1296
+ "new",
1297
+ "old",
1298
+ "good",
1299
+ "bad",
1300
+ "make",
1301
+ "makes",
1302
+ "made",
1303
+ "get",
1304
+ "gets",
1305
+ "got",
1306
+ "set",
1307
+ "sets",
1308
+ "go",
1309
+ "going",
1310
+ "into",
1311
+ "over",
1312
+ "under",
1313
+ "through",
1314
+ "because",
1315
+ "when",
1316
+ "where",
1317
+ "while",
1318
+ "there",
1319
+ "here",
1320
+ "what",
1321
+ "which",
1322
+ "who",
1323
+ "why",
1324
+ "how",
1325
+ "live",
1326
+ "lives",
1327
+ "living",
1328
+ "keep",
1329
+ "kept",
1330
+ "keeps",
1331
+ "take",
1332
+ "takes",
1333
+ "took",
1334
+ "taken",
1335
+ "say",
1336
+ "says",
1337
+ "said",
1338
+ "tell",
1339
+ "tells",
1340
+ "told",
1341
+ "know",
1342
+ "knows",
1343
+ "known",
1344
+ "knew",
1345
+ "redirect",
1346
+ "redirects",
1347
+ "redirected",
1348
+ "redirecting",
1349
+ "user",
1350
+ "users",
1351
+ "page",
1352
+ "pages"
1353
+ ]);
1354
+ function inferMemoryType(text) {
1355
+ const scores = /* @__PURE__ */ new Map();
1356
+ for (const rule of TYPE_RULES) {
1357
+ let score = 0;
1358
+ for (const { pattern, weight } of rule.patterns) {
1359
+ if (pattern.test(text)) {
1360
+ score += weight;
1361
+ }
1362
+ }
1363
+ if (score > 0) {
1364
+ scores.set(rule.type, (scores.get(rule.type) ?? 0) + score);
1365
+ }
1366
+ }
1367
+ if (AUTH_KEYWORDS.test(text)) {
1368
+ if (CHOICE_KEYWORDS.test(text)) {
1369
+ scores.set("decision", (scores.get("decision") ?? 0) + 3);
1370
+ } else {
1371
+ scores.set("convention", (scores.get("convention") ?? 0) + 2);
1372
+ }
1373
+ }
1374
+ if (scores.size === 0) {
1375
+ return "convention";
1376
+ }
1377
+ let bestType = "convention";
1378
+ let bestScore = -1;
1379
+ for (const [type, score] of scores) {
1380
+ if (score > bestScore) {
1381
+ bestType = type;
1382
+ bestScore = score;
1383
+ }
1384
+ }
1385
+ return bestType;
1386
+ }
1387
+ function extractKeywordTags(text) {
1388
+ const found = [];
1389
+ for (const { tag, pattern } of TAG_KEYWORDS) {
1390
+ if (pattern.test(text)) {
1391
+ found.push(tag);
1392
+ }
1393
+ }
1394
+ return found;
1395
+ }
1396
+ function extractIdentifierTags(text) {
1397
+ const tokens = /* @__PURE__ */ new Set();
1398
+ const pathLike = text.match(/\/(?:[a-z0-9_-]+\/?){1,4}/gi);
1399
+ if (pathLike) {
1400
+ for (const segment of pathLike) {
1401
+ for (const part of segment.split("/")) {
1402
+ if (part.length >= 3 && /^[a-z0-9_-]+$/i.test(part)) {
1403
+ tokens.add(part.toLowerCase());
1404
+ }
1405
+ }
1406
+ }
1407
+ }
1408
+ 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);
1409
+ if (fileLike) {
1410
+ for (const file of fileLike) {
1411
+ const base = file.split(".").slice(0, -1).join(".");
1412
+ if (base.length >= 3) {
1413
+ tokens.add(base.toLowerCase());
1414
+ }
1415
+ }
1416
+ }
1417
+ return Array.from(tokens);
1418
+ }
1419
+ function extractSalientWords(text, limit) {
1420
+ const words = text.toLowerCase().replace(/[^a-z0-9\s/_-]/g, " ").split(/\s+/).filter((word) => word.length >= 4 && !STOP_WORDS.has(word));
1421
+ const counts = /* @__PURE__ */ new Map();
1422
+ for (const word of words) {
1423
+ counts.set(word, (counts.get(word) ?? 0) + 1);
1424
+ }
1425
+ return Array.from(counts.entries()).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, limit).map(([word]) => word);
1426
+ }
1427
+ function inferTags(text) {
1428
+ const ordered = [];
1429
+ const seen = /* @__PURE__ */ new Set();
1430
+ const push = (value) => {
1431
+ const normalized = value.trim().toLowerCase();
1432
+ if (!normalized || seen.has(normalized)) {
1433
+ return;
1434
+ }
1435
+ seen.add(normalized);
1436
+ ordered.push(normalized);
1437
+ };
1438
+ for (const tag of extractKeywordTags(text)) {
1439
+ push(tag);
1440
+ }
1441
+ for (const tag of extractIdentifierTags(text)) {
1442
+ push(tag);
1443
+ }
1444
+ if (ordered.length < 5) {
1445
+ for (const word of extractSalientWords(text, 8)) {
1446
+ push(word);
1447
+ if (ordered.length >= 5) {
1448
+ break;
1449
+ }
1450
+ }
1451
+ }
1452
+ return ordered.slice(0, 5);
1453
+ }
1454
+ function inferMemoryFromNote(text) {
1455
+ return {
1456
+ type: inferMemoryType(text),
1457
+ tags: inferTags(text)
1458
+ };
1459
+ }
1460
+
1461
+ // src/tools/remember.ts
1462
+ var rememberInputSchema = {
1463
+ note: z6.string().trim().min(1, "note is required"),
1464
+ repo: z6.string().trim().min(1).optional(),
1465
+ type: z6.enum(MEMORY_TYPES).optional(),
1466
+ tags: z6.array(z6.string().trim().min(1)).optional()
1467
+ };
1468
+ function mergeTagLists2(...lists) {
1469
+ const seen = /* @__PURE__ */ new Set();
1470
+ const out = [];
1471
+ for (const list of lists) {
1472
+ if (!list) continue;
1473
+ for (const raw of list) {
1474
+ const value = raw.trim().toLowerCase();
1475
+ if (!value || seen.has(value)) continue;
1476
+ seen.add(value);
1477
+ out.push(value);
1478
+ }
1479
+ }
1480
+ return out;
1481
+ }
1482
+ function parseStoredTags(raw) {
1483
+ try {
1484
+ const parsed = JSON.parse(raw);
1485
+ return Array.isArray(parsed) ? parsed.filter((value) => typeof value === "string") : [];
1486
+ } catch {
1487
+ return [];
1488
+ }
1489
+ }
1490
+ function parseStoredMetadata(raw) {
1491
+ try {
1492
+ const parsed = JSON.parse(raw);
1493
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
1494
+ return parsed;
1495
+ }
1496
+ } catch {
1497
+ }
1498
+ return {};
1499
+ }
1500
+ function registerRememberTool(server) {
1501
+ server.registerTool(
1502
+ "remember",
1503
+ {
1504
+ 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.",
1505
+ inputSchema: rememberInputSchema
1506
+ },
1507
+ async ({ note, repo, type, tags }) => {
1508
+ try {
1509
+ const db = getDb();
1510
+ const resolved = resolveRepoArg(repo, getWorkspaceRoot(), db);
1511
+ const inferred = inferMemoryFromNote(note);
1512
+ const finalType = type ?? inferred.type;
1513
+ const finalTags = mergeTagLists2(tags, inferred.tags).slice(0, 5);
1514
+ const now = Math.floor(Date.now() / 1e3);
1515
+ const duplicate = findDuplicate(db, resolved.canonical, note);
1516
+ if (duplicate) {
1517
+ const existing = duplicate.memory;
1518
+ const existingTags = parseStoredTags(existing.tags);
1519
+ const mergedTags = mergeTagLists2(existingTags, finalTags);
1520
+ const metadata2 = parseStoredMetadata(
1521
+ existing.metadata_json ?? "{}"
1522
+ );
1523
+ const changelog = metadata2.changelog ?? [];
1524
+ changelog.push({
1525
+ at: now,
1526
+ action: "merged",
1527
+ similarity: Number(duplicate.similarity.toFixed(3)),
1528
+ previous_note: existing.note
1529
+ });
1530
+ metadata2.changelog = changelog;
1531
+ const longerNote = note.length > existing.note.length ? note : existing.note;
1532
+ const nextType = type ?? existing.type;
1533
+ db.prepare(
1534
+ `
1535
+ UPDATE memories
1536
+ SET note = ?, note_normalized = ?, tags = ?, type = ?, metadata_json = ?, updated_at = ?
1537
+ WHERE rowid = ?
1538
+ `
1539
+ ).run(
1540
+ longerNote,
1541
+ normalizeText(longerNote),
1542
+ JSON.stringify(mergedTags),
1543
+ nextType,
1544
+ JSON.stringify(metadata2),
1545
+ now,
1546
+ existing.row_id
1547
+ );
1548
+ return {
1549
+ content: [
1550
+ {
1551
+ type: "text",
1552
+ text: `Merged into memory ${existing.row_id} for ${resolved.canonical} (similarity ${duplicate.similarity.toFixed(2)}, type ${nextType}, tags: ${mergedTags.join(", ") || "none"}).`
1553
+ }
1554
+ ]
1555
+ };
1556
+ }
1557
+ const id = nanoid();
1558
+ const metadata = {
1559
+ changelog: [
1560
+ {
1561
+ at: now,
1562
+ action: "created"
1563
+ }
1564
+ ],
1565
+ inferred: {
1566
+ type: inferred.type,
1567
+ tags: inferred.tags,
1568
+ type_overridden: type !== void 0
1569
+ }
1570
+ };
1571
+ db.prepare(
1572
+ `
1573
+ INSERT INTO memories (id, repo, type, note, tags, created_at, updated_at, pinned, metadata_json, note_normalized)
1574
+ VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?)
1575
+ `
1576
+ ).run(
1577
+ id,
1578
+ resolved.canonical,
1579
+ finalType,
1580
+ note,
1581
+ JSON.stringify(finalTags),
1582
+ now,
1583
+ now,
1584
+ JSON.stringify(metadata),
1585
+ normalizeText(note)
1586
+ );
1587
+ const inserted = db.prepare("SELECT rowid AS row_id FROM memories WHERE id = ?").get(id);
1588
+ return {
1589
+ content: [
1590
+ {
1591
+ type: "text",
1592
+ text: `Stored memory ${inserted?.row_id ?? "?"} for ${resolved.canonical} (type ${finalType}, tags: ${finalTags.join(", ") || "none"}).`
1593
+ }
1594
+ ]
1595
+ };
1596
+ } catch (error) {
1597
+ const message = error instanceof Error ? error.message : "Unknown error while remembering note.";
1598
+ return {
1599
+ isError: true,
1600
+ content: [
1601
+ {
1602
+ type: "text",
1603
+ text: `Failed to remember note: ${message}`
1604
+ }
1605
+ ]
1606
+ };
1607
+ }
1608
+ }
1609
+ );
1610
+ }
1611
+
1612
+ // src/tools/resolve-repo.ts
1613
+ import { z as z7 } from "zod";
1614
+ var resolveRepoInputSchema = {
1615
+ cwd: z7.string().trim().min(1).optional()
1616
+ };
1617
+ function registerResolveRepoTool(server) {
1618
+ server.registerTool(
1619
+ "resolve_repo",
1620
+ {
1621
+ 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.",
1622
+ inputSchema: resolveRepoInputSchema
1623
+ },
1624
+ async ({ cwd }) => {
1625
+ try {
1626
+ const db = getDb();
1627
+ const target = cwd?.trim() || getWorkspaceRoot();
1628
+ const resolved = resolveRepo(target, db);
1629
+ const payload = {
1630
+ canonical: resolved.canonical,
1631
+ aliases: resolved.aliases,
1632
+ cwd: resolved.cwd,
1633
+ gitRemote: resolved.gitRemote,
1634
+ source: resolved.source
1635
+ };
1636
+ return {
1637
+ content: [
1638
+ {
1639
+ type: "text",
1640
+ text: JSON.stringify(payload, null, 2)
1641
+ }
1642
+ ]
1643
+ };
1644
+ } catch (error) {
1645
+ const message = error instanceof Error ? error.message : "Unknown error while resolving repo.";
1646
+ return {
1647
+ isError: true,
1648
+ content: [
1649
+ {
1650
+ type: "text",
1651
+ text: `Failed to resolve repo: ${message}`
1652
+ }
1653
+ ]
1654
+ };
1655
+ }
1656
+ }
1657
+ );
1658
+ }
1659
+
404
1660
  // src/tools/search.ts
405
- import { z as z4 } from "zod";
1661
+ import { z as z8 } from "zod";
406
1662
  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)
1663
+ query: z8.string().trim().min(1, "query is required"),
1664
+ repo: z8.string().trim().min(1).optional(),
1665
+ limit: z8.number().int().positive().max(50).default(5)
410
1666
  };
411
- function normalizeFtsQuery(query) {
412
- const terms = query.trim().split(/\s+/).map((term) => term.replaceAll('"', '""')).filter(Boolean);
413
- if (terms.length === 0) {
414
- throw new Error("query must contain searchable text");
1667
+ function tokenizeQuery(query) {
1668
+ return query.toLowerCase().replace(/["()]/g, " ").split(/[\s/_\-.,;:!?]+/).map((token) => token.replace(/[^a-z0-9*]/g, "")).filter((token) => token.length >= 2);
1669
+ }
1670
+ function buildFtsQuery2(tokens) {
1671
+ if (tokens.length === 0) {
1672
+ return null;
415
1673
  }
416
- return terms.map((term) => `"${term}"`).join(" AND ");
1674
+ return tokens.map((token) => `"${token.replace(/"/g, '""')}"`).join(" AND ");
417
1675
  }
418
- function parseTags2(raw) {
1676
+ function buildFtsQueryOr(tokens) {
1677
+ if (tokens.length === 0) {
1678
+ return null;
1679
+ }
1680
+ return tokens.map((token) => `"${token.replace(/"/g, '""')}"`).join(" OR ");
1681
+ }
1682
+ function parseTags4(raw) {
419
1683
  try {
420
1684
  const parsed = JSON.parse(raw);
421
1685
  return Array.isArray(parsed) ? parsed.filter((value) => typeof value === "string") : [];
@@ -423,58 +1687,89 @@ function parseTags2(raw) {
423
1687
  return [];
424
1688
  }
425
1689
  }
1690
+ function runFts(ftsQuery, resolvedRepo, limit) {
1691
+ const db = getDb();
1692
+ try {
1693
+ if (resolvedRepo) {
1694
+ return db.prepare(
1695
+ `
1696
+ SELECT m.rowid AS row_id, m.id, m.repo, m.type, m.note, m.tags,
1697
+ m.created_at, m.updated_at, m.pinned, bm25(memories_fts) AS rank
1698
+ FROM memories_fts
1699
+ JOIN memories AS m ON m.rowid = memories_fts.rowid
1700
+ WHERE memories_fts MATCH ? AND m.repo = ?
1701
+ ORDER BY rank
1702
+ LIMIT ?
1703
+ `
1704
+ ).all(ftsQuery, resolvedRepo, limit);
1705
+ }
1706
+ return db.prepare(
1707
+ `
1708
+ SELECT m.rowid AS row_id, m.id, m.repo, m.type, m.note, m.tags,
1709
+ m.created_at, m.updated_at, m.pinned, bm25(memories_fts) AS rank
1710
+ FROM memories_fts
1711
+ JOIN memories AS m ON m.rowid = memories_fts.rowid
1712
+ WHERE memories_fts MATCH ?
1713
+ ORDER BY rank
1714
+ LIMIT ?
1715
+ `
1716
+ ).all(ftsQuery, limit);
1717
+ } catch {
1718
+ return [];
1719
+ }
1720
+ }
426
1721
  function registerSearchMemoryTool(server) {
427
1722
  server.registerTool(
428
1723
  "search_memory",
429
1724
  {
430
- description: "Search memories using full-text search with optional repository filtering.",
1725
+ description: "Search memories using full-text search with optional repository filtering. Falls back to recent + pinned context when the query has no exact matches.",
431
1726
  inputSchema: searchMemoryInputSchema
432
1727
  },
433
1728
  async ({ query, repo, limit }) => {
434
1729
  try {
435
1730
  const db = getDb();
436
- const ftsQuery = normalizeFtsQuery(query);
437
- const rows = repo ? db.prepare(
438
- `
439
- 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
- FROM memories_fts
441
- JOIN memories AS m ON m.rowid = memories_fts.rowid
442
- WHERE memories_fts MATCH ? AND m.repo = ?
443
- ORDER BY rank
444
- LIMIT ?
445
- `
446
- ).all(ftsQuery, repo, limit) : db.prepare(
447
- `
448
- 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
- FROM memories_fts
450
- JOIN memories AS m ON m.rowid = memories_fts.rowid
451
- WHERE memories_fts MATCH ?
452
- ORDER BY rank
453
- LIMIT ?
454
- `
455
- ).all(ftsQuery, limit);
1731
+ const tokens = tokenizeQuery(query);
1732
+ const resolvedRepo = repo ? resolveRepoArg(repo, getWorkspaceRoot(), db).canonical : void 0;
1733
+ const andQuery = buildFtsQuery2(tokens);
1734
+ let rows = [];
1735
+ if (andQuery) {
1736
+ rows = runFts(andQuery, resolvedRepo, limit);
1737
+ }
1738
+ if (rows.length === 0 && tokens.length > 1) {
1739
+ const orQuery = buildFtsQueryOr(tokens);
1740
+ if (orQuery) {
1741
+ rows = runFts(orQuery, resolvedRepo, limit);
1742
+ }
1743
+ }
1744
+ let usedFallback = false;
1745
+ if (rows.length === 0 && resolvedRepo) {
1746
+ const fallback = fetchRepoContext(db, resolvedRepo, limit);
1747
+ rows = fallback.map((row) => ({ ...row, rank: 0 }));
1748
+ usedFallback = fallback.length > 0;
1749
+ }
456
1750
  if (rows.length === 0) {
457
1751
  return {
458
1752
  content: [
459
1753
  {
460
1754
  type: "text",
461
- text: repo ? `No memories matched "${query}" in ${repo}.` : `No memories matched "${query}".`
1755
+ text: resolvedRepo ? `No memories matched "${query}" in ${resolvedRepo}.` : `No memories matched "${query}".`
462
1756
  }
463
1757
  ]
464
1758
  };
465
1759
  }
466
1760
  const formatted = rows.map((row, index) => {
467
- const tags = parseTags2(row.tags);
1761
+ const tags = parseTags4(row.tags);
468
1762
  const tagsText = tags.length > 0 ? ` | tags: ${tags.join(", ")}` : "";
469
1763
  const pinPrefix = row.pinned ? "\u{1F4CC} Pinned " : "";
470
1764
  return `${index + 1}. [${row.repo}] ${row.type} (${row.row_id} | legacy: ${row.id})
471
1765
  ${pinPrefix}${row.note}${tagsText}`;
472
1766
  }).join("\n\n");
1767
+ const header = usedFallback ? `No exact match for "${query}"${resolvedRepo ? ` in ${resolvedRepo}` : ""}; showing recent + pinned context:` : `Search results for "${query}"${resolvedRepo ? ` in ${resolvedRepo}` : ""}:`;
473
1768
  return {
474
1769
  content: [
475
1770
  {
476
1771
  type: "text",
477
- text: `Search results for "${query}"${repo ? ` in ${repo}` : ""}:
1772
+ text: `${header}
478
1773
 
479
1774
  ${formatted}`
480
1775
  }
@@ -497,35 +1792,45 @@ ${formatted}`
497
1792
  }
498
1793
 
499
1794
  // src/tools/store.ts
500
- import { nanoid } from "nanoid";
501
- import { z as z5 } from "zod";
1795
+ import { nanoid as nanoid2 } from "nanoid";
1796
+ import { z as z9 } from "zod";
502
1797
  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()
1798
+ repo: z9.string().trim().min(1).optional(),
1799
+ type: z9.enum(MEMORY_TYPES),
1800
+ note: z9.string().trim().min(1, "note is required"),
1801
+ tags: z9.array(z9.string().trim().min(1)).optional()
507
1802
  };
508
1803
  function registerStoreContextTool(server) {
509
1804
  server.registerTool(
510
1805
  "store_context",
511
1806
  {
512
- description: "Store repository-specific contributor context such as bug fixes, conventions, and decisions.",
1807
+ 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
1808
  inputSchema: storeContextInputSchema
514
1809
  },
515
1810
  async ({ repo, type, note, tags }) => {
516
1811
  try {
517
1812
  const db = getDb();
1813
+ const resolved = resolveRepoArg(repo, getWorkspaceRoot(), db);
518
1814
  const now = Math.floor(Date.now() / 1e3);
519
- const id = nanoid();
1815
+ const id = nanoid2();
520
1816
  const normalizedTags = Array.from(
521
1817
  new Set((tags ?? []).map((tag) => tag.trim()).filter(Boolean))
522
1818
  );
523
1819
  db.prepare(
524
1820
  `
525
- INSERT INTO memories (id, repo, type, note, tags, created_at, updated_at)
526
- VALUES (?, ?, ?, ?, ?, ?, ?)
1821
+ INSERT INTO memories (id, repo, type, note, tags, created_at, updated_at, pinned, metadata_json, note_normalized)
1822
+ VALUES (?, ?, ?, ?, ?, ?, ?, 0, '{}', ?)
527
1823
  `
528
- ).run(id, repo, type, note, JSON.stringify(normalizedTags), now, now);
1824
+ ).run(
1825
+ id,
1826
+ resolved.canonical,
1827
+ type,
1828
+ note,
1829
+ JSON.stringify(normalizedTags),
1830
+ now,
1831
+ now,
1832
+ normalizeText(note)
1833
+ );
529
1834
  const stored = db.prepare(
530
1835
  `
531
1836
  SELECT rowid AS row_id, id
@@ -537,7 +1842,7 @@ function registerStoreContextTool(server) {
537
1842
  content: [
538
1843
  {
539
1844
  type: "text",
540
- text: `Stored memory ${id} (numeric id: ${stored?.row_id ?? "unknown"}) for ${repo} (${type}).`
1845
+ text: `Stored memory ${id} (numeric id: ${stored?.row_id ?? "unknown"}) for ${resolved.canonical} (${type}).`
541
1846
  }
542
1847
  ]
543
1848
  };
@@ -558,9 +1863,9 @@ function registerStoreContextTool(server) {
558
1863
  }
559
1864
 
560
1865
  // src/tools/summarize.ts
561
- import { z as z6 } from "zod";
1866
+ import { z as z10 } from "zod";
562
1867
  var summarizeRepoContextInputSchema = {
563
- repo: z6.string().trim().min(1, "repo is required")
1868
+ repo: z10.string().trim().min(1).optional()
564
1869
  };
565
1870
  var sectionTitleByType = {
566
1871
  convention: "Conventions",
@@ -580,6 +1885,7 @@ function registerSummarizeRepoContextTool(server) {
580
1885
  async ({ repo }) => {
581
1886
  try {
582
1887
  const db = getDb();
1888
+ const resolved = resolveRepoArg(repo, getWorkspaceRoot(), db);
583
1889
  const rows = db.prepare(
584
1890
  `
585
1891
  SELECT rowid AS row_id, type, note, pinned
@@ -587,13 +1893,13 @@ function registerSummarizeRepoContextTool(server) {
587
1893
  WHERE repo = ?
588
1894
  ORDER BY pinned DESC, updated_at DESC
589
1895
  `
590
- ).all(repo);
1896
+ ).all(resolved.canonical);
591
1897
  if (rows.length === 0) {
592
1898
  return {
593
1899
  content: [
594
1900
  {
595
1901
  type: "text",
596
- text: `Fossel Context Summary: ${repo}
1902
+ text: `Fossel Context Summary: ${resolved.canonical}
597
1903
 
598
1904
  No memories found.`
599
1905
  }
@@ -601,7 +1907,7 @@ No memories found.`
601
1907
  };
602
1908
  }
603
1909
  const pinnedLines = rows.filter((row) => row.pinned === 1).map((row) => `- (${row.row_id}) ${row.note}`);
604
- const sections = [`Fossel Context Summary: ${repo}`];
1910
+ const sections = [`Fossel Context Summary: ${resolved.canonical}`];
605
1911
  if (pinnedLines.length > 0) {
606
1912
  sections.push(`\u{1F4CC} Pinned
607
1913
  ${pinnedLines.join("\n")}`);
@@ -639,13 +1945,15 @@ ${entries.join("\n")}`);
639
1945
  }
640
1946
 
641
1947
  // src/tools/update.ts
642
- import { z as z7 } from "zod";
1948
+ import { z as z11 } from "zod";
643
1949
  var updateMemoryInputSchema = {
644
- id: z7.number().int().positive(),
645
- content: z7.string().trim().min(1).optional(),
646
- memory_type: z7.enum(MEMORY_TYPES).optional()
1950
+ // Accept numeric row_id or legacy string id so callers can paste whichever
1951
+ // form they have.
1952
+ id: z11.union([z11.number().int().positive(), z11.string().trim().min(1)]),
1953
+ content: z11.string().trim().min(1).optional(),
1954
+ memory_type: z11.enum(MEMORY_TYPES).optional()
647
1955
  };
648
- function parseTags3(raw) {
1956
+ function parseTags5(raw) {
649
1957
  try {
650
1958
  const parsed = JSON.parse(raw);
651
1959
  return Array.isArray(parsed) ? parsed.filter((value) => typeof value === "string") : [];
@@ -654,7 +1962,7 @@ function parseTags3(raw) {
654
1962
  }
655
1963
  }
656
1964
  function formatMemory(memory) {
657
- const tags = parseTags3(memory.tags);
1965
+ const tags = parseTags5(memory.tags);
658
1966
  const tagsLine = tags.length > 0 ? tags.join(", ") : "(none)";
659
1967
  return [
660
1968
  `Memory ${memory.row_id} updated successfully.`,
@@ -673,7 +1981,7 @@ function registerUpdateMemoryTool(server) {
673
1981
  server.registerTool(
674
1982
  "update_memory",
675
1983
  {
676
- description: "Update an existing memory by numeric id with partial fields.",
1984
+ description: "Update an existing memory by id (numeric or legacy string) with partial fields.",
677
1985
  inputSchema: updateMemoryInputSchema
678
1986
  },
679
1987
  async ({ id, content, memory_type }) => {
@@ -690,14 +1998,8 @@ function registerUpdateMemoryTool(server) {
690
1998
  };
691
1999
  }
692
2000
  const db = getDb();
693
- const existing = db.prepare(
694
- `
695
- SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned
696
- FROM memories
697
- WHERE rowid = ?
698
- `
699
- ).get(id);
700
- if (!existing) {
2001
+ const target = findMemoryByAnyId(db, id);
2002
+ if (!target) {
701
2003
  return {
702
2004
  isError: true,
703
2005
  content: [
@@ -708,23 +2010,41 @@ function registerUpdateMemoryTool(server) {
708
2010
  ]
709
2011
  };
710
2012
  }
2013
+ const existing = db.prepare(
2014
+ `
2015
+ SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned
2016
+ FROM memories
2017
+ WHERE rowid = ?
2018
+ `
2019
+ ).get(target.row_id);
711
2020
  const now = Math.floor(Date.now() / 1e3);
712
2021
  const nextType = memory_type ?? existing.type;
713
2022
  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);
2023
+ const nextNormalized = content ? normalizeText(content) : null;
2024
+ if (nextNormalized !== null) {
2025
+ db.prepare(
2026
+ `
2027
+ UPDATE memories
2028
+ SET type = ?, note = ?, note_normalized = ?, updated_at = ?
2029
+ WHERE rowid = ?
2030
+ `
2031
+ ).run(nextType, nextNote, nextNormalized, now, existing.row_id);
2032
+ } else {
2033
+ db.prepare(
2034
+ `
2035
+ UPDATE memories
2036
+ SET type = ?, note = ?, updated_at = ?
2037
+ WHERE rowid = ?
2038
+ `
2039
+ ).run(nextType, nextNote, now, existing.row_id);
2040
+ }
721
2041
  const updated = db.prepare(
722
2042
  `
723
2043
  SELECT rowid AS row_id, id, repo, type, note, tags, created_at, updated_at, pinned
724
2044
  FROM memories
725
2045
  WHERE rowid = ?
726
2046
  `
727
- ).get(id);
2047
+ ).get(existing.row_id);
728
2048
  if (!updated) {
729
2049
  return {
730
2050
  isError: true,
@@ -764,13 +2084,61 @@ function registerUpdateMemoryTool(server) {
764
2084
  function resolveDbPath() {
765
2085
  return process.env.FOSSEL_DB_PATH?.trim() || join(homedir(), ".fossel", "memory.db");
766
2086
  }
2087
+ function registerStartupContextResource(server) {
2088
+ server.registerResource(
2089
+ "fossel-startup-context",
2090
+ "fossel://context/current-repo",
2091
+ {
2092
+ title: "Fossel: current repo context",
2093
+ 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.",
2094
+ mimeType: "text/markdown"
2095
+ },
2096
+ async (uri) => {
2097
+ try {
2098
+ const db = getDb();
2099
+ const resolved = resolveRepo(getWorkspaceRoot(), db);
2100
+ const rows = fetchRepoContext(db, resolved.canonical, 5);
2101
+ const text = formatContext(rows, {
2102
+ repo: resolved.canonical,
2103
+ format: "markdown"
2104
+ });
2105
+ return {
2106
+ contents: [
2107
+ {
2108
+ uri: uri.href,
2109
+ mimeType: "text/markdown",
2110
+ text
2111
+ }
2112
+ ]
2113
+ };
2114
+ } catch (error) {
2115
+ const message = error instanceof Error ? error.message : String(error);
2116
+ return {
2117
+ contents: [
2118
+ {
2119
+ uri: uri.href,
2120
+ mimeType: "text/markdown",
2121
+ text: `# Fossel context unavailable
2122
+
2123
+ ${message}`
2124
+ }
2125
+ ]
2126
+ };
2127
+ }
2128
+ }
2129
+ );
2130
+ }
767
2131
  async function startServer() {
768
2132
  const dbPath = resolveDbPath();
769
2133
  initDb(dbPath);
770
2134
  const server = new McpServer({
771
2135
  name: "fossel",
772
- version: "1.0.0"
2136
+ version: "1.1.1"
773
2137
  });
2138
+ registerRememberTool(server);
2139
+ registerGetContextTool(server);
2140
+ registerResolveRepoTool(server);
2141
+ registerDedupeRepoTool(server);
774
2142
  registerStoreContextTool(server);
775
2143
  registerGetRepoContextTool(server);
776
2144
  registerSearchMemoryTool(server);
@@ -779,6 +2147,7 @@ async function startServer() {
779
2147
  registerPinMemoryTool(server);
780
2148
  registerUnpinMemoryTool(server);
781
2149
  registerSummarizeRepoContextTool(server);
2150
+ registerStartupContextResource(server);
782
2151
  const transport = new StdioServerTransport();
783
2152
  await server.connect(transport);
784
2153
  }