Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,10 +110,32 @@ libscope serve --dashboard --port 8080
|---------|-------------|
| `libscope docs list` | List indexed documents |
| `libscope docs show <id>` | Show a specific document |
| `libscope docs update <id>` | Update a document |
| `libscope docs delete <id>` | Delete a document |
| `libscope docs history <id>` | View version history |
| `libscope docs rollback <id> <version>` | 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 <documentId> --title "New Title"
libscope docs update <documentId> --content "Updated content here"
libscope docs update <documentId> --library vue --version 3.0.0
```

| Option | Description |
|--------|-------------|
| `--title <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 |
Expand Down
37 changes: 37 additions & 0 deletions src/api/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
getDocument,
indexDocument,
deleteDocument,
updateDocument,
listTopics,
createTopic,
listTags,
Expand Down Expand Up @@ -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>;
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PUT/PATCH currently accept an empty JSON object (or a body with no supported fields) and still call updateDocument, which will create a new version / update timestamps despite being a no-op. Consider validating that at least one updatable field is present and return 400 for empty updates (matches the issue’s “invalid/empty body” requirement).

Suggested change
const b = body as Record<string, unknown>;
const b = body as Record<string, unknown>;
const hasUpdatableField =
"title" in b ||
"content" in b ||
"library" in b ||
"version" in b ||
"url" in b ||
"topicId" in b;
if (!hasUpdatableField) {
sendError(
res,
400,
"VALIDATION_ERROR",
"Request body must contain at least one updatable field",
);
return;
}

Copilot uses AI. Check for mistakes.
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;
Comment on lines +432 to +438
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For library/version/url/topicId, if the field is present but not a string (e.g. 123), this code coerces it to null, which will silently clear the stored value. It would be safer to treat “present but not string or null” as a validation error (400) to avoid unintended data loss.

Suggested change
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 library = b["library"];
if (library !== undefined) {
if (library !== null && typeof library !== "string") {
sendError(res, 400, "VALIDATION_ERROR", "Field 'library' must be a string or null");
return;
}
metadata.library = library as string | null;
}
const version = b["version"];
if (version !== undefined) {
if (version !== null && typeof version !== "string") {
sendError(res, 400, "VALIDATION_ERROR", "Field 'version' must be a string or null");
return;
}
metadata.version = version as string | null;
}
const url = b["url"];
if (url !== undefined) {
if (url !== null && typeof url !== "string") {
sendError(res, 400, "VALIDATION_ERROR", "Field 'url' must be a string or null");
return;
}
metadata.url = url as string | null;
}
const topicId = b["topicId"];
if (topicId !== undefined) {
if (topicId !== null && typeof topicId !== "string") {
sendError(res, 400, "VALIDATION_ERROR", "Field 'topicId' must be a string or null");
return;
}
metadata.topicId = topicId as string | null;
}

Copilot uses AI. Check for mistakes.

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") {
Expand Down
51 changes: 50 additions & 1 deletion src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Comment on lines +730 to +735
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CLI command can be invoked with no flags (only <documentId>), which will call updateDocument with no changes and still create a new version / bump updated_at. Add a guard to require at least one of the update options and show a helpful error when none are provided.

Copilot uses AI. Check for mistakes.
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");

Expand Down
4 changes: 2 additions & 2 deletions src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
41 changes: 40 additions & 1 deletion src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;

Comment on lines +257 to +263
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This tool allows calls with only documentId and no update fields; that will still create a new version / bump timestamps even though nothing changed. Consider validating that at least one of title, content, or the metadata fields is provided and return a ValidationError otherwise.

Copilot uses AI. Check for mistakes.
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",
Expand Down
93 changes: 92 additions & 1 deletion tests/unit/documents.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
Comment on lines +164 to +173
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The “should update content and re-chunk” assertion only checks that at least one chunk exists after the update, which would also pass if updateDocument did not delete/recreate chunks (because chunks already exist from the initial index). Strengthen this test to verify re-chunking actually happened (e.g., compare old vs new chunk IDs/content, verify chunk count changes appropriately, and/or confirm chunk_embeddings rows were replaced).

Suggested change
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);
// Capture original chunks and their embeddings
const originalChunks = vecDb
.prepare(
"SELECT id, content FROM chunks WHERE document_id = ? ORDER BY id",
)
.all(indexed.id) as { id: string; content: string }[];
expect(originalChunks.length).toBeGreaterThan(0);
const originalChunkIds = originalChunks.map((c) => c.id);
const originalEmbeddings = vecDb
.prepare(
"SELECT chunk_id FROM chunk_embeddings WHERE document_id = ? ORDER BY chunk_id",
)
.all(indexed.id) as { chunk_id: string }[];
expect(originalEmbeddings.length).toBeGreaterThan(0);
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: new chunks exist and have different IDs
const newChunks = vecDb
.prepare(
"SELECT id, content FROM chunks WHERE document_id = ? ORDER BY id",
)
.all(indexed.id) as { id: string; content: string }[];
expect(newChunks.length).toBeGreaterThan(0);
const newChunkIds = newChunks.map((c) => c.id);
// Ensure none of the original chunk IDs are reused
expect(
newChunkIds.every((id) => !originalChunkIds.includes(id)),
).toBe(true);
// Verify embeddings now correspond to the new chunks
const newEmbeddings = vecDb
.prepare(
"SELECT chunk_id FROM chunk_embeddings WHERE document_id = ? ORDER BY chunk_id",
)
.all(indexed.id) as { chunk_id: string }[];
expect(newEmbeddings.length).toBeGreaterThan(0);
const newEmbeddingChunkIds = newEmbeddings.map((e) => e.chunk_id);
const sortedNewChunkIds = [...newChunkIds].sort();
const sortedNewEmbeddingChunkIds = [...newEmbeddingChunkIds].sort();
expect(sortedNewEmbeddingChunkIds).toEqual(sortedNewChunkIds);

Copilot uses AI. Check for mistakes.
});

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;
Expand Down
Loading