diff --git a/.gitignore b/.gitignore index 83b6c05..794c235 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ -node_modules/\ndist/\n*.js.map\n*.d.ts.map +node_modules/ +dist/ +*.js.map +*.d.ts.map diff --git a/src/index.ts b/src/index.ts index 567dbde..b1f6e83 100644 --- a/src/index.ts +++ b/src/index.ts @@ -7,6 +7,11 @@ 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"; +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({ name: "homelab-mcp", @@ -60,7 +65,7 @@ server.tool( 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.", + "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, async (input) => ({ content: [{ type: "text", text: await updateContext(updateContextSchema.parse(input)) }], @@ -69,13 +74,58 @@ server.tool( server.tool( "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, async (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() { const transport = new StdioServerTransport(); await server.connect(transport); diff --git a/src/lib/projects.ts b/src/lib/projects.ts new file mode 100644 index 0000000..8c25484 --- /dev/null +++ b/src/lib/projects.ts @@ -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 { + 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 { + 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 }], + }); + return id; +} diff --git a/src/lib/qdrant.ts b/src/lib/qdrant.ts index 13f7b05..153cc7a 100644 --- a/src/lib/qdrant.ts +++ b/src/lib/qdrant.ts @@ -18,8 +18,8 @@ export function getClient(): QdrantClient { return client; } -export function deterministicId(source: string, section: string, host: string): string { - const hash = createHash("sha256").update(`${source}\0${section}\0${host}`).digest("hex"); +export function deterministicId(source: string, section: string, host: string, project: string = ""): string { + const hash = createHash("sha256").update(`${source}\0${section}\0${host}\0${project}`).digest("hex"); return [ hash.slice(0, 8), hash.slice(8, 12), @@ -34,6 +34,7 @@ export interface ChunkPayload { section: string; type: string; host: string; + project?: string; tags: string[]; updated: string; text: string; diff --git a/src/tools/delete-context.ts b/src/tools/delete-context.ts index 2ec9f10..3c43349 100644 --- a/src/tools/delete-context.ts +++ b/src/tools/delete-context.ts @@ -4,11 +4,12 @@ import { getClient, COLLECTION } from "../lib/qdrant.js"; export const deleteContextSchema = z .object({ 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"), 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", + .refine((d) => d.host || d.source || d.section || d.project, { + message: "At least one filter field (host, source, section, project) is required", }); export type DeleteContextInput = z.infer; @@ -18,6 +19,7 @@ export async function deleteContext(input: DeleteContextInput): Promise const must: Array> = []; 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.section) must.push({ key: "section", match: { value: input.section } }); diff --git a/src/tools/delete-project.ts b/src/tools/delete-project.ts new file mode 100644 index 0000000..704d9bd --- /dev/null +++ b/src/tools/delete-project.ts @@ -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; + +export async function deleteProject(input: DeleteProjectInput): Promise { + 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}).`; +} diff --git a/src/tools/get-project.ts b/src/tools/get-project.ts new file mode 100644 index 0000000..ea6eeb8 --- /dev/null +++ b/src/tools/get-project.ts @@ -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; + +export async function getProject(input: GetProjectInput): Promise { + 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); +} diff --git a/src/tools/list-projects.ts b/src/tools/list-projects.ts new file mode 100644 index 0000000..1fb67b1 --- /dev/null +++ b/src/tools/list-projects.ts @@ -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; + +export async function listProjects(input: ListProjectsInput): Promise { + const client = getClient(); + const results: Array<{ slug: string; name: string; status: string; summary: string; tags: string[]; updated: string }> = []; + + const must: Array> = [{ key: "type", match: { value: "project" } }]; + if (input.status !== "all") { + must.push({ key: "status", match: { value: input.status } }); + } + + let offset: string | number | Record | 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); +} diff --git a/src/tools/search-context.ts b/src/tools/search-context.ts index c460a95..49e3bce 100644 --- a/src/tools/search-context.ts +++ b/src/tools/search-context.ts @@ -5,7 +5,7 @@ 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"]) + .enum(["device", "infrastructure", "network", "automation", "tool", "general", "project"]) .optional() .describe("Filter by document type"), host: z.string().optional().describe("Filter by hostname"), diff --git a/src/tools/update-context.ts b/src/tools/update-context.ts index 3d9e2c9..8c46df7 100644 --- a/src/tools/update-context.ts +++ b/src/tools/update-context.ts @@ -5,20 +5,22 @@ import { getClient, COLLECTION, type ChunkPayload } from "../lib/qdrant.js"; export const updateContextSchema = z .object({ 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"), 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"]) + .enum(["device", "infrastructure", "network", "automation", "tool", "general", "project"]) .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"), + new_project: z.string().optional().describe("Set project name"), 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", + .refine((d) => d.host || d.source || d.section || d.project, { + message: "At least one filter field (host, source, section, project) is required", }); export type UpdateContextInput = z.infer; @@ -28,6 +30,7 @@ export async function updateContext(input: UpdateContextInput): Promise const must: Array> = []; 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.section) must.push({ key: "section", match: { value: input.section } }); @@ -65,6 +68,7 @@ export async function updateContext(input: UpdateContextInput): Promise 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.new_project !== undefined) patch.project = input.new_project; if (input.text !== undefined) { // Re-embed and full upsert for each point diff --git a/src/tools/update-project.ts b/src/tools/update-project.ts new file mode 100644 index 0000000..b03dd92 --- /dev/null +++ b/src/tools/update-project.ts @@ -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; + +export async function updateProject(input: UpdateProjectInput): Promise { + 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, + points: [id], + }); + } + + return `Updated project "${payload.name}" (${input.slug}): ${changes.join(", ")}`; +} diff --git a/src/tools/upsert-context.ts b/src/tools/upsert-context.ts index 1b93dbd..d625e8f 100644 --- a/src/tools/upsert-context.ts +++ b/src/tools/upsert-context.ts @@ -11,9 +11,10 @@ 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"]) + .enum(["device", "infrastructure", "network", "automation", "tool", "general", "project"]) .describe("Document type"), host: z.string().describe("Hostname"), + project: z.string().optional().describe("Project name"), tags: z.array(z.string()).default([]).describe("Optional tags"), text: z.string().describe("Content text"), }); @@ -21,7 +22,7 @@ export const upsertContextSchema = z.object({ export type UpsertContextInput = z.infer; export async function upsertContext(input: UpsertContextInput): Promise { - 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 client = getClient(); @@ -30,6 +31,7 @@ export async function upsertContext(input: UpsertContextInput): Promise section: input.section, type: input.type, host: input.host, + ...(input.project ? { project: input.project } : {}), tags: input.tags, updated: new Date().toISOString(), text: input.text, diff --git a/src/tools/upsert-project.ts b/src/tools/upsert-project.ts new file mode 100644 index 0000000..4259cb2 --- /dev/null +++ b/src/tools/upsert-project.ts @@ -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; + +export async function upsertProject(input: UpsertProjectInput): Promise { + 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}]`; +}