Add structured project tools with rich relationships
Replace chunk-based project tools with dedicated structured project tools that store projects as single Qdrant points with rich JSON payloads. Adds list_projects, get_project, upsert_project, update_project, and delete_project tools with Zod discriminated union validation for per-category relationship operations.
This commit is contained in:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1 +1,4 @@
|
|||||||
node_modules/\ndist/\n*.js.map\n*.d.ts.map
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.js.map
|
||||||
|
*.d.ts.map
|
||||||
|
|||||||
54
src/index.ts
54
src/index.ts
@@ -7,6 +7,11 @@ import { searchIssues, searchIssuesSchema } from "./tools/search-issues.js";
|
|||||||
import { upsertContext, upsertContextSchema } from "./tools/upsert-context.js";
|
import { upsertContext, upsertContextSchema } from "./tools/upsert-context.js";
|
||||||
import { updateContext, updateContextSchema } from "./tools/update-context.js";
|
import { updateContext, updateContextSchema } from "./tools/update-context.js";
|
||||||
import { deleteContext, deleteContextSchema } from "./tools/delete-context.js";
|
import { deleteContext, deleteContextSchema } from "./tools/delete-context.js";
|
||||||
|
import { listProjects, listProjectsSchema } from "./tools/list-projects.js";
|
||||||
|
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";
|
||||||
|
|
||||||
const server = new McpServer({
|
const server = new McpServer({
|
||||||
name: "homelab-mcp",
|
name: "homelab-mcp",
|
||||||
@@ -60,7 +65,7 @@ server.tool(
|
|||||||
|
|
||||||
server.tool(
|
server.tool(
|
||||||
"update_context",
|
"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.",
|
"Update metadata fields on existing homelab documentation chunk(s). Filter by host, source, section, or project. Optionally provide new text to trigger re-embedding.",
|
||||||
updateContextSchema._def.schema.shape,
|
updateContextSchema._def.schema.shape,
|
||||||
async (input) => ({
|
async (input) => ({
|
||||||
content: [{ type: "text", text: await updateContext(updateContextSchema.parse(input)) }],
|
content: [{ type: "text", text: await updateContext(updateContextSchema.parse(input)) }],
|
||||||
@@ -69,13 +74,58 @@ server.tool(
|
|||||||
|
|
||||||
server.tool(
|
server.tool(
|
||||||
"delete_context",
|
"delete_context",
|
||||||
"Delete homelab documentation chunks matching a filter. At least one filter field (host, source, section) is required.",
|
"Delete homelab documentation chunks matching a filter. At least one filter field (host, source, section, project) is required.",
|
||||||
deleteContextSchema._def.schema.shape,
|
deleteContextSchema._def.schema.shape,
|
||||||
async (input) => ({
|
async (input) => ({
|
||||||
content: [{ type: "text", text: await deleteContext(deleteContextSchema.parse(input)) }],
|
content: [{ type: "text", text: await deleteContext(deleteContextSchema.parse(input)) }],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
"list_projects",
|
||||||
|
"List all tracked projects. Filter by status (default: 'active', or 'all' for everything). Returns JSON array of project summaries.",
|
||||||
|
listProjectsSchema.shape,
|
||||||
|
async (input) => ({
|
||||||
|
content: [{ type: "text", text: await listProjects(listProjectsSchema.parse(input)) }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
"get_project",
|
||||||
|
"Retrieve the full structured record for a project by slug. Returns all fields including relationships, blockers, history, and key facts.",
|
||||||
|
getProjectSchema.shape,
|
||||||
|
async (input) => ({
|
||||||
|
content: [{ type: "text", text: await getProject(getProjectSchema.parse(input)) }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
"upsert_project",
|
||||||
|
"Create or update a project. On create, 'name' is required; other fields default to empty. On update, only provided fields are changed. Relationships merge at sub-key level (hosts, software, git_repos, local_dirs).",
|
||||||
|
upsertProjectSchema.shape,
|
||||||
|
async (input) => ({
|
||||||
|
content: [{ type: "text", text: await upsertProject(upsertProjectSchema.parse(input)) }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
"update_project",
|
||||||
|
"Apply granular mutations to an existing project. Supports scalar updates (status, summary, current_state) and array add/remove operations (blockers, tags, key_facts, history, decisions_pending, relationships).",
|
||||||
|
updateProjectSchema.shape,
|
||||||
|
async (input) => ({
|
||||||
|
content: [{ type: "text", text: await updateProject(updateProjectSchema.parse(input)) }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
server.tool(
|
||||||
|
"delete_project",
|
||||||
|
"Delete a project by slug. Removes the single project point from the database.",
|
||||||
|
deleteProjectSchema.shape,
|
||||||
|
async (input) => ({
|
||||||
|
content: [{ type: "text", text: await deleteProject(deleteProjectSchema.parse(input)) }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const transport = new StdioServerTransport();
|
const transport = new StdioServerTransport();
|
||||||
await server.connect(transport);
|
await server.connect(transport);
|
||||||
|
|||||||
102
src/lib/projects.ts
Normal file
102
src/lib/projects.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { createHash } from "crypto";
|
||||||
|
import { embed } from "./embeddings.js";
|
||||||
|
import { getClient, COLLECTION } from "./qdrant.js";
|
||||||
|
|
||||||
|
// --- Relationship types ---
|
||||||
|
|
||||||
|
export interface HostRelationship {
|
||||||
|
host: string;
|
||||||
|
role: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SoftwareRelationship {
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
version?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitRepoRelationship {
|
||||||
|
repo: string;
|
||||||
|
path?: string;
|
||||||
|
role: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LocalDirRelationship {
|
||||||
|
path: string;
|
||||||
|
role: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectRelationships {
|
||||||
|
hosts: HostRelationship[];
|
||||||
|
software: SoftwareRelationship[];
|
||||||
|
git_repos: GitRepoRelationship[];
|
||||||
|
local_dirs: LocalDirRelationship[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Project payload (stored as Qdrant point payload) ---
|
||||||
|
|
||||||
|
export interface ProjectPayload {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
summary: string;
|
||||||
|
tags: string[];
|
||||||
|
current_state: string;
|
||||||
|
blockers: string[];
|
||||||
|
decisions_pending: string[];
|
||||||
|
relationships: ProjectRelationships;
|
||||||
|
key_facts: string[];
|
||||||
|
history: string[];
|
||||||
|
updated: string;
|
||||||
|
type: "project";
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
export function projectPointId(slug: string): string {
|
||||||
|
const hash = createHash("sha256").update(`project\0${slug}`).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 function buildEmbeddingText(payload: ProjectPayload): string {
|
||||||
|
const parts = [payload.summary, payload.current_state, ...payload.key_facts, ...payload.tags];
|
||||||
|
return parts.filter(Boolean).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emptyRelationships(): ProjectRelationships {
|
||||||
|
return { hosts: [], software: [], git_repos: [], local_dirs: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Data access ---
|
||||||
|
|
||||||
|
export async function getProjectPoint(slug: string): Promise<ProjectPayload | null> {
|
||||||
|
const client = getClient();
|
||||||
|
const id = projectPointId(slug);
|
||||||
|
try {
|
||||||
|
const points = await client.retrieve(COLLECTION, { ids: [id], with_payload: true, with_vector: false });
|
||||||
|
if (points.length === 0) return null;
|
||||||
|
return points[0].payload as unknown as ProjectPayload;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertProjectPoint(payload: ProjectPayload): Promise<string> {
|
||||||
|
const client = getClient();
|
||||||
|
const id = projectPointId(payload.slug);
|
||||||
|
const vector = await embed(buildEmbeddingText(payload));
|
||||||
|
await client.upsert(COLLECTION, {
|
||||||
|
points: [{ id, vector, payload: payload as unknown as Record<string, unknown> }],
|
||||||
|
});
|
||||||
|
return id;
|
||||||
|
}
|
||||||
@@ -18,8 +18,8 @@ export function getClient(): QdrantClient {
|
|||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deterministicId(source: string, section: string, host: string): string {
|
export function deterministicId(source: string, section: string, host: string, project: string = ""): string {
|
||||||
const hash = createHash("sha256").update(`${source}\0${section}\0${host}`).digest("hex");
|
const hash = createHash("sha256").update(`${source}\0${section}\0${host}\0${project}`).digest("hex");
|
||||||
return [
|
return [
|
||||||
hash.slice(0, 8),
|
hash.slice(0, 8),
|
||||||
hash.slice(8, 12),
|
hash.slice(8, 12),
|
||||||
@@ -34,6 +34,7 @@ export interface ChunkPayload {
|
|||||||
section: string;
|
section: string;
|
||||||
type: string;
|
type: string;
|
||||||
host: string;
|
host: string;
|
||||||
|
project?: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
updated: string;
|
updated: string;
|
||||||
text: string;
|
text: string;
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import { getClient, COLLECTION } from "../lib/qdrant.js";
|
|||||||
export const deleteContextSchema = z
|
export const deleteContextSchema = z
|
||||||
.object({
|
.object({
|
||||||
host: z.string().optional().describe("Filter by hostname"),
|
host: z.string().optional().describe("Filter by hostname"),
|
||||||
|
project: z.string().optional().describe("Filter by project name"),
|
||||||
source: z.string().optional().describe("Filter by source path"),
|
source: z.string().optional().describe("Filter by source path"),
|
||||||
section: z.string().optional().describe("Filter by section heading"),
|
section: z.string().optional().describe("Filter by section heading"),
|
||||||
})
|
})
|
||||||
.refine((d) => d.host || d.source || d.section, {
|
.refine((d) => d.host || d.source || d.section || d.project, {
|
||||||
message: "At least one filter field (host, source, section) is required",
|
message: "At least one filter field (host, source, section, project) is required",
|
||||||
});
|
});
|
||||||
|
|
||||||
export type DeleteContextInput = z.infer<typeof deleteContextSchema>;
|
export type DeleteContextInput = z.infer<typeof deleteContextSchema>;
|
||||||
@@ -18,6 +19,7 @@ export async function deleteContext(input: DeleteContextInput): Promise<string>
|
|||||||
|
|
||||||
const must: Array<Record<string, unknown>> = [];
|
const must: Array<Record<string, unknown>> = [];
|
||||||
if (input.host) must.push({ key: "host", match: { value: input.host } });
|
if (input.host) must.push({ key: "host", match: { value: input.host } });
|
||||||
|
if (input.project) must.push({ key: "project", match: { value: input.project } });
|
||||||
if (input.source) must.push({ key: "source", match: { value: input.source } });
|
if (input.source) must.push({ key: "source", match: { value: input.source } });
|
||||||
if (input.section) must.push({ key: "section", match: { value: input.section } });
|
if (input.section) must.push({ key: "section", match: { value: input.section } });
|
||||||
|
|
||||||
|
|||||||
22
src/tools/delete-project.ts
Normal file
22
src/tools/delete-project.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { getClient, COLLECTION } from "../lib/qdrant.js";
|
||||||
|
import { projectPointId, getProjectPoint } from "../lib/projects.js";
|
||||||
|
|
||||||
|
export const deleteProjectSchema = z.object({
|
||||||
|
slug: z.string().describe("Project slug identifier to delete"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type DeleteProjectInput = z.infer<typeof deleteProjectSchema>;
|
||||||
|
|
||||||
|
export async function deleteProject(input: DeleteProjectInput): Promise<string> {
|
||||||
|
const existing = await getProjectPoint(input.slug);
|
||||||
|
if (!existing) {
|
||||||
|
return `Project "${input.slug}" not found.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = getClient();
|
||||||
|
const id = projectPointId(input.slug);
|
||||||
|
await client.delete(COLLECTION, { points: [id] });
|
||||||
|
|
||||||
|
return `Deleted project "${existing.name}" (${input.slug}).`;
|
||||||
|
}
|
||||||
16
src/tools/get-project.ts
Normal file
16
src/tools/get-project.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { getProjectPoint } from "../lib/projects.js";
|
||||||
|
|
||||||
|
export const getProjectSchema = z.object({
|
||||||
|
slug: z.string().describe("Project slug identifier"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type GetProjectInput = z.infer<typeof getProjectSchema>;
|
||||||
|
|
||||||
|
export async function getProject(input: GetProjectInput): Promise<string> {
|
||||||
|
const payload = await getProjectPoint(input.slug);
|
||||||
|
if (!payload) {
|
||||||
|
return `Project "${input.slug}" not found. Use list_projects to see available projects.`;
|
||||||
|
}
|
||||||
|
return JSON.stringify(payload, null, 2);
|
||||||
|
}
|
||||||
64
src/tools/list-projects.ts
Normal file
64
src/tools/list-projects.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { getClient, COLLECTION } from "../lib/qdrant.js";
|
||||||
|
import type { ProjectPayload } from "../lib/projects.js";
|
||||||
|
|
||||||
|
export const listProjectsSchema = z.object({
|
||||||
|
status: z
|
||||||
|
.string()
|
||||||
|
.default("active")
|
||||||
|
.describe("Filter by project status (e.g. 'active', 'planning', 'completed', 'paused'), or 'all' to list every project"),
|
||||||
|
limit: z
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.min(1)
|
||||||
|
.max(100)
|
||||||
|
.default(20)
|
||||||
|
.describe("Maximum number of projects to return"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ListProjectsInput = z.infer<typeof listProjectsSchema>;
|
||||||
|
|
||||||
|
export async function listProjects(input: ListProjectsInput): Promise<string> {
|
||||||
|
const client = getClient();
|
||||||
|
const results: Array<{ slug: string; name: string; status: string; summary: string; tags: string[]; updated: string }> = [];
|
||||||
|
|
||||||
|
const must: Array<Record<string, unknown>> = [{ key: "type", match: { value: "project" } }];
|
||||||
|
if (input.status !== "all") {
|
||||||
|
must.push({ key: "status", match: { value: input.status } });
|
||||||
|
}
|
||||||
|
|
||||||
|
let offset: string | number | Record<string, unknown> | undefined | null = undefined;
|
||||||
|
while (results.length < input.limit) {
|
||||||
|
const result = await client.scroll(COLLECTION, {
|
||||||
|
limit: Math.min(100, input.limit - results.length),
|
||||||
|
offset,
|
||||||
|
with_payload: ["slug", "name", "status", "summary", "tags", "updated"],
|
||||||
|
with_vector: false,
|
||||||
|
filter: { must },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const point of result.points) {
|
||||||
|
const p = point.payload as unknown as ProjectPayload;
|
||||||
|
results.push({
|
||||||
|
slug: p.slug,
|
||||||
|
name: p.name,
|
||||||
|
status: p.status,
|
||||||
|
summary: p.summary,
|
||||||
|
tags: p.tags ?? [],
|
||||||
|
updated: p.updated,
|
||||||
|
});
|
||||||
|
if (results.length >= input.limit) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.next_page_offset) break;
|
||||||
|
offset = result.next_page_offset;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (results.length === 0) {
|
||||||
|
return input.status === "all"
|
||||||
|
? "No projects found."
|
||||||
|
: `No projects with status "${input.status}" found.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify(results, null, 2);
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import { getClient, COLLECTION, type ChunkPayload } from "../lib/qdrant.js";
|
|||||||
export const searchContextSchema = z.object({
|
export const searchContextSchema = z.object({
|
||||||
query: z.string().describe("Natural language search query"),
|
query: z.string().describe("Natural language search query"),
|
||||||
type: z
|
type: z
|
||||||
.enum(["device", "infrastructure", "network", "automation", "tool", "general"])
|
.enum(["device", "infrastructure", "network", "automation", "tool", "general", "project"])
|
||||||
.optional()
|
.optional()
|
||||||
.describe("Filter by document type"),
|
.describe("Filter by document type"),
|
||||||
host: z.string().optional().describe("Filter by hostname"),
|
host: z.string().optional().describe("Filter by hostname"),
|
||||||
|
|||||||
@@ -5,20 +5,22 @@ import { getClient, COLLECTION, type ChunkPayload } from "../lib/qdrant.js";
|
|||||||
export const updateContextSchema = z
|
export const updateContextSchema = z
|
||||||
.object({
|
.object({
|
||||||
host: z.string().optional().describe("Filter by hostname"),
|
host: z.string().optional().describe("Filter by hostname"),
|
||||||
|
project: z.string().optional().describe("Filter by project name"),
|
||||||
source: z.string().optional().describe("Filter by source path"),
|
source: z.string().optional().describe("Filter by source path"),
|
||||||
section: z.string().optional().describe("Filter by section heading"),
|
section: z.string().optional().describe("Filter by section heading"),
|
||||||
new_tags: z.array(z.string()).optional().describe("Set tags"),
|
new_tags: z.array(z.string()).optional().describe("Set tags"),
|
||||||
new_type: z
|
new_type: z
|
||||||
.enum(["device", "infrastructure", "network", "automation", "tool", "general"])
|
.enum(["device", "infrastructure", "network", "automation", "tool", "general", "project"])
|
||||||
.optional()
|
.optional()
|
||||||
.describe("Set document type"),
|
.describe("Set document type"),
|
||||||
new_source: z.string().optional().describe("Set source path"),
|
new_source: z.string().optional().describe("Set source path"),
|
||||||
new_section: z.string().optional().describe("Set section heading"),
|
new_section: z.string().optional().describe("Set section heading"),
|
||||||
new_host: z.string().optional().describe("Set hostname"),
|
new_host: z.string().optional().describe("Set hostname"),
|
||||||
|
new_project: z.string().optional().describe("Set project name"),
|
||||||
text: z.string().optional().describe("New content text (triggers re-embedding)"),
|
text: z.string().optional().describe("New content text (triggers re-embedding)"),
|
||||||
})
|
})
|
||||||
.refine((d) => d.host || d.source || d.section, {
|
.refine((d) => d.host || d.source || d.section || d.project, {
|
||||||
message: "At least one filter field (host, source, section) is required",
|
message: "At least one filter field (host, source, section, project) is required",
|
||||||
});
|
});
|
||||||
|
|
||||||
export type UpdateContextInput = z.infer<typeof updateContextSchema>;
|
export type UpdateContextInput = z.infer<typeof updateContextSchema>;
|
||||||
@@ -28,6 +30,7 @@ export async function updateContext(input: UpdateContextInput): Promise<string>
|
|||||||
|
|
||||||
const must: Array<Record<string, unknown>> = [];
|
const must: Array<Record<string, unknown>> = [];
|
||||||
if (input.host) must.push({ key: "host", match: { value: input.host } });
|
if (input.host) must.push({ key: "host", match: { value: input.host } });
|
||||||
|
if (input.project) must.push({ key: "project", match: { value: input.project } });
|
||||||
if (input.source) must.push({ key: "source", match: { value: input.source } });
|
if (input.source) must.push({ key: "source", match: { value: input.source } });
|
||||||
if (input.section) must.push({ key: "section", match: { value: input.section } });
|
if (input.section) must.push({ key: "section", match: { value: input.section } });
|
||||||
|
|
||||||
@@ -65,6 +68,7 @@ export async function updateContext(input: UpdateContextInput): Promise<string>
|
|||||||
if (input.new_source !== undefined) patch.source = input.new_source;
|
if (input.new_source !== undefined) patch.source = input.new_source;
|
||||||
if (input.new_section !== undefined) patch.section = input.new_section;
|
if (input.new_section !== undefined) patch.section = input.new_section;
|
||||||
if (input.new_host !== undefined) patch.host = input.new_host;
|
if (input.new_host !== undefined) patch.host = input.new_host;
|
||||||
|
if (input.new_project !== undefined) patch.project = input.new_project;
|
||||||
|
|
||||||
if (input.text !== undefined) {
|
if (input.text !== undefined) {
|
||||||
// Re-embed and full upsert for each point
|
// Re-embed and full upsert for each point
|
||||||
|
|||||||
195
src/tools/update-project.ts
Normal file
195
src/tools/update-project.ts
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { getClient, COLLECTION } from "../lib/qdrant.js";
|
||||||
|
import {
|
||||||
|
getProjectPoint,
|
||||||
|
projectPointId,
|
||||||
|
upsertProjectPoint,
|
||||||
|
} from "../lib/projects.js";
|
||||||
|
|
||||||
|
const addRelationshipSchema = z.discriminatedUnion("category", [
|
||||||
|
z.object({
|
||||||
|
category: z.literal("hosts"),
|
||||||
|
host: z.string(),
|
||||||
|
role: z.string(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
category: z.literal("software"),
|
||||||
|
name: z.string(),
|
||||||
|
role: z.string(),
|
||||||
|
version: z.string().optional(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
category: z.literal("git_repos"),
|
||||||
|
repo: z.string(),
|
||||||
|
path: z.string().optional(),
|
||||||
|
role: z.string(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
category: z.literal("local_dirs"),
|
||||||
|
path: z.string(),
|
||||||
|
role: z.string(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const removeRelationshipSchema = z.discriminatedUnion("category", [
|
||||||
|
z.object({ category: z.literal("hosts"), host: z.string() }),
|
||||||
|
z.object({ category: z.literal("software"), name: z.string() }),
|
||||||
|
z.object({ category: z.literal("git_repos"), repo: z.string() }),
|
||||||
|
z.object({ category: z.literal("local_dirs"), path: z.string() }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const updateProjectSchema = z.object({
|
||||||
|
slug: z.string().describe("Project slug identifier"),
|
||||||
|
// Scalar updates
|
||||||
|
status: z.string().optional().describe("New project status"),
|
||||||
|
current_state: z.string().optional().describe("New current state description"),
|
||||||
|
summary: z.string().optional().describe("New project summary"),
|
||||||
|
name: z.string().optional().describe("New project name"),
|
||||||
|
// Array add/remove operations
|
||||||
|
add_blocker: z.string().optional().describe("Add a blocker"),
|
||||||
|
remove_blocker: z.string().optional().describe("Remove a blocker"),
|
||||||
|
add_history: z.string().optional().describe("Append a history entry"),
|
||||||
|
add_key_fact: z.string().optional().describe("Add a key fact"),
|
||||||
|
remove_key_fact: z.string().optional().describe("Remove a key fact"),
|
||||||
|
add_tag: z.string().optional().describe("Add a tag"),
|
||||||
|
remove_tag: z.string().optional().describe("Remove a tag"),
|
||||||
|
add_decision_pending: z.string().optional().describe("Add a pending decision"),
|
||||||
|
remove_decision_pending: z.string().optional().describe("Remove a pending decision"),
|
||||||
|
add_relationship: addRelationshipSchema.optional().describe("Add a relationship"),
|
||||||
|
remove_relationship: removeRelationshipSchema.optional().describe("Remove a relationship"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type UpdateProjectInput = z.infer<typeof updateProjectSchema>;
|
||||||
|
|
||||||
|
export async function updateProject(input: UpdateProjectInput): Promise<string> {
|
||||||
|
const payload = await getProjectPoint(input.slug);
|
||||||
|
if (!payload) {
|
||||||
|
return `Project "${input.slug}" not found.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let needsReembed = false;
|
||||||
|
const changes: string[] = [];
|
||||||
|
|
||||||
|
// Scalar updates
|
||||||
|
if (input.status !== undefined) {
|
||||||
|
payload.status = input.status;
|
||||||
|
changes.push(`status → ${input.status}`);
|
||||||
|
}
|
||||||
|
if (input.name !== undefined) {
|
||||||
|
payload.name = input.name;
|
||||||
|
changes.push(`name → ${input.name}`);
|
||||||
|
}
|
||||||
|
if (input.summary !== undefined) {
|
||||||
|
payload.summary = input.summary;
|
||||||
|
needsReembed = true;
|
||||||
|
changes.push("summary updated");
|
||||||
|
}
|
||||||
|
if (input.current_state !== undefined) {
|
||||||
|
payload.current_state = input.current_state;
|
||||||
|
needsReembed = true;
|
||||||
|
changes.push("current_state updated");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Array mutations
|
||||||
|
if (input.add_blocker !== undefined) {
|
||||||
|
payload.blockers.push(input.add_blocker);
|
||||||
|
changes.push(`+blocker: ${input.add_blocker}`);
|
||||||
|
}
|
||||||
|
if (input.remove_blocker !== undefined) {
|
||||||
|
payload.blockers = payload.blockers.filter((b) => b !== input.remove_blocker);
|
||||||
|
changes.push(`-blocker: ${input.remove_blocker}`);
|
||||||
|
}
|
||||||
|
if (input.add_history !== undefined) {
|
||||||
|
payload.history.push(input.add_history);
|
||||||
|
changes.push(`+history: ${input.add_history}`);
|
||||||
|
}
|
||||||
|
if (input.add_key_fact !== undefined) {
|
||||||
|
payload.key_facts.push(input.add_key_fact);
|
||||||
|
needsReembed = true;
|
||||||
|
changes.push(`+key_fact: ${input.add_key_fact}`);
|
||||||
|
}
|
||||||
|
if (input.remove_key_fact !== undefined) {
|
||||||
|
payload.key_facts = payload.key_facts.filter((f) => f !== input.remove_key_fact);
|
||||||
|
needsReembed = true;
|
||||||
|
changes.push(`-key_fact: ${input.remove_key_fact}`);
|
||||||
|
}
|
||||||
|
if (input.add_tag !== undefined) {
|
||||||
|
payload.tags.push(input.add_tag);
|
||||||
|
needsReembed = true;
|
||||||
|
changes.push(`+tag: ${input.add_tag}`);
|
||||||
|
}
|
||||||
|
if (input.remove_tag !== undefined) {
|
||||||
|
payload.tags = payload.tags.filter((t) => t !== input.remove_tag);
|
||||||
|
needsReembed = true;
|
||||||
|
changes.push(`-tag: ${input.remove_tag}`);
|
||||||
|
}
|
||||||
|
if (input.add_decision_pending !== undefined) {
|
||||||
|
payload.decisions_pending.push(input.add_decision_pending);
|
||||||
|
changes.push(`+decision: ${input.add_decision_pending}`);
|
||||||
|
}
|
||||||
|
if (input.remove_decision_pending !== undefined) {
|
||||||
|
payload.decisions_pending = payload.decisions_pending.filter((d) => d !== input.remove_decision_pending);
|
||||||
|
changes.push(`-decision: ${input.remove_decision_pending}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relationship mutations
|
||||||
|
if (input.add_relationship) {
|
||||||
|
const r = input.add_relationship;
|
||||||
|
switch (r.category) {
|
||||||
|
case "hosts":
|
||||||
|
payload.relationships.hosts.push({ host: r.host, role: r.role, description: r.description });
|
||||||
|
break;
|
||||||
|
case "software":
|
||||||
|
payload.relationships.software.push({ name: r.name, role: r.role, version: r.version, description: r.description });
|
||||||
|
break;
|
||||||
|
case "git_repos":
|
||||||
|
payload.relationships.git_repos.push({ repo: r.repo, path: r.path, role: r.role, description: r.description });
|
||||||
|
break;
|
||||||
|
case "local_dirs":
|
||||||
|
payload.relationships.local_dirs.push({ path: r.path, role: r.role, description: r.description });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
changes.push(`+relationship: ${r.category}`);
|
||||||
|
}
|
||||||
|
if (input.remove_relationship) {
|
||||||
|
const r = input.remove_relationship;
|
||||||
|
switch (r.category) {
|
||||||
|
case "hosts":
|
||||||
|
payload.relationships.hosts = payload.relationships.hosts.filter((h) => h.host !== r.host);
|
||||||
|
break;
|
||||||
|
case "software":
|
||||||
|
payload.relationships.software = payload.relationships.software.filter((s) => s.name !== r.name);
|
||||||
|
break;
|
||||||
|
case "git_repos":
|
||||||
|
payload.relationships.git_repos = payload.relationships.git_repos.filter((g) => g.repo !== r.repo);
|
||||||
|
break;
|
||||||
|
case "local_dirs":
|
||||||
|
payload.relationships.local_dirs = payload.relationships.local_dirs.filter((d) => d.path !== r.path);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
changes.push(`-relationship: ${r.category}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changes.length === 0) {
|
||||||
|
return "No changes provided.";
|
||||||
|
}
|
||||||
|
|
||||||
|
payload.updated = new Date().toISOString();
|
||||||
|
|
||||||
|
if (needsReembed) {
|
||||||
|
await upsertProjectPoint(payload);
|
||||||
|
} else {
|
||||||
|
const client = getClient();
|
||||||
|
const id = projectPointId(input.slug);
|
||||||
|
await client.setPayload(COLLECTION, {
|
||||||
|
payload: payload as unknown as Record<string, unknown>,
|
||||||
|
points: [id],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Updated project "${payload.name}" (${input.slug}): ${changes.join(", ")}`;
|
||||||
|
}
|
||||||
@@ -11,9 +11,10 @@ export const upsertContextSchema = z.object({
|
|||||||
source: z.string().describe("Source path, e.g. 'devices/proxmox.md'"),
|
source: z.string().describe("Source path, e.g. 'devices/proxmox.md'"),
|
||||||
section: z.string().describe("Section heading"),
|
section: z.string().describe("Section heading"),
|
||||||
type: z
|
type: z
|
||||||
.enum(["device", "infrastructure", "network", "automation", "tool", "general"])
|
.enum(["device", "infrastructure", "network", "automation", "tool", "general", "project"])
|
||||||
.describe("Document type"),
|
.describe("Document type"),
|
||||||
host: z.string().describe("Hostname"),
|
host: z.string().describe("Hostname"),
|
||||||
|
project: z.string().optional().describe("Project name"),
|
||||||
tags: z.array(z.string()).default([]).describe("Optional tags"),
|
tags: z.array(z.string()).default([]).describe("Optional tags"),
|
||||||
text: z.string().describe("Content text"),
|
text: z.string().describe("Content text"),
|
||||||
});
|
});
|
||||||
@@ -21,7 +22,7 @@ export const upsertContextSchema = z.object({
|
|||||||
export type UpsertContextInput = z.infer<typeof upsertContextSchema>;
|
export type UpsertContextInput = z.infer<typeof upsertContextSchema>;
|
||||||
|
|
||||||
export async function upsertContext(input: UpsertContextInput): Promise<string> {
|
export async function upsertContext(input: UpsertContextInput): Promise<string> {
|
||||||
const pointId = deterministicId(input.source, input.section, input.host);
|
const pointId = deterministicId(input.source, input.section, input.host, input.project ?? "");
|
||||||
const vector = await embed(input.text);
|
const vector = await embed(input.text);
|
||||||
const client = getClient();
|
const client = getClient();
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ export async function upsertContext(input: UpsertContextInput): Promise<string>
|
|||||||
section: input.section,
|
section: input.section,
|
||||||
type: input.type,
|
type: input.type,
|
||||||
host: input.host,
|
host: input.host,
|
||||||
|
...(input.project ? { project: input.project } : {}),
|
||||||
tags: input.tags,
|
tags: input.tags,
|
||||||
updated: new Date().toISOString(),
|
updated: new Date().toISOString(),
|
||||||
text: input.text,
|
text: input.text,
|
||||||
|
|||||||
85
src/tools/upsert-project.ts
Normal file
85
src/tools/upsert-project.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
getProjectPoint,
|
||||||
|
upsertProjectPoint,
|
||||||
|
emptyRelationships,
|
||||||
|
type ProjectPayload,
|
||||||
|
type ProjectRelationships,
|
||||||
|
} from "../lib/projects.js";
|
||||||
|
|
||||||
|
const relationshipSchema = z.object({
|
||||||
|
hosts: z.array(z.object({ host: z.string(), role: z.string(), description: z.string().optional() })).optional(),
|
||||||
|
software: z.array(z.object({ name: z.string(), role: z.string(), version: z.string().optional(), description: z.string().optional() })).optional(),
|
||||||
|
git_repos: z.array(z.object({ repo: z.string(), path: z.string().optional(), role: z.string(), description: z.string().optional() })).optional(),
|
||||||
|
local_dirs: z.array(z.object({ path: z.string(), role: z.string(), description: z.string().optional() })).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const upsertProjectSchema = z.object({
|
||||||
|
slug: z.string().describe("Project slug identifier (kebab-case)"),
|
||||||
|
name: z.string().optional().describe("Human-readable project name (required on create)"),
|
||||||
|
status: z.string().optional().describe("Project status (e.g. planning, active, completed, paused)"),
|
||||||
|
summary: z.string().optional().describe("Brief project summary"),
|
||||||
|
tags: z.array(z.string()).optional().describe("Project tags"),
|
||||||
|
current_state: z.string().optional().describe("Current state description"),
|
||||||
|
blockers: z.array(z.string()).optional().describe("Current blockers"),
|
||||||
|
decisions_pending: z.array(z.string()).optional().describe("Pending decisions"),
|
||||||
|
relationships: relationshipSchema.optional().describe("Project relationships (replaces at sub-key level)"),
|
||||||
|
key_facts: z.array(z.string()).optional().describe("Key facts about the project"),
|
||||||
|
history: z.array(z.string()).optional().describe("History entries"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type UpsertProjectInput = z.infer<typeof upsertProjectSchema>;
|
||||||
|
|
||||||
|
export async function upsertProject(input: UpsertProjectInput): Promise<string> {
|
||||||
|
const existing = await getProjectPoint(input.slug);
|
||||||
|
|
||||||
|
if (!existing && !input.name) {
|
||||||
|
return `Project "${input.slug}" does not exist. Provide "name" to create a new project.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const base: ProjectPayload = existing ?? {
|
||||||
|
slug: input.slug,
|
||||||
|
name: "",
|
||||||
|
status: "planning",
|
||||||
|
summary: "",
|
||||||
|
tags: [],
|
||||||
|
current_state: "",
|
||||||
|
blockers: [],
|
||||||
|
decisions_pending: [],
|
||||||
|
relationships: emptyRelationships(),
|
||||||
|
key_facts: [],
|
||||||
|
history: [],
|
||||||
|
updated: "",
|
||||||
|
type: "project",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Merge scalar fields
|
||||||
|
const merged: ProjectPayload = {
|
||||||
|
...base,
|
||||||
|
...(input.name !== undefined && { name: input.name }),
|
||||||
|
...(input.status !== undefined && { status: input.status }),
|
||||||
|
...(input.summary !== undefined && { summary: input.summary }),
|
||||||
|
...(input.tags !== undefined && { tags: input.tags }),
|
||||||
|
...(input.current_state !== undefined && { current_state: input.current_state }),
|
||||||
|
...(input.blockers !== undefined && { blockers: input.blockers }),
|
||||||
|
...(input.decisions_pending !== undefined && { decisions_pending: input.decisions_pending }),
|
||||||
|
...(input.key_facts !== undefined && { key_facts: input.key_facts }),
|
||||||
|
...(input.history !== undefined && { history: input.history }),
|
||||||
|
updated: new Date().toISOString(),
|
||||||
|
type: "project",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Merge relationships at sub-key level
|
||||||
|
if (input.relationships) {
|
||||||
|
const rel: ProjectRelationships = { ...base.relationships };
|
||||||
|
if (input.relationships.hosts !== undefined) rel.hosts = input.relationships.hosts;
|
||||||
|
if (input.relationships.software !== undefined) rel.software = input.relationships.software;
|
||||||
|
if (input.relationships.git_repos !== undefined) rel.git_repos = input.relationships.git_repos;
|
||||||
|
if (input.relationships.local_dirs !== undefined) rel.local_dirs = input.relationships.local_dirs;
|
||||||
|
merged.relationships = rel;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = await upsertProjectPoint(merged);
|
||||||
|
const verb = existing ? "Updated" : "Created";
|
||||||
|
return `${verb} project "${merged.name}" (${input.slug}) [${id}]`;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user