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:
2026-03-01 18:14:37 +00:00
parent 8353280bbf
commit 94198d3897
13 changed files with 559 additions and 13 deletions

5
.gitignore vendored
View File

@@ -1 +1,4 @@
node_modules/\ndist/\n*.js.map\n*.d.ts.map node_modules/
dist/
*.js.map
*.d.ts.map

View File

@@ -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
View 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;
}

View File

@@ -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;

View File

@@ -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 } });

View 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
View 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);
}

View 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);
}

View File

@@ -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"),

View File

@@ -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
View 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(", ")}`;
}

View File

@@ -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,

View 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}]`;
}