From 6846774b4127f384845c4db9af6f7a4b67573106 Mon Sep 17 00:00:00 2001 From: adam Date: Sun, 1 Mar 2026 19:41:30 +0000 Subject: [PATCH] Add rebuild_host and update_section tools rebuild_host: wipe-and-replace for full host re-profiles. Deletes all chunks for a hostname then bulk-ingests fresh sections. Supports confirm: false for dry-run preview. update_section: surgical in-place update with fuzzy section matching (case-insensitive substring, then vector similarity fallback). Preserves metadata, replaces text, re-embeds. Returns existing section list on no match. Addresses duplicate chunk problem caused by inconsistent section naming across profiling runs. --- src/index.ts | 20 +++++++ src/tools/rebuild-host.ts | 89 ++++++++++++++++++++++++++++ src/tools/update-section.ts | 114 ++++++++++++++++++++++++++++++++++++ 3 files changed, 223 insertions(+) create mode 100644 src/tools/rebuild-host.ts create mode 100644 src/tools/update-section.ts diff --git a/src/index.ts b/src/index.ts index 68c7e5d..4b1b354 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,8 @@ import { getProject, getProjectSchema } from "./tools/get-project.js"; import { upsertProject, upsertProjectSchema } from "./tools/upsert-project.js"; import { updateProject, updateProjectSchema } from "./tools/update-project.js"; import { deleteProject, deleteProjectSchema } from "./tools/delete-project.js"; +import { rebuildHost, rebuildHostSchema } from "./tools/rebuild-host.js"; +import { updateSection, updateSectionSchema } from "./tools/update-section.js"; const server = new McpServer({ name: "homelab-mcp", @@ -126,6 +128,24 @@ server.tool( }), ); +server.tool( + "rebuild_host", + 'Nuclear option for re-profiling a host. Deletes ALL existing chunks for the hostname, then bulk-ingests fresh sections. Use when doing a full re-profile of a machine to avoid stale/duplicate chunks. Set confirm: false first to preview what will be deleted. Example: {host: "usco-dc-nas01", source: "devices/usco-dc-nas01.md", sections: [{section: "System", text: "...", tags: ["nas"]}, ...], confirm: true}. Do NOT use for single-section updates — use update_section instead.', + rebuildHostSchema.shape, + async (input) => ({ + content: [{ type: "text", text: await rebuildHost(rebuildHostSchema.parse(input)) }], + }), +); + +server.tool( + "update_section", + 'Surgical in-place update of a single section for a host. Fuzzy-matches the section name against existing sections (case-insensitive substring, then vector similarity). Preserves source/tags/type metadata, replaces text and re-embeds. If no match is found, returns the list of existing sections so you can pick the right one. Example: {host: "usco-dc-nas01", section: "Known Constraints", text: "new content..."}. Do NOT use for full re-profiles — use rebuild_host instead.', + updateSectionSchema.shape, + async (input) => ({ + content: [{ type: "text", text: await updateSection(updateSectionSchema.parse(input)) }], + }), +); + async function main() { const transport = new StdioServerTransport(); await server.connect(transport); diff --git a/src/tools/rebuild-host.ts b/src/tools/rebuild-host.ts new file mode 100644 index 0000000..cf4c13e --- /dev/null +++ b/src/tools/rebuild-host.ts @@ -0,0 +1,89 @@ +import { z } from "zod"; +import { embed } from "../lib/embeddings.js"; +import { getClient, COLLECTION, deterministicId, type ChunkPayload } from "../lib/qdrant.js"; + +const sectionSchema = z.object({ + section: z.string().describe("Section heading"), + text: z.string().describe("Content text"), + tags: z.array(z.string()).default([]).describe("Optional tags"), + type: z + .enum(["device", "infrastructure", "network", "automation", "tool", "general", "project"]) + .default("device") + .describe("Document type (default: device)"), +}); + +export const rebuildHostSchema = z.object({ + host: z.string().describe("Hostname to rebuild"), + source: z.string().describe("Source path for all sections, e.g. 'devices/usco-dc-nas01.md'"), + sections: z.array(sectionSchema).min(1).describe("New sections to ingest"), + confirm: z + .boolean() + .default(false) + .describe("Must be true to proceed. When false, returns a preview of what would be deleted and ingested."), +}); + +export type RebuildHostInput = z.infer; + +export async function rebuildHost(input: RebuildHostInput): Promise { + const client = getClient(); + const filter = { must: [{ key: "host" as const, match: { value: input.host } }] }; + + // Count existing chunks + let existingCount = 0; + let offset: string | number | Record | undefined | null = undefined; + while (true) { + const result = await client.scroll(COLLECTION, { + limit: 100, + offset, + with_payload: ["section"], + with_vector: false, + filter, + }); + existingCount += result.points.length; + if (!result.next_page_offset) break; + offset = result.next_page_offset; + } + + if (!input.confirm) { + const sectionList = input.sections.map((s) => ` - ${s.section}`).join("\n"); + return [ + `**Rebuild preview for "${input.host}"**`, + `Will delete: ${existingCount} existing chunk(s)`, + `Will ingest: ${input.sections.length} new section(s)`, + `Source: ${input.source}`, + `Sections:`, + sectionList, + "", + "Set confirm: true to proceed.", + ].join("\n"); + } + + // Delete all existing chunks for this host + if (existingCount > 0) { + await client.delete(COLLECTION, { filter }); + } + + // Ingest new sections + const now = new Date().toISOString(); + const points = await Promise.all( + input.sections.map(async (s) => { + const pointId = deterministicId(input.source, s.section, input.host); + const vector = await embed(s.text); + const payload: ChunkPayload = { + source: input.source, + section: s.section, + type: s.type, + host: input.host, + tags: s.tags, + updated: now, + text: s.text, + }; + return { id: pointId, vector, payload: payload as unknown as Record }; + }), + ); + + // Batch upsert (Qdrant handles batches fine) + await client.upsert(COLLECTION, { points }); + + return `Rebuilt "${input.host}": deleted ${existingCount} old chunk(s), ingested ${input.sections.length} new section(s).`; +} diff --git a/src/tools/update-section.ts b/src/tools/update-section.ts new file mode 100644 index 0000000..c8a80f8 --- /dev/null +++ b/src/tools/update-section.ts @@ -0,0 +1,114 @@ +import { z } from "zod"; +import { embed } from "../lib/embeddings.js"; +import { getClient, COLLECTION, deterministicId, type ChunkPayload } from "../lib/qdrant.js"; + +export const updateSectionSchema = z.object({ + host: z.string().describe("Hostname to update"), + section: z.string().describe("Section name (fuzzy matched against existing sections for this host)"), + text: z.string().describe("New content text (replaces existing text, triggers re-embedding)"), + new_tags: z.array(z.string()).optional().describe("Replace tags (omit to keep existing)"), +}); + +export type UpdateSectionInput = z.infer; + +export async function updateSection(input: UpdateSectionInput): Promise { + const client = getClient(); + + // Gather all chunks for this host + const chunks: Array<{ id: string | number; payload: ChunkPayload }> = []; + let offset: string | number | Record | undefined | null = undefined; + while (true) { + const result = await client.scroll(COLLECTION, { + limit: 100, + offset, + with_payload: true, + with_vector: false, + filter: { must: [{ key: "host", match: { value: input.host } }] }, + }); + for (const point of result.points) { + chunks.push({ id: point.id, payload: point.payload as unknown as ChunkPayload }); + } + if (!result.next_page_offset) break; + offset = result.next_page_offset; + } + + if (chunks.length === 0) { + return `No chunks found for host "${input.host}". Use list_hosts to see available hosts.`; + } + + // Try exact match first + let match = chunks.find((c) => c.payload.section === input.section); + + // Case-insensitive exact match + if (!match) { + const lower = input.section.toLowerCase(); + match = chunks.find((c) => c.payload.section.toLowerCase() === lower); + } + + // Substring match (input is substring of existing section name, or vice versa) + if (!match) { + const lower = input.section.toLowerCase(); + const substringMatches = chunks.filter( + (c) => + c.payload.section.toLowerCase().includes(lower) || + lower.includes(c.payload.section.toLowerCase()), + ); + if (substringMatches.length === 1) { + match = substringMatches[0]; + } else if (substringMatches.length > 1) { + const names = substringMatches.map((c) => ` - "${c.payload.section}"`).join("\n"); + return `Multiple sections match "${input.section}":\n${names}\n\nBe more specific.`; + } + } + + // Vector similarity fallback + if (!match) { + const queryVector = await embed(input.section); + const results = await client.search(COLLECTION, { + vector: queryVector, + limit: 1, + with_payload: true, + filter: { must: [{ key: "host", match: { value: input.host } }] }, + }); + + if (results.length > 0 && results[0].score > 0.7) { + const candidate = results[0].payload as unknown as ChunkPayload; + const candidateChunk = chunks.find((c) => c.payload.section === candidate.section); + if (candidateChunk) { + match = candidateChunk; + } + } + } + + if (!match) { + const existing = chunks + .map((c) => c.payload.section) + .sort() + .map((s) => ` - "${s}"`) + .join("\n"); + return `No section matching "${input.section}" found for host "${input.host}".\n\nExisting sections:\n${existing}`; + } + + // Update the matched chunk: preserve metadata, replace text, re-embed + const payload = match.payload; + const newPayload: ChunkPayload = { + ...payload, + text: input.text, + updated: new Date().toISOString(), + ...(input.new_tags !== undefined ? { tags: input.new_tags } : {}), + }; + + const vector = await embed(input.text); + const pointId = deterministicId(payload.source, payload.section, payload.host, payload.project ?? ""); + + // Delete the old point (in case the stored ID differs from the computed one due to legacy data) + await client.delete(COLLECTION, { points: [match.id] }); + + // Upsert with the correct deterministic ID + await client.upsert(COLLECTION, { + points: [{ id: pointId, vector, payload: newPayload as unknown as Record }], + }); + + const fuzzyNote = payload.section !== input.section ? ` (matched: "${payload.section}")` : ""; + return `Updated section "${payload.section}"${fuzzyNote} for host "${input.host}".`; +}