This tool finds candidates to quote on Kaspa twitter, and suggests the text following to the quotation. You can edit the prompt that "judges" tweets in src/prompt.ts.
- Node.js 18+
- An OpenAI API key with access to GPT-5.1
- (Optional) X API credentials for direct tweet fetching
npm install
cp .env.example .envEdit .env with your credentials:
# Required
OPENAI_API_KEY=sk-your-key
# Optional: X API OAuth 1.0a credentials (for --source x-api)
X_API_KEY=your-api-key
X_API_SECRET=your-api-secret
X_ACCESS_TOKEN=your-access-token
X_ACCESS_TOKEN_SECRET=your-access-token-secretTo generate X API access tokens, run the OAuth bootstrap script:
npx tsx scripts/x-auth-bootstrap.tsnpm run devnpm run build
npm startThe app fetches tweets from kaspa.news by default, or directly from X API if configured. Use --source to switch sources, --limit to cap how many tweets are processed, or --tweet-id to process specific tweets.
# process all tweets from kaspa.news (default)
npm run dev
# fetch from X API instead
npm run dev -- --source x-api
# fetch from both sources (deduplicated)
npm run dev -- --source both
# process only the latest 50 tweets
npm run dev -- --limit 50
# process a specific tweet by id (searches kaspa.news by default)
npm run dev -- --tweet-id 1992657727361868193
# process multiple tweets by id (comma-separated)
npm run dev -- --tweet-id 1992657727361868193,1992726968492786130,1992637633194016807
# fetch tweet directly from X API by id
npm run dev -- --tweet-id 1992657727361868193 --source x-api
# try kaspa.news first, fallback to X API if not found
npm run dev -- --tweet-id 1992657727361868193 --source bothNote: --limit only applies to feed fetching, not --tweet-id. When using --tweet-id:
--source kaspa-news(default): searches kaspa.news only, errors if not found--source x-api: fetches directly from X API by ID--source both: tries kaspa.news first, falls back to X API for any missing IDs
The system prompt lives in src/prompt.ts. Edit that file if you need a different tone or instruction set. The script prints the model's answer to stdout and falls back to dumping the raw response if no text output is available.
A simple SQLite migration for storing tweet decisions is included in migrations/001_create_tweets_table.sql. Run it through npm (powered by the bundled better-sqlite3 driver—no external sqlite binary needed):
# optional: export SQLITE_DB_PATH=/absolute/path/to/db.sqlite
npm run migrate
# or supply the db path as a positional argument
npm run migrate -- ./data/moderation.dbBy default the script writes to data/app.db. All .sql files inside migrations/ are executed in order, creating the tweets table with id, text, quote, url, approved, createdAt, and humanDecision columns (id remains unique via the primary key and index).
To completely reset the database (deletes all tweets and recreates the schema):
npm run reset-dbEnsure there are no other processes running.
Review stored tweets and adjust the human decision field via the built-in web UI:
# optional overrides
export SQLITE_DB_PATH=./data/app.db
export PORT=4000
npm run webThen visit http://localhost:4000. Use the filter controls to narrow results by the model's approved status or by the humanDecision column (Approved, Rejected, or Unset). Each row exposes a dropdown that lets you set the human decision to APPROVED or REJECTED; changes persist immediately to the SQLite database.
The moderation UI consumes the /api/tweets endpoint, which you can also call directly. Query parameters:
approved:true,false, or omit for all results.humanDecision:APPROVED,REJECTED,UNSET, or omit.page(default1): 1-based page number.pageSize(default20, max100): number of rows per page.password: required ifADMIN_PASSWORDis set and you need access to the protectedquotetext.
The response includes the requested rows plus pagination metadata:
{
"data": [
{
"id": "...",
"text": "...",
"quote": "...",
"url": "...",
"approved": true,
"createdAt": "2024-01-01T00:00:00Z",
"humanDecision": "APPROVED"
}
],
"pagination": {
"page": 1,
"pageSize": 20,
"total": 123,
"totalPages": 7,
"hasNextPage": true,
"hasPreviousPage": false
}
}Unauthorized callers still receive the metadata but the quote field is blank when the model has not yet approved the tweet.
You can also run the dashboard in Docker (uses the same SQLite file path inside the container unless overridden):
docker build -t kaspa-handsfree .
docker run -p 4000:4000 \
-e OPENAI_API_KEY="$OPENAI_API_KEY" \
-e SQLITE_DB_PATH=/data/app.db \
-v "$(pwd)/data:/data" \
kaspa-handsfreeThe entrypoint automatically runs database migrations before starting dist/server.js. Customize PORT, SQLITE_DB_PATH, or mount an external SQLite file as needed.
For a ready-to-run setup with an automatic named volume:
# launch the web dashboard
OPENAI_API_KEY=sk-your-key docker compose up --build web
# run the CLI processor
OPENAI_API_KEY=sk-your-key docker compose run processorThe web service exposes http://localhost:4000 and stays running, while the processor service executes the GPT moderation loop defined in dist/index.js. Both share the kaspa-data volume, so database changes are visible between them.