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 { 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);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -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<typeof deleteContextSchema>;
|
||||
@@ -18,6 +19,7 @@ export async function deleteContext(input: DeleteContextInput): Promise<string>
|
||||
|
||||
const must: Array<Record<string, unknown>> = [];
|
||||
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 } });
|
||||
|
||||
|
||||
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({
|
||||
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"),
|
||||
|
||||
@@ -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<typeof updateContextSchema>;
|
||||
@@ -28,6 +30,7 @@ export async function updateContext(input: UpdateContextInput): Promise<string>
|
||||
|
||||
const must: Array<Record<string, unknown>> = [];
|
||||
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<string>
|
||||
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
|
||||
|
||||
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'"),
|
||||
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<typeof upsertContextSchema>;
|
||||
|
||||
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 client = getClient();
|
||||
|
||||
@@ -30,6 +31,7 @@ export async function upsertContext(input: UpsertContextInput): Promise<string>
|
||||
section: input.section,
|
||||
type: input.type,
|
||||
host: input.host,
|
||||
...(input.project ? { project: input.project } : {}),
|
||||
tags: input.tags,
|
||||
updated: new Date().toISOString(),
|
||||
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