Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ plugins/mcp-recall/ Marketplace-installable plugin bundle
- `recall__export` — JSON dump of all items, oldest-first
- `recall__session_summary` — per-session digest (tool breakdown, top accessed, pinned, notes)
- `recall__context` — orientation snapshot: pinned + notes + recently accessed + last session headline
- `recall__suggest` — surface pin candidates (frequently accessed) and stale items (never accessed) with actionable commands

## CLI Commands

Expand Down
45 changes: 45 additions & 0 deletions plugins/mcp-recall/dist/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -21374,6 +21374,44 @@ function toolStats(db, projectKey, args = {}) {
return lines.join(`
`);
}
function toolSuggest(db, projectKey, args = {}) {
const config2 = loadConfig();
const suggestions = getSuggestions(db, projectKey, {
pin_threshold: args.pin_threshold ?? config2.store.pin_recommendation_threshold,
stale_days: args.stale_days ?? config2.store.stale_item_days,
limit: args.limit
});
const hasPin = suggestions.pin_candidates.length > 0;
const hasStale = suggestions.stale_candidates.length > 0;
if (!hasPin && !hasStale) {
return "[recall: no suggestions \u2014 no frequently accessed unpinned items and no stale items]";
}
const lines = ["Recall suggestions:"];
if (hasPin) {
lines.push("", "Pin candidates (frequently accessed, not yet pinned):");
for (const item of suggestions.pin_candidates) {
const excerpt = item.summary.slice(0, 80).replace(/\n/g, " ");
const ellipsis = item.summary.length > 80 ? "\u2026" : "";
lines.push(` ${item.id} (accessed ${item.access_count}\xD7) ${item.tool_name}`);
lines.push(` ${excerpt}${ellipsis}`);
lines.push(` \u2192 recall__pin id="${item.id}"`);
}
}
if (hasStale) {
lines.push("", "Stale items (never accessed, consider forgetting):");
const now = Math.floor(Date.now() / 1000);
for (const item of suggestions.stale_candidates) {
const ageDays = Math.floor((now - item.created_at) / 86400);
const excerpt = item.summary.slice(0, 80).replace(/\n/g, " ");
const ellipsis = item.summary.length > 80 ? "\u2026" : "";
lines.push(` ${item.id} (${ageDays} day${ageDays === 1 ? "" : "s"} old) ${item.tool_name}`);
lines.push(` ${excerpt}${ellipsis}`);
lines.push(` \u2192 recall__forget id="${item.id}"`);
}
}
return lines.join(`
`);
}

// src/server.ts
var projectKey = getProjectKey(process.cwd());
Expand Down Expand Up @@ -21462,6 +21500,13 @@ server.tool("recall__context", "Session orientation: pinned items, recent notes,
}, safeTool((args) => ({
content: [{ type: "text", text: toolContext(db, projectKey, args) }]
})));
server.tool("recall__suggest", "Surface actionable maintenance suggestions: items worth pinning (frequently accessed but unpinned) and stale items (never accessed, candidates for deletion). Call periodically to keep the store healthy.", {
pin_threshold: exports_external.number().int().positive().optional().describe("Min access count to qualify as a pin candidate (default 5)"),
stale_days: exports_external.number().int().positive().optional().describe("Items with zero accesses older than N days are stale (default 3)"),
limit: exports_external.number().int().positive().optional().describe("Max items per category (default 3)")
}, safeTool((args) => ({
content: [{ type: "text", text: toolSuggest(db, projectKey, args) }]
})));
process.on("exit", () => closeDb());
process.on("SIGTERM", () => {
closeDb();
Expand Down
30 changes: 30 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
* recall__stats — aggregate session efficiency report
* recall__session_summary — digest of a single session's activity
* recall__context — session orientation: pinned, notes, recent, last session
* recall__suggest — surface pin candidates and stale items
*/
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
Expand All @@ -30,6 +31,7 @@ import {
toolStats,
toolSessionSummary,
toolContext,
toolSuggest,
} from "./tools";

const projectKey = getProjectKey(process.cwd());
Expand Down Expand Up @@ -204,6 +206,34 @@ server.tool(
}))
);

server.tool(
"recall__suggest",
"Surface actionable maintenance suggestions: items worth pinning (frequently accessed but unpinned) and stale items (never accessed, candidates for deletion). Call periodically to keep the store healthy.",
{
pin_threshold: z
.number()
.int()
.positive()
.optional()
.describe("Min access count to qualify as a pin candidate (default 5)"),
stale_days: z
.number()
.int()
.positive()
.optional()
.describe("Items with zero accesses older than N days are stale (default 3)"),
limit: z
.number()
.int()
.positive()
.optional()
.describe("Max items per category (default 3)"),
},
safeTool((args) => ({
content: [{ type: "text", text: toolSuggest(db, projectKey, args) }],
}))
);

// Close the DB cleanly on exit so WAL is checkpointed before the process ends.
process.on("exit", () => closeDb());
process.on("SIGTERM", () => { closeDb(); process.exit(0); });
Expand Down
58 changes: 58 additions & 0 deletions src/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -559,3 +559,61 @@ export function toolStats(

return lines.join("\n");
}

// ---------------------------------------------------------------------------
// recall__suggest
// ---------------------------------------------------------------------------

export interface SuggestArgs {
pin_threshold?: number;
stale_days?: number;
limit?: number;
}

export function toolSuggest(
db: Database,
projectKey: string,
args: SuggestArgs = {}
): string {
const config = loadConfig();
const suggestions = getSuggestions(db, projectKey, {
pin_threshold: args.pin_threshold ?? config.store.pin_recommendation_threshold,
stale_days: args.stale_days ?? config.store.stale_item_days,
limit: args.limit,
});

const hasPin = suggestions.pin_candidates.length > 0;
const hasStale = suggestions.stale_candidates.length > 0;

if (!hasPin && !hasStale) {
return "[recall: no suggestions — no frequently accessed unpinned items and no stale items]";
}

const lines: string[] = ["Recall suggestions:"];

if (hasPin) {
lines.push("", "Pin candidates (frequently accessed, not yet pinned):");
for (const item of suggestions.pin_candidates) {
const excerpt = item.summary.slice(0, 80).replace(/\n/g, " ");
const ellipsis = item.summary.length > 80 ? "…" : "";
lines.push(` ${item.id} (accessed ${item.access_count}×) ${item.tool_name}`);
lines.push(` ${excerpt}${ellipsis}`);
lines.push(` → recall__pin id="${item.id}"`);
}
}

if (hasStale) {
lines.push("", "Stale items (never accessed, consider forgetting):");
const now = Math.floor(Date.now() / 1000);
for (const item of suggestions.stale_candidates) {
const ageDays = Math.floor((now - item.created_at) / 86400);
const excerpt = item.summary.slice(0, 80).replace(/\n/g, " ");
const ellipsis = item.summary.length > 80 ? "…" : "";
lines.push(` ${item.id} (${ageDays} day${ageDays === 1 ? "" : "s"} old) ${item.tool_name}`);
lines.push(` ${excerpt}${ellipsis}`);
lines.push(` → recall__forget id="${item.id}"`);
}
}

return lines.join("\n");
}
73 changes: 73 additions & 0 deletions tests/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
toolStats,
toolSessionSummary,
toolContext,
toolSuggest,
} from "../src/tools";
import { resetConfig } from "../src/config";
import { formatRelativeTime } from "../src/format";
Expand Down Expand Up @@ -772,6 +773,78 @@ describe("MCP tool handlers", () => {
expect(result).toContain("no context available");
});
});

// -------------------------------------------------------------------------
// toolSuggest
// -------------------------------------------------------------------------

describe("toolSuggest", () => {
it("returns no-suggestions message when store is empty", () => {
const result = toolSuggest(db, PROJECT_KEY);
expect(result).toContain("no suggestions");
});

it("returns no-suggestions when items are fresh and not accessed enough", () => {
storeOutput(db, makeInput());
const result = toolSuggest(db, PROJECT_KEY, { pin_threshold: 5, stale_days: 3 });
expect(result).toContain("no suggestions");
});

it("shows pin candidates when access_count meets threshold", () => {
const stored = storeOutput(db, makeInput());
recordAccess(db, stored.id);
const result = toolSuggest(db, PROJECT_KEY, { pin_threshold: 1 });
expect(result).toContain("Pin candidates");
expect(result).toContain(stored.id);
expect(result).toContain("accessed 1×");
expect(result).toContain(`recall__pin id="${stored.id}"`);
});

it("shows stale items when old and never accessed", () => {
const stored = storeOutput(db, makeInput());
const fiveDaysAgo = Math.floor(Date.now() / 1000) - 5 * 86400;
db.prepare(`UPDATE stored_outputs SET created_at = ? WHERE id = ?`)
.run(fiveDaysAgo, stored.id);
const result = toolSuggest(db, PROJECT_KEY, { stale_days: 3 });
expect(result).toContain("Stale items");
expect(result).toContain(stored.id);
expect(result).toContain("days old");
expect(result).toContain(`recall__forget id="${stored.id}"`);
});

it("shows both sections when both qualify", () => {
const pinItem = storeOutput(db, makeInput());
recordAccess(db, pinItem.id);

const staleItem = storeOutput(db, makeInput());
const fiveDaysAgo = Math.floor(Date.now() / 1000) - 5 * 86400;
db.prepare(`UPDATE stored_outputs SET created_at = ? WHERE id = ?`)
.run(fiveDaysAgo, staleItem.id);

const result = toolSuggest(db, PROJECT_KEY, { pin_threshold: 1, stale_days: 3 });
expect(result).toContain("Pin candidates");
expect(result).toContain("Stale items");
});

it("does not suggest already-pinned items as pin candidates", () => {
const stored = storeOutput(db, makeInput());
pinOutput(db, stored.id, PROJECT_KEY, true);
recordAccess(db, stored.id);
const result = toolSuggest(db, PROJECT_KEY, { pin_threshold: 1 });
expect(result).not.toContain("Pin candidates");
});

it("respects limit option", () => {
for (let i = 0; i < 5; i++) {
const stored = storeOutput(db, makeInput({ summary: `item ${i}` }));
recordAccess(db, stored.id);
}
const result = toolSuggest(db, PROJECT_KEY, { pin_threshold: 1, limit: 2 });
// Only 2 pin candidates should appear — count recall__pin occurrences
const pinCount = (result.match(/recall__pin/g) ?? []).length;
expect(pinCount).toBe(2);
});
});
});

// ---------------------------------------------------------------------------
Expand Down
Loading