diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 91da2bd..ceb5ddb 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -110,10 +110,32 @@ libscope serve --dashboard --port 8080 |---------|-------------| | `libscope docs list` | List indexed documents | | `libscope docs show ` | Show a specific document | +| `libscope docs update ` | Update a document | | `libscope docs delete ` | Delete a document | | `libscope docs history ` | View version history | | `libscope docs rollback ` | Rollback to a previous version | +## Document Updates + +### `libscope docs update` + +Update an existing document's title, content, or metadata. Changing content triggers re-chunking and re-indexing of embeddings. + +```bash +libscope docs update --title "New Title" +libscope docs update --content "Updated content here" +libscope docs update --library vue --version 3.0.0 +``` + +| Option | Description | +|--------|-------------| +| `--title ` | New document title | +| `--content <content>` | New content (triggers re-chunking) | +| `--library <name>` | New library name | +| `--version <ver>` | New library version | +| `--url <url>` | New source URL | +| `--topic <topicId>` | New topic ID | + ## Document Links (Cross-references) | Command | Description | diff --git a/src/api/routes.ts b/src/api/routes.ts index ff5200d..465cbcd 100644 --- a/src/api/routes.ts +++ b/src/api/routes.ts @@ -9,6 +9,7 @@ import { getDocument, indexDocument, deleteDocument, + updateDocument, listTopics, createTopic, listTags, @@ -418,6 +419,42 @@ export async function handleRequest( return; } + if (docId && method === "PATCH") { + const body = await parseJsonBody(req); + if (!body || typeof body !== "object") { + sendError(res, 400, "VALIDATION_ERROR", "Request body must be a JSON object"); + return; + } + const b = body as Record<string, unknown>; + const title = typeof b["title"] === "string" ? b["title"] : undefined; + const content = typeof b["content"] === "string" ? b["content"] : undefined; + const metadata: Record<string, string | null | undefined> = {}; + if (b["library"] !== undefined) + metadata.library = typeof b["library"] === "string" ? b["library"] : null; + if (b["version"] !== undefined) + metadata.version = typeof b["version"] === "string" ? b["version"] : null; + if (b["url"] !== undefined) metadata.url = typeof b["url"] === "string" ? b["url"] : null; + if (b["topicId"] !== undefined) + metadata.topicId = typeof b["topicId"] === "string" ? b["topicId"] : null; + + const doc = await updateDocument(db, provider, docId, { + title, + content, + metadata: + Object.keys(metadata).length > 0 + ? (metadata as { + library?: string | null; + version?: string | null; + url?: string | null; + topicId?: string | null; + }) + : undefined, + }); + const took = Math.round(performance.now() - start); + sendJson(res, 200, doc, took); + return; + } + // Document links: GET/POST /api/v1/documents/:id/links const linksDocId = matchDocumentLinks(segments); if (linksDocId && method === "GET") { diff --git a/src/cli/index.ts b/src/cli/index.ts index 09987e8..a72f4c6 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -9,7 +9,7 @@ import { searchDocuments } from "../core/search.js"; import { askQuestion, createLlmProvider } from "../core/rag.js"; import { getDocumentRatings, listRatings } from "../core/ratings.js"; import { createTopic, listTopics } from "../core/topics.js"; -import { getDocument, listDocuments, deleteDocument } from "../core/documents.js"; +import { getDocument, listDocuments, deleteDocument, updateDocument } from "../core/documents.js"; import { createLink, getDocumentLinks, deleteLink, getPrerequisiteChain } from "../core/links.js"; import type { LinkType } from "../core/links.js"; import { getVersionHistory, rollbackToVersion } from "../core/versioning.js"; @@ -706,6 +706,55 @@ docsCmd } }); +docsCmd + .command("update <documentId>") + .description("Update an existing document") + .option("--title <title>", "New title") + .option("--content <content>", "New content (will re-chunk and re-index)") + .option("--library <name>", "New library name") + .option("--version <ver>", "New version") + .option("--url <url>", "New URL") + .option("--topic <topicId>", "New topic ID") + .action( + async ( + documentId: string, + opts: { + title?: string; + content?: string; + library?: string; + version?: string; + url?: string; + topic?: string; + }, + ) => { + const { db, provider } = initializeAppWithEmbedding(); + try { + const metadata: Record<string, string | null | undefined> = {}; + if (opts.library !== undefined) metadata.library = opts.library; + if (opts.version !== undefined) metadata.version = opts.version; + if (opts.url !== undefined) metadata.url = opts.url; + if (opts.topic !== undefined) metadata.topicId = opts.topic; + + const doc = await updateDocument(db, provider, documentId, { + title: opts.title, + content: opts.content, + metadata: + Object.keys(metadata).length > 0 + ? (metadata as { + library?: string | null; + version?: string | null; + url?: string | null; + topicId?: string | null; + }) + : undefined, + }); + console.log(`✓ Updated "${doc.title}" (${doc.id})`); + } finally { + closeDatabase(); + } + }, + ); + // tag const tagCmd = program.command("tag").description("Manage document tags"); diff --git a/src/core/index.ts b/src/core/index.ts index dfcf1ff..603aa7e 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -39,8 +39,8 @@ export type { export { rateDocument, getDocumentRatings, listRatings } from "./ratings.js"; export type { RateDocumentInput, Rating, RatingSummary } from "./ratings.js"; -export { getDocument, deleteDocument, listDocuments } from "./documents.js"; -export type { Document } from "./documents.js"; +export { getDocument, deleteDocument, listDocuments, updateDocument } from "./documents.js"; +export type { Document, UpdateDocumentInput } from "./documents.js"; export { saveVersion, diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 274495f..256b3da 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -7,7 +7,7 @@ import { getActiveWorkspace, getWorkspacePath } from "../core/workspace.js"; import { createEmbeddingProvider } from "../providers/index.js"; import { searchDocuments } from "../core/search.js"; import { askQuestion, createLlmProvider, type LlmProvider } from "../core/rag.js"; -import { getDocument, listDocuments, deleteDocument } from "../core/documents.js"; +import { getDocument, listDocuments, deleteDocument, updateDocument } from "../core/documents.js"; import { rateDocument, getDocumentRatings } from "../core/ratings.js"; import { indexDocument } from "../core/indexing.js"; import { listTopics } from "../core/topics.js"; @@ -241,6 +241,45 @@ async function main(): Promise<void> { }), ); + // Tool: update-document + server.tool( + "update-document", + "Update an existing document's title, content, or metadata", + { + documentId: z.string().describe("The document ID to update"), + title: z.string().optional().describe("New title"), + content: z.string().optional().describe("New content (will re-chunk and re-index)"), + library: z.string().nullable().optional().describe("New library name (null to clear)"), + version: z.string().nullable().optional().describe("New version (null to clear)"), + url: z.string().nullable().optional().describe("New URL (null to clear)"), + topicId: z.string().nullable().optional().describe("New topic ID (null to clear)"), + }, + withErrorHandling(async (params) => { + const metadata: Record<string, string | null | undefined> = {}; + if (params.library !== undefined) metadata.library = params.library; + if (params.version !== undefined) metadata.version = params.version; + if (params.url !== undefined) metadata.url = params.url; + if (params.topicId !== undefined) metadata.topicId = params.topicId; + + const doc = await updateDocument(db, provider, params.documentId, { + title: params.title, + content: params.content, + metadata: + Object.keys(metadata).length > 0 + ? (metadata as { + library?: string | null; + version?: string | null; + url?: string | null; + topicId?: string | null; + }) + : undefined, + }); + return { + content: [{ type: "text" as const, text: `Document updated: ${doc.title} (${doc.id})` }], + }; + }), + ); + // Tool: rate-document server.tool( "rate-document", diff --git a/tests/unit/documents.test.ts b/tests/unit/documents.test.ts index 6a1a109..08f0166 100644 --- a/tests/unit/documents.test.ts +++ b/tests/unit/documents.test.ts @@ -1,6 +1,11 @@ import { describe, it, expect, beforeEach } from "vitest"; import { createTestDb, createTestDbWithVec } from "../fixtures/test-db.js"; -import { getDocument, deleteDocument, listDocuments } from "../../src/core/documents.js"; +import { + getDocument, + deleteDocument, + listDocuments, + updateDocument, +} from "../../src/core/documents.js"; import { indexDocument, type IndexDocumentInput } from "../../src/core/indexing.js"; import { MockEmbeddingProvider } from "../fixtures/mock-provider.js"; import type Database from "better-sqlite3"; @@ -125,6 +130,92 @@ describe("documents", () => { }); }); + describe("updateDocument", () => { + let vecDb: Database.Database; + let provider: MockEmbeddingProvider; + + beforeEach(() => { + vecDb = createTestDbWithVec(); + provider = new MockEmbeddingProvider(); + }); + + it("should update title only", async () => { + const indexed = await indexDocument(vecDb, provider, { + title: "Original Title", + content: "Some content here.", + sourceType: "manual", + }); + + const updated = await updateDocument(vecDb, provider, indexed.id, { + title: "New Title", + }); + + expect(updated.title).toBe("New Title"); + expect(updated.content).toBe("Some content here."); + }); + + it("should update content and re-chunk", async () => { + const indexed = await indexDocument(vecDb, provider, { + title: "Doc", + content: "Old content.", + sourceType: "manual", + }); + + const updated = await updateDocument(vecDb, provider, indexed.id, { + content: "Brand new content that is different.", + }); + + expect(updated.content).toBe("Brand new content that is different."); + // Verify chunks were recreated + const chunks = vecDb + .prepare("SELECT id FROM chunks WHERE document_id = ?") + .all(indexed.id) as { id: string }[]; + expect(chunks.length).toBeGreaterThan(0); + }); + + it("should update metadata fields", async () => { + const indexed = await indexDocument(vecDb, provider, { + title: "Doc", + content: "Content here.", + sourceType: "library", + library: "react", + version: "18.0.0", + }); + + const updated = await updateDocument(vecDb, provider, indexed.id, { + metadata: { library: "vue", version: "3.0.0", url: "https://vue.org" }, + }); + + expect(updated.library).toBe("vue"); + expect(updated.version).toBe("3.0.0"); + expect(updated.url).toBe("https://vue.org"); + }); + + it("should reject empty title", async () => { + const indexed = await indexDocument(vecDb, provider, { + title: "Doc", + content: "Content.", + sourceType: "manual", + }); + + await expect(updateDocument(vecDb, provider, indexed.id, { title: " " })).rejects.toThrow( + "Document title cannot be empty", + ); + }); + + it("should reject empty content", async () => { + const indexed = await indexDocument(vecDb, provider, { + title: "Doc", + content: "Content.", + sourceType: "manual", + }); + + await expect(updateDocument(vecDb, provider, indexed.id, { content: "" })).rejects.toThrow( + "Document content cannot be empty", + ); + }); + }); + describe("duplicate detection", () => { let vecDb: Database.Database; let provider: MockEmbeddingProvider;