From 8406bb7da777b3b7e5224e62ddff1d1adc5467d7 Mon Sep 17 00:00:00 2001 From: Vlad Ra Date: Wed, 18 Mar 2026 13:19:41 +0000 Subject: [PATCH 1/6] feat: add `traul get` command to retrieve full threads Adds a new `get` command that retrieves complete conversation threads by thread_id, plus a --date option to list all threads from a given day. Also exposes thread_id in search results (text and JSON output) so users can copy it for use with `traul get`. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/commands/get.ts | 54 ++++++++++++++++++++++++++++++++++++++++++ src/commands/search.ts | 1 + src/db/database.ts | 12 ++++++++++ src/db/queries.ts | 18 ++++++++++++++ src/index.ts | 12 ++++++++++ src/lib/formatter.ts | 3 ++- 6 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 src/commands/get.ts diff --git a/src/commands/get.ts b/src/commands/get.ts new file mode 100644 index 0000000..84394a5 --- /dev/null +++ b/src/commands/get.ts @@ -0,0 +1,54 @@ +import type { TraulDB, MessageRow } from "../db/database"; +import { writeJSON } from "../lib/formatter"; + +function formatTimestamp(unixTs: number): string { + return new Date(unixTs * 1000).toISOString().replace("T", " ").slice(0, 19); +} + +export async function runGet( + db: TraulDB, + threadId: string | undefined, + options: { date?: string; json?: boolean } +): Promise { + let messages: MessageRow[]; + + if (options.date) { + const dayStart = Math.floor(new Date(options.date).getTime() / 1000); + const dayEnd = dayStart + 86400; + messages = db.getThreadsByDate(dayStart, dayEnd); + } else if (threadId) { + messages = db.getThread(threadId); + } else { + console.error("Usage: traul get or traul get --date 2026-03-10"); + process.exit(1); + } + + if (messages.length === 0) { + if (options.json) { + console.log("[]"); + } else { + console.log("No messages found."); + } + return; + } + + if (options.json) { + const jsonData = messages.map((msg) => ({ + sent_at: new Date(msg.sent_at * 1000).toISOString(), + author: msg.author_name, + content: msg.content, + channel: msg.channel_name, + source: msg.source, + thread_id: msg.thread_id, + })); + await writeJSON(jsonData); + } else { + for (const msg of messages) { + const time = formatTimestamp(msg.sent_at); + const author = msg.author_name ?? "unknown"; + console.log(`${time} ${author}:`); + console.log(msg.content); + console.log(); + } + } +} diff --git a/src/commands/search.ts b/src/commands/search.ts index 667ab0e..72e27f4 100644 --- a/src/commands/search.ts +++ b/src/commands/search.ts @@ -74,6 +74,7 @@ export async function runSearch( content: msg.content, channel: msg.channel_name, source: msg.source, + ...(msg.thread_id ? { thread_id: msg.thread_id } : {}), ...(msg.rank != null ? { rank: msg.rank } : {}), })); await writeJSON(jsonData); diff --git a/src/db/database.ts b/src/db/database.ts index 7c6d23b..a08e9cb 100644 --- a/src/db/database.ts +++ b/src/db/database.ts @@ -730,6 +730,18 @@ export class TraulDB { return this.db.query(sql).all(...params); } + getThread(threadId: string): MessageRow[] { + return this.db + .query(Q.GET_THREAD) + .all(threadId); + } + + getThreadsByDate(dayStart: number, dayEnd: number): MessageRow[] { + return this.db + .query(Q.GET_THREADS_BY_DATE) + .all(dayStart, dayEnd); + } + getDetailedStats(): { db_size: number; total_messages: number; diff --git a/src/db/queries.ts b/src/db/queries.ts index 1f57ceb..cfd55a4 100644 --- a/src/db/queries.ts +++ b/src/db/queries.ts @@ -228,6 +228,24 @@ export const GET_UNCHUNKED_LONG_MESSAGES = ` LIMIT ? `; +export const GET_THREAD = ` + SELECT m.source, m.channel_name, m.thread_id, + m.author_name, m.content, m.sent_at, m.metadata + FROM messages m + WHERE m.thread_id = ? + ORDER BY m.sent_at ASC +`; + +export const GET_THREADS_BY_DATE = ` + SELECT m.source, m.channel_name, m.thread_id, + m.author_name, m.content, m.sent_at, m.metadata, + COUNT(*) OVER (PARTITION BY m.thread_id) AS thread_size + FROM messages m + WHERE m.thread_id IS NOT NULL + AND m.sent_at >= ? AND m.sent_at < ? + ORDER BY m.sent_at ASC +`; + export const GET_CHANNELS = ` SELECT source, channel_name, COUNT(*) AS msg_count, diff --git a/src/index.ts b/src/index.ts index 20aeb19..9ce309a 100755 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ import { runStats } from "./commands/stats"; import { runWhatsAppAuth } from "./commands/whatsapp-auth"; import { runDaemonStart, runDaemonStop, runDaemonStatus } from "./commands/daemon"; import { runSql, runSchema } from "./commands/sql"; +import { runGet } from "./commands/get"; const config = loadConfig(); ensureDbDir(config.database.path); @@ -94,6 +95,17 @@ program db.close(); }); +program + .command("get") + .description("Get full thread/conversation by thread ID") + .argument("[thread-id]", "thread ID (e.g. Claude Code session UUID)") + .option("-d, --date ", "get all threads from a date (ISO 8601)") + .option("--json", "output as JSON") + .action(async (threadId: string | undefined, options) => { + await runGet(db, threadId, options); + db.close(); + }); + program .command("channels") .description("List known channels with message counts") diff --git a/src/lib/formatter.ts b/src/lib/formatter.ts index 21a5eb0..c833cfb 100644 --- a/src/lib/formatter.ts +++ b/src/lib/formatter.ts @@ -13,8 +13,9 @@ export function formatMessage(msg: MessageRow): string { const time = formatTimestamp(msg.sent_at); const channel = msg.channel_name ? `#${msg.channel_name}` : msg.source; const author = msg.author_name ?? "unknown"; + const thread = msg.thread_id ? ` [thread:${msg.thread_id.slice(0, 12)}]` : ""; const content = truncate(msg.content.replace(/\n/g, " "), 120); - return `${time} ${channel} ${author}: ${content}`; + return `${time} ${channel} ${author}${thread}: ${content}`; } export function formatStats(stats: Stats): string { From 4a0544ffa63948728b8eb821f29312a1439534a8 Mon Sep 17 00:00:00 2001 From: Vlad Ra Date: Wed, 18 Mar 2026 13:20:50 +0000 Subject: [PATCH 2/6] test: add tests for getThread and getThreadsByDate Co-Authored-By: Claude Opus 4.6 (1M context) --- test/commands/get.test.ts | 131 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 test/commands/get.test.ts diff --git a/test/commands/get.test.ts b/test/commands/get.test.ts new file mode 100644 index 0000000..72f39fa --- /dev/null +++ b/test/commands/get.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, beforeEach } from "bun:test"; +import { TraulDB } from "../../src/db/database"; + +describe("getThread / getThreadsByDate", () => { + let db: TraulDB; + + beforeEach(() => { + db = new TraulDB(":memory:"); + }); + + describe("getThread", () => { + it("returns all messages for a thread_id ordered by sent_at", () => { + db.upsertMessage({ + source: "claudecode", + source_id: "cc:sess1:msg1", + channel_name: "traul", + thread_id: "session-uuid-123", + author_name: "user", + content: "First message", + sent_at: 1700000000, + }); + db.upsertMessage({ + source: "claudecode", + source_id: "cc:sess1:msg2", + channel_name: "traul", + thread_id: "session-uuid-123", + author_name: "claude", + content: "Second message", + sent_at: 1700000001, + }); + db.upsertMessage({ + source: "claudecode", + source_id: "cc:sess1:msg3", + channel_name: "traul", + thread_id: "session-uuid-123", + author_name: "user", + content: "Third message", + sent_at: 1700000002, + }); + + const results = db.getThread("session-uuid-123"); + expect(results).toHaveLength(3); + expect(results[0].content).toBe("First message"); + expect(results[1].content).toBe("Second message"); + expect(results[2].content).toBe("Third message"); + }); + + it("does not return messages from other threads", () => { + db.upsertMessage({ + source: "claudecode", + source_id: "cc:sess1:msg1", + thread_id: "session-aaa", + content: "Thread A", + sent_at: 1700000000, + }); + db.upsertMessage({ + source: "claudecode", + source_id: "cc:sess2:msg1", + thread_id: "session-bbb", + content: "Thread B", + sent_at: 1700000001, + }); + + const results = db.getThread("session-aaa"); + expect(results).toHaveLength(1); + expect(results[0].content).toBe("Thread A"); + }); + + it("returns empty array for non-existent thread", () => { + const results = db.getThread("does-not-exist"); + expect(results).toHaveLength(0); + }); + }); + + describe("getThreadsByDate", () => { + it("returns messages from threads within the date range", () => { + const dayStart = 1700000000; + const dayEnd = dayStart + 86400; + + db.upsertMessage({ + source: "claudecode", + source_id: "cc:sess1:msg1", + thread_id: "session-today", + author_name: "user", + content: "Today's message", + sent_at: dayStart + 100, + }); + db.upsertMessage({ + source: "claudecode", + source_id: "cc:sess2:msg1", + thread_id: "session-yesterday", + author_name: "user", + content: "Yesterday's message", + sent_at: dayStart - 100, + }); + + const results = db.getThreadsByDate(dayStart, dayEnd); + expect(results).toHaveLength(1); + expect(results[0].content).toBe("Today's message"); + }); + + it("excludes messages with null thread_id", () => { + const dayStart = 1700000000; + const dayEnd = dayStart + 86400; + + db.upsertMessage({ + source: "slack", + source_id: "C1:1", + channel_name: "general", + content: "No thread", + sent_at: dayStart + 100, + }); + db.upsertMessage({ + source: "claudecode", + source_id: "cc:sess1:msg1", + thread_id: "session-123", + content: "Has thread", + sent_at: dayStart + 200, + }); + + const results = db.getThreadsByDate(dayStart, dayEnd); + expect(results).toHaveLength(1); + expect(results[0].thread_id).toBe("session-123"); + }); + + it("returns empty array when no threads in range", () => { + const results = db.getThreadsByDate(1700000000, 1700086400); + expect(results).toHaveLength(0); + }); + }); +}); From 6499f018dcc8226603f6a595998691369c54be9e Mon Sep 17 00:00:00 2001 From: Vlad Ra Date: Wed, 18 Mar 2026 13:21:37 +0000 Subject: [PATCH 3/6] docs: add TDD and testing requirements to CLAUDE.md Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 0404afc..bffff67 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,3 +2,5 @@ - The project is meant to be type-checked via Bun, not vanilla `tsc`. - All changes must go through a PR — never push directly to `main`. +- New features and refactors must include tests. Follow TDD: write failing tests first, then implement until they pass. +- Bug fixes must include a test that fails before the fix and passes after. From 378fe9a12dbb378db5f3ffe41056ab7b210e812a Mon Sep 17 00:00:00 2001 From: Vlad Ra Date: Wed, 18 Mar 2026 13:23:26 +0000 Subject: [PATCH 4/6] docs: require API changes to be documented in README and skill file Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CLAUDE.md b/CLAUDE.md index bffff67..fc2aab2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,3 +4,4 @@ - All changes must go through a PR — never push directly to `main`. - New features and refactors must include tests. Follow TDD: write failing tests first, then implement until they pass. - Bug fixes must include a test that fails before the fix and passes after. +- API changes (new commands, changed options, new output fields) must be documented in `~/.claude/skills/traul/README.md` and `~/.claude/skills/traul/skill.md`. From 9ba5fbeb93ccf9c8426a2688d6054790ccfca03b Mon Sep 17 00:00:00 2001 From: Vlad Ra Date: Wed, 18 Mar 2026 13:24:10 +0000 Subject: [PATCH 5/6] docs: add get command to in-repo skill.md Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 2 +- skill.md | 34 ++++++++++++++++++++++++++++++++-- 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index fc2aab2..d958bb1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,4 +4,4 @@ - All changes must go through a PR — never push directly to `main`. - New features and refactors must include tests. Follow TDD: write failing tests first, then implement until they pass. - Bug fixes must include a test that fails before the fix and passes after. -- API changes (new commands, changed options, new output fields) must be documented in `~/.claude/skills/traul/README.md` and `~/.claude/skills/traul/skill.md`. +- API changes (new commands, changed options, new output fields) must be documented in `skill.md` and `~/.claude/skills/traul/README.md`. diff --git a/skill.md b/skill.md index 3b5b73f..ba309d0 100644 --- a/skill.md +++ b/skill.md @@ -61,6 +61,31 @@ Hybrid search combining vector similarity (semantic) and FTS5 keyword matching w | `--or` | Join terms with OR instead of AND (works with `--fts` and hybrid) | | `--like` | Substring match (LIKE) — bypasses FTS, useful for exact phrases | +### `traul get [thread-id]` + +Retrieve a full conversation thread by its thread ID, or list all threads from a given date. Search results display thread IDs in the output so you can copy them for use with `get`. + +| Option | Description | +|--------|-------------| +| `thread-id` (positional) | Thread ID (e.g. Claude Code session UUID) | +| `-d, --date ` | Get all threads from a date (ISO 8601) | +| `--json` | Output as JSON | + +```bash +# Search shows thread IDs in results +traul search "mixpanel metrics" +# → 2026-03-10 13:06 #base claude [thread:abc-123-uuid]: Let me try... + +# Get full conversation by thread ID +traul get abc-123-session-uuid + +# List all threads from a specific date +traul get --date 2026-03-10 + +# JSON output +traul get abc-123-session-uuid --json +``` + ### `traul messages [channel]` Browse messages chronologically (no FTS required). @@ -178,6 +203,7 @@ All `--json` outputs use clean, normalized field names (not raw SQL column names | `content` | string | Message content | | `channel` | string | Channel name | | `source` | string | Source connector name | +| `thread_id` | string | Thread/session ID (optional, present when available) | | `rank` | number | Search relevance score (optional) | --- @@ -312,7 +338,7 @@ ENV vars override config values. Empty `channels`/`chats` arrays = sync all. src/ index.ts # CLI entry (Commander.js) commands/ # Command handlers - sync.ts, search.ts, messages.ts, channels.ts, signals.ts, briefing.ts, sql.ts + sync.ts, search.ts, messages.ts, channels.ts, get.ts, signals.ts, briefing.ts, sql.ts connectors/ # Source adapters types.ts, slack.ts, telegram.ts, linear.ts, claude-code.ts, markdown.ts db/ # Data layer @@ -358,9 +384,13 @@ traul messages "general" --limit 20 # Find channels matching a keyword traul channels --search "dev" -# Search for a topic across all messages +# Search for a topic (results include thread IDs) traul search "deployment issue" --after 2026-03-01 +# Get a full thread/conversation +traul get +traul get --date 2026-03-10 + # Exploratory search matching ANY term traul search "deposit withdraw broken" --fts --or From 15b01abe57b38e32511c283c71bb0aa1d46cb3a0 Mon Sep 17 00:00:00 2001 From: Vlad Ra Date: Wed, 18 Mar 2026 13:24:34 +0000 Subject: [PATCH 6/6] docs: simplify skill docs requirement in CLAUDE.md Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index d958bb1..275c3e8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,4 +4,4 @@ - All changes must go through a PR — never push directly to `main`. - New features and refactors must include tests. Follow TDD: write failing tests first, then implement until they pass. - Bug fixes must include a test that fails before the fix and passes after. -- API changes (new commands, changed options, new output fields) must be documented in `skill.md` and `~/.claude/skills/traul/README.md`. +- API changes (new commands, changed options, new output fields) must be documented in `skill.md`.