Add CUD operations to homelab-mcp server
Add upsert_context, update_context, and delete_context tools to complete CRUD functionality. Upserts use deterministic IDs based on source+section+host for idempotency. Updates support metadata-only patches or full re-embedding when text changes.
This commit is contained in:
4
.dockerignore
Normal file
4
.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
*.md
|
||||
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
node_modules/\ndist/\n*.js.map\n*.d.ts.map
|
||||
15
Dockerfile
Normal file
15
Dockerfile
Normal file
@@ -0,0 +1,15 @@
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /build
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install
|
||||
COPY tsconfig.json ./
|
||||
COPY src/ ./src/
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
COPY --from=build /build/package.json ./
|
||||
COPY --from=build /build/node_modules ./node_modules
|
||||
COPY --from=build /build/dist ./dist
|
||||
USER node
|
||||
CMD ["node", "dist/index.js"]
|
||||
1753
package-lock.json
generated
Normal file
1753
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
22
package.json
Normal file
22
package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "homelab-mcp",
|
||||
"version": "1.0.0",
|
||||
"description": "MCP server for homelab context search via Qdrant",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"dev": "tsx src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.12.1",
|
||||
"@qdrant/js-client-rest": "^1.13.0",
|
||||
"zod": "^3.24.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
87
src/index.ts
Normal file
87
src/index.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import { searchContext, searchContextSchema } from "./tools/search-context.js";
|
||||
import { listHosts } from "./tools/list-hosts.js";
|
||||
import { getHostContext, getHostContextSchema } from "./tools/get-host-context.js";
|
||||
import { searchIssues, searchIssuesSchema } from "./tools/search-issues.js";
|
||||
import { upsertContext, upsertContextSchema } from "./tools/upsert-context.js";
|
||||
import { updateContext, updateContextSchema } from "./tools/update-context.js";
|
||||
import { deleteContext, deleteContextSchema } from "./tools/delete-context.js";
|
||||
|
||||
const server = new McpServer({
|
||||
name: "homelab-mcp",
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
server.tool(
|
||||
"search_context",
|
||||
"Semantic search across all homelab documentation. Returns the most relevant sections for a natural language query. Optionally filter by document type (device, infrastructure, network, automation, tool) or hostname.",
|
||||
searchContextSchema.shape,
|
||||
async (input) => ({
|
||||
content: [{ type: "text", text: await searchContext(searchContextSchema.parse(input)) }],
|
||||
}),
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"list_hosts",
|
||||
"List all documented hosts/devices in the homelab context database. Returns a table of hostnames, source files, and section counts.",
|
||||
{},
|
||||
async () => ({
|
||||
content: [{ type: "text", text: await listHosts() }],
|
||||
}),
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"get_host_context",
|
||||
"Retrieve the full documentation for a specific host. Returns all sections from the host's context file, assembled in order.",
|
||||
getHostContextSchema.shape,
|
||||
async (input) => ({
|
||||
content: [{ type: "text", text: await getHostContext(getHostContextSchema.parse(input)) }],
|
||||
}),
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"search_issues",
|
||||
"Search for operational notes, troubleshooting info, known issues, and incident reports across all homelab documentation.",
|
||||
searchIssuesSchema.shape,
|
||||
async (input) => ({
|
||||
content: [{ type: "text", text: await searchIssues(searchIssuesSchema.parse(input)) }],
|
||||
}),
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"upsert_context",
|
||||
"Create or overwrite a homelab documentation chunk. Uses a deterministic ID based on source, section, and host so repeated upserts are idempotent.",
|
||||
upsertContextSchema.shape,
|
||||
async (input) => ({
|
||||
content: [{ type: "text", text: await upsertContext(upsertContextSchema.parse(input)) }],
|
||||
}),
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"update_context",
|
||||
"Update metadata fields on existing homelab documentation chunk(s). Filter by host, source, or section. Optionally provide new text to trigger re-embedding.",
|
||||
updateContextSchema._def.schema.shape,
|
||||
async (input) => ({
|
||||
content: [{ type: "text", text: await updateContext(updateContextSchema.parse(input)) }],
|
||||
}),
|
||||
);
|
||||
|
||||
server.tool(
|
||||
"delete_context",
|
||||
"Delete homelab documentation chunks matching a filter. At least one filter field (host, source, section) is required.",
|
||||
deleteContextSchema._def.schema.shape,
|
||||
async (input) => ({
|
||||
content: [{ type: "text", text: await deleteContext(deleteContextSchema.parse(input)) }],
|
||||
}),
|
||||
);
|
||||
|
||||
async function main() {
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Fatal error:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
16
src/lib/embeddings.ts
Normal file
16
src/lib/embeddings.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
const OLLAMA_URL = process.env.OLLAMA_URL || "http://localhost:11434";
|
||||
const EMBED_MODEL = process.env.EMBED_MODEL || "snowflake-arctic-embed:335m";
|
||||
const MAX_EMBED_CHARS = 1000;
|
||||
|
||||
export async function embed(text: string): Promise<number[]> {
|
||||
const resp = await fetch(`${OLLAMA_URL}/api/embed`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model: EMBED_MODEL, input: [text.slice(0, MAX_EMBED_CHARS)] }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
throw new Error(`Ollama embed failed: ${resp.status} ${await resp.text()}`);
|
||||
}
|
||||
const data = (await resp.json()) as { embeddings: number[][] };
|
||||
return data.embeddings[0];
|
||||
}
|
||||
40
src/lib/qdrant.ts
Normal file
40
src/lib/qdrant.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { createHash } from "crypto";
|
||||
import { QdrantClient } from "@qdrant/js-client-rest";
|
||||
|
||||
const QDRANT_URL = process.env.QDRANT_URL || "http://localhost:6333";
|
||||
const QDRANT_API_KEY = process.env.QDRANT_API_KEY || "";
|
||||
|
||||
export const COLLECTION = process.env.QDRANT_COLLECTION || "homelab_context";
|
||||
|
||||
let client: QdrantClient | null = null;
|
||||
|
||||
export function getClient(): QdrantClient {
|
||||
if (!client) {
|
||||
client = new QdrantClient({
|
||||
url: QDRANT_URL,
|
||||
...(QDRANT_API_KEY ? { apiKey: QDRANT_API_KEY } : {}),
|
||||
});
|
||||
}
|
||||
return client;
|
||||
}
|
||||
|
||||
export function deterministicId(source: string, section: string, host: string): string {
|
||||
const hash = createHash("sha256").update(`${source}\0${section}\0${host}`).digest("hex");
|
||||
return [
|
||||
hash.slice(0, 8),
|
||||
hash.slice(8, 12),
|
||||
hash.slice(12, 16),
|
||||
hash.slice(16, 20),
|
||||
hash.slice(20, 32),
|
||||
].join("-");
|
||||
}
|
||||
|
||||
export interface ChunkPayload {
|
||||
source: string;
|
||||
section: string;
|
||||
type: string;
|
||||
host: string;
|
||||
tags: string[];
|
||||
updated: string;
|
||||
text: string;
|
||||
}
|
||||
49
src/tools/delete-context.ts
Normal file
49
src/tools/delete-context.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { z } from "zod";
|
||||
import { getClient, COLLECTION } from "../lib/qdrant.js";
|
||||
|
||||
export const deleteContextSchema = z
|
||||
.object({
|
||||
host: z.string().optional().describe("Filter by hostname"),
|
||||
source: z.string().optional().describe("Filter by source path"),
|
||||
section: z.string().optional().describe("Filter by section heading"),
|
||||
})
|
||||
.refine((d) => d.host || d.source || d.section, {
|
||||
message: "At least one filter field (host, source, section) is required",
|
||||
});
|
||||
|
||||
export type DeleteContextInput = z.infer<typeof deleteContextSchema>;
|
||||
|
||||
export async function deleteContext(input: DeleteContextInput): Promise<string> {
|
||||
const client = getClient();
|
||||
|
||||
const must: Array<Record<string, unknown>> = [];
|
||||
if (input.host) must.push({ key: "host", match: { value: input.host } });
|
||||
if (input.source) must.push({ key: "source", match: { value: input.source } });
|
||||
if (input.section) must.push({ key: "section", match: { value: input.section } });
|
||||
|
||||
const filter = { must };
|
||||
|
||||
// Count matches first
|
||||
let count = 0;
|
||||
let offset: string | number | Record<string, unknown> | undefined | null = undefined;
|
||||
while (true) {
|
||||
const result = await client.scroll(COLLECTION, {
|
||||
limit: 100,
|
||||
offset,
|
||||
with_payload: false,
|
||||
with_vector: false,
|
||||
filter,
|
||||
});
|
||||
count += result.points.length;
|
||||
if (!result.next_page_offset) break;
|
||||
offset = result.next_page_offset;
|
||||
}
|
||||
|
||||
if (count === 0) {
|
||||
return "No matching points found.";
|
||||
}
|
||||
|
||||
await client.delete(COLLECTION, { filter });
|
||||
|
||||
return `Deleted ${count} point(s).`;
|
||||
}
|
||||
40
src/tools/get-host-context.ts
Normal file
40
src/tools/get-host-context.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { z } from "zod";
|
||||
import { getClient, COLLECTION, type ChunkPayload } from "../lib/qdrant.js";
|
||||
|
||||
export const getHostContextSchema = z.object({
|
||||
host: z.string().describe("Hostname to retrieve full context for"),
|
||||
});
|
||||
|
||||
export type GetHostContextInput = z.infer<typeof getHostContextSchema>;
|
||||
|
||||
export async function getHostContext(input: GetHostContextInput): Promise<string> {
|
||||
const client = getClient();
|
||||
const chunks: ChunkPayload[] = [];
|
||||
|
||||
let offset: string | number | Record<string, unknown> | 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(point.payload as unknown as ChunkPayload);
|
||||
}
|
||||
|
||||
if (!result.next_page_offset) break;
|
||||
offset = result.next_page_offset;
|
||||
}
|
||||
|
||||
if (chunks.length === 0) {
|
||||
return `No context found for host "${input.host}". Use list_hosts to see available hosts.`;
|
||||
}
|
||||
|
||||
// Sort by source then section to reconstruct document order
|
||||
chunks.sort((a, b) => a.source.localeCompare(b.source) || a.section.localeCompare(b.section));
|
||||
|
||||
return chunks.map((c) => c.text).join("\n\n---\n\n");
|
||||
}
|
||||
42
src/tools/list-hosts.ts
Normal file
42
src/tools/list-hosts.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { getClient, COLLECTION, type ChunkPayload } from "../lib/qdrant.js";
|
||||
|
||||
export async function listHosts(): Promise<string> {
|
||||
const client = getClient();
|
||||
const hosts = new Map<string, { source: string; sections: number }>();
|
||||
|
||||
let offset: string | number | Record<string, unknown> | undefined | null = undefined;
|
||||
while (true) {
|
||||
const result = await client.scroll(COLLECTION, {
|
||||
limit: 100,
|
||||
offset,
|
||||
with_payload: ["source", "host", "type"],
|
||||
with_vector: false,
|
||||
filter: { must: [{ key: "type", match: { value: "device" } }] },
|
||||
});
|
||||
|
||||
for (const point of result.points) {
|
||||
const p = point.payload as unknown as ChunkPayload;
|
||||
if (p.host) {
|
||||
const existing = hosts.get(p.host);
|
||||
if (existing) {
|
||||
existing.sections++;
|
||||
} else {
|
||||
hosts.set(p.host, { source: p.source, sections: 1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!result.next_page_offset) break;
|
||||
offset = result.next_page_offset;
|
||||
}
|
||||
|
||||
if (hosts.size === 0) {
|
||||
return "No hosts found in the context database.";
|
||||
}
|
||||
|
||||
const lines = ["| Host | Source | Sections |", "|---|---|---|"];
|
||||
for (const [host, info] of [...hosts.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
|
||||
lines.push(`| ${host} | ${info.source} | ${info.sections} |`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
52
src/tools/search-context.ts
Normal file
52
src/tools/search-context.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { z } from "zod";
|
||||
import { embed } from "../lib/embeddings.js";
|
||||
import { getClient, COLLECTION, type ChunkPayload } from "../lib/qdrant.js";
|
||||
|
||||
export const searchContextSchema = z.object({
|
||||
query: z.string().describe("Natural language search query"),
|
||||
type: z
|
||||
.enum(["device", "infrastructure", "network", "automation", "tool", "general"])
|
||||
.optional()
|
||||
.describe("Filter by document type"),
|
||||
host: z.string().optional().describe("Filter by hostname"),
|
||||
limit: z.number().min(1).max(20).default(5).describe("Number of results"),
|
||||
});
|
||||
|
||||
export type SearchContextInput = z.infer<typeof searchContextSchema>;
|
||||
|
||||
export async function searchContext(input: SearchContextInput): Promise<string> {
|
||||
const vector = await embed(input.query);
|
||||
const client = getClient();
|
||||
|
||||
const must: Array<Record<string, unknown>> = [];
|
||||
if (input.type) {
|
||||
must.push({ key: "type", match: { value: input.type } });
|
||||
}
|
||||
if (input.host) {
|
||||
must.push({ key: "host", match: { value: input.host } });
|
||||
}
|
||||
|
||||
const results = await client.search(COLLECTION, {
|
||||
vector,
|
||||
limit: input.limit,
|
||||
with_payload: true,
|
||||
...(must.length > 0 ? { filter: { must } } : {}),
|
||||
});
|
||||
|
||||
if (results.length === 0) {
|
||||
return "No results found.";
|
||||
}
|
||||
|
||||
return results
|
||||
.map((r, i) => {
|
||||
const p = r.payload as unknown as ChunkPayload;
|
||||
return [
|
||||
`### Result ${i + 1} (score: ${r.score.toFixed(3)})`,
|
||||
`**Source**: ${p.source} | **Section**: ${p.section} | **Type**: ${p.type}${p.host ? ` | **Host**: ${p.host}` : ""}`,
|
||||
`**Tags**: ${p.tags.length > 0 ? p.tags.join(", ") : "none"} | **Updated**: ${p.updated}`,
|
||||
"",
|
||||
p.text,
|
||||
].join("\n");
|
||||
})
|
||||
.join("\n\n---\n\n");
|
||||
}
|
||||
55
src/tools/search-issues.ts
Normal file
55
src/tools/search-issues.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { z } from "zod";
|
||||
import { embed } from "../lib/embeddings.js";
|
||||
import { getClient, COLLECTION, type ChunkPayload } from "../lib/qdrant.js";
|
||||
|
||||
export const searchIssuesSchema = z.object({
|
||||
query: z.string().describe("Search query for operational notes and issues"),
|
||||
limit: z.number().min(1).max(20).default(5).describe("Number of results"),
|
||||
});
|
||||
|
||||
export type SearchIssuesInput = z.infer<typeof searchIssuesSchema>;
|
||||
|
||||
export async function searchIssues(input: SearchIssuesInput): Promise<string> {
|
||||
const vector = await embed(input.query);
|
||||
const client = getClient();
|
||||
|
||||
// Search across infrastructure and device docs, filtered to sections
|
||||
// that are likely operational notes, issues, or troubleshooting
|
||||
const results = await client.search(COLLECTION, {
|
||||
vector,
|
||||
limit: input.limit * 2, // over-fetch then filter
|
||||
with_payload: true,
|
||||
});
|
||||
|
||||
// Filter to operational/issue-related sections
|
||||
const issuePatterns = /operational|issue|troubleshoot|problem|fix|error|outage|rca|incident|quirk|gotcha|known/i;
|
||||
const filtered = results.filter((r) => {
|
||||
const p = r.payload as unknown as ChunkPayload;
|
||||
return issuePatterns.test(p.section) || issuePatterns.test(p.text);
|
||||
});
|
||||
|
||||
const final = filtered.slice(0, input.limit);
|
||||
|
||||
if (final.length === 0) {
|
||||
// Fall back to top results even if not explicitly issue-tagged
|
||||
const fallback = results.slice(0, input.limit);
|
||||
if (fallback.length === 0) return "No results found.";
|
||||
|
||||
return (
|
||||
"*No explicitly operational sections found. Showing best general matches:*\n\n" +
|
||||
fallback
|
||||
.map((r, i) => {
|
||||
const p = r.payload as unknown as ChunkPayload;
|
||||
return `### Result ${i + 1} (score: ${r.score.toFixed(3)})\n**Source**: ${p.source} | **Section**: ${p.section}\n\n${p.text}`;
|
||||
})
|
||||
.join("\n\n---\n\n")
|
||||
);
|
||||
}
|
||||
|
||||
return final
|
||||
.map((r, i) => {
|
||||
const p = r.payload as unknown as ChunkPayload;
|
||||
return `### Result ${i + 1} (score: ${r.score.toFixed(3)})\n**Source**: ${p.source} | **Section**: ${p.section}\n\n${p.text}`;
|
||||
})
|
||||
.join("\n\n---\n\n");
|
||||
}
|
||||
94
src/tools/update-context.ts
Normal file
94
src/tools/update-context.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { z } from "zod";
|
||||
import { embed } from "../lib/embeddings.js";
|
||||
import { getClient, COLLECTION, type ChunkPayload } from "../lib/qdrant.js";
|
||||
|
||||
export const updateContextSchema = z
|
||||
.object({
|
||||
host: z.string().optional().describe("Filter by hostname"),
|
||||
source: z.string().optional().describe("Filter by source path"),
|
||||
section: z.string().optional().describe("Filter by section heading"),
|
||||
new_tags: z.array(z.string()).optional().describe("Set tags"),
|
||||
new_type: z
|
||||
.enum(["device", "infrastructure", "network", "automation", "tool", "general"])
|
||||
.optional()
|
||||
.describe("Set document type"),
|
||||
new_source: z.string().optional().describe("Set source path"),
|
||||
new_section: z.string().optional().describe("Set section heading"),
|
||||
new_host: z.string().optional().describe("Set hostname"),
|
||||
text: z.string().optional().describe("New content text (triggers re-embedding)"),
|
||||
})
|
||||
.refine((d) => d.host || d.source || d.section, {
|
||||
message: "At least one filter field (host, source, section) is required",
|
||||
});
|
||||
|
||||
export type UpdateContextInput = z.infer<typeof updateContextSchema>;
|
||||
|
||||
export async function updateContext(input: UpdateContextInput): Promise<string> {
|
||||
const client = getClient();
|
||||
|
||||
const must: Array<Record<string, unknown>> = [];
|
||||
if (input.host) must.push({ key: "host", match: { value: input.host } });
|
||||
if (input.source) must.push({ key: "source", match: { value: input.source } });
|
||||
if (input.section) must.push({ key: "section", match: { value: input.section } });
|
||||
|
||||
const filter = { must };
|
||||
|
||||
// Scroll to find matching points
|
||||
const points: Array<{ id: string | number; payload: ChunkPayload }> = [];
|
||||
let offset: string | number | Record<string, unknown> | undefined | null = undefined;
|
||||
while (true) {
|
||||
const result = await client.scroll(COLLECTION, {
|
||||
limit: 100,
|
||||
offset,
|
||||
with_payload: true,
|
||||
with_vector: false,
|
||||
filter,
|
||||
});
|
||||
for (const point of result.points) {
|
||||
points.push({
|
||||
id: point.id,
|
||||
payload: point.payload as unknown as ChunkPayload,
|
||||
});
|
||||
}
|
||||
if (!result.next_page_offset) break;
|
||||
offset = result.next_page_offset;
|
||||
}
|
||||
|
||||
if (points.length === 0) {
|
||||
return "No matching points found.";
|
||||
}
|
||||
|
||||
// Build payload patch from new_* fields
|
||||
const patch: Record<string, unknown> = {};
|
||||
if (input.new_tags !== undefined) patch.tags = input.new_tags;
|
||||
if (input.new_type !== undefined) patch.type = input.new_type;
|
||||
if (input.new_source !== undefined) patch.source = input.new_source;
|
||||
if (input.new_section !== undefined) patch.section = input.new_section;
|
||||
if (input.new_host !== undefined) patch.host = input.new_host;
|
||||
|
||||
if (input.text !== undefined) {
|
||||
// Re-embed and full upsert for each point
|
||||
const vector = await embed(input.text);
|
||||
const upsertPoints = points.map((p) => ({
|
||||
id: p.id,
|
||||
vector,
|
||||
payload: {
|
||||
...p.payload,
|
||||
...patch,
|
||||
text: input.text,
|
||||
updated: new Date().toISOString(),
|
||||
},
|
||||
}));
|
||||
await client.upsert(COLLECTION, { points: upsertPoints });
|
||||
} else if (Object.keys(patch).length > 0) {
|
||||
patch.updated = new Date().toISOString();
|
||||
await client.setPayload(COLLECTION, {
|
||||
payload: patch,
|
||||
filter,
|
||||
});
|
||||
} else {
|
||||
return `Found ${points.length} matching point(s) but no update fields provided.`;
|
||||
}
|
||||
|
||||
return `Updated ${points.length} point(s).`;
|
||||
}
|
||||
43
src/tools/upsert-context.ts
Normal file
43
src/tools/upsert-context.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { z } from "zod";
|
||||
import { embed } from "../lib/embeddings.js";
|
||||
import {
|
||||
getClient,
|
||||
COLLECTION,
|
||||
deterministicId,
|
||||
type ChunkPayload,
|
||||
} from "../lib/qdrant.js";
|
||||
|
||||
export const upsertContextSchema = z.object({
|
||||
source: z.string().describe("Source path, e.g. 'devices/proxmox.md'"),
|
||||
section: z.string().describe("Section heading"),
|
||||
type: z
|
||||
.enum(["device", "infrastructure", "network", "automation", "tool", "general"])
|
||||
.describe("Document type"),
|
||||
host: z.string().describe("Hostname"),
|
||||
tags: z.array(z.string()).default([]).describe("Optional tags"),
|
||||
text: z.string().describe("Content text"),
|
||||
});
|
||||
|
||||
export type UpsertContextInput = z.infer<typeof upsertContextSchema>;
|
||||
|
||||
export async function upsertContext(input: UpsertContextInput): Promise<string> {
|
||||
const pointId = deterministicId(input.source, input.section, input.host);
|
||||
const vector = await embed(input.text);
|
||||
const client = getClient();
|
||||
|
||||
const payload: ChunkPayload = {
|
||||
source: input.source,
|
||||
section: input.section,
|
||||
type: input.type,
|
||||
host: input.host,
|
||||
tags: input.tags,
|
||||
updated: new Date().toISOString(),
|
||||
text: input.text,
|
||||
};
|
||||
|
||||
await client.upsert(COLLECTION, {
|
||||
points: [{ id: pointId, vector, payload: payload as unknown as Record<string, unknown> }],
|
||||
});
|
||||
|
||||
return `Upserted point ${pointId} (source: ${input.source}, section: ${input.section}, host: ${input.host}).`;
|
||||
}
|
||||
18
tsconfig.json
Normal file
18
tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user