Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
16 changes: 16 additions & 0 deletions changelogs/v2.2.7.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
## What's new

### Features

- **Bidirectional Link Removal**: Introduced the `unlink_note_from_task` tool to allow bidirectionally removing links between task cards and notes.
- **Task Dependencies Visibility**: Added `blockingCardIds: string[]` to the output of `get_task` tool, letting the model discover which tasks are blocked by a given card without pulling the entire graph.

### Fixes

- **Note Content Preservation on Update**: Fixed a data loss bug in `ensure_note` where calling the tool to update metadata (such as moving a note to a subfolder, pinning, or tagging) without providing the `content` argument would overwrite the note's existing content with an empty string (`""`). The `ensure_note` tool now preserves the existing note content if the `content` argument is omitted (`undefined`).
- **Template Literal Escaping in Prompt**: Fixed a syntax error in esbuild packaging where unescaped backticks around the `content` parameter description inside the system prompt builder closed the template literal string prematurely, preventing builds and typescript type checking from passing.
- **Note-Task Linking File Sync**: Updated `link_note_to_task` to accept `workspacePath` and trigger `writeNoteFile` to keep the frontmatter of notes in the physical file system synchronized with link metadata updates in the database.

### Changes

- **AI Tools Schema Descriptions**: Enhanced the Zod schema and system prompt definitions for `ensure_note` to explicitly instruct the LLM that the `content` parameter is optional and that omitting it preserves the existing note body, enabling the model to perform metadata and folder-reorganization actions efficiently without a read-then-write cycle.
67 changes: 67 additions & 0 deletions electron/ipc/chat-executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,15 @@ describe("get_task", () => {
const result = await exec(db, "get_task", { cardId: "nope" }) as Record<string, unknown>;
expect(result).toHaveProperty("error");
});

it("returns blockingCardIds when other cards are blocked by this task", async () => {
const db = makeDb();
seed(db);
createCard(db, { id: "card2", columnId: "col1", projectId: "proj1", workspaceId: "ws1", title: "Task Two", order: 1 });
db.prepare("UPDATE task_cards SET blocked_by_ids = '[\"card1\"]' WHERE id = 'card2'").run();
const result = await exec(db, "get_task", { cardId: "card1" }) as Record<string, unknown>;
expect(result.blockingCardIds).toContain("card2");
});
});

// ── get_note ──────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -333,6 +342,43 @@ describe("link_note_to_task", () => {
});
});

describe("unlink_note_from_task", () => {
it("unlinks note and card bidirectionally", async () => {
const db = makeDb();
seed(db);
// Link them first
await exec(db, "link_note_to_task", { noteId: "note1", cardId: "card1" });

// Check they are linked in DB
const notePre = await exec(db, "get_note", { noteId: "note1" }) as Record<string, unknown>;
expect(notePre.linkedCardIds).toContain("card1");

const taskPre = await exec(db, "get_task", { cardId: "card1" }) as Record<string, unknown>;
expect(taskPre.linkedNoteIds).toContain("note1");

// Unlink
const result = await exec(db, "unlink_note_from_task", { noteId: "note1", cardId: "card1" }) as Record<string, unknown>;
expect(result.unlinked).toBe(true);

// Verify unlinked
const notePost = await exec(db, "get_note", { noteId: "note1" }) as Record<string, unknown>;
expect(notePost.linkedCardIds).not.toContain("card1");

const taskPost = await exec(db, "get_task", { cardId: "card1" }) as Record<string, unknown>;
expect(taskPost.linkedNoteIds).not.toContain("note1");
});

it("returns { error } for missing note or task", async () => {
const db = makeDb();
seed(db);
const result1 = await exec(db, "unlink_note_from_task", { noteId: "nope", cardId: "card1" }) as Record<string, unknown>;
expect(result1).toHaveProperty("error");

const result2 = await exec(db, "unlink_note_from_task", { noteId: "note1", cardId: "nope" }) as Record<string, unknown>;
expect(result2).toHaveProperty("error");
});
});

// ── unknown tool ──────────────────────────────────────────────────────────────

describe("unknown tool", () => {
Expand Down Expand Up @@ -436,6 +482,27 @@ describe("ensure_note", () => {
expect(result.action).toBe("updated");
});

it("preserves content on update when content argument is omitted but clears it when content is empty string", async () => {
const db = makeDb();
seed(db);
// 1. Omitted content case
await exec(db, "ensure_note", { projectId: "proj1", title: "README", content: "initial content" });
const result1 = await exec(db, "ensure_note", { projectId: "proj1", title: "README", folder: "new_folder" }) as Record<string, unknown>;
expect(result1.action).toBe("updated");
expect(result1.folder).toBe("new_folder");

const note1 = await exec(db, "get_note", { noteId: result1.id }) as Record<string, unknown>;
expect(note1.content).toBe("initial content");

// 2. Empty string content case
const result2 = await exec(db, "ensure_note", { projectId: "proj1", title: "README", content: "" }) as Record<string, unknown>;
expect(result2.action).toBe("updated");

const note2 = await exec(db, "get_note", { noteId: result2.id }) as Record<string, unknown>;
expect(note2.content).toBe("");
});


it("only one note exists after two ensure calls with same title", async () => {
const db = makeDb();
seed(db);
Expand Down
1 change: 1 addition & 0 deletions electron/ipc/chat-executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ export async function executeTool(
case "delete_task":
case "bulk_update_task_status":
case "link_note_to_task":
case "unlink_note_from_task":
case "create_task":
case "list_ready_tasks":
case "upsert_project":
Expand Down
12 changes: 10 additions & 2 deletions electron/lib/tool-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,11 @@ export const TOOL_SCHEMAS = {
// ── Notes ────────────────────────────────────────────────────────────────────

ensure_note: {
description: "Create-or-update a note by title+projectId. Idempotent — safe to call repeatedly.",
description: "Create-or-update a note by title+projectId. Idempotent — safe to call repeatedly. Content is optional; if omitted on update, existing content is preserved.",
schema: z.object({
projectId: sId,
title: sStr,
content: sStrOpt,
content: sStrOpt.describe("Markdown content. If omitted on update, existing content is preserved."),
tagIds: sTagIds,
tagNames: sTagNames,
isPinned: sBoolOpt,
Expand Down Expand Up @@ -175,6 +175,14 @@ export const TOOL_SCHEMAS = {
}),
},

unlink_note_from_task: {
description: "Bidirectionally remove a link between a note and a task card.",
schema: z.object({
noteId: sId,
cardId: sId,
}),
},

list_ready_tasks: {
description: "Return only unblocked, active tasks — tasks with no pending blockers and not in a done column. Use this to find work that can start right now.",
schema: z.object({
Expand Down
6 changes: 4 additions & 2 deletions electron/lib/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export const TOOL_LABELS: Record<string, (args: ToolArgs) => string> = {
generate_prd: (a) => `Generating PRD "${a.title}"`,
spawn_tasks_from_note: () => "Spawning tasks from note",
link_note_to_task: () => "Linking note to task",
unlink_note_from_task: () => "Unlinking note from task",
get_idea_flow: () => "Reading Idea Flow",
create_idea_flow_node: (a) => `Adding ${(a.type as string) ?? "node"} to Idea Flow`,
update_idea_flow_node: () => "Updating Idea Flow node",
Expand Down Expand Up @@ -137,8 +138,8 @@ IDs (workspaceId, projectId, columnId) are stable for the app session. Call get_
- Keep responses concise and actionable

## Notes
- Use ensure_note to create or update notes — it is idempotent and safe to call repeatedly.
- Use the optional \`folder\` parameter for subfolders, e.g. \`folder="Research/Papers"\`.
- Use ensure_note to create or update notes — it is idempotent and safe to call repeatedly. Omitting the \`content\` parameter on update preserves the note's existing content.
- Use the optional \`folder\` parameter for subfolders, e.g. \`folder="Research/Papers"\` (can be used with ensure_note to move notes without overwriting content).
- Use patch_note for targeted edits, append_to_note to add content without replacing.
- search_notes with an empty query returns all notes in a project.
- search_notes_semantic uses local embeddings for natural-language queries — better than search_notes when concepts are described in different words. Requires embeddings enabled.
Expand All @@ -148,6 +149,7 @@ IDs (workspaceId, projectId, columnId) are stable for the app session. Call get_
- Use update_task with \`columnId\` to move a task to a different column.
- Use list_ready_tasks to find unblocked work; use search_tasks with an empty query to list all tasks.
- Use update_task with \`blockedBy\` to add a blocker, \`unblockFrom\` to remove one. Blockers auto-clear when moved to done or archived.
- Use \`link_note_to_task\` to link a note and a task card, and \`unlink_note_from_task\` to remove that connection.
- You can pass \`tagNames\` (array of strings) to automatically resolve or create tags case-insensitively.

## Dashboards
Expand Down
7 changes: 5 additions & 2 deletions electron/mcp/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { getCairnContext, getProjectContextPack, DASHBOARD_CONSTANTS, IDEA_FLOW_
import { create_tag } from "./tags";
import { upsert_project, delete_project } from "./projects";
import { get_note, search_notes, ensure_note, append_to_note, patch_note, delete_note } from "./notes";
import { get_task, search_tasks, create_task, bulk_update_task_status, link_note_to_task, delete_task, list_ready_tasks, update_task } from "./tasks";
import { get_task, search_tasks, create_task, bulk_update_task_status, link_note_to_task, unlink_note_from_task, delete_task, list_ready_tasks, update_task } from "./tasks";
import { create_dashboard, update_dashboard } from "./dashboards";
import {
get_idea_flow,
Expand Down Expand Up @@ -60,7 +60,10 @@ export function executeTool(db: Database.Database, workspacePath: string, toolNa
return bulk_update_task_status(db, snap, args);

case "link_note_to_task":
return link_note_to_task(db, snap, args);
return link_note_to_task(db, snap, workspacePath, args);

case "unlink_note_from_task":
return unlink_note_from_task(db, snap, workspacePath, args);

case "get_note":
return get_note(db, snap, args);
Expand Down
21 changes: 10 additions & 11 deletions electron/mcp/tools/notes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,6 @@
requestedTitle: typeof title === "string" ? title : "",
matchedId: existing?.id ?? "none",
});
const markdown = (content as string | undefined) ?? "";

const resolvedFromNameIds = resolveTagNames(db, project.workspaceId, tagNames);
let ensureResolvedTagIds = Array.isArray(ensureTagIds) ? ensureTagIds as string[] : undefined;
if (resolvedFromNameIds.length > 0) {
Expand All @@ -67,11 +65,11 @@
lockNote(db, ensureNoteId);
try {
if (existing) {
const updatedFolder = ensureFolder ?? (existing.folder as string) ?? "";

Check warning on line 68 in electron/mcp/tools/notes.ts

View workflow job for this annotation

GitHub Actions / Lint

'updatedFolder' is assigned a value but never used. Allowed unused vars must match /^_/u
let updatedNote: any;
db.transaction(() => {
q.updateNote(db, existing.id, {
content: markdown,
contentText: stripMarkdown(markdown),
updatedNote = q.updateNote(db, existing.id, {
...(content !== undefined ? { content, contentText: stripMarkdown(content as string) } : {}),
...(ensureResolvedTagIds ? { tagIds: ensureResolvedTagIds } : {}),
...(ensureResolvedIsPinned !== undefined ? { isPinned: ensureResolvedIsPinned } : {}),
...(ensureFolder !== undefined ? { folder: ensureFolder } : {}),
Expand All @@ -80,19 +78,20 @@
})();
writeNoteFile(workspacePath, {
id: existing.id, projectId, workspaceId: existing.workspaceId as string,
title: existing.title as string, content: markdown,
tagIds: ensureResolvedTagIds ?? existing.tagIds as string[], linkedNoteIds: existing.linkedNoteIds as string[],
linkedCardIds: existing.linkedCardIds as string[], isPinned: ensureResolvedIsPinned ?? existing.isPinned as boolean,
folder: updatedFolder,
createdAt: existing.createdAt as string, updatedAt: new Date().toISOString(),
title: existing.title as string, content: updatedNote.content,
tagIds: updatedNote.tagIds, linkedNoteIds: updatedNote.linkedNoteIds,
linkedCardIds: updatedNote.linkedCardIds, isPinned: updatedNote.isPinned,
folder: updatedNote.folder,
createdAt: existing.createdAt as string, updatedAt: updatedNote.updatedAt,
archivedAt: existing.archivedAt as string | undefined,
projectName: project.name,
});
return { id: existing.id, title, folder: updatedFolder, action: "updated", updatedAt: new Date().toISOString() };
return { id: existing.id, title, folder: updatedNote.folder, action: "updated", updatedAt: updatedNote.updatedAt };
} else {
const newTagIds = ensureResolvedTagIds ?? [];
const newIsPinned = ensureResolvedIsPinned ?? false;
const newFolder = ensureFolder ?? "";
const markdown = (content as string | undefined) ?? "";
const note = db.transaction(() => {
const n = q.createNote(db, {
id: ensureNoteId,
Expand Down
53 changes: 49 additions & 4 deletions electron/mcp/tools/tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,23 @@ import {
Snapshot,
getCardVersion,
insertNotification,
resolveTagNames
resolveTagNames,
writeNoteFile
} from "../db";

export function get_task(db: Database.Database, snap: Snapshot, args: Record<string, any>) {
const card = snap.cards.find((c) => c.id === args.cardId);
if (!card) return { error: "Task not found" };
const col = snap.columns.find((c) => c.id === card.columnId);
const blockingCardIds = snap.cards
.filter((c) => Array.isArray(c.blockedByIds) && c.blockedByIds.includes(card.id))
.map((c) => c.id);
return {
id: card.id, title: card.title, description: card.description,
priority: card.priority, dueDate: card.dueDate,
columnId: card.columnId, columnName: col?.name ?? "Unknown", columnType: col?.type ?? "custom",
linkedNoteIds: card.linkedNoteIds, blockedByIds: card.blockedByIds ?? [],
blockingCardIds,
projectId: card.projectId, createdAt: card.createdAt, updatedAt: card.updatedAt,
version: getCardVersion(db, card.id) ?? 0,
};
Expand Down Expand Up @@ -88,22 +93,62 @@ export function bulk_update_task_status(db: Database.Database, snap: Snapshot, a
return { moved, failed, targetColumnId, targetColumnName: col.name };
}

export function link_note_to_task(db: Database.Database, snap: Snapshot, args: Record<string, any>) {
export function link_note_to_task(db: Database.Database, snap: Snapshot, workspacePath: string, args: Record<string, any>) {
const { noteId, cardId } = args;
const note = snap.notes.find((n) => n.id === noteId);
const card = snap.cards.find((c) => c.id === cardId);
if (!note) return { error: "Note not found" };
if (!card) return { error: "Card not found" };
const newCardIds = Array.from(new Set([...(note.linkedCardIds as string[]), cardId]));
const newNoteIds = Array.from(new Set([...(card.linkedNoteIds as string[]), noteId]));
db.transaction(() => {
q.updateNote(db, noteId as string, { linkedCardIds: newCardIds });
const updatedNote = db.transaction(() => {
const u = q.updateNote(db, noteId as string, { linkedCardIds: newCardIds });
q.updateCard(db, cardId as string, { linkedNoteIds: newNoteIds });
return u;
})();
const proj = snap.projects.find((p) => p.id === note.projectId);
writeNoteFile(workspacePath, {
id: noteId as string, projectId: note.projectId as string, workspaceId: note.workspaceId as string,
title: note.title as string, content: updatedNote?.content ?? note.content as string,
tagIds: note.tagIds as string[], linkedNoteIds: note.linkedNoteIds as string[],
linkedCardIds: newCardIds, isPinned: note.isPinned as boolean,
createdAt: note.createdAt as string, updatedAt: updatedNote?.updatedAt ?? new Date().toISOString(),
archivedAt: note.archivedAt as string | undefined,
projectName: proj?.name ?? note.projectId as string,
folder: (note.folder as string) ?? "",
});
insertNotification(db, "link_note_to_task", "Note linked to task", `"${note.title}" linked to "${card.title}"`);
return { noteId, cardId, linked: true };
}

export function unlink_note_from_task(db: Database.Database, snap: Snapshot, workspacePath: string, args: Record<string, any>) {
const { noteId, cardId } = args;
const note = snap.notes.find((n) => n.id === noteId);
const card = snap.cards.find((c) => c.id === cardId);
if (!note) return { error: "Note not found" };
if (!card) return { error: "Card not found" };
const newCardIds = (note.linkedCardIds as string[]).filter((id) => id !== cardId);
const newNoteIds = (card.linkedNoteIds as string[]).filter((id) => id !== noteId);
const updatedNote = db.transaction(() => {
const u = q.updateNote(db, noteId as string, { linkedCardIds: newCardIds });
q.updateCard(db, cardId as string, { linkedNoteIds: newNoteIds });
return u;
})();
const proj = snap.projects.find((p) => p.id === note.projectId);
writeNoteFile(workspacePath, {
id: noteId as string, projectId: note.projectId as string, workspaceId: note.workspaceId as string,
title: note.title as string, content: updatedNote?.content ?? note.content as string,
tagIds: note.tagIds as string[], linkedNoteIds: note.linkedNoteIds as string[],
linkedCardIds: newCardIds, isPinned: note.isPinned as boolean,
createdAt: note.createdAt as string, updatedAt: updatedNote?.updatedAt ?? new Date().toISOString(),
archivedAt: note.archivedAt as string | undefined,
projectName: proj?.name ?? note.projectId as string,
folder: (note.folder as string) ?? "",
});
insertNotification(db, "unlink_note_from_task", "Note unlinked from task", `"${note.title}" unlinked from "${card.title}"`);
return { noteId, cardId, unlinked: true };
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

export function delete_task(db: Database.Database, snap: Snapshot, args: Record<string, any>) {
const card = snap.cards.find((c) => c.id === args.cardId);
if (!card) return { error: "Task not found" };
Expand Down
Loading