diff --git a/src/commands/delete.ts b/src/commands/delete.ts index 2027d51..200893b 100644 --- a/src/commands/delete.ts +++ b/src/commands/delete.ts @@ -1,6 +1,5 @@ import { Command } from 'commander'; -import { deleteThreadFile, loadMetaIndex, validateTag, prompt } from '../core/index.js'; -import { getSemanticIndex } from '../embeddings/index.js'; +import { validateTag, prompt, deleteThread } from '../core/index.js'; export const deleteCommand = new Command('delete') .description('Delete a thread') @@ -10,15 +9,6 @@ export const deleteCommand = new Command('delete') try { const validatedId = validateTag(threadId); - // Check existence via meta index - const meta = loadMetaIndex(); - if (!meta.threads[validatedId]) { - console.error(`Thread ID '${validatedId}' not found.`); - console.error('Tip: Run `threadlinking list` to see available threads.'); - process.exitCode = 1; - return; - } - if (!options.yes) { const answer = await prompt( `Delete thread '${validatedId}'? This cannot be undone. (y/N): ` @@ -29,17 +19,18 @@ export const deleteCommand = new Command('delete') } } - deleteThreadFile(validatedId); + const result = await deleteThread({ threadId: validatedId }); - // Clean up semantic index embeddings for the deleted thread - try { - const semanticIndex = await getSemanticIndex(); - await semanticIndex.deleteThread(validatedId); - } catch { - // Non-fatal: semantic index may not exist + if (!result.success) { + console.error(result.message); + if (result.error === 'THREAD_NOT_FOUND') { + console.error('Tip: Run `threadlinking list` to see available threads.'); + } + process.exitCode = 1; + return; } - console.log(`Deleted thread '${validatedId}'.`); + console.log(result.message); } catch (error) { console.error(`Error: ${error instanceof Error ? error.message : error}`); process.exitCode = 1; diff --git a/src/core/index.ts b/src/core/index.ts index cde1110..cdfd695 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -8,7 +8,7 @@ export * from './types.js'; export { loadIndex, saveIndex, - getIndexPath, + getMetaIndexPath, getBaseDir, getThreadsDir, loadPending, diff --git a/src/core/operations/delete.ts b/src/core/operations/delete.ts new file mode 100644 index 0000000..dfe3dde --- /dev/null +++ b/src/core/operations/delete.ts @@ -0,0 +1,89 @@ +// Delete operation - permanently remove a thread and clean up references +// Returns result object for MCP compatibility + +import { loadMetaIndex, loadThread, deleteThreadFile, updateThread } from '../storage.js'; +import { validateTag } from '../utils.js'; +import { getSemanticIndex } from '../../embeddings/index.js'; +import type { OperationResult } from '../types.js'; + +export interface DeleteInput { + threadId: string; +} + +export interface DeleteResult { + threadId: string; + snippetsRemoved: number; + filesUnlinked: number; + semanticEntriesRemoved: number; + relatedThreadsUpdated: string[]; +} + +export async function deleteThread(input: DeleteInput): Promise> { + try { + const validatedId = validateTag(input.threadId); + + // Check thread exists via meta index (fast) + const meta = loadMetaIndex(); + if (!meta.threads[validatedId]) { + return { + success: false, + message: `Thread '${validatedId}' not found.`, + error: 'THREAD_NOT_FOUND', + }; + } + + // Load full thread data so we can report what's being removed + // and clean up bidirectional related-thread references + const thread = loadThread(validatedId); + const snippetsRemoved = thread?.snippets?.length ?? 0; + const filesUnlinked = thread?.linked_files?.length ?? 0; + const relatedThreads = thread?.related ?? []; + + // Remove this thread from every related thread's `related` array. + // Without this, deleting A leaves stale references in B, C, etc. + const relatedThreadsUpdated: string[] = []; + for (const relatedId of relatedThreads) { + try { + updateThread(relatedId, (relatedThread) => { + const related = relatedThread.related || []; + relatedThread.related = related.filter((r) => r !== validatedId); + relatedThread.date_modified = new Date().toISOString(); + return relatedThread; + }); + relatedThreadsUpdated.push(relatedId); + } catch { + // Related thread may already be gone — not fatal + } + } + + // Delete the thread file and remove from meta index + deleteThreadFile(validatedId); + + // Clean up semantic index embeddings (non-fatal if index doesn't exist) + let semanticEntriesRemoved = 0; + try { + const semanticIndex = await getSemanticIndex(); + semanticEntriesRemoved = await semanticIndex.deleteThread(validatedId); + } catch { + // Semantic index may not exist or may not be initialized + } + + return { + success: true, + message: `Thread '${validatedId}' deleted.`, + data: { + threadId: validatedId, + snippetsRemoved, + filesUnlinked, + semanticEntriesRemoved, + relatedThreadsUpdated, + }, + }; + } catch (error) { + return { + success: false, + message: error instanceof Error ? error.message : String(error), + error: 'DELETE_ERROR', + }; + } +} diff --git a/src/core/operations/index.ts b/src/core/operations/index.ts index 310755d..8465b1c 100644 --- a/src/core/operations/index.ts +++ b/src/core/operations/index.ts @@ -3,6 +3,8 @@ export { addSnippet } from './snippet.js'; export { createThread } from './create.js'; +export { deleteThread } from './delete.js'; +export type { DeleteResult } from './delete.js'; export { attachFile, detachFile } from './attach.js'; export { explainFile } from './explain.js'; export { showThread, getThread } from './show.js'; diff --git a/src/core/operations/semantic.ts b/src/core/operations/semantic.ts index eace2df..772d201 100644 --- a/src/core/operations/semantic.ts +++ b/src/core/operations/semantic.ts @@ -1,7 +1,7 @@ // Semantic search operation // Uses all-MiniLM-L6-v2 embeddings via @xenova/transformers (pure Node.js) -import { loadMetaIndex, loadThread, loadIndex, getIndexPath } from '../storage.js'; +import { loadMetaIndex, loadThread, loadIndex, getMetaIndexPath } from '../storage.js'; import type { OperationResult, Thread } from '../types.js'; import { getEmbedder, stopEmbedder } from '../../embeddings/embedder.js'; import { @@ -49,7 +49,7 @@ export async function semanticSearch( // Check if index is stale and reload if needed let staleWarning: string | undefined; - const threadIndexPath = getIndexPath(); + const threadIndexPath = getMetaIndexPath(); if (fs.existsSync(threadIndexPath)) { const stats = fs.statSync(threadIndexPath); if (semanticIndex.isStale(stats.mtime)) { diff --git a/src/core/storage.ts b/src/core/storage.ts index b82a596..9eca88a 100644 --- a/src/core/storage.ts +++ b/src/core/storage.ts @@ -430,7 +430,7 @@ export function updateIndex(updateFn: (index: ThreadIndex) => ThreadIndex): Thre // ===== Path Getters ===== -export function getIndexPath(): string { +export function getMetaIndexPath(): string { return META_INDEX_PATH; } diff --git a/src/embeddings/index.ts b/src/embeddings/index.ts index 7f4cad6..08fd60d 100644 --- a/src/embeddings/index.ts +++ b/src/embeddings/index.ts @@ -255,6 +255,6 @@ export function resetSemanticIndex(): void { defaultIndex = null; } -export function getIndexPath(): string { +export function getSemanticIndexPath(): string { return INDEX_DIR; } diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 937fa87..441e088 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -7,6 +7,7 @@ import { // Core operations addSnippet, createThread, + deleteThread, attachFile, detachFile, explainFile, @@ -18,6 +19,7 @@ import { rebuildSemanticIndex, getAnalytics, exportThread, + parseTags, } from '../core/index.js'; import { VERSION } from '../version.js'; @@ -61,7 +63,7 @@ Proactively save context when: source: z.string().optional().describe('Source identifier (defaults to claude-code)'), }, async (args) => { - const tags = args.tags?.split(',').map((t) => t.trim()).filter((t) => t); + const tags = args.tags ? parseTags(args.tags) : undefined; const result = await addSnippet({ threadId: args.thread_id, content: args.content, @@ -113,6 +115,40 @@ Proactively save context when: } ); + // threadlinking_delete - Permanently delete a thread + server.tool( + 'threadlinking_delete', + 'Permanently delete a thread and all its snippets. Also cleans up related-thread references and semantic index entries. This cannot be undone.', + { + thread_id: z.string().describe('Thread name to delete'), + }, + async (args) => { + const result = await deleteThread({ threadId: args.thread_id }); + + if (!result.success) { + return { + content: [{ type: 'text', text: `Error: ${result.message}` }], + isError: true, + }; + } + + const d = result.data!; + const parts: string[] = [result.message]; + parts.push(`- ${d.snippetsRemoved} snippet(s) removed`); + parts.push(`- ${d.filesUnlinked} file link(s) removed`); + if (d.relatedThreadsUpdated.length > 0) { + parts.push(`- Updated related threads: ${d.relatedThreadsUpdated.join(', ')}`); + } + if (d.semanticEntriesRemoved > 0) { + parts.push(`- ${d.semanticEntriesRemoved} semantic index entry/entries removed`); + } + + return { + content: [{ type: 'text', text: parts.join('\n') }], + }; + } + ); + // threadlinking_attach - Link a file to a thread server.tool( 'threadlinking_attach', @@ -377,7 +413,7 @@ Proactively save context when: parts.push('## Available Features'); parts.push(''); parts.push('**Core:**'); - parts.push('- snippet, attach, detach, explain, show, list, search, create'); + parts.push('- snippet, attach, detach, explain, show, list, search, create, delete'); parts.push(''); parts.push('**Advanced:**'); parts.push('- semantic_search (natural language search)'); diff --git a/src/storage.ts b/src/storage.ts deleted file mode 100644 index 2923e3c..0000000 --- a/src/storage.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Re-export from core for backwards compatibility -export * from './core/storage.js'; diff --git a/src/types.ts b/src/types.ts deleted file mode 100644 index 13c8cc8..0000000 --- a/src/types.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Re-export from core for backwards compatibility -export * from './core/types.js'; diff --git a/src/utils.ts b/src/utils.ts deleted file mode 100644 index 585021c..0000000 --- a/src/utils.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Re-export from core for backwards compatibility -export * from './core/utils.js';