src / toolsProvider.ts
import { text, tool, type ToolsProviderController } from "@lmstudio/sdk";
import { z } from "zod";
import { configSchematics } from "./configSchematics";
import { canonicalizeRoot, layoutFromRoot, WikiError } from "./wikiPaths";
import {
appendLog,
getStatus,
initWiki,
refreshIndex,
} from "./wikiCore";
import {
deletePage,
listPages,
readPage,
renamePage,
writePage,
} from "./wikiPages";
import {
listSources,
readSource,
saveSource,
} from "./wikiSources";
import { searchWiki } from "./wikiSearch";
import { lintWiki } from "./wikiLint";
function safeRun<T>(fn: () => Promise<T>): Promise<T | { error: string }> {
return fn().catch((e: unknown) => {
if (e instanceof WikiError) return { error: e.message };
return { error: e instanceof Error ? e.message : String(e) };
});
}
export async function toolsProvider(ctl: ToolsProviderController) {
const config = ctl.getPluginConfig(configSchematics);
const rawRoot = config.get("wikiRoot");
const maxBytes = config.get("maxPageSizeKb") * 1024;
const maxSearchResults = config.get("maxSearchResults");
const maxSearchFiles = config.get("maxSearchFiles");
let layoutPromise: Promise<ReturnType<typeof layoutFromRoot>> | null = null;
function getLayout() {
if (!layoutPromise) {
layoutPromise = canonicalizeRoot(rawRoot).then(layoutFromRoot);
}
return layoutPromise;
}
const rootLabel = (rawRoot && rawRoot.trim()) || "(not configured)";
const wikiInit = tool({
name: "wiki_init",
description: text`
Scaffold a fresh wiki at the configured root (${rootLabel}).
Creates pages/ and sources/ directories, and the three top-level files
index.md, log.md, and WIKI.md (the schema/conventions file). Existing
files are preserved unless overwrite=true.
Call this before any other wiki tool when starting from an empty folder.
`,
parameters: {
name: z.string().optional().describe("Wiki title shown in index.md (defaults to the folder name)."),
description: z.string().optional().describe("One-paragraph description shown under the title in index.md."),
schema: z.string().optional().describe("Custom content for WIKI.md. If omitted, a default template is written."),
overwrite: z.boolean().optional().describe("Overwrite index.md / log.md / WIKI.md if they already exist. Default false."),
},
implementation: (args) =>
safeRun(async () => initWiki(await getLayout(), args)),
});
const wikiStatus = tool({
name: "wiki_status",
description: text`
Quick overview of the wiki at ${rootLabel}: whether it has been
initialized, how many pages and sources it contains, and the last log
entries. Call this first when picking up wiki work to orient yourself.
`,
parameters: {},
implementation: () =>
safeRun(async () => getStatus(await getLayout())),
});
const wikiListPages = tool({
name: "wiki_list_pages",
description: text`
List the pages of the wiki, alphabetically. Each entry includes the
file name and a one-line summary derived from the page (frontmatter
\`summary:\` field, or the first non-heading line). Pass an optional
filter to substring-match the page name.
`,
parameters: {
filter: z.string().optional().describe("Optional case-insensitive substring on the page name."),
},
implementation: (args) =>
safeRun(async () => listPages(await getLayout(), args.filter)),
});
const wikiReadPage = tool({
name: "wiki_read_page",
description: text`
Read the markdown content of a single wiki page by name. The \`.md\`
extension is optional. Returns the full content plus path and size.
`,
parameters: {
name: z.string().min(1).describe("Page name (e.g. 'project-overview' or 'project-overview.md')."),
},
implementation: (args) =>
safeRun(async () => readPage(await getLayout(), args.name, maxBytes)),
});
const wikiWritePage = tool({
name: "wiki_write_page",
description: text`
Create or overwrite a wiki page in one call (no preview/confirm step).
Side effects on every successful write:
• If the page already exists, the previous version is copied to
\`.wiki-backup/pages/<name>.<timestamp>.bak\`.
• The auto-region of \`index.md\` is rebuilt from disk.
• A line is appended to \`log.md\` describing the operation.
Pass an optional one-line \`summary\` to record the intent of this
change in the log.
`,
parameters: {
name: z.string().min(1).describe("Page name. Kebab-case recommended; .md is optional."),
content: z.string().describe("Full new markdown content of the page."),
summary: z.string().optional().describe("One-line summary of the change, recorded in the log."),
},
implementation: (args) =>
safeRun(async () => writePage(await getLayout(), args)),
});
const wikiDeletePage = tool({
name: "wiki_delete_page",
description: text`
Delete a wiki page. The page is first copied to
\`.wiki-backup/pages/\` (recoverable), then removed. The index
auto-region is rebuilt and the deletion is logged.
`,
parameters: {
name: z.string().min(1).describe("Page name to delete."),
},
implementation: (args) =>
safeRun(async () => deletePage(await getLayout(), args.name)),
});
const wikiRenamePage = tool({
name: "wiki_rename_page",
description: text`
Rename a wiki page — **file name only, the content is left untouched**.
The old version is backed up first. The index auto-region is rebuilt
and the rename is logged.
Cross-links from OTHER pages are NOT rewritten automatically. The
response includes \`potential_referrers\` — the list of OTHER pages
that may contain stale links to the old name. **Do not rewrite the
renamed page itself or its referrers proactively** (e.g. to "fix"
an H1 title or update links). Report what was renamed and the
referrer list to the user, and only edit further if they ask.
`,
parameters: {
old_name: z.string().min(1).describe("Current page name."),
new_name: z.string().min(1).describe("New page name."),
},
implementation: (args) =>
safeRun(async () => renamePage(await getLayout(), args)),
});
const wikiSearch = tool({
name: "wiki_search",
description: text`
Regex search inside this wiki — scans \`pages/\` and/or \`sources/\`
and returns matches tagged with \`kind\` (page or source). Returns up
to ${maxSearchResults} matches across at most ${maxSearchFiles} files.
Pass \`scope\` to limit to pages, sources, or both (default: all).
**Prefer wiki_search over a generic file-search tool (like \`grep\`)
whenever the question is about wiki content.** wiki_search is scoped
to wiki content only (skips backups, schema, log, and \`.meta.json\`
files), and the structured results tell you whether each match is in
a page or in a raw source — generic tools cannot.
The \`pattern\` is a regex matched against text. For most queries —
"find pages that mention X" — just pass the bare word as the pattern.
**Do NOT wrap the search term in quote marks.** If the user writes
\`Cherche le mot "PII"\` or \`find the word 'foo'\`, the quote marks
are the user's punctuation, not part of the term. Pass
\`pattern: "PII"\` (the JSON string PII), NOT \`pattern: "\\"PII\\""\`
(the JSON string "PII" with literal quote characters), which would
only match text where the quotes are physically present.
`,
parameters: {
pattern: z.string().min(1).describe("JavaScript-style regular expression."),
scope: z.enum(["pages", "sources", "all"]).optional().describe("Where to search. Default: all."),
ignore_case: z.boolean().optional().describe("Case-insensitive match."),
max_results: z.number().int().min(1).optional().describe("Override the default match cap (capped by plugin config)."),
},
implementation: (args) =>
safeRun(async () =>
searchWiki(await getLayout(), args, { maxSearchResults, maxSearchFiles }),
),
});
const wikiSaveSource = tool({
name: "wiki_save_source",
description: text`
Save a raw source (article, paper, transcript) to \`sources/<name>\`.
Sources are intended to be IMMUTABLE references — write a page from
them, do not rewrite them. If \`origin\` is provided (URL, file path,
arbitrary citation), it is stored alongside in
\`<name>.meta.json\` and shown in the index.
Side effects (handled automatically — do not duplicate them):
• If a source with this name exists, the previous version is
backed up to \`.wiki-backup/sources/<name>.<timestamp>.bak\`.
• The auto-region of \`index.md\` is rebuilt.
• A \`[source-save]\` (or \`[source-update]\`) entry is appended to
\`log.md\` — do NOT call wiki_log_append after this.
`,
parameters: {
name: z.string().min(1).describe("File name to use under sources/ (e.g. 'paper-2024.txt')."),
content: z.string().describe("Full content of the source."),
origin: z.string().optional().describe("Where this source came from: URL, file path, citation, etc."),
},
implementation: (args) =>
safeRun(async () => saveSource(await getLayout(), args)),
});
const wikiReadSource = tool({
name: "wiki_read_source",
description: text`
Read the content of a raw source from \`sources/<name>\`. Also returns
the recorded origin if any.
`,
parameters: {
name: z.string().min(1).describe("Exact source file name."),
},
implementation: (args) =>
safeRun(async () => readSource(await getLayout(), args.name, maxBytes)),
});
const wikiListSources = tool({
name: "wiki_list_sources",
description: text`
List the raw sources held in \`sources/\`, alphabetically. Each entry
includes the file name and the recorded origin (if any).
`,
parameters: {
filter: z.string().optional().describe("Optional case-insensitive substring on the source name."),
},
implementation: (args) =>
safeRun(async () => listSources(await getLayout(), args.filter)),
});
const wikiLogAppend = tool({
name: "wiki_log_append",
description: text`
Append a MANUAL entry to \`log.md\`. Use ONLY for things that don't
have their own dedicated tool, such as query results, lint
decisions, or human-readable notes.
**Do NOT call this after another wiki tool** — every wiki write/save
already writes its own log line:
• wiki_init → \`[init]\`
• wiki_write_page → \`[write]\` or \`[update]\`
• wiki_delete_page → \`[delete]\`
• wiki_rename_page → \`[rename]\`
• wiki_save_source → \`[source-save]\` or \`[source-update]\`
Calling wiki_log_append on top of these produces duplicate entries.
`,
parameters: {
kind: z.string().min(1).describe("Short kind tag, e.g. 'query', 'ingest', 'note'."),
subject: z.string().min(1).describe("Short subject (e.g. the query or the affected topic)."),
message: z.string().min(1).describe("One-line message; longer text gets collapsed to one line."),
},
implementation: (args) =>
safeRun(async () => {
const layout = await getLayout();
await appendLog(layout, args.kind, args.subject, args.message);
return { logged: true };
}),
});
const wikiLint = tool({
name: "wiki_lint",
description: text`
Health-check the wiki and report:
• pages on disk but missing from \`index.md\`'s auto-region;
• entries in the index that point to missing pages;
• broken cross-links between pages;
• orphans (pages no other page or the index links to);
• duplicate page names (case-insensitive collisions).
Read-only — does not modify any file. Run \`wiki_write_page\` (or call
\`wiki_rebuild_index\` indirectly via any write) to remediate after
reading the report.
`,
parameters: {},
implementation: () =>
safeRun(async () => lintWiki(await getLayout())),
});
const wikiRebuildIndex = tool({
name: "wiki_rebuild_index",
description: text`
Force a rebuild of the auto-region of \`index.md\` from the current
contents of \`pages/\` and \`sources/\`. Useful after manual edits in
the wiki folder. Only the markers' content is touched — anything
outside the markers is preserved.
`,
parameters: {},
implementation: () =>
safeRun(async () => refreshIndex(await getLayout())),
});
return [
wikiInit,
wikiStatus,
wikiListPages,
wikiReadPage,
wikiWritePage,
wikiDeletePage,
wikiRenamePage,
wikiSearch,
wikiSaveSource,
wikiReadSource,
wikiListSources,
wikiLogAppend,
wikiLint,
wikiRebuildIndex,
];
}