Skip to content
Open
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
90 changes: 89 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ The setup wizard will guide you through:
- **Tags support** - Organize tasks with tags
- **Interactive mode** - Create tasks with guided prompts
- **Human-readable output** - Table format by default, JSON optional
- **Semantic search** - Vector similarity search via Qdrant + Ollama (optional)
- OAuth 2.0 with automatic token refresh
- Supports global and China regions
- MCP server for Claude Desktop and Claude Code integration
Expand Down Expand Up @@ -144,11 +145,16 @@ ticktick tasks update 685cfca6 --title "New title" --priority medium
ticktick tasks complete PROJECT_ID 685cfca6
ticktick tasks delete PROJECT_ID 685cfca6

# Search (by text, tags, or priority)
# Keyword search (by text, tags, or priority)
ticktick tasks search "meeting"
ticktick tasks search --tags "work"
ticktick tasks search --priority high

# Semantic search (requires Qdrant + Ollama, see below)
ticktick tasks semantic "anything related to deployments"
ticktick tasks semantic "client follow-ups" --limit 10
ticktick tasks similar 685cfca6 # Find similar tasks

# Filter by due date
ticktick tasks due 3 # Tasks due in 3 days
ticktick tasks priority # High priority tasks
Expand Down Expand Up @@ -196,6 +202,84 @@ ticktick projects list
ticktick projects list --format json
```

## Vector Search (Optional)

The built-in keyword search iterates every project and every task via the API on each query. For a handful of tasks this is fine, but once you have hundreds of tasks across many projects, each search fires N+1 API calls (1 to list projects, then 1 per project to fetch tasks) and does substring matching, which misses semantically related results.

Vector search solves both problems:

- **Speed**: queries hit a local Qdrant index instead of the TickTick API. A search that took 3-5 seconds over the API returns in under 100ms.
- **Relevance**: "deployment tasks" finds tasks titled "push release to prod" or "update CI pipeline" that keyword search would never match.

This has been running in production for several months with ~500 tasks and the difference is significant.

### Prerequisites

You need two services running locally (Docker is the easiest path):

```bash
# Qdrant (vector database)
docker run -d --name qdrant -p 6333:6333 qdrant/qdrant

# Ollama (local embeddings)
docker run -d --name ollama -p 11434:11434 ollama/ollama
docker exec ollama ollama pull nomic-embed-text
```

Or install natively:
- [Qdrant](https://qdrant.tech/documentation/guides/installation/)
- [Ollama](https://ollama.com/download) + `ollama pull nomic-embed-text`

### Configuration

Set these environment variables to override defaults:

```bash
export QDRANT_URL="http://localhost:6333" # default
export OLLAMA_URL="http://localhost:11434" # default
export EMBEDDING_MODEL="nomic-embed-text" # default
```

### Usage

```bash
# 1. Build the index (run once, then periodically)
ticktick tasks vector-sync

# 2. Search semantically
ticktick tasks semantic "client follow-ups"
ticktick tasks semantic "anything about kubernetes" --limit 10

# 3. Find similar tasks (deduplication, related work)
ticktick tasks similar TASK_ID

# 4. Check index health
ticktick tasks vector-status
```

### How sync works

`vector-sync` is incremental by default:

1. Fetches all active tasks from the TickTick API
2. Computes an MD5 hash of `title|content|tags` for each task
3. Only re-embeds tasks whose content hash changed since last sync
4. Updates metadata (priority, dueDate) without re-embedding when only those fields changed
5. Removes tasks from the index that no longer exist

This means a typical sync with a few changed tasks finishes in seconds, not minutes. Use `--full` to force a complete re-index.

For automated sync, add a cron job:

```bash
# Sync every 4 hours
0 */4 * * * ticktick tasks vector-sync --format json >> /var/log/ticktick-vector-sync.log 2>&1
```

### Graceful fallback

If Qdrant or Ollama are not running, semantic search automatically falls back to keyword search and reports the reason. Nothing breaks; you just get the slower path.

## MCP Server

The package includes an MCP (Model Context Protocol) server for AI assistant integration.
Expand Down Expand Up @@ -256,8 +340,12 @@ Once configured, the AI assistant can use these tools:
| `ticktick_tasks_complete` | Mark task as complete |
| `ticktick_tasks_delete` | Delete a task |
| `ticktick_tasks_search` | Search by keyword, tags, or priority |
| `ticktick_tasks_semantic_search` | Semantic search via vector similarity |
| `ticktick_tasks_similar` | Find semantically similar tasks |
| `ticktick_tasks_due` | Get tasks due within N days |
| `ticktick_tasks_priority` | Get high priority tasks |
| `ticktick_vector_sync` | Sync tasks into vector index |
| `ticktick_vector_status` | Check vector index health |

**Example prompts for Claude:**
- "What tasks do I have due this week?"
Expand Down
28 changes: 28 additions & 0 deletions bin/ticktick.js
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,34 @@ async function handleTasks() {
endDate: args.options.to,
});
}
case 'semantic': {
const query = args.positional[0];
if (!query) {
console.error('Usage: ticktick tasks semantic QUERY [--limit N] [--priority LEVEL]');
process.exit(1);
}
return await tasks.semanticSearch(query, {
limit: parseInt(args.options.limit) || 5,
priority: args.options.priority,
});
}
case 'similar': {
const taskId = args.positional[0];
if (!taskId) {
console.error('Usage: ticktick tasks similar TASK_ID [--limit N]');
process.exit(1);
}
return await tasks.findSimilar(taskId, {
limit: parseInt(args.options.limit) || 5,
});
}
case 'vector-sync':
return await tasks.vectorSync({
forceFull: !!args.options.full,
maxEmbeddings: parseInt(args.options.max) || 200,
});
case 'vector-status':
return await tasks.vectorStatus();
default:
console.error(`Unknown tasks subcommand: ${args.subcommand}`);
console.log(getTasksHelp());
Expand Down
60 changes: 59 additions & 1 deletion lib/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,31 @@ function formatObject(obj) {
return lines.join('\n');
}

// Handle semantic search results
if (obj.tasks && Array.isArray(obj.tasks) && obj.mode) {
lines.push(`Search: "${obj.query}" (${obj.mode})`);
if (obj.reason) lines.push(`Fallback reason: ${obj.reason}`);
lines.push(`Found: ${obj.count} tasks`);
lines.push('');
if (obj.mode === 'semantic' && obj.tasks.length > 0) {
lines.push(formatScoredResults(obj.tasks));
} else {
lines.push(formatArray(obj.tasks));
}
return lines.join('\n');
}

// Handle similar tasks results
if (obj.source && obj.similar) {
lines.push(`Similar to: "${obj.source.title}" (${obj.source.id})`);
lines.push(`Found: ${obj.similar.length} similar tasks`);
lines.push('');
if (obj.similar.length > 0) {
lines.push(formatScoredResults(obj.similar));
}
return lines.join('\n');
}

// Handle search/due/priority results
if (obj.tasks && Array.isArray(obj.tasks)) {
if (obj.keyword !== undefined) lines.push(`Search: "${obj.keyword}"`);
Expand Down Expand Up @@ -238,6 +263,24 @@ function formatAuthStatus(status) {
return lines.join('\n');
}

/**
* Format search results that include relevance scores
*/
function formatScoredResults(results) {
const lines = [];
lines.push('Score | Title | Project | Pri | Due');
lines.push('-'.repeat(90));
for (const r of results) {
const score = String(r.score).padEnd(5);
const title = truncate(r.title || '', 30).padEnd(30);
const project = truncate(r.project || '', 20).padEnd(20);
const pri = (r.priority || 'none').padEnd(6);
const due = r.dueDate ? r.dueDate.slice(0, 10) : '';
lines.push(`${score} | ${title} | ${project} | ${pri} | ${due}`);
}
return lines.join('\n');
}

/**
* Truncate string to max length
*/
Expand Down Expand Up @@ -363,10 +406,14 @@ Subcommands:
update <task_id> Update task
complete <project_id> <task_id> Complete task
delete <project_id> <task_id> Delete task
search <keyword> Search all tasks
search <keyword> Search all tasks (keyword match)
semantic <query> Semantic search (vector similarity)
similar <task_id> Find semantically similar tasks
due [days] Tasks due within N days (default: 7)
priority High priority tasks
completed List completed tasks in a date range
vector-sync Sync tasks into vector index
vector-status Check vector index health

Create/Update options:
--project <id> Project ID (for create, optional)
Expand All @@ -389,6 +436,14 @@ Completed options:
--to <date> End of date range (ISO 8601)
--projects <ids> Comma-separated project IDs to filter

Semantic search options:
--limit <n> Max results (default: 5)
--priority <level> Filter by priority

Vector sync options:
--full Re-embed all tasks (default: incremental)
--max <n> Max embeddings per run (default: 200)

Examples:
ticktick tasks create "Buy groceries" --due 2026-01-30 --priority high
ticktick tasks create "Call mom" --tags "personal,family"
Expand All @@ -397,6 +452,9 @@ Examples:
ticktick tasks complete PROJECT_ID TASK_ID
ticktick tasks search "meeting"
ticktick tasks search --tags "work"
ticktick tasks semantic "tasks related to deployment"
ticktick tasks similar TASK_ID --limit 3
ticktick tasks vector-sync
ticktick tasks due 3
ticktick tasks completed --from 2026-03-06T00:00:00.000+0000 --to 2026-03-06T23:59:59.000+0000
ticktick tasks completed --projects PROJECT_ID1,PROJECT_ID2`;
Expand Down
59 changes: 59 additions & 0 deletions lib/mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -245,5 +245,64 @@ export function createServer(deps = {}) {
}
);

// Vector search tools
server.tool(
'ticktick_tasks_semantic_search',
'Semantic search across all TickTick tasks using vector similarity. Much faster and more relevant than keyword search for natural language queries. Falls back to keyword search if vector infra is unavailable.',
{
query: z.string().describe('Natural language search query'),
limit: z.number().optional().default(5).describe('Max results (default: 5)'),
priority: z.enum(['none', 'low', 'medium', 'high']).optional().describe('Filter by priority'),
},
async ({ query, limit, priority }) => {
const result = await tasksModule.semanticSearch(query, { limit, priority }, moduleDeps);
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
};
}
);

server.tool(
'ticktick_tasks_similar',
'Find tasks semantically similar to a given task. Useful for deduplication or finding related work.',
{
taskId: z.string().describe('Task ID (short or full)'),
limit: z.number().optional().default(5).describe('Max results (default: 5)'),
},
async ({ taskId, limit }) => {
const result = await tasksModule.findSimilar(taskId, { limit }, moduleDeps);
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
};
}
);

server.tool(
'ticktick_vector_sync',
'Sync tasks into the vector index for semantic search. Run this after adding many tasks, or set up as a cron job.',
{
forceFull: z.boolean().optional().default(false).describe('Re-embed all tasks (default: incremental)'),
maxEmbeddings: z.number().optional().default(200).describe('Max embeddings per run (default: 200)'),
},
async ({ forceFull, maxEmbeddings }) => {
const result = await tasksModule.vectorSync({ forceFull, maxEmbeddings }, moduleDeps);
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
};
}
);

server.tool(
'ticktick_vector_status',
'Check vector index health and statistics',
{},
async () => {
const result = await tasksModule.vectorStatus(moduleDeps);
return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
};
}
);

return server;
}
Loading