Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ dist/
*.js.map
.env
.env.local
pnpm*
12 changes: 12 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@

This is the Field Theory CLI — a standalone tool for syncing and querying X/Twitter bookmarks locally.

## Running locally

**Important:** If you have this installed globally (`npm install -g fieldtheory`), make sure to run the local version for development:

```bash
pnpm start <command>
# or
npm run start -- <command>
```

The global `ft` command may be a different version than the local codebase.

## Commands

```bash
Expand Down
240 changes: 180 additions & 60 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,18 @@ Sync and store locally all of your X/Twitter bookmarks. Search, classify, and ma

Free and open source. Designed for Mac.

## Running locally

**Important:** If you have this installed globally (`npm install -g fieldtheory`), make sure to run the local version for development:

```bash
pnpm start <command>
# or
npm run start -- <command>
```

The global `ft` command may be a different version than the local codebase.

## Install

```bash
Expand Down Expand Up @@ -31,66 +43,174 @@ On first run, `ft sync` extracts your X session from Chrome and downloads your b

## Commands

### Sync

| Command | Description |
|---------|-------------|
| `ft sync` | Download and sync bookmarks (no API required) |
| `ft sync --full` | Full history crawl (not just incremental) |
| `ft sync --gaps` | Backfill missing quoted tweets and expand truncated articles |
| `ft sync --classify` | Sync then classify new bookmarks with LLM |
| `ft sync --api` | Sync via OAuth API (cross-platform) |
| `ft auth` | Set up OAuth for API-based sync (optional) |

### Search and browse

| Command | Description |
|---------|-------------|
| `ft search <query>` | Full-text search with BM25 ranking |
| `ft list` | Filter by author, date, category, domain |
| `ft show <id>` | Show one bookmark in detail |
| `ft sample <category>` | Random sample from a category |
| `ft stats` | Top authors, languages, date range |
| `ft viz` | Terminal dashboard with sparklines, categories, and domains |
| `ft categories` | Show category distribution |
| `ft domains` | Subject domain distribution |

### Classification

| Command | Description |
|---------|-------------|
| `ft classify` | Classify by category and domain using LLM |
| `ft classify --regex` | Classify by category using simple regex |
| `ft classify-domains` | Classify by subject domain only (LLM) |
| `ft model` | View or change the default LLM engine |

### Knowledge base

| Command | Description |
|---------|-------------|
| `ft md` | Export bookmarks as individual markdown files |
| `ft wiki` | Compile a Karpathy-style interlinked knowledge base |
| `ft ask <question>` | Ask questions against the knowledge base |
| `ft ask <question> --save` | Ask and save the answer as a concept page |
| `ft lint` | Health-check the wiki for broken links and missing pages |
| `ft lint --fix` | Auto-fix fixable wiki issues |

### Agent integration

| Command | Description |
|---------|-------------|
| `ft skill install` | Install `/fieldtheory` skill for Claude Code and Codex |
| `ft skill show` | Print skill content to stdout |
| `ft skill uninstall` | Remove installed skill files |

### Utilities

| Command | Description |
|---------|-------------|
| `ft index` | Rebuild search index from JSONL cache (preserves classifications) |
| `ft fetch-media` | Download media assets (static images only) |
| `ft status` | Show sync status and data location |
| `ft path` | Print data directory path |
### sync
Download and sync bookmarks from X into your local database.

| Flag | Description |
|------|-------------|
| (default) | Incremental sync from your last bookmark |
| `--rebuild` | Full re-crawl of all bookmarks |
| `--gaps` | Backfill missing data (quoted tweets, truncated articles) |
| `--classify` | Classify new bookmarks with LLM after syncing |
| `--api` | Use OAuth v2 API instead of Chrome session |
| `--yes` | Skip confirmation prompts |
| `--max-pages <n>` | Max pages to fetch (default: 500) |
| `--target-adds <n>` | Stop after N new bookmarks |
| `--delay-ms <n>` | Delay between requests in ms (default: 600) |
| `--max-minutes <n>` | Max runtime in minutes (default: 30) |
| `--browser <name>` | Browser to read session from (chrome, chromium, brave, firefox) |
| `--cookies <values...>` | Pass ct0 and auth_token directly (skips browser extraction) |
| `--chrome-user-data-dir <path>` | Chrome-family user-data directory |
| `--chrome-profile-directory <name>` | Chrome-family profile name |
| `--firefox-profile-dir <path>` | Firefox profile directory |

### search
Full-text search across bookmarks with BM25 ranking.

| Flag | Description |
|------|-------------|
| `<query>` | Search query (supports FTS5 syntax: AND, OR, NOT, "exact phrase") |
| `--author <handle>` | Filter by author handle |
| `--after <date>` | Bookmarks posted after this date (YYYY-MM-DD) |
| `--before <date>` | Bookmarks posted before this date (YYYY-MM-DD) |
| `--limit <n>` | Max results (default: 20) |

### list
List bookmarks with filters.

| Flag | Description |
|------|-------------|
| `--query <query>` | Text query (FTS5 syntax) |
| `--author <handle>` | Filter by author handle |
| `--after <date>` | Posted after (YYYY-MM-DD) |
| `--before <date>` | Posted before (YYYY-MM-DD) |
| `--category <cat>` | Filter by category |
| `--domain <dom>` | Filter by domain |
| `--limit <n>` | Max results (default: 30) |
| `--offset <n>` | Offset into results (default: 0) |
| `--json` | JSON output |

### show
Show one bookmark in detail.

| Flag | Description |
|------|-------------|
| `<id>` | Bookmark ID |
| `--json` | JSON output |

### sample
Random sample from a category or domain.

| Flag | Description |
|------|-------------|
| `<category>` | Category or domain to sample from |
| `--limit <n>` | Max results (default: 10) |

### classify
Classify bookmarks by category and domain using LLM.

| Flag | Description |
|------|-------------|
| (default) | Classify categories and domains with LLM |
| `--regex` | Use simple regex classification instead of LLM |
| `--fail-fast` | Stop immediately on first classification failure |

### classify-domains
Classify bookmarks by subject domain only (LLM).

| Flag | Description |
|------|-------------|
| (default) | Classify only missing domains |
| `--all` | Re-classify all bookmarks, not just missing |
| `--fail-fast` | Stop immediately on first classification failure |

### md
Export bookmarks as individual markdown files.

| Flag | Description |
|------|-------------|
| `--force` | Re-export all bookmarks (overwrite existing files) |
| `--format <type>` | Filename format: `rev-iso` (default, e.g. 2024-01-15-id.md) or `legacy` (e.g. id-tweettext.md) |

### wiki
Compile a Karpathy-style interlinked knowledge base.

| Flag | Description |
|------|-------------|
| (default) | Incremental: only pages whose source bookmark count changed |
| `--full` | Recompile all pages (ignore incremental cache) |

### ask
Ask questions against the knowledge base.

| Flag | Description |
|------|-------------|
| `<question>` | Question to ask |
| `--save` | Save the answer as a concept page |
| `--json` | Output JSON instead of text |

### lint
Health-check the wiki for broken links and missing pages.

| Flag | Description |
|------|-------------|
| (default) | Check and report issues |
| `--fix` | Auto-fix fixable issues with targeted recompile |
| `--json` | Output JSON instead of text |

### index
Rebuild search index from JSONL cache.

| Flag | Description |
|------|-------------|
| (default) | Preserve existing classifications |
| `--force` | Drop and rebuild from scratch (loses classifications) |

### fetch-media
Download media assets (static images only).

| Flag | Description |
|------|-------------|
| `--limit <n>` | Max bookmarks to process (default: 100) |
| `--max-bytes <n>` | Per-asset byte limit (default: 50MB) |

### model
View or change the default LLM engine.

| Flag | Description |
|------|-------------|
| (default) | Show current model and available options |
| `<engine>` | Set engine to `claude` or `codex` |

### auth
Set up OAuth for API-based sync (optional).

### status
Show sync status and data location.

### path
Print data directory path.

### categories
Show category distribution.

### domains
Show subject domain distribution.

### stats
Top authors, languages, date range.

### viz
Terminal dashboard with sparklines, categories, and domains.

### skill install
Install `/fieldtheory` skill for Claude Code and Codex.

### skill show
Print skill content to stdout.

### skill uninstall
Remove installed skill files.

## Agent integration

Expand Down
25 changes: 19 additions & 6 deletions src/bookmark-classify-llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { openDb, saveDb } from './db.js';
import { twitterBookmarksIndexPath } from './paths.js';
import type { ResolvedEngine } from './engine.js';
import { invokeEngine } from './engine.js';
import { C, RESET } from './bookmarks-viz.js';

const BATCH_SIZE = 50;

Expand Down Expand Up @@ -110,9 +111,9 @@ export interface LlmClassifyResult {
}

export async function classifyWithLlm(
options: { engine: ResolvedEngine; onBatch?: (done: number, total: number) => void },
options: { engine: ResolvedEngine; onBatch?: (done: number, total: number) => void; failFast?: boolean },
): Promise<LlmClassifyResult> {
const { engine } = options;
const { engine, failFast = false } = options;

const dbPath = twitterBookmarksIndexPath();
const db = await openDb(dbPath);
Expand Down Expand Up @@ -170,7 +171,13 @@ export async function classifyWithLlm(
saveDb(db, dbPath);
} catch (err) {
failed += batch.length;
process.stderr.write(` Batch ${batchCount} failed: ${(err as Error).message}\n`);
const errMsg = (err as Error).message;
const firstLine = errMsg.split('\n')[0];
console.error(`\n${C.gold} Error: ${firstLine}${RESET}`);
if (failFast) {
console.error(`${C.gold} fail-fast enabled — stopping classification${RESET}`);
break;
}
}
}

Expand Down Expand Up @@ -223,9 +230,9 @@ ${items}`;
}

export async function classifyDomainsWithLlm(
options: { engine: ResolvedEngine; all?: boolean; onBatch?: (done: number, total: number) => void },
options: { engine: ResolvedEngine; all?: boolean; onBatch?: (done: number, total: number) => void; failFast?: boolean },
): Promise<LlmClassifyResult> {
const { engine } = options;
const { engine, failFast = false } = options;

const dbPath = twitterBookmarksIndexPath();
const db = await openDb(dbPath);
Expand Down Expand Up @@ -285,7 +292,13 @@ export async function classifyDomainsWithLlm(
saveDb(db, dbPath);
} catch (err) {
failed += batch.length;
process.stderr.write(` Batch ${batchCount} failed: ${(err as Error).message}\n`);
const errMsg = (err as Error).message;
const firstLine = errMsg.split('\n')[0];
console.error(`\n${C.gold} Error: ${firstLine}${RESET}`);
if (failFast) {
console.error(`${C.gold} fail-fast enabled — stopping classification${RESET}`);
break;
}
}
}

Expand Down
8 changes: 4 additions & 4 deletions src/bookmarks-viz.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,15 @@ import { twitterBookmarksIndexPath } from './paths.js';

// ── ANSI helpers ─────────────────────────────────────────────────────────────

const ESC = '\x1b[';
const RESET = `${ESC}0m`;
export const ESC = '\x1b[';
export const RESET = `${ESC}0m`;
const BOLD = `${ESC}1m`;
const DIM = `${ESC}2m`;

const rgb = (r: number, g: number, b: number) => `${ESC}38;2;${r};${g};${b}m`;

// Palette — muted, tasteful
const C = {
export const C = {
title: rgb(199, 146, 234), // soft lavender
accent: rgb(130, 170, 255), // periwinkle
warm: rgb(255, 180, 120), // peach
Expand Down Expand Up @@ -93,7 +93,7 @@ function boxBottom(width: number): string {
return C.dim + '╰' + '─'.repeat(width - 2) + '╯' + RESET;
}
function boxRow(content: string, width: number): string {
const stripped = content.replace(/\x1b\[[^m]*m/g, '');
const stripped = content.replace(new RegExp(ESC + '\\[([^m]*m)', 'g'), '');
Comment thread
MrDwarf7 marked this conversation as resolved.
Outdated
const pad = Math.max(0, width - 4 - stripped.length);
return C.dim + '│ ' + RESET + content + ' '.repeat(pad) + C.dim + ' │' + RESET;
}
Expand Down
Loading