Skip to content
Merged
29 changes: 10 additions & 19 deletions src/commands/delete.ts
Original file line number Diff line number Diff line change
@@ -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')
Expand All @@ -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): `
Expand All @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export * from './types.js';
export {
loadIndex,
saveIndex,
getIndexPath,
getMetaIndexPath,
getBaseDir,
getThreadsDir,
loadPending,
Expand Down
89 changes: 89 additions & 0 deletions src/core/operations/delete.ts
Original file line number Diff line number Diff line change
@@ -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<OperationResult<DeleteResult>> {
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',
};
}
}
2 changes: 2 additions & 0 deletions src/core/operations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
4 changes: 2 additions & 2 deletions src/core/operations/semantic.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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)) {
Expand Down
2 changes: 1 addition & 1 deletion src/core/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
2 changes: 1 addition & 1 deletion src/embeddings/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,6 @@ export function resetSemanticIndex(): void {
defaultIndex = null;
}

export function getIndexPath(): string {
export function getSemanticIndexPath(): string {
return INDEX_DIR;
}
40 changes: 38 additions & 2 deletions src/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
// Core operations
addSnippet,
createThread,
deleteThread,
attachFile,
detachFile,
explainFile,
Expand All @@ -18,6 +19,7 @@ import {
rebuildSemanticIndex,
getAnalytics,
exportThread,
parseTags,
} from '../core/index.js';
import { VERSION } from '../version.js';

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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)');
Expand Down
2 changes: 0 additions & 2 deletions src/storage.ts

This file was deleted.

2 changes: 0 additions & 2 deletions src/types.ts

This file was deleted.

2 changes: 0 additions & 2 deletions src/utils.ts

This file was deleted.

Loading