src / toolsProvider.ts
src / toolsProvider.ts
import { tool, text, type ToolsProviderController } from "@lmstudio/sdk";
import { z } from "zod";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { configSchematics } from "./configSchematics";
import { canonicalizeRoots, resolveSafe, PathError } from "./pathGuard";
import { appendAudit } from "./auditLog";
import {
getLayout,
ensureWikiScaffold,
type WikiLayout,
listRawSources,
listIndexablePages,
listMarkdownFiles,
readText,
fileExists,
parsePage,
slugify,
writePage,
readIndex,
appendWikiLog,
formatLogEntry,
upsertIndexEntries,
categoryFromPath,
sourceSlugForFile,
} from "./wikiStore";
import { readDocx } from "./docxRead";
import { transcribePdf } from "./pdfTranscribe";
import { pickEmbeddingModel, embedOne } from "./embeddings";
import {
reindex as reindexStore,
loadIndex,
staleRelPaths,
search as ragSearch,
} from "./ragIndex";
import {
pickChatModel,
runJson,
buildExtractPrompt,
buildComposePrompt,
buildQueryPrompt,
buildLintPrompt,
type ExtractResult,
type ComposeResult,
type ExistingPage,
type QueryResult,
type LintResult,
} from "./llmAgent";
const LOG_PREFIX = "[kwikines]";
const DEFAULT_SCHEMA =
"Wiki Karpathy : raw/ immuable, wiki/ généré, AGENTS.md = schéma. " +
"Pages: sources/ entities/ concepts/ analyses/. Frontmatter YAML, wikilinks [[slug]], " +
"une page par sujet, cite les sources, signale les contradictions, ne fabrique rien.";
export async function toolsProvider(ctl: ToolsProviderController) {
const config = ctl.getPluginConfig(configSchematics);
const wikiBaseDir = config.get("wikiBaseDir");
const chatModelOverride = config.get("chatModelOverride");
const embeddingModelOverride = config.get("embeddingModelOverride");
const vlModelOverride = config.get("vlModelOverride");
const pdfRenderScale = config.get("pdfRenderScale");
const pdfMaxPages = config.get("pdfMaxPages");
const pdfLanguage = config.get("pdfLanguage");
const pdfTranscriptionStyle = config.get("pdfTranscriptionStyle");
const chunkSizeChars = config.get("chunkSizeChars");
const chunkOverlapChars = config.get("chunkOverlapChars");
const defaultTopK = config.get("defaultTopK");
const hybridSearch = config.get("hybridSearch");
const autoReindexOnSearch = config.get("autoReindexOnSearch");
const supervisedIngest = config.get("supervisedIngest");
const maxFileSizeMb = config.get("maxFileSizeMb");
const verboseLogging = config.get("verboseLogging");
const roots = await canonicalizeRoots([wikiBaseDir]);
const layout: WikiLayout | null = roots.length ? getLayout(roots[0]) : null;
const log = (msg: string) => {
if (verboseLogging) console.log(`${LOG_PREFIX} ${msg}`);
};
function requireLayout(): WikiLayout {
if (!layout) {
throw new PathError(
"No wiki base directory configured. Set 'Wiki base directory' in the plugin settings " +
"(the folder containing AGENTS.md, raw/ and wiki/).",
);
}
return layout;
}
async function loadSchema(l: WikiLayout): Promise<string> {
try {
return await readText(l.agentsPath);
} catch {
return DEFAULT_SCHEMA;
}
}
// Resolve a path that must live INSIDE wiki/ and end in .md. Accepts paths
// relative to wiki/ (e.g. "entities/x.md") or absolute inside the base.
async function resolveWikiPage(l: WikiLayout, raw: string): Promise<{ abs: string; relWithinWiki: string }> {
const candidate = path.isAbsolute(raw) ? raw : path.join(l.wikiDir, raw);
const { abs } = await resolveSafe(candidate, roots);
const relWithinWiki = path.relative(l.wikiDir, abs);
if (relWithinWiki.startsWith("..") || path.isAbsolute(relWithinWiki)) {
throw new PathError(`Page path must be inside wiki/, got: ${raw}`);
}
if (path.extname(abs).toLowerCase() !== ".md") {
throw new PathError(`Wiki pages must end in .md, got: ${raw}`);
}
return { abs, relWithinWiki: relWithinWiki.replace(/\\/g, "/") };
}
function indexEntryFor(relWithinWiki: string, summary: string) {
const category = categoryFromPath(relWithinWiki);
if (!category) return null;
return { category, wikilink: relWithinWiki.replace(/\.md$/i, ""), summary: summary.slice(0, 160) };
}
// ---- shared embedder / reindex helpers --------------------------------
async function ensureFreshIndex(l: WikiLayout, ctx: { status: (s: string) => void; signal: AbortSignal }) {
const picked = await pickEmbeddingModel(ctl.client, embeddingModelOverride);
if ("error" in picked) return picked;
const idx = await loadIndex(l);
const stale = await staleRelPaths(l, idx);
if (!idx || stale.length > 0) {
ctx.status(idx ? `Réindexation de ${stale.length} page(s) modifiée(s)…` : "Construction de l'index RAG…");
await reindexStore(l, picked.model, picked.identifier, {
chunkSizeChars,
chunkOverlapChars,
abortSignal: ctx.signal,
onProgress: (d, t, label) => ctx.status(`${label} (${d}/${t})`),
});
}
return picked;
}
// =======================================================================
// 1. kwikines_status
// =======================================================================
const statusTool = tool({
name: "kwikines_status",
description: text`
Orient yourself at the start of a wiki session. Returns the AGENTS.md schema
(the rules you must follow), the wiki structure with page counts, the head of
index.md, the list of raw sources with their ingest status, and the state of
the RAG index (fresh / stale, embedding model, chunk count).
Call this first when the user mentions "le wiki", "ingest", "fiche", a knowledge
base, or asks a question that should be answered from the wiki.
`,
parameters: {},
implementation: async (_args, ctx) => {
try {
const l = requireLayout();
await ensureWikiScaffold(l);
const schema = await loadSchema(l);
const counts: Record<string, number> = {};
for (const sub of ["sources", "entities", "concepts", "analyses"]) {
counts[sub] = (await listMarkdownFiles(path.join(l.wikiDir, sub))).length;
}
const indexHead = (await readIndex(l)).split(/\r?\n/).slice(0, 40).join("\n");
const sources = await listRawSources(l);
const idx = await loadIndex(l);
const stale = await staleRelPaths(l, idx);
return {
wiki_base: l.base,
schema_present: await fileExists(l.agentsPath),
schema_excerpt: schema.slice(0, 1500),
page_counts: counts,
index_head: indexHead,
raw_sources: sources.map((s) => ({
name: s.name,
ext: s.ext,
ingested: s.ingested,
needs_conversion: s.needsConversion,
...(s.derivedFrom ? { derived_from: s.derivedFrom } : {}),
})),
rag_index: idx
? { built: true, model: idx.model, chunks: Object.keys(idx.chunks).length, stale_pages: stale.length }
: { built: false, stale_pages: stale.length },
};
} catch (e) {
return errOut(e);
}
},
});
// =======================================================================
// 2. kwikines_list_sources
// =======================================================================
const listSourcesTool = tool({
name: "kwikines_list_sources",
description: text`
List the files in raw/ with their format, size and whether they have already
been ingested (a wiki/sources/<slug>.md exists). .docx and .pdf sources are
ingested directly: .docx is converted via mammoth, .pdf is transcribed by a
loaded vision-language model. Both write a sibling .md into raw/ on ingest.
`,
parameters: {},
implementation: async (_args, ctx) => {
try {
const l = requireLayout();
const sources = await listRawSources(l);
return {
raw_dir: l.rawDir,
count: sources.length,
sources: sources.map((s) => ({
name: s.name,
ext: s.ext,
size_kb: +(s.sizeBytes / 1024).toFixed(1),
ingested: s.ingested,
needs_conversion: s.needsConversion,
...(s.derivedFrom ? { derived_from: s.derivedFrom } : {}),
hint: s.derivedFrom
? `Issu de la conversion de ${s.derivedFrom} — même contenu ; ingère l'un OU l'autre, pas les deux.`
: s.needsConversion
? "Exporter d'abord en .docx, .pdf ou .md, puis déposer dans raw/."
: s.ingested
? "Déjà ingéré — relancer kwikines_ingest pour rafraîchir."
: s.ext === ".docx"
? "Prêt pour kwikines_ingest (conversion .docx → .md automatique)."
: s.ext === ".pdf"
? "Prêt pour kwikines_ingest (transcription par modèle de vision — un modèle VL doit être chargé)."
: "Prêt pour kwikines_ingest.",
})),
};
} catch (e) {
return errOut(e);
}
},
});
// =======================================================================
// 3. kwikines_read
// =======================================================================
const readTool = tool({
name: "kwikines_read",
description: text`
Read a markdown/text file located under the wiki base directory (either in
wiki/ or raw/). A .docx is converted to markdown on the fly (mammoth, no model);
a .pdf is transcribed page-by-page by a loaded vision-language model. Returns
the resulting content.
`,
parameters: {
path: z.string().min(1).describe("Path inside the wiki base (relative to it, or absolute)."),
},
implementation: async (args, ctx) => {
try {
const l = requireLayout();
const candidate = path.isAbsolute(args.path) ? args.path : path.join(l.base, args.path);
const { abs } = await resolveSafe(candidate, roots);
const ext = path.extname(abs).toLowerCase();
const stat = await fs.stat(abs);
if (stat.size / (1024 * 1024) > maxFileSizeMb) {
return { error: `File exceeds the ${maxFileSizeMb} MB cap.` };
}
if (ext === ".docx") {
const { markdown, warnings } = await readDocx(abs, { preserveStyles: true, includeMetadata: true });
return { path: abs, content: markdown, chars: markdown.length, converted_from: ".docx", warnings };
}
if (ext === ".pdf") {
const buffer = await fs.readFile(abs);
const res = await transcribePdf(
ctl.client,
abs,
buffer,
{ renderScale: pdfRenderScale, maxPages: pdfMaxPages, language: pdfLanguage, style: pdfTranscriptionStyle, vlModelOverride },
{ status: (s) => ctx.status(s), signal: ctx.signal },
);
if ("error" in res) return { error: res.error };
return { path: abs, content: res.markdown, chars: res.markdown.length, converted_from: ".pdf", model: res.model, pages_processed: res.pagesProcessed, pages_failed: res.pagesFailed, warnings: res.warnings };
}
if (![".md", ".markdown", ".txt"].includes(ext)) {
return { error: `Only .md/.txt/.docx/.pdf are read here. For ${ext}, export it to one of those formats first.` };
}
return { path: abs, content: await readText(abs), chars: stat.size };
} catch (e) {
return errOut(e);
}
},
});
// =======================================================================
// 4. kwikines_write_page
// =======================================================================
const writePageTool = tool({
name: "kwikines_write_page",
description: text`
Create or update a single wiki page (a .md file under wiki/). The previous
version, if any, is backed up before overwriting. Use this for fine-grained
manual edits; for ingesting a source prefer kwikines_ingest, which writes and
cross-links many pages at once.
`,
parameters: {
path: z.string().min(1).describe("Page path under wiki/, e.g. 'entities/marie-curie.md'."),
content: z.string().min(1).describe("Full markdown content of the page (frontmatter + body)."),
index_summary: z.string().optional().describe("One-line summary to register in index.md."),
},
implementation: async (args, ctx) => {
try {
const l = requireLayout();
await ensureWikiScaffold(l);
const reqPath = /\.md$/i.test(args.path) ? args.path : `${args.path}.md`;
const { abs, relWithinWiki } = await resolveWikiPage(l, reqPath);
const res = await writePage(l, abs, args.content);
const entry = indexEntryFor(relWithinWiki, args.index_summary ?? relWithinWiki);
if (entry) await upsertIndexEntries(l, [entry]);
await appendAudit(l.base, res.created ? "wiki-create" : "wiki-update", relWithinWiki, `${res.bytes} bytes`);
log(`${res.created ? "created" : "updated"} ${relWithinWiki}`);
return { written: { path: abs, bytes: res.bytes, created: res.created }, backup_path: res.backupPath, note: "RAG index will refresh on next search/reindex." };
} catch (e) {
return errOut(e);
}
},
});
// =======================================================================
// 5. kwikines_append_log
// =======================================================================
const appendLogTool = tool({
name: "kwikines_append_log",
description: text`
Append a chronological entry to wiki/log.md (format: '## [YYYY-MM-DD] kind | subject').
Use it to record ingests, queries or lint passes.
`,
parameters: {
kind: z.string().min(1).describe("Entry kind, e.g. 'ingest', 'query', 'lint', 'note'."),
subject: z.string().min(1).describe("Short subject of the entry."),
bullets: z.array(z.string()).optional().describe("Detail bullet lines."),
},
implementation: async (args, ctx) => {
try {
const l = requireLayout();
await appendWikiLog(l, formatLogEntry(args.kind, args.subject, args.bullets ?? []));
return { logged: true, path: l.logPath };
} catch (e) {
return errOut(e);
}
},
});
// =======================================================================
// 6. kwikines_reindex
// =======================================================================
const reindexTool = tool({
name: "kwikines_reindex",
description: text`
(Re)build the RAG vector index over the wiki pages using an embedding model
loaded in LM Studio. Incremental: only pages whose file changed since the last
build are re-embedded. Run it after large manual edits; ingest/search trigger it
automatically when needed.
`,
parameters: {},
implementation: async (_args, ctx) => {
try {
const l = requireLayout();
await ensureWikiScaffold(l);
const picked = await pickEmbeddingModel(ctl.client, embeddingModelOverride);
if ("error" in picked) return { error: picked.error };
ctx.status(`Indexation via ${picked.identifier}…`);
const stats = await reindexStore(l, picked.model, picked.identifier, {
chunkSizeChars,
chunkOverlapChars,
abortSignal: ctx.signal,
onProgress: (d, t, label) => ctx.status(`${label} (${d}/${t})`),
});
log(`reindex: ${JSON.stringify(stats)}`);
return { ...stats };
} catch (e) {
return errOut(e);
}
},
});
// =======================================================================
// 7. kwikines_search
// =======================================================================
const searchTool = tool({
name: "kwikines_search",
description: text`
Semantic (or hybrid semantic+keyword) search over the wiki — retrieves the
"compressed" passages most relevant to a query, each with its page wikilink,
heading path and score. This is the retrieval primitive of the RAG layer; use
it to ground answers in the wiki. kwikines_query wraps it with LLM synthesis.
`,
parameters: {
query: z.string().min(1).describe("The search query / question."),
top_k: z.number().int().min(1).max(30).optional().describe("How many passages to return."),
hybrid: z.boolean().optional().describe("Override hybrid (semantic+keyword) search."),
},
implementation: async (args, ctx) => {
try {
const l = requireLayout();
await ensureWikiScaffold(l);
let picked;
if (autoReindexOnSearch) {
picked = await ensureFreshIndex(l, ctx);
} else {
picked = await pickEmbeddingModel(ctl.client, embeddingModelOverride);
}
if ("error" in picked) return { error: picked.error };
const idx = await loadIndex(l);
if (!idx || Object.keys(idx.chunks).length === 0) {
return { hits: [], note: "L'index est vide. Ingère des sources puis relance kwikines_reindex." };
}
ctx.status("Recherche…");
const qVec = await embedOne(picked.model, args.query);
const hits = ragSearch(idx, qVec, args.query, {
topK: args.top_k ?? defaultTopK,
hybrid: args.hybrid ?? hybridSearch,
});
return {
model: idx.model,
query: args.query,
hits: hits.map((h) => ({
wikilink: h.chunk.wikilink,
page_title: h.chunk.pageTitle,
heading_path: h.chunk.headingPath,
score: +h.score.toFixed(4),
semantic: +h.semantic.toFixed(4),
lexical: +h.lexical.toFixed(4),
snippet: h.snippet,
})),
};
} catch (e) {
return errOut(e);
}
},
});
// =======================================================================
// 8. kwikines_ingest (★ agentic)
// =======================================================================
const ingestTool = tool({
name: "kwikines_ingest",
description: text`
Ingest a raw source (in raw/) into the wiki. CALL THIS TOOL DIRECTLY with the
source filename when the user asks to add / ingest / index a file or to update
the wiki from raw/ — do NOT narrate the steps, do NOT ask the user to convert or
pre-check anything, and do NOT invent a placeholder path: pass the real filename
as shown by kwikines_list_sources.
The plugin does all the work itself: a .md/.txt is read as-is; a .docx is
converted (mammoth) and a .pdf is transcribed by the loaded vision model — in
every case a sibling .md is written into raw/, then it reads the text, drives the
chat LLM to extract entities/concepts per AGENTS.md, writes the source page and
creates/merges entity & concept pages, updates index.md, appends log.md, and
refreshes the RAG index. A single source typically touches several pages.
When 'Supervised ingest' is ON (default), the first call returns a PREVIEW of the
pages it will write; call again with confirm=true to apply. Set confirm=true to
write directly. For a large .pdf, optionally pass pages (e.g. "1-20") to transcribe
only a range. (Transcribing a .pdf needs a vision-language model loaded; if none is,
the tool returns a clear error — just call it and surface that, don't pre-check.)
`,
parameters: {
source: z.string().min(1).describe("Source filename in raw/ (e.g. 'article.md') or a path inside raw/."),
confirm: z.boolean().optional().describe("Apply the changes (skip preview). Required when supervised ingest is ON."),
pages: z.string().optional().describe("PDF only: page range to transcribe, e.g. '1-20', '3,5,7'. Defaults to all pages (capped by the plugin's PDF max pages). Ignored for non-PDF sources."),
},
implementation: async (args, ctx) => {
try {
const l = requireLayout();
await ensureWikiScaffold(l);
// Resolve the source inside raw/. The source is given relative to raw/, but
// the LLM routinely prepends a redundant "raw/" (our own examples show paths
// like "raw/x.pdf"), which would otherwise resolve to raw/raw/x.pdf. Tolerate
// a leading "raw/" (and "wiki/" base-relative form) on a relative source.
const candidate = path.isAbsolute(args.source)
? args.source
: path.join(l.rawDir, args.source.replace(/\\/g, "/").replace(/^\/+/, "").replace(/^raw\//i, ""));
let { abs: srcAbs } = await resolveSafe(candidate, roots);
let relToRaw = path.relative(l.rawDir, srcAbs);
if (relToRaw.startsWith("..")) return { error: `Source must live in raw/, got: ${args.source}` };
let ext = path.extname(srcAbs).toLowerCase();
const stat = await fs.stat(srcAbs);
if (stat.size / (1024 * 1024) > maxFileSizeMb) return { error: `Source exceeds the ${maxFileSizeMb} MB cap.` };
// Conversion summary surfaced in the preview/result so a truncated or
// lossy conversion is never silent.
let conversion: { from: string; to: string; via: string; warnings: string[]; pages_processed?: number; pages_failed?: number[] } | null = null;
// .docx is the one convertible-format we can handle locally (mammoth, no
// model). We materialise a sibling .md in raw/ — the only write allowed in
// raw/ is a conversion output — then ingest that .md. The .docx stays.
if (ext === ".docx") {
ctx.status("Conversion du .docx en markdown…");
const { markdown, warnings } = await readDocx(srcAbs, { preserveStyles: true, includeMetadata: true });
const mdCandidate = srcAbs.slice(0, srcAbs.length - ext.length) + ".md";
const { abs: mdAbs } = await resolveSafe(mdCandidate, roots);
const res = await writePage(l, mdAbs, markdown);
await appendAudit(l.base, "raw-convert", path.relative(l.base, mdAbs), `from ${path.basename(srcAbs)}${warnings.length ? `, ${warnings.length} warning(s)` : ""}`);
log(`converted ${path.basename(srcAbs)} -> ${path.basename(mdAbs)} (${res.bytes} bytes)`);
conversion = { from: path.basename(srcAbs), to: path.basename(mdAbs), via: "mammoth (.docx)", warnings };
srcAbs = mdAbs;
relToRaw = path.relative(l.rawDir, srcAbs);
ext = ".md";
} else if (ext === ".pdf") {
// .pdf is transcribed page-by-page by a loaded vision model, then the
// resulting .md is written into raw/ (allowed conversion output) and ingested.
ctx.status("Transcription du PDF (modèle de vision)…");
const buffer = await fs.readFile(srcAbs);
const res = await transcribePdf(
ctl.client,
srcAbs,
buffer,
{ renderScale: pdfRenderScale, maxPages: pdfMaxPages, language: pdfLanguage, style: pdfTranscriptionStyle, vlModelOverride, pages: args.pages },
{ status: (s) => ctx.status(s), signal: ctx.signal },
);
if ("error" in res) return { error: res.error };
const mdCandidate = srcAbs.slice(0, srcAbs.length - ext.length) + ".md";
const { abs: mdAbs } = await resolveSafe(mdCandidate, roots);
const w = await writePage(l, mdAbs, res.markdown);
await appendAudit(l.base, "raw-convert", path.relative(l.base, mdAbs), `from ${path.basename(srcAbs)} (${res.pagesProcessed} page(s), VL ${res.model})${res.pagesFailed.length ? `, ${res.pagesFailed.length} failed` : ""}`);
log(`transcribed ${path.basename(srcAbs)} -> ${path.basename(mdAbs)} (${w.bytes} bytes)`);
conversion = { from: path.basename(srcAbs), to: path.basename(mdAbs), via: `vision-model ${res.model} (.pdf)`, warnings: res.warnings, pages_processed: res.pagesProcessed, pages_failed: res.pagesFailed };
srcAbs = mdAbs;
relToRaw = path.relative(l.rawDir, srcAbs);
ext = ".md";
} else if ([".odt", ".doc"].includes(ext)) {
return { error: `${ext} is not directly ingestible. Export it to .docx or .pdf (converted automatically), or to .md, and drop it into raw/.` };
} else if (![".md", ".markdown", ".txt"].includes(ext)) {
return { error: `Unsupported source type ${ext}. Provide .md/.txt (or .docx for automatic conversion).` };
}
const sourceText = await readText(srcAbs);
const sourceFileName = path.basename(srcAbs);
const sourceSlug = sourceSlugForFile(sourceFileName);
const { data: srcData } = parsePage(sourceText);
const sourceTitle = ((srcData.title as string) || "").trim() || sourceFileName;
const schema = await loadSchema(l);
const picked = await pickChatModel(ctl.client, chatModelOverride);
if ("error" in picked) return { error: picked.error };
log(`ingest ${relToRaw} via ${picked.identifier}`);
// Step 1 — extract entities/concepts.
ctx.status("Analyse de la source (entités & concepts)…");
const ex = buildExtractPrompt(schema, sourceTitle, sourceText);
const extracted = await runJson<ExtractResult>({
model: picked.model,
system: ex.system,
user: ex.user,
jsonSchema: ex.jsonSchema,
abortSignal: ctx.signal,
maxTokens: 1500,
});
// Read existing pages for those slugs so the LLM can merge them.
const existing: ExistingPage[] = [];
const slugDirs: Array<[string, string]> = [];
for (const e of extracted.entities ?? []) slugDirs.push(["entities", slugify(e.slug || e.name)]);
for (const c of extracted.concepts ?? []) slugDirs.push(["concepts", slugify(c.slug || c.name)]);
for (const [dir, slug] of slugDirs) {
const rel = `${dir}/${slug}.md`;
const abs = path.join(l.wikiDir, rel);
if (await fileExists(abs)) existing.push({ path: rel, content: await readText(abs) });
}
// Step 2 — compose source page + create/merge pages.
ctx.status("Rédaction et fusion des pages…");
const today = new Date().toISOString().slice(0, 10);
const co = buildComposePrompt(schema, relToRaw, sourceTitle, sourceText, await readIndex(l), existing, today);
const plan = await runJson<ComposeResult>({
model: picked.model,
system: co.system,
user: co.user,
jsonSchema: co.jsonSchema,
abortSignal: ctx.signal,
maxTokens: 6000,
});
// Normalise the planned pages (force the source page path/slug).
const planned: Array<{ path: string; action: string; markdown: string; reason?: string }> = [];
planned.push({ path: `sources/${sourceSlug}.md`, action: "create", markdown: plan.source_page?.markdown ?? "", reason: "page source" });
for (const p of plan.pages ?? []) {
if (!p?.path || !p?.markdown) continue;
planned.push({ path: normalizePlannedPagePath(p.path), action: p.action ?? "create", markdown: p.markdown, reason: p.reason });
}
// Preview mode.
if (supervisedIngest && !args.confirm) {
return {
preview: true,
source: relToRaw,
...(conversion ? { conversion } : {}),
pages_to_write: planned.map((p) => ({ path: p.path, action: p.action, reason: p.reason })),
contradictions: plan.contradictions ?? [],
notes: plan.notes ?? [],
hint: "Relance kwikines_ingest avec confirm=true pour écrire ces pages.",
};
}
// Apply: write each page, register in index, then reindex.
const written: Array<{ path: string; bytes: number; created: boolean; backup: string | null }> = [];
const indexEntries = [];
for (const p of planned) {
if (!p.markdown.trim()) continue;
const { abs, relWithinWiki } = await resolveWikiPage(l, p.path);
const res = await writePage(l, abs, p.markdown);
written.push({ path: relWithinWiki, bytes: res.bytes, created: res.created, backup: res.backupPath });
const summary = relWithinWiki.startsWith("sources/") ? `${sourceTitle} (source)` : firstLineSummary(p.markdown) || relWithinWiki;
const entry = indexEntryFor(relWithinWiki, summary);
if (entry) indexEntries.push(entry);
}
await upsertIndexEntries(l, indexEntries);
const logBullets = [
`Source : [[sources/${sourceSlug}]]`,
`Pages touchées : ${written.map((w) => w.path).join(", ") || "(aucune)"}`,
...(plan.contradictions?.length ? [`Contradictions : ${plan.contradictions.join(" ; ")}`] : []),
...(plan.log_bullets ?? []),
];
await appendWikiLog(l, formatLogEntry("ingest", sourceTitle, logBullets));
await appendAudit(l.base, "wiki-ingest", relToRaw, `${written.length} page(s)`);
// Refresh RAG.
const emb = await pickEmbeddingModel(ctl.client, embeddingModelOverride);
let reindexStats: unknown = "skipped (no embedding model loaded)";
if (!("error" in emb)) {
ctx.status("Réindexation RAG…");
reindexStats = await reindexStore(l, emb.model, emb.identifier, {
chunkSizeChars,
chunkOverlapChars,
abortSignal: ctx.signal,
});
}
ctx.status(`Ingéré — ${written.length} page(s).`);
return {
ingested: relToRaw,
...(conversion ? { conversion } : {}),
model: picked.identifier,
pages_written: written,
contradictions: plan.contradictions ?? [],
notes: plan.notes ?? [],
reindex: reindexStats,
};
} catch (e) {
return errOut(e);
}
},
});
// =======================================================================
// 9. kwikines_query (★ agentic, RAG-grounded)
// =======================================================================
const queryTool = tool({
name: "kwikines_query",
description: text`
Answer a question against the wiki: the plugin runs the RAG search, then drives
the loaded chat LLM to synthesise a cited answer (each claim linked to its wiki
page). If the answer has lasting value, it can be filed as a wiki/analyses/<slug>.md
page (so explorations compound). The answer ends with a "Sources utilisées"
section listing the wiki pages used (title, sections, relevance); the same is
returned structured as 'sources_used'. Also returns the raw passages and whether
an analysis page was written.
`,
parameters: {
question: z.string().min(1).describe("The question to answer from the wiki."),
top_k: z.number().int().min(1).max(30).optional().describe("Passages to retrieve."),
file_analysis: z.boolean().optional().describe("Force (true) or forbid (false) filing the answer as an analysis page. Default: follow the model's suggestion."),
},
implementation: async (args, ctx) => {
try {
const l = requireLayout();
await ensureWikiScaffold(l);
const emb = autoReindexOnSearch ? await ensureFreshIndex(l, ctx) : await pickEmbeddingModel(ctl.client, embeddingModelOverride);
if ("error" in emb) return { error: emb.error };
const idx = await loadIndex(l);
if (!idx || Object.keys(idx.chunks).length === 0) {
return { error: "Le wiki est vide ou non indexé. Ingère des sources d'abord (kwikines_ingest)." };
}
ctx.status("Recherche des passages…");
const qVec = await embedOne(emb.model, args.question);
const hits = ragSearch(idx, qVec, args.question, { topK: args.top_k ?? defaultTopK, hybrid: hybridSearch });
const chat = await pickChatModel(ctl.client, chatModelOverride);
if ("error" in chat) return { error: chat.error };
ctx.status(`Synthèse via ${chat.identifier}…`);
const schema = await loadSchema(l);
const today = new Date().toISOString().slice(0, 10);
const qp = buildQueryPrompt(
schema,
args.question,
hits.map((h) => ({ wikilink: h.chunk.wikilink, headingPath: h.chunk.headingPath, snippet: h.snippet })),
today,
);
const result = await runJson<QueryResult>({ model: chat.model, system: qp.system, user: qp.user, jsonSchema: qp.jsonSchema, abortSignal: ctx.signal, maxTokens: 3000 });
// Optionally file the answer as an analysis page.
let filed: { path: string; created: boolean } | null = null;
const shouldFile = args.file_analysis === true || (args.file_analysis !== false && result.file_as_analysis === true);
if (shouldFile && result.analysis?.markdown?.trim()) {
const slug = slugify(result.analysis.slug || result.analysis.title || args.question);
const rel = `analyses/${slug}.md`;
const { abs, relWithinWiki } = await resolveWikiPage(l, rel);
const res = await writePage(l, abs, result.analysis.markdown);
await upsertIndexEntries(l, [{ category: "analyses", wikilink: relWithinWiki.replace(/\.md$/i, ""), summary: result.analysis.title || args.question }]);
await appendAudit(l.base, "wiki-analysis", relWithinWiki, `${res.bytes} bytes`);
filed = { path: relWithinWiki, created: res.created };
}
await appendWikiLog(
l,
formatLogEntry("query", args.question.slice(0, 80), [
`Passages : ${hits.map((h) => `[[${h.chunk.wikilink}]]`).join(", ") || "(aucun)"}`,
...(filed ? [`Analyse classée : [[${filed.path.replace(/\.md$/i, "")}]]`] : []),
...(result.log_note ? [result.log_note] : []),
]),
);
// Build a deduplicated "sources used" presentation from the retrieved passages.
const sourcesUsed = summariseSources(hits);
const sourcesBlock = formatSourcesBlock(sourcesUsed);
const answerWithSources = sourcesBlock
? `${result.answer_markdown.trim()}\n\n${sourcesBlock}`
: result.answer_markdown;
return {
answer: answerWithSources,
sources_used: sourcesUsed,
passages: hits.map((h) => ({ wikilink: h.chunk.wikilink, heading_path: h.chunk.headingPath, score: +h.score.toFixed(4), snippet: h.snippet })),
filed_as_analysis: filed,
};
} catch (e) {
return errOut(e);
}
},
});
// =======================================================================
// 10. kwikines_lint
// =======================================================================
const lintTool = tool({
name: "kwikines_lint",
description: text`
Health-check the wiki. The plugin computes structural issues (orphan pages with
no inbound links, pages with no outbound links, broken wikilinks), then drives the
chat LLM to spot contradictions, stale claims, missing concept pages and data gaps,
and to suggest new questions/sources. Returns a report and logs the pass. Read-only:
it does not modify pages.
`,
parameters: {},
implementation: async (_args, ctx) => {
try {
const l = requireLayout();
await ensureWikiScaffold(l);
ctx.status("Analyse structurelle du wiki…");
const pages = await listIndexablePages(l);
// Build slug -> relPath map and parse each page's title + outbound links.
const info: Array<{ rel: string; slug: string; title: string; outbound: string[] }> = [];
const slugToRel = new Map<string, string>();
for (const abs of pages) {
const rel = path.relative(l.wikiDir, abs).replace(/\\/g, "/");
if (rel === "index.md") continue;
const content = await readText(abs);
const { data, body } = parsePage(content);
const slug = path.basename(rel, ".md");
const title = (data.title as string) || (data.name as string) || slug.replace(/-/g, " ");
const outbound = [...body.matchAll(/\[\[([^\]|#]+)/g)].map((m) => lastSlug(m[1]));
info.push({ rel, slug, title, outbound });
slugToRel.set(slug, rel);
}
const inboundCount = new Map<string, number>();
const structuralNotes: string[] = [];
for (const p of info) {
for (const target of p.outbound) {
inboundCount.set(target, (inboundCount.get(target) ?? 0) + 1);
if (!slugToRel.has(target)) structuralNotes.push(`Lien cassé : ${p.rel} → [[${target}]] (page absente)`);
}
}
for (const p of info) {
const inbound = inboundCount.get(p.slug) ?? 0;
if (inbound === 0 && p.rel !== "synthese.md") structuralNotes.push(`Page orpheline (aucun lien entrant) : ${p.rel}`);
if (p.outbound.length === 0) structuralNotes.push(`Aucun lien sortant : ${p.rel}`);
}
// LLM review of contradictions / gaps / suggestions.
let llmReview: LintResult = { findings: [], suggestions: [] };
const chat = await pickChatModel(ctl.client, chatModelOverride);
if (!("error" in chat)) {
ctx.status(`Revue via ${chat.identifier}…`);
const schema = await loadSchema(l);
const lp = buildLintPrompt(
schema,
info.map((p) => ({ path: p.rel, title: p.title, outbound: p.outbound, inbound: inboundCount.get(p.slug) ?? 0 })),
structuralNotes.slice(0, 40),
);
try {
llmReview = await runJson<LintResult>({ model: chat.model, system: lp.system, user: lp.user, jsonSchema: lp.jsonSchema, abortSignal: ctx.signal, maxTokens: 2500 });
} catch {
structuralNotes.push("(La revue LLM a échoué — résultats structurels seuls.)");
}
} else {
structuralNotes.push(`(Pas de LLM chargé : ${chat.error})`);
}
await appendWikiLog(
l,
formatLogEntry("lint", `${info.length} pages`, [
`Notes structurelles : ${structuralNotes.length}`,
`Findings LLM : ${llmReview.findings?.length ?? 0}`,
]),
);
return {
pages_checked: info.length,
structural_issues: structuralNotes,
llm_findings: llmReview.findings ?? [],
suggestions: llmReview.suggestions ?? [],
};
} catch (e) {
return errOut(e);
}
},
});
return [
statusTool,
listSourcesTool,
readTool,
writePageTool,
appendLogTool,
reindexTool,
searchTool,
ingestTool,
queryTool,
lintTool,
];
}
// ---------------------------------------------------------------------------
function errOut(e: unknown): { error: string } {
if (e instanceof PathError) return { error: e.message };
return { error: e instanceof Error ? e.message : String(e) };
}
function firstLineSummary(markdown: string): string {
const body = markdown.replace(/^---[\s\S]*?---\s*/m, "");
for (const line of body.split(/\r?\n/)) {
const t = line.trim();
if (t && !t.startsWith("#") && !t.startsWith("```")) return t.replace(/^[-*]\s*/, "").slice(0, 160);
}
return "";
}
/** Normalise a wiki page path coming from the (untrusted) LLM compose output:
* forward slashes, no leading "wiki/" or "/" prefix, and a guaranteed .md
* extension. LLMs routinely emit e.g. "concepts/rag" instead of
* "concepts/rag.md", which resolveWikiPage would otherwise reject. */
function normalizePlannedPagePath(raw: string): string {
let s = raw.replace(/\\/g, "/").trim().replace(/^\/+/, "").replace(/^wiki\//i, "");
if (!/\.md$/i.test(s)) s += ".md";
return s;
}
function lastSlug(wikilinkTarget: string): string {
const cleaned = wikilinkTarget.trim().split("/").pop() ?? wikilinkTarget;
return cleaned.trim();
}
interface SourceUsed {
wikilink: string;
title: string;
sections: string[];
relevance: number;
}
/** Deduplicate retrieved passages into one entry per wiki page, keeping the
* best relevance and the union of the sections (heading paths) touched. */
function summariseSources(
hits: Array<{ chunk: { wikilink: string; pageTitle: string; headingPath: string[] }; score: number }>,
): SourceUsed[] {
const byLink = new Map<string, SourceUsed & { _sections: Set<string> }>();
for (const h of hits) {
const key = h.chunk.wikilink;
let cur = byLink.get(key);
if (!cur) {
cur = { wikilink: key, title: h.chunk.pageTitle, sections: [], relevance: 0, _sections: new Set() };
byLink.set(key, cur);
}
if (h.chunk.headingPath.length) cur._sections.add(h.chunk.headingPath.join(" › "));
cur.relevance = Math.max(cur.relevance, h.score);
}
return [...byLink.values()]
.sort((a, b) => b.relevance - a.relevance)
.map((s) => ({ wikilink: s.wikilink, title: s.title, sections: [...s._sections], relevance: +s.relevance.toFixed(4) }));
}
/** Render the "sources used" presentation appended to a query answer. */
function formatSourcesBlock(sources: SourceUsed[]): string {
if (sources.length === 0) return "";
const lines = ["## Sources utilisées"];
for (const s of sources) {
const sections = s.sections.length ? ` — sections : ${s.sections.join(" ; ")}` : "";
lines.push(`- [[${s.wikilink}]] — ${s.title}${sections} · pertinence ${s.relevance.toFixed(2)}`);
}
return lines.join("\n");
}
import { tool, text, type ToolsProviderController } from "@lmstudio/sdk";
import { z } from "zod";
import * as fs from "node:fs/promises";
import * as path from "node:path";
import { configSchematics } from "./configSchematics";
import { canonicalizeRoots, resolveSafe, PathError } from "./pathGuard";
import { appendAudit } from "./auditLog";
import {
getLayout,
ensureWikiScaffold,
type WikiLayout,
listRawSources,
listIndexablePages,
listMarkdownFiles,
readText,
fileExists,
parsePage,
slugify,
writePage,
readIndex,
appendWikiLog,
formatLogEntry,
upsertIndexEntries,
categoryFromPath,
sourceSlugForFile,
} from "./wikiStore";
import { readDocx } from "./docxRead";
import { transcribePdf } from "./pdfTranscribe";
import { pickEmbeddingModel, embedOne } from "./embeddings";
import {
reindex as reindexStore,
loadIndex,
staleRelPaths,
search as ragSearch,
} from "./ragIndex";
import {
pickChatModel,
runJson,
buildExtractPrompt,
buildComposePrompt,
buildQueryPrompt,
buildLintPrompt,
type ExtractResult,
type ComposeResult,
type ExistingPage,
type QueryResult,
type LintResult,
} from "./llmAgent";
const LOG_PREFIX = "[kwikines]";
const DEFAULT_SCHEMA =
"Wiki Karpathy : raw/ immuable, wiki/ généré, AGENTS.md = schéma. " +
"Pages: sources/ entities/ concepts/ analyses/. Frontmatter YAML, wikilinks [[slug]], " +
"une page par sujet, cite les sources, signale les contradictions, ne fabrique rien.";
export async function toolsProvider(ctl: ToolsProviderController) {
const config = ctl.getPluginConfig(configSchematics);
const wikiBaseDir = config.get("wikiBaseDir");
const chatModelOverride = config.get("chatModelOverride");
const embeddingModelOverride = config.get("embeddingModelOverride");
const vlModelOverride = config.get("vlModelOverride");
const pdfRenderScale = config.get("pdfRenderScale");
const pdfMaxPages = config.get("pdfMaxPages");
const pdfLanguage = config.get("pdfLanguage");
const pdfTranscriptionStyle = config.get("pdfTranscriptionStyle");
const chunkSizeChars = config.get("chunkSizeChars");
const chunkOverlapChars = config.get("chunkOverlapChars");
const defaultTopK = config.get("defaultTopK");
const hybridSearch = config.get("hybridSearch");
const autoReindexOnSearch = config.get("autoReindexOnSearch");
const supervisedIngest = config.get("supervisedIngest");
const maxFileSizeMb = config.get("maxFileSizeMb");
const verboseLogging = config.get("verboseLogging");
const roots = await canonicalizeRoots([wikiBaseDir]);
const layout: WikiLayout | null = roots.length ? getLayout(roots[0]) : null;
const log = (msg: string) => {
if (verboseLogging) console.log(`${LOG_PREFIX} ${msg}`);
};
function requireLayout(): WikiLayout {
if (!layout) {
throw new PathError(
"No wiki base directory configured. Set 'Wiki base directory' in the plugin settings " +
"(the folder containing AGENTS.md, raw/ and wiki/).",
);
}
return layout;
}
async function loadSchema(l: WikiLayout): Promise<string> {
try {
return await readText(l.agentsPath);
} catch {
return DEFAULT_SCHEMA;
}
}
// Resolve a path that must live INSIDE wiki/ and end in .md. Accepts paths
// relative to wiki/ (e.g. "entities/x.md") or absolute inside the base.
async function resolveWikiPage(l: WikiLayout, raw: string): Promise<{ abs: string; relWithinWiki: string }> {
const candidate = path.isAbsolute(raw) ? raw : path.join(l.wikiDir, raw);
const { abs } = await resolveSafe(candidate, roots);
const relWithinWiki = path.relative(l.wikiDir, abs);
if (relWithinWiki.startsWith("..") || path.isAbsolute(relWithinWiki)) {
throw new PathError(`Page path must be inside wiki/, got: ${raw}`);
}
if (path.extname(abs).toLowerCase() !== ".md") {
throw new PathError(`Wiki pages must end in .md, got: ${raw}`);
}
return { abs, relWithinWiki: relWithinWiki.replace(/\\/g, "/") };
}
function indexEntryFor(relWithinWiki: string, summary: string) {
const category = categoryFromPath(relWithinWiki);
if (!category) return null;
return { category, wikilink: relWithinWiki.replace(/\.md$/i, ""), summary: summary.slice(0, 160) };
}
// ---- shared embedder / reindex helpers --------------------------------
async function ensureFreshIndex(l: WikiLayout, ctx: { status: (s: string) => void; signal: AbortSignal }) {
const picked = await pickEmbeddingModel(ctl.client, embeddingModelOverride);
if ("error" in picked) return picked;
const idx = await loadIndex(l);
const stale = await staleRelPaths(l, idx);
if (!idx || stale.length > 0) {
ctx.status(idx ? `Réindexation de ${stale.length} page(s) modifiée(s)…` : "Construction de l'index RAG…");
await reindexStore(l, picked.model, picked.identifier, {
chunkSizeChars,
chunkOverlapChars,
abortSignal: ctx.signal,
onProgress: (d, t, label) => ctx.status(`${label} (${d}/${t})`),
});
}
return picked;
}
// =======================================================================
// 1. kwikines_status
// =======================================================================
const statusTool = tool({
name: "kwikines_status",
description: text`
Orient yourself at the start of a wiki session. Returns the AGENTS.md schema
(the rules you must follow), the wiki structure with page counts, the head of
index.md, the list of raw sources with their ingest status, and the state of
the RAG index (fresh / stale, embedding model, chunk count).
Call this first when the user mentions "le wiki", "ingest", "fiche", a knowledge
base, or asks a question that should be answered from the wiki.
`,
parameters: {},
implementation: async (_args, ctx) => {
try {
const l = requireLayout();
await ensureWikiScaffold(l);
const schema = await loadSchema(l);
const counts: Record<string, number> = {};
for (const sub of ["sources", "entities", "concepts", "analyses"]) {
counts[sub] = (await listMarkdownFiles(path.join(l.wikiDir, sub))).length;
}
const indexHead = (await readIndex(l)).split(/\r?\n/).slice(0, 40).join("\n");
const sources = await listRawSources(l);
const idx = await loadIndex(l);
const stale = await staleRelPaths(l, idx);
return {
wiki_base: l.base,
schema_present: await fileExists(l.agentsPath),
schema_excerpt: schema.slice(0, 1500),
page_counts: counts,
index_head: indexHead,
raw_sources: sources.map((s) => ({
name: s.name,
ext: s.ext,
ingested: s.ingested,
needs_conversion: s.needsConversion,
...(s.derivedFrom ? { derived_from: s.derivedFrom } : {}),
})),
rag_index: idx
? { built: true, model: idx.model, chunks: Object.keys(idx.chunks).length, stale_pages: stale.length }
: { built: false, stale_pages: stale.length },
};
} catch (e) {
return errOut(e);
}
},
});
// =======================================================================
// 2. kwikines_list_sources
// =======================================================================
const listSourcesTool = tool({
name: "kwikines_list_sources",
description: text`
List the files in raw/ with their format, size and whether they have already
been ingested (a wiki/sources/<slug>.md exists). .docx and .pdf sources are
ingested directly: .docx is converted via mammoth, .pdf is transcribed by a
loaded vision-language model. Both write a sibling .md into raw/ on ingest.
`,
parameters: {},
implementation: async (_args, ctx) => {
try {
const l = requireLayout();
const sources = await listRawSources(l);
return {
raw_dir: l.rawDir,
count: sources.length,
sources: sources.map((s) => ({
name: s.name,
ext: s.ext,
size_kb: +(s.sizeBytes / 1024).toFixed(1),
ingested: s.ingested,
needs_conversion: s.needsConversion,
...(s.derivedFrom ? { derived_from: s.derivedFrom } : {}),
hint: s.derivedFrom
? `Issu de la conversion de ${s.derivedFrom} — même contenu ; ingère l'un OU l'autre, pas les deux.`
: s.needsConversion
? "Exporter d'abord en .docx, .pdf ou .md, puis déposer dans raw/."
: s.ingested
? "Déjà ingéré — relancer kwikines_ingest pour rafraîchir."
: s.ext === ".docx"
? "Prêt pour kwikines_ingest (conversion .docx → .md automatique)."
: s.ext === ".pdf"
? "Prêt pour kwikines_ingest (transcription par modèle de vision — un modèle VL doit être chargé)."
: "Prêt pour kwikines_ingest.",
})),
};
} catch (e) {
return errOut(e);
}
},
});
// =======================================================================
// 3. kwikines_read
// =======================================================================
const readTool = tool({
name: "kwikines_read",
description: text`
Read a markdown/text file located under the wiki base directory (either in
wiki/ or raw/). A .docx is converted to markdown on the fly (mammoth, no model);
a .pdf is transcribed page-by-page by a loaded vision-language model. Returns
the resulting content.
`,
parameters: {
path: z.string().min(1).describe("Path inside the wiki base (relative to it, or absolute)."),
},
implementation: async (args, ctx) => {
try {
const l = requireLayout();
const candidate = path.isAbsolute(args.path) ? args.path : path.join(l.base, args.path);
const { abs } = await resolveSafe(candidate, roots);
const ext = path.extname(abs).toLowerCase();
const stat = await fs.stat(abs);
if (stat.size / (1024 * 1024) > maxFileSizeMb) {
return { error: `File exceeds the ${maxFileSizeMb} MB cap.` };
}
if (ext === ".docx") {
const { markdown, warnings } = await readDocx(abs, { preserveStyles: true, includeMetadata: true });
return { path: abs, content: markdown, chars: markdown.length, converted_from: ".docx", warnings };
}
if (ext === ".pdf") {
const buffer = await fs.readFile(abs);
const res = await transcribePdf(
ctl.client,
abs,
buffer,
{ renderScale: pdfRenderScale, maxPages: pdfMaxPages, language: pdfLanguage, style: pdfTranscriptionStyle, vlModelOverride },
{ status: (s) => ctx.status(s), signal: ctx.signal },
);
if ("error" in res) return { error: res.error };
return { path: abs, content: res.markdown, chars: res.markdown.length, converted_from: ".pdf", model: res.model, pages_processed: res.pagesProcessed, pages_failed: res.pagesFailed, warnings: res.warnings };
}
if (![".md", ".markdown", ".txt"].includes(ext)) {
return { error: `Only .md/.txt/.docx/.pdf are read here. For ${ext}, export it to one of those formats first.` };
}
return { path: abs, content: await readText(abs), chars: stat.size };
} catch (e) {
return errOut(e);
}
},
});
// =======================================================================
// 4. kwikines_write_page
// =======================================================================
const writePageTool = tool({
name: "kwikines_write_page",
description: text`
Create or update a single wiki page (a .md file under wiki/). The previous
version, if any, is backed up before overwriting. Use this for fine-grained
manual edits; for ingesting a source prefer kwikines_ingest, which writes and
cross-links many pages at once.
`,
parameters: {
path: z.string().min(1).describe("Page path under wiki/, e.g. 'entities/marie-curie.md'."),
content: z.string().min(1).describe("Full markdown content of the page (frontmatter + body)."),
index_summary: z.string().optional().describe("One-line summary to register in index.md."),
},
implementation: async (args, ctx) => {
try {
const l = requireLayout();
await ensureWikiScaffold(l);
const reqPath = /\.md$/i.test(args.path) ? args.path : `${args.path}.md`;
const { abs, relWithinWiki } = await resolveWikiPage(l, reqPath);
const res = await writePage(l, abs, args.content);
const entry = indexEntryFor(relWithinWiki, args.index_summary ?? relWithinWiki);
if (entry) await upsertIndexEntries(l, [entry]);
await appendAudit(l.base, res.created ? "wiki-create" : "wiki-update", relWithinWiki, `${res.bytes} bytes`);
log(`${res.created ? "created" : "updated"} ${relWithinWiki}`);
return { written: { path: abs, bytes: res.bytes, created: res.created }, backup_path: res.backupPath, note: "RAG index will refresh on next search/reindex." };
} catch (e) {
return errOut(e);
}
},
});
// =======================================================================
// 5. kwikines_append_log
// =======================================================================
const appendLogTool = tool({
name: "kwikines_append_log",
description: text`
Append a chronological entry to wiki/log.md (format: '## [YYYY-MM-DD] kind | subject').
Use it to record ingests, queries or lint passes.
`,
parameters: {
kind: z.string().min(1).describe("Entry kind, e.g. 'ingest', 'query', 'lint', 'note'."),
subject: z.string().min(1).describe("Short subject of the entry."),
bullets: z.array(z.string()).optional().describe("Detail bullet lines."),
},
implementation: async (args, ctx) => {
try {
const l = requireLayout();
await appendWikiLog(l, formatLogEntry(args.kind, args.subject, args.bullets ?? []));
return { logged: true, path: l.logPath };
} catch (e) {
return errOut(e);
}
},
});
// =======================================================================
// 6. kwikines_reindex
// =======================================================================
const reindexTool = tool({
name: "kwikines_reindex",
description: text`
(Re)build the RAG vector index over the wiki pages using an embedding model
loaded in LM Studio. Incremental: only pages whose file changed since the last
build are re-embedded. Run it after large manual edits; ingest/search trigger it
automatically when needed.
`,
parameters: {},
implementation: async (_args, ctx) => {
try {
const l = requireLayout();
await ensureWikiScaffold(l);
const picked = await pickEmbeddingModel(ctl.client, embeddingModelOverride);
if ("error" in picked) return { error: picked.error };
ctx.status(`Indexation via ${picked.identifier}…`);
const stats = await reindexStore(l, picked.model, picked.identifier, {
chunkSizeChars,
chunkOverlapChars,
abortSignal: ctx.signal,
onProgress: (d, t, label) => ctx.status(`${label} (${d}/${t})`),
});
log(`reindex: ${JSON.stringify(stats)}`);
return { ...stats };
} catch (e) {
return errOut(e);
}
},
});
// =======================================================================
// 7. kwikines_search
// =======================================================================
const searchTool = tool({
name: "kwikines_search",
description: text`
Semantic (or hybrid semantic+keyword) search over the wiki — retrieves the
"compressed" passages most relevant to a query, each with its page wikilink,
heading path and score. This is the retrieval primitive of the RAG layer; use
it to ground answers in the wiki. kwikines_query wraps it with LLM synthesis.
`,
parameters: {
query: z.string().min(1).describe("The search query / question."),
top_k: z.number().int().min(1).max(30).optional().describe("How many passages to return."),
hybrid: z.boolean().optional().describe("Override hybrid (semantic+keyword) search."),
},
implementation: async (args, ctx) => {
try {
const l = requireLayout();
await ensureWikiScaffold(l);
let picked;
if (autoReindexOnSearch) {
picked = await ensureFreshIndex(l, ctx);
} else {
picked = await pickEmbeddingModel(ctl.client, embeddingModelOverride);
}
if ("error" in picked) return { error: picked.error };
const idx = await loadIndex(l);
if (!idx || Object.keys(idx.chunks).length === 0) {
return { hits: [], note: "L'index est vide. Ingère des sources puis relance kwikines_reindex." };
}
ctx.status("Recherche…");
const qVec = await embedOne(picked.model, args.query);
const hits = ragSearch(idx, qVec, args.query, {
topK: args.top_k ?? defaultTopK,
hybrid: args.hybrid ?? hybridSearch,
});
return {
model: idx.model,
query: args.query,
hits: hits.map((h) => ({
wikilink: h.chunk.wikilink,
page_title: h.chunk.pageTitle,
heading_path: h.chunk.headingPath,
score: +h.score.toFixed(4),
semantic: +h.semantic.toFixed(4),
lexical: +h.lexical.toFixed(4),
snippet: h.snippet,
})),
};
} catch (e) {
return errOut(e);
}
},
});
// =======================================================================
// 8. kwikines_ingest (★ agentic)
// =======================================================================
const ingestTool = tool({
name: "kwikines_ingest",
description: text`
Ingest a raw source (in raw/) into the wiki. CALL THIS TOOL DIRECTLY with the
source filename when the user asks to add / ingest / index a file or to update
the wiki from raw/ — do NOT narrate the steps, do NOT ask the user to convert or
pre-check anything, and do NOT invent a placeholder path: pass the real filename
as shown by kwikines_list_sources.
The plugin does all the work itself: a .md/.txt is read as-is; a .docx is
converted (mammoth) and a .pdf is transcribed by the loaded vision model — in
every case a sibling .md is written into raw/, then it reads the text, drives the
chat LLM to extract entities/concepts per AGENTS.md, writes the source page and
creates/merges entity & concept pages, updates index.md, appends log.md, and
refreshes the RAG index. A single source typically touches several pages.
When 'Supervised ingest' is ON (default), the first call returns a PREVIEW of the
pages it will write; call again with confirm=true to apply. Set confirm=true to
write directly. For a large .pdf, optionally pass pages (e.g. "1-20") to transcribe
only a range. (Transcribing a .pdf needs a vision-language model loaded; if none is,
the tool returns a clear error — just call it and surface that, don't pre-check.)
`,
parameters: {
source: z.string().min(1).describe("Source filename in raw/ (e.g. 'article.md') or a path inside raw/."),
confirm: z.boolean().optional().describe("Apply the changes (skip preview). Required when supervised ingest is ON."),
pages: z.string().optional().describe("PDF only: page range to transcribe, e.g. '1-20', '3,5,7'. Defaults to all pages (capped by the plugin's PDF max pages). Ignored for non-PDF sources."),
},
implementation: async (args, ctx) => {
try {
const l = requireLayout();
await ensureWikiScaffold(l);
// Resolve the source inside raw/. The source is given relative to raw/, but
// the LLM routinely prepends a redundant "raw/" (our own examples show paths
// like "raw/x.pdf"), which would otherwise resolve to raw/raw/x.pdf. Tolerate
// a leading "raw/" (and "wiki/" base-relative form) on a relative source.
const candidate = path.isAbsolute(args.source)
? args.source
: path.join(l.rawDir, args.source.replace(/\\/g, "/").replace(/^\/+/, "").replace(/^raw\//i, ""));
let { abs: srcAbs } = await resolveSafe(candidate, roots);
let relToRaw = path.relative(l.rawDir, srcAbs);
if (relToRaw.startsWith("..")) return { error: `Source must live in raw/, got: ${args.source}` };
let ext = path.extname(srcAbs).toLowerCase();
const stat = await fs.stat(srcAbs);
if (stat.size / (1024 * 1024) > maxFileSizeMb) return { error: `Source exceeds the ${maxFileSizeMb} MB cap.` };
// Conversion summary surfaced in the preview/result so a truncated or
// lossy conversion is never silent.
let conversion: { from: string; to: string; via: string; warnings: string[]; pages_processed?: number; pages_failed?: number[] } | null = null;
// .docx is the one convertible-format we can handle locally (mammoth, no
// model). We materialise a sibling .md in raw/ — the only write allowed in
// raw/ is a conversion output — then ingest that .md. The .docx stays.
if (ext === ".docx") {
ctx.status("Conversion du .docx en markdown…");
const { markdown, warnings } = await readDocx(srcAbs, { preserveStyles: true, includeMetadata: true });
const mdCandidate = srcAbs.slice(0, srcAbs.length - ext.length) + ".md";
const { abs: mdAbs } = await resolveSafe(mdCandidate, roots);
const res = await writePage(l, mdAbs, markdown);
await appendAudit(l.base, "raw-convert", path.relative(l.base, mdAbs), `from ${path.basename(srcAbs)}${warnings.length ? `, ${warnings.length} warning(s)` : ""}`);
log(`converted ${path.basename(srcAbs)} -> ${path.basename(mdAbs)} (${res.bytes} bytes)`);
conversion = { from: path.basename(srcAbs), to: path.basename(mdAbs), via: "mammoth (.docx)", warnings };
srcAbs = mdAbs;
relToRaw = path.relative(l.rawDir, srcAbs);
ext = ".md";
} else if (ext === ".pdf") {
// .pdf is transcribed page-by-page by a loaded vision model, then the
// resulting .md is written into raw/ (allowed conversion output) and ingested.
ctx.status("Transcription du PDF (modèle de vision)…");
const buffer = await fs.readFile(srcAbs);
const res = await transcribePdf(
ctl.client,
srcAbs,
buffer,
{ renderScale: pdfRenderScale, maxPages: pdfMaxPages, language: pdfLanguage, style: pdfTranscriptionStyle, vlModelOverride, pages: args.pages },
{ status: (s) => ctx.status(s), signal: ctx.signal },
);
if ("error" in res) return { error: res.error };
const mdCandidate = srcAbs.slice(0, srcAbs.length - ext.length) + ".md";
const { abs: mdAbs } = await resolveSafe(mdCandidate, roots);
const w = await writePage(l, mdAbs, res.markdown);
await appendAudit(l.base, "raw-convert", path.relative(l.base, mdAbs), `from ${path.basename(srcAbs)} (${res.pagesProcessed} page(s), VL ${res.model})${res.pagesFailed.length ? `, ${res.pagesFailed.length} failed` : ""}`);
log(`transcribed ${path.basename(srcAbs)} -> ${path.basename(mdAbs)} (${w.bytes} bytes)`);
conversion = { from: path.basename(srcAbs), to: path.basename(mdAbs), via: `vision-model ${res.model} (.pdf)`, warnings: res.warnings, pages_processed: res.pagesProcessed, pages_failed: res.pagesFailed };
srcAbs = mdAbs;
relToRaw = path.relative(l.rawDir, srcAbs);
ext = ".md";
} else if ([".odt", ".doc"].includes(ext)) {
return { error: `${ext} is not directly ingestible. Export it to .docx or .pdf (converted automatically), or to .md, and drop it into raw/.` };
} else if (![".md", ".markdown", ".txt"].includes(ext)) {
return { error: `Unsupported source type ${ext}. Provide .md/.txt (or .docx for automatic conversion).` };
}
const sourceText = await readText(srcAbs);
const sourceFileName = path.basename(srcAbs);
const sourceSlug = sourceSlugForFile(sourceFileName);
const { data: srcData } = parsePage(sourceText);
const sourceTitle = ((srcData.title as string) || "").trim() || sourceFileName;
const schema = await loadSchema(l);
const picked = await pickChatModel(ctl.client, chatModelOverride);
if ("error" in picked) return { error: picked.error };
log(`ingest ${relToRaw} via ${picked.identifier}`);
// Step 1 — extract entities/concepts.
ctx.status("Analyse de la source (entités & concepts)…");
const ex = buildExtractPrompt(schema, sourceTitle, sourceText);
const extracted = await runJson<ExtractResult>({
model: picked.model,
system: ex.system,
user: ex.user,
jsonSchema: ex.jsonSchema,
abortSignal: ctx.signal,
maxTokens: 1500,
});
// Read existing pages for those slugs so the LLM can merge them.
const existing: ExistingPage[] = [];
const slugDirs: Array<[string, string]> = [];
for (const e of extracted.entities ?? []) slugDirs.push(["entities", slugify(e.slug || e.name)]);
for (const c of extracted.concepts ?? []) slugDirs.push(["concepts", slugify(c.slug || c.name)]);
for (const [dir, slug] of slugDirs) {
const rel = `${dir}/${slug}.md`;
const abs = path.join(l.wikiDir, rel);
if (await fileExists(abs)) existing.push({ path: rel, content: await readText(abs) });
}
// Step 2 — compose source page + create/merge pages.
ctx.status("Rédaction et fusion des pages…");
const today = new Date().toISOString().slice(0, 10);
const co = buildComposePrompt(schema, relToRaw, sourceTitle, sourceText, await readIndex(l), existing, today);
const plan = await runJson<ComposeResult>({
model: picked.model,
system: co.system,
user: co.user,
jsonSchema: co.jsonSchema,
abortSignal: ctx.signal,
maxTokens: 6000,
});
// Normalise the planned pages (force the source page path/slug).
const planned: Array<{ path: string; action: string; markdown: string; reason?: string }> = [];
planned.push({ path: `sources/${sourceSlug}.md`, action: "create", markdown: plan.source_page?.markdown ?? "", reason: "page source" });
for (const p of plan.pages ?? []) {
if (!p?.path || !p?.markdown) continue;
planned.push({ path: normalizePlannedPagePath(p.path), action: p.action ?? "create", markdown: p.markdown, reason: p.reason });
}
// Preview mode.
if (supervisedIngest && !args.confirm) {
return {
preview: true,
source: relToRaw,
...(conversion ? { conversion } : {}),
pages_to_write: planned.map((p) => ({ path: p.path, action: p.action, reason: p.reason })),
contradictions: plan.contradictions ?? [],
notes: plan.notes ?? [],
hint: "Relance kwikines_ingest avec confirm=true pour écrire ces pages.",
};
}
// Apply: write each page, register in index, then reindex.
const written: Array<{ path: string; bytes: number; created: boolean; backup: string | null }> = [];
const indexEntries = [];
for (const p of planned) {
if (!p.markdown.trim()) continue;
const { abs, relWithinWiki } = await resolveWikiPage(l, p.path);
const res = await writePage(l, abs, p.markdown);
written.push({ path: relWithinWiki, bytes: res.bytes, created: res.created, backup: res.backupPath });
const summary = relWithinWiki.startsWith("sources/") ? `${sourceTitle} (source)` : firstLineSummary(p.markdown) || relWithinWiki;
const entry = indexEntryFor(relWithinWiki, summary);
if (entry) indexEntries.push(entry);
}
await upsertIndexEntries(l, indexEntries);
const logBullets = [
`Source : [[sources/${sourceSlug}]]`,
`Pages touchées : ${written.map((w) => w.path).join(", ") || "(aucune)"}`,
...(plan.contradictions?.length ? [`Contradictions : ${plan.contradictions.join(" ; ")}`] : []),
...(plan.log_bullets ?? []),
];
await appendWikiLog(l, formatLogEntry("ingest", sourceTitle, logBullets));
await appendAudit(l.base, "wiki-ingest", relToRaw, `${written.length} page(s)`);
// Refresh RAG.
const emb = await pickEmbeddingModel(ctl.client, embeddingModelOverride);
let reindexStats: unknown = "skipped (no embedding model loaded)";
if (!("error" in emb)) {
ctx.status("Réindexation RAG…");
reindexStats = await reindexStore(l, emb.model, emb.identifier, {
chunkSizeChars,
chunkOverlapChars,
abortSignal: ctx.signal,
});
}
ctx.status(`Ingéré — ${written.length} page(s).`);
return {
ingested: relToRaw,
...(conversion ? { conversion } : {}),
model: picked.identifier,
pages_written: written,
contradictions: plan.contradictions ?? [],
notes: plan.notes ?? [],
reindex: reindexStats,
};
} catch (e) {
return errOut(e);
}
},
});
// =======================================================================
// 9. kwikines_query (★ agentic, RAG-grounded)
// =======================================================================
const queryTool = tool({
name: "kwikines_query",
description: text`
Answer a question against the wiki: the plugin runs the RAG search, then drives
the loaded chat LLM to synthesise a cited answer (each claim linked to its wiki
page). If the answer has lasting value, it can be filed as a wiki/analyses/<slug>.md
page (so explorations compound). The answer ends with a "Sources utilisées"
section listing the wiki pages used (title, sections, relevance); the same is
returned structured as 'sources_used'. Also returns the raw passages and whether
an analysis page was written.
`,
parameters: {
question: z.string().min(1).describe("The question to answer from the wiki."),
top_k: z.number().int().min(1).max(30).optional().describe("Passages to retrieve."),
file_analysis: z.boolean().optional().describe("Force (true) or forbid (false) filing the answer as an analysis page. Default: follow the model's suggestion."),
},
implementation: async (args, ctx) => {
try {
const l = requireLayout();
await ensureWikiScaffold(l);
const emb = autoReindexOnSearch ? await ensureFreshIndex(l, ctx) : await pickEmbeddingModel(ctl.client, embeddingModelOverride);
if ("error" in emb) return { error: emb.error };
const idx = await loadIndex(l);
if (!idx || Object.keys(idx.chunks).length === 0) {
return { error: "Le wiki est vide ou non indexé. Ingère des sources d'abord (kwikines_ingest)." };
}
ctx.status("Recherche des passages…");
const qVec = await embedOne(emb.model, args.question);
const hits = ragSearch(idx, qVec, args.question, { topK: args.top_k ?? defaultTopK, hybrid: hybridSearch });
const chat = await pickChatModel(ctl.client, chatModelOverride);
if ("error" in chat) return { error: chat.error };
ctx.status(`Synthèse via ${chat.identifier}…`);
const schema = await loadSchema(l);
const today = new Date().toISOString().slice(0, 10);
const qp = buildQueryPrompt(
schema,
args.question,
hits.map((h) => ({ wikilink: h.chunk.wikilink, headingPath: h.chunk.headingPath, snippet: h.snippet })),
today,
);
const result = await runJson<QueryResult>({ model: chat.model, system: qp.system, user: qp.user, jsonSchema: qp.jsonSchema, abortSignal: ctx.signal, maxTokens: 3000 });
// Optionally file the answer as an analysis page.
let filed: { path: string; created: boolean } | null = null;
const shouldFile = args.file_analysis === true || (args.file_analysis !== false && result.file_as_analysis === true);
if (shouldFile && result.analysis?.markdown?.trim()) {
const slug = slugify(result.analysis.slug || result.analysis.title || args.question);
const rel = `analyses/${slug}.md`;
const { abs, relWithinWiki } = await resolveWikiPage(l, rel);
const res = await writePage(l, abs, result.analysis.markdown);
await upsertIndexEntries(l, [{ category: "analyses", wikilink: relWithinWiki.replace(/\.md$/i, ""), summary: result.analysis.title || args.question }]);
await appendAudit(l.base, "wiki-analysis", relWithinWiki, `${res.bytes} bytes`);
filed = { path: relWithinWiki, created: res.created };
}
await appendWikiLog(
l,
formatLogEntry("query", args.question.slice(0, 80), [
`Passages : ${hits.map((h) => `[[${h.chunk.wikilink}]]`).join(", ") || "(aucun)"}`,
...(filed ? [`Analyse classée : [[${filed.path.replace(/\.md$/i, "")}]]`] : []),
...(result.log_note ? [result.log_note] : []),
]),
);
// Build a deduplicated "sources used" presentation from the retrieved passages.
const sourcesUsed = summariseSources(hits);
const sourcesBlock = formatSourcesBlock(sourcesUsed);
const answerWithSources = sourcesBlock
? `${result.answer_markdown.trim()}\n\n${sourcesBlock}`
: result.answer_markdown;
return {
answer: answerWithSources,
sources_used: sourcesUsed,
passages: hits.map((h) => ({ wikilink: h.chunk.wikilink, heading_path: h.chunk.headingPath, score: +h.score.toFixed(4), snippet: h.snippet })),
filed_as_analysis: filed,
};
} catch (e) {
return errOut(e);
}
},
});
// =======================================================================
// 10. kwikines_lint
// =======================================================================
const lintTool = tool({
name: "kwikines_lint",
description: text`
Health-check the wiki. The plugin computes structural issues (orphan pages with
no inbound links, pages with no outbound links, broken wikilinks), then drives the
chat LLM to spot contradictions, stale claims, missing concept pages and data gaps,
and to suggest new questions/sources. Returns a report and logs the pass. Read-only:
it does not modify pages.
`,
parameters: {},
implementation: async (_args, ctx) => {
try {
const l = requireLayout();
await ensureWikiScaffold(l);
ctx.status("Analyse structurelle du wiki…");
const pages = await listIndexablePages(l);
// Build slug -> relPath map and parse each page's title + outbound links.
const info: Array<{ rel: string; slug: string; title: string; outbound: string[] }> = [];
const slugToRel = new Map<string, string>();
for (const abs of pages) {
const rel = path.relative(l.wikiDir, abs).replace(/\\/g, "/");
if (rel === "index.md") continue;
const content = await readText(abs);
const { data, body } = parsePage(content);
const slug = path.basename(rel, ".md");
const title = (data.title as string) || (data.name as string) || slug.replace(/-/g, " ");
const outbound = [...body.matchAll(/\[\[([^\]|#]+)/g)].map((m) => lastSlug(m[1]));
info.push({ rel, slug, title, outbound });
slugToRel.set(slug, rel);
}
const inboundCount = new Map<string, number>();
const structuralNotes: string[] = [];
for (const p of info) {
for (const target of p.outbound) {
inboundCount.set(target, (inboundCount.get(target) ?? 0) + 1);
if (!slugToRel.has(target)) structuralNotes.push(`Lien cassé : ${p.rel} → [[${target}]] (page absente)`);
}
}
for (const p of info) {
const inbound = inboundCount.get(p.slug) ?? 0;
if (inbound === 0 && p.rel !== "synthese.md") structuralNotes.push(`Page orpheline (aucun lien entrant) : ${p.rel}`);
if (p.outbound.length === 0) structuralNotes.push(`Aucun lien sortant : ${p.rel}`);
}
// LLM review of contradictions / gaps / suggestions.
let llmReview: LintResult = { findings: [], suggestions: [] };
const chat = await pickChatModel(ctl.client, chatModelOverride);
if (!("error" in chat)) {
ctx.status(`Revue via ${chat.identifier}…`);
const schema = await loadSchema(l);
const lp = buildLintPrompt(
schema,
info.map((p) => ({ path: p.rel, title: p.title, outbound: p.outbound, inbound: inboundCount.get(p.slug) ?? 0 })),
structuralNotes.slice(0, 40),
);
try {
llmReview = await runJson<LintResult>({ model: chat.model, system: lp.system, user: lp.user, jsonSchema: lp.jsonSchema, abortSignal: ctx.signal, maxTokens: 2500 });
} catch {
structuralNotes.push("(La revue LLM a échoué — résultats structurels seuls.)");
}
} else {
structuralNotes.push(`(Pas de LLM chargé : ${chat.error})`);
}
await appendWikiLog(
l,
formatLogEntry("lint", `${info.length} pages`, [
`Notes structurelles : ${structuralNotes.length}`,
`Findings LLM : ${llmReview.findings?.length ?? 0}`,
]),
);
return {
pages_checked: info.length,
structural_issues: structuralNotes,
llm_findings: llmReview.findings ?? [],
suggestions: llmReview.suggestions ?? [],
};
} catch (e) {
return errOut(e);
}
},
});
return [
statusTool,
listSourcesTool,
readTool,
writePageTool,
appendLogTool,
reindexTool,
searchTool,
ingestTool,
queryTool,
lintTool,
];
}
// ---------------------------------------------------------------------------
function errOut(e: unknown): { error: string } {
if (e instanceof PathError) return { error: e.message };
return { error: e instanceof Error ? e.message : String(e) };
}
function firstLineSummary(markdown: string): string {
const body = markdown.replace(/^---[\s\S]*?---\s*/m, "");
for (const line of body.split(/\r?\n/)) {
const t = line.trim();
if (t && !t.startsWith("#") && !t.startsWith("```")) return t.replace(/^[-*]\s*/, "").slice(0, 160);
}
return "";
}
/** Normalise a wiki page path coming from the (untrusted) LLM compose output:
* forward slashes, no leading "wiki/" or "/" prefix, and a guaranteed .md
* extension. LLMs routinely emit e.g. "concepts/rag" instead of
* "concepts/rag.md", which resolveWikiPage would otherwise reject. */
function normalizePlannedPagePath(raw: string): string {
let s = raw.replace(/\\/g, "/").trim().replace(/^\/+/, "").replace(/^wiki\//i, "");
if (!/\.md$/i.test(s)) s += ".md";
return s;
}
function lastSlug(wikilinkTarget: string): string {
const cleaned = wikilinkTarget.trim().split("/").pop() ?? wikilinkTarget;
return cleaned.trim();
}
interface SourceUsed {
wikilink: string;
title: string;
sections: string[];
relevance: number;
}
/** Deduplicate retrieved passages into one entry per wiki page, keeping the
* best relevance and the union of the sections (heading paths) touched. */
function summariseSources(
hits: Array<{ chunk: { wikilink: string; pageTitle: string; headingPath: string[] }; score: number }>,
): SourceUsed[] {
const byLink = new Map<string, SourceUsed & { _sections: Set<string> }>();
for (const h of hits) {
const key = h.chunk.wikilink;
let cur = byLink.get(key);
if (!cur) {
cur = { wikilink: key, title: h.chunk.pageTitle, sections: [], relevance: 0, _sections: new Set() };
byLink.set(key, cur);
}
if (h.chunk.headingPath.length) cur._sections.add(h.chunk.headingPath.join(" › "));
cur.relevance = Math.max(cur.relevance, h.score);
}
return [...byLink.values()]
.sort((a, b) => b.relevance - a.relevance)
.map((s) => ({ wikilink: s.wikilink, title: s.title, sections: [...s._sections], relevance: +s.relevance.toFixed(4) }));
}
/** Render the "sources used" presentation appended to a query answer. */
function formatSourcesBlock(sources: SourceUsed[]): string {
if (sources.length === 0) return "";
const lines = ["## Sources utilisées"];
for (const s of sources) {
const sections = s.sections.length ? ` — sections : ${s.sections.join(" ; ")}` : "";
lines.push(`- [[${s.wikilink}]] — ${s.title}${sections} · pertinence ${s.relevance.toFixed(2)}`);
}
return lines.join("\n");
}