Skip to content
Merged
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
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,6 @@

- 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.
- API changes (new commands, changed options, new output fields) must be documented in `skill.md`.
34 changes: 32 additions & 2 deletions skill.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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).
Expand Down Expand Up @@ -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) |

---
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 <thread-id>
traul get --date 2026-03-10

# Exploratory search matching ANY term
traul search "deposit withdraw broken" --fts --or

Expand Down
54 changes: 54 additions & 0 deletions src/commands/get.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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 <thread-id> 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();
}
}
}
1 change: 1 addition & 0 deletions src/commands/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
12 changes: 12 additions & 0 deletions src/db/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -730,6 +730,18 @@ export class TraulDB {
return this.db.query<MessageRow, (Uint8Array | string | number)[]>(sql).all(...params);
}

getThread(threadId: string): MessageRow[] {
return this.db
.query<MessageRow, [string]>(Q.GET_THREAD)
.all(threadId);
}

getThreadsByDate(dayStart: number, dayEnd: number): MessageRow[] {
return this.db
.query<MessageRow, [number, number]>(Q.GET_THREADS_BY_DATE)
.all(dayStart, dayEnd);
}

getDetailedStats(): {
db_size: number;
total_messages: number;
Expand Down
18 changes: 18 additions & 0 deletions src/db/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 <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")
Expand Down
3 changes: 2 additions & 1 deletion src/lib/formatter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
131 changes: 131 additions & 0 deletions test/commands/get.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
Loading