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