Forked from ceveyne/playbook
Forked from ceveyne/playbook
src / tools / recall.ts
src / tools / recall.ts
/**
* recall — metadata-only document search.
* Never reads file content; returns title, filename, tags, description, and score.
* Skill bundles (`<name>/SKILL.md`) are indexed as a single document per bundle.
*/
import { tool, type Tool, type ToolsProviderController } from "@lmstudio/sdk";
// @ts-ignore — zod/lib re-export chain breaks with NodeNext; runtime is fine
import { z } from "zod";
import type { IndexManager } from "../indexManager.js";
import { EmbeddingClient } from "../embeddings/embeddingClient.js";
import {
checkEmbeddingCapability,
type EmbeddingPrimerResult,
} from "../helpers/embeddingCapabilityPrimer.js";
import { formatToolMetaBlock } from "../helpers/pluginMeta.js";
// ── module-level cache ────────────────────────────────────────────────────────
let embeddingClient: EmbeddingClient | null = null;
let cachedCapabilityResult: EmbeddingPrimerResult | null = null;
let lastCapabilityCheckMs = 0;
const CAPABILITY_CHECK_INTERVAL_MS = 30_000;
function truncate(text: string, maxLength: number): string {
return text.length > maxLength ? `${text.slice(0, maxLength)}…` : text;
}
export function createRecallTool(
_ctl: ToolsProviderController,
index: IndexManager
): Tool {
return tool({
name: "recall",
description: `Search the playbook — the agent's persistent Markdown knowledge base of notes and skills.
Returns document metadata only (title, filename, tags, description, score), never full file contents.
Skill bundles (folders containing a SKILL.md with \`name\`/\`description\` frontmatter) are returned exactly like notes — one hit per bundle, with its description shown instead of a title-only summary.
Use this tool:
- At the start of complex or multi-step tasks
- Before advising on a topic you may have notes about
- When the user references something familiar but you're not certain about
- To check whether a note or skill already exists before writing a new one
Returns:
- Ranked list of matching documents with title, filename (relative path — may include a skill folder, e.g. "my-skill/SKILL.md"), tags, description, and relevance score (0–100)
- Documents below the configured minRecallScore are excluded
Examples:
- "project planning strategies" — finds notes about planning approaches
- "API authentication" — finds any notes on auth topics
- "Abendroutine" — also finds "evening routine" when semantic search is enabled
Follow up with read to retrieve the full text of any matching document (for a skill, read the SKILL.md hit first — it links to any companion files under its folder).
${formatToolMetaBlock()}`,
parameters: {
query: z.string().describe("Natural-language search query."),
tags: z
.array(z.string())
.optional()
.describe("Optional list of tags to filter by. Only documents that have ALL listed tags will be returned."),
},
implementation: async (args, ctx) => {
const config = index.getConfig();
const now = Date.now();
// Check (or refresh) embedding capability every 30 s
const needsCheck =
!cachedCapabilityResult ||
now - lastCapabilityCheckMs > CAPABILITY_CHECK_INTERVAL_MS ||
embeddingClient?.getModelName() !== config.embeddingModel;
if (needsCheck) {
ctx.status("Checking embedding model availability...");
cachedCapabilityResult = await checkEmbeddingCapability({
modelId: config.embeddingModel,
baseUrl: config.lmStudioBaseUrl,
});
lastCapabilityCheckMs = Date.now();
if (cachedCapabilityResult.ready) {
embeddingClient = new EmbeddingClient({
baseUrl: config.lmStudioBaseUrl,
model: cachedCapabilityResult.modelId,
});
index.setEmbeddingClient(embeddingClient);
} else {
embeddingClient = null;
index.setEmbeddingClient(null);
}
}
const docCount = index.getDocumentCount();
ctx.status(`Searching ${docCount} documents...`);
const results = await index.search(args.query, args.tags);
const lines: string[] = [];
if (results.length === 0) {
lines.push("No documents found matching your query.");
} else {
lines.push(
...results.map((r) => {
// Relative path (not basename) — skill bundles all share the filename "SKILL.md".
const filename = index.toDisplayPath(r.filePath);
const tagStr = r.tags.length > 0 ? ` [${r.tags.join(", ")}]` : "";
const desc = r.description ? ` — ${truncate(r.description, 300)}` : "";
return `- **${r.title}** (${filename})${tagStr}${desc} — score: ${r.score}`;
})
);
}
// Append capability warning when semantic search is degraded
if (cachedCapabilityResult && !cachedCapabilityResult.ready && cachedCapabilityResult.userMessage) {
lines.push("", cachedCapabilityResult.userMessage);
} else if (cachedCapabilityResult?.userMessage && cachedCapabilityResult.messageSeverity === "info") {
lines.push("", `_${cachedCapabilityResult.userMessage}_`);
}
return lines.join("\n");
},
});
}
/**
* recall — metadata-only document search.
* Never reads file content; returns title, filename, tags, description, and score.
* Skill bundles (`<name>/SKILL.md`) are indexed as a single document per bundle.
*/
import { tool, type Tool, type ToolsProviderController } from "@lmstudio/sdk";
// @ts-ignore — zod/lib re-export chain breaks with NodeNext; runtime is fine
import { z } from "zod";
import type { IndexManager } from "../indexManager.js";
import { EmbeddingClient } from "../embeddings/embeddingClient.js";
import {
checkEmbeddingCapability,
type EmbeddingPrimerResult,
} from "../helpers/embeddingCapabilityPrimer.js";
import { formatToolMetaBlock } from "../helpers/pluginMeta.js";
// ── module-level cache ────────────────────────────────────────────────────────
let embeddingClient: EmbeddingClient | null = null;
let cachedCapabilityResult: EmbeddingPrimerResult | null = null;
let lastCapabilityCheckMs = 0;
const CAPABILITY_CHECK_INTERVAL_MS = 30_000;
function truncate(text: string, maxLength: number): string {
return text.length > maxLength ? `${text.slice(0, maxLength)}…` : text;
}
export function createRecallTool(
_ctl: ToolsProviderController,
index: IndexManager
): Tool {
return tool({
name: "recall",
description: `Search the playbook — the agent's persistent Markdown knowledge base of notes and skills.
Returns document metadata only (title, filename, tags, description, score), never full file contents.
Skill bundles (folders containing a SKILL.md with \`name\`/\`description\` frontmatter) are returned exactly like notes — one hit per bundle, with its description shown instead of a title-only summary.
Use this tool:
- At the start of complex or multi-step tasks
- Before advising on a topic you may have notes about
- When the user references something familiar but you're not certain about
- To check whether a note or skill already exists before writing a new one
Returns:
- Ranked list of matching documents with title, filename (relative path — may include a skill folder, e.g. "my-skill/SKILL.md"), tags, description, and relevance score (0–100)
- Documents below the configured minRecallScore are excluded
Examples:
- "project planning strategies" — finds notes about planning approaches
- "API authentication" — finds any notes on auth topics
- "Abendroutine" — also finds "evening routine" when semantic search is enabled
Follow up with read to retrieve the full text of any matching document (for a skill, read the SKILL.md hit first — it links to any companion files under its folder).
${formatToolMetaBlock()}`,
parameters: {
query: z.string().describe("Natural-language search query."),
tags: z
.array(z.string())
.optional()
.describe("Optional list of tags to filter by. Only documents that have ALL listed tags will be returned."),
},
implementation: async (args, ctx) => {
const config = index.getConfig();
const now = Date.now();
// Check (or refresh) embedding capability every 30 s
const needsCheck =
!cachedCapabilityResult ||
now - lastCapabilityCheckMs > CAPABILITY_CHECK_INTERVAL_MS ||
embeddingClient?.getModelName() !== config.embeddingModel;
if (needsCheck) {
ctx.status("Checking embedding model availability...");
cachedCapabilityResult = await checkEmbeddingCapability({
modelId: config.embeddingModel,
baseUrl: config.lmStudioBaseUrl,
});
lastCapabilityCheckMs = Date.now();
if (cachedCapabilityResult.ready) {
embeddingClient = new EmbeddingClient({
baseUrl: config.lmStudioBaseUrl,
model: cachedCapabilityResult.modelId,
});
index.setEmbeddingClient(embeddingClient);
} else {
embeddingClient = null;
index.setEmbeddingClient(null);
}
}
const docCount = index.getDocumentCount();
ctx.status(`Searching ${docCount} documents...`);
const results = await index.search(args.query, args.tags);
const lines: string[] = [];
if (results.length === 0) {
lines.push("No documents found matching your query.");
} else {
lines.push(
...results.map((r) => {
// Relative path (not basename) — skill bundles all share the filename "SKILL.md".
const filename = index.toDisplayPath(r.filePath);
const tagStr = r.tags.length > 0 ? ` [${r.tags.join(", ")}]` : "";
const desc = r.description ? ` — ${truncate(r.description, 300)}` : "";
return `- **${r.title}** (${filename})${tagStr}${desc} — score: ${r.score}`;
})
);
}
// Append capability warning when semantic search is degraded
if (cachedCapabilityResult && !cachedCapabilityResult.ready && cachedCapabilityResult.userMessage) {
lines.push("", cachedCapabilityResult.userMessage);
} else if (cachedCapabilityResult?.userMessage && cachedCapabilityResult.messageSeverity === "info") {
lines.push("", `_${cachedCapabilityResult.userMessage}_`);
}
return lines.join("\n");
},
});
}