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.
- package/README.md +110 -43
- package/dist/cli.js +1704 -275
- package/dist/index.js +1300 -54
- 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/
|
|
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:
|
|
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
|
|
840
|
+
import { z as z4 } from "zod";
|
|
199
841
|
var getRepoContextInputSchema = {
|
|
200
|
-
repo:
|
|
201
|
-
limit:
|
|
842
|
+
repo: z4.string().trim().min(1).optional(),
|
|
843
|
+
limit: z4.number().int().positive().max(100).default(10)
|
|
202
844
|
};
|
|
203
|
-
function
|
|
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(
|
|
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 ${
|
|
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 =
|
|
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 ${
|
|
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
|
|
933
|
+
import { z as z5 } from "zod";
|
|
291
934
|
var pinInputSchema = {
|
|
292
|
-
id:
|
|
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
|
|
1579
|
+
import { z as z8 } from "zod";
|
|
406
1580
|
var searchMemoryInputSchema = {
|
|
407
|
-
query:
|
|
408
|
-
repo:
|
|
409
|
-
limit:
|
|
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
|
|
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
|
|
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,
|
|
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:
|
|
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 =
|
|
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}"${
|
|
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
|
|
1675
|
+
import { nanoid as nanoid2 } from "nanoid";
|
|
1676
|
+
import { z as z9 } from "zod";
|
|
502
1677
|
var storeContextInputSchema = {
|
|
503
|
-
repo:
|
|
504
|
-
type:
|
|
505
|
-
note:
|
|
506
|
-
tags:
|
|
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 =
|
|
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(
|
|
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 ${
|
|
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
|
|
1746
|
+
import { z as z10 } from "zod";
|
|
562
1747
|
var summarizeRepoContextInputSchema = {
|
|
563
|
-
repo:
|
|
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(
|
|
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: ${
|
|
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: ${
|
|
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
|
|
1828
|
+
import { z as z11 } from "zod";
|
|
643
1829
|
var updateMemoryInputSchema = {
|
|
644
|
-
id:
|
|
645
|
-
content:
|
|
646
|
-
memory_type:
|
|
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
|
|
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 =
|
|
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
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
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.
|
|
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
|
}
|