dexterm-cli 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/dist/packages/agents/src/audit.js +122 -0
- package/dist/packages/agents/src/bus.js +379 -0
- package/dist/packages/agents/src/executor.js +177 -0
- package/dist/packages/agents/src/gate.js +176 -0
- package/dist/packages/agents/src/identity.js +179 -0
- package/dist/packages/agents/src/index.js +15 -0
- package/dist/packages/agents/src/proposals.js +153 -0
- package/dist/packages/agents/src/schema.js +87 -0
- package/dist/packages/agents/src/sdk.js +140 -0
- package/dist/packages/agents/src/tiers.js +114 -0
- package/dist/packages/agents/src/tools.js +69 -0
- package/dist/packages/agents/src/ws.js +158 -0
- package/dist/packages/cli/assets/claude/analyst-system.md +76 -0
- package/dist/packages/cli/assets/claude/plugin/.claude-plugin/plugin.json +5 -0
- package/dist/packages/cli/assets/claude/plugin/skills/REGISTRY.json +16 -0
- package/dist/packages/cli/assets/claude/plugin/skills/larp-check/SKILL.md +67 -0
- package/dist/packages/cli/assets/claude/plugin/skills/rug-check/SKILL.md +64 -0
- package/dist/packages/cli/assets/claude/plugin/skills/token-dossier/SKILL.md +63 -0
- package/dist/packages/cli/assets/claude/plugin/skills/trench-scan/SKILL.md +44 -0
- package/dist/packages/cli/assets/claude/schemas/larp.schema.json +117 -0
- package/dist/packages/cli/assets/claude/schemas/trench-scan.schema.json +71 -0
- package/dist/packages/cli/assets/claude/schemas/verdict.schema.json +115 -0
- package/dist/packages/cli/bin/dexterm.mjs +11 -0
- package/dist/packages/cli/src/agent-cmd.js +303 -0
- package/dist/packages/cli/src/agent-roster.js +26 -0
- package/dist/packages/cli/src/analyst/backend.js +526 -0
- package/dist/packages/cli/src/analyst/results.js +38 -0
- package/dist/packages/cli/src/analyst/session.js +319 -0
- package/dist/packages/cli/src/audit.js +51 -0
- package/dist/packages/cli/src/backtest-cmd.js +149 -0
- package/dist/packages/cli/src/boot-state.js +62 -0
- package/dist/packages/cli/src/custom.js +261 -0
- package/dist/packages/cli/src/display.js +191 -0
- package/dist/packages/cli/src/doctor.js +361 -0
- package/dist/packages/cli/src/feed.js +634 -0
- package/dist/packages/cli/src/fixtures.js +275 -0
- package/dist/packages/cli/src/follow-cmd.js +221 -0
- package/dist/packages/cli/src/images-config.js +59 -0
- package/dist/packages/cli/src/index.js +31 -0
- package/dist/packages/cli/src/kb-skill-cmd.js +240 -0
- package/dist/packages/cli/src/larp-record.js +21 -0
- package/dist/packages/cli/src/live/copy.js +80 -0
- package/dist/packages/cli/src/live/flow.js +233 -0
- package/dist/packages/cli/src/live/index.js +509 -0
- package/dist/packages/cli/src/live/intel.js +227 -0
- package/dist/packages/cli/src/live/newpairs.js +334 -0
- package/dist/packages/cli/src/live/screens/newpairs-live.js +219 -0
- package/dist/packages/cli/src/live/screens/token-live.js +459 -0
- package/dist/packages/cli/src/live/screens/watchlist-live.js +149 -0
- package/dist/packages/cli/src/live/token.js +585 -0
- package/dist/packages/cli/src/live/trade.js +929 -0
- package/dist/packages/cli/src/live/watchlist.js +178 -0
- package/dist/packages/cli/src/main.js +5583 -0
- package/dist/packages/cli/src/narrative-cmd.js +76 -0
- package/dist/packages/cli/src/pack-cmd.js +323 -0
- package/dist/packages/cli/src/plan-cmd.js +278 -0
- package/dist/packages/cli/src/popup.js +810 -0
- package/dist/packages/cli/src/rules-cmd.js +131 -0
- package/dist/packages/cli/src/rules.js +339 -0
- package/dist/packages/cli/src/scan/command.js +57 -0
- package/dist/packages/cli/src/scan/engine.js +419 -0
- package/dist/packages/cli/src/scan/observe.js +64 -0
- package/dist/packages/cli/src/scan/ui.js +98 -0
- package/dist/packages/cli/src/screener/filter.js +241 -0
- package/dist/packages/cli/src/screener/presets.js +80 -0
- package/dist/packages/cli/src/screener/rows.js +146 -0
- package/dist/packages/cli/src/screener/rug.js +50 -0
- package/dist/packages/cli/src/screener/sort.js +52 -0
- package/dist/packages/cli/src/screener/view.js +183 -0
- package/dist/packages/cli/src/screens/analyst-session.js +343 -0
- package/dist/packages/cli/src/screens/analyst.js +326 -0
- package/dist/packages/cli/src/screens/boot.js +74 -0
- package/dist/packages/cli/src/screens/bubbles.js +255 -0
- package/dist/packages/cli/src/screens/coin.js +33 -0
- package/dist/packages/cli/src/screens/follows.js +201 -0
- package/dist/packages/cli/src/screens/kol.js +90 -0
- package/dist/packages/cli/src/screens/narrative.js +90 -0
- package/dist/packages/cli/src/screens/newpairs.js +150 -0
- package/dist/packages/cli/src/screens/overlay.js +257 -0
- package/dist/packages/cli/src/screens/plans.js +126 -0
- package/dist/packages/cli/src/screens/positions.js +176 -0
- package/dist/packages/cli/src/screens/rules.js +170 -0
- package/dist/packages/cli/src/screens/screener.js +459 -0
- package/dist/packages/cli/src/screens/social-panel.js +107 -0
- package/dist/packages/cli/src/screens/ticket.js +342 -0
- package/dist/packages/cli/src/screens/token.js +312 -0
- package/dist/packages/cli/src/screens/trending.js +181 -0
- package/dist/packages/cli/src/screens/watchlist.js +104 -0
- package/dist/packages/cli/src/social/demo.js +96 -0
- package/dist/packages/cli/src/social/view.js +250 -0
- package/dist/packages/cli/src/state.js +463 -0
- package/dist/packages/cli/src/theme-cmd.js +126 -0
- package/dist/packages/cli/src/trade.js +319 -0
- package/dist/packages/cli/src/voice-hud.js +42 -0
- package/dist/packages/cli/src/wallet-cmd.js +440 -0
- package/dist/packages/cli/src/wallet-command.js +31 -0
- package/dist/packages/cli/src/widgets.js +208 -0
- package/dist/packages/cli/src/workspace-cmd.js +68 -0
- package/dist/packages/cli/src/x-cmd.js +154 -0
- package/dist/packages/custom/src/config.js +213 -0
- package/dist/packages/custom/src/index.js +10 -0
- package/dist/packages/custom/src/keymap.js +107 -0
- package/dist/packages/custom/src/layout.js +235 -0
- package/dist/packages/custom/src/packs.js +633 -0
- package/dist/packages/custom/src/rules.js +704 -0
- package/dist/packages/custom/src/schema.js +515 -0
- package/dist/packages/custom/src/themes.js +176 -0
- package/dist/packages/custom/src/widget-html.js +222 -0
- package/dist/packages/custom/src/workspace.js +141 -0
- package/dist/packages/data/src/analyst-actions.js +133 -0
- package/dist/packages/data/src/budget.js +88 -0
- package/dist/packages/data/src/cache.js +142 -0
- package/dist/packages/data/src/candles.js +168 -0
- package/dist/packages/data/src/clock.js +64 -0
- package/dist/packages/data/src/credits.js +137 -0
- package/dist/packages/data/src/detail.js +97 -0
- package/dist/packages/data/src/fast-lane.js +321 -0
- package/dist/packages/data/src/fixtures.js +135 -0
- package/dist/packages/data/src/health.js +245 -0
- package/dist/packages/data/src/http.js +277 -0
- package/dist/packages/data/src/images/cache.js +141 -0
- package/dist/packages/data/src/images/decode-worker.js +28 -0
- package/dist/packages/data/src/images/decode.js +81 -0
- package/dist/packages/data/src/images/fetch.js +162 -0
- package/dist/packages/data/src/images/gif.js +214 -0
- package/dist/packages/data/src/images/index.js +10 -0
- package/dist/packages/data/src/images/jpeg.js +544 -0
- package/dist/packages/data/src/images/png.js +310 -0
- package/dist/packages/data/src/images/service.js +334 -0
- package/dist/packages/data/src/images/types.js +27 -0
- package/dist/packages/data/src/index.js +208 -0
- package/dist/packages/data/src/intel/helius-history.js +59 -0
- package/dist/packages/data/src/intel/holder-class.js +125 -0
- package/dist/packages/data/src/intel/rules.js +421 -0
- package/dist/packages/data/src/intel/scan.js +170 -0
- package/dist/packages/data/src/larp/evidence.js +288 -0
- package/dist/packages/data/src/larp/links.js +137 -0
- package/dist/packages/data/src/larp/robots.js +90 -0
- package/dist/packages/data/src/larp/safe-fetch.js +389 -0
- package/dist/packages/data/src/limiter.js +341 -0
- package/dist/packages/data/src/model.js +12 -0
- package/dist/packages/data/src/paper.js +331 -0
- package/dist/packages/data/src/rpc-config.js +126 -0
- package/dist/packages/data/src/rpc-pool.js +166 -0
- package/dist/packages/data/src/social/chain.js +83 -0
- package/dist/packages/data/src/social/dedupe.js +205 -0
- package/dist/packages/data/src/social/feed.js +462 -0
- package/dist/packages/data/src/social/firewall.js +110 -0
- package/dist/packages/data/src/social/index.js +11 -0
- package/dist/packages/data/src/social/kol.js +269 -0
- package/dist/packages/data/src/social/model.js +11 -0
- package/dist/packages/data/src/social/rank.js +184 -0
- package/dist/packages/data/src/social/rss.js +113 -0
- package/dist/packages/data/src/social/summary.js +104 -0
- package/dist/packages/data/src/social/venues.js +186 -0
- package/dist/packages/data/src/sources/dexscreener.js +170 -0
- package/dist/packages/data/src/sources/geckoterminal.js +133 -0
- package/dist/packages/data/src/sources/goplus.js +161 -0
- package/dist/packages/data/src/sources/jupiter-swap.js +236 -0
- package/dist/packages/data/src/sources/jupiter.js +198 -0
- package/dist/packages/data/src/sources/pda.js +113 -0
- package/dist/packages/data/src/sources/pump-curve.js +58 -0
- package/dist/packages/data/src/sources/pumpportal.js +247 -0
- package/dist/packages/data/src/sources/rugcheck.js +71 -0
- package/dist/packages/data/src/sources/solana-rpc.js +299 -0
- package/dist/packages/data/src/sources/solana-ws.js +287 -0
- package/dist/packages/data/src/store.js +289 -0
- package/dist/packages/data/src/trade/bridge.js +464 -0
- package/dist/packages/data/src/trade/caps.js +323 -0
- package/dist/packages/data/src/trade/copy/auto.js +385 -0
- package/dist/packages/data/src/trade/copy/follows.js +103 -0
- package/dist/packages/data/src/trade/copy/index.js +9 -0
- package/dist/packages/data/src/trade/copy/mirror.js +91 -0
- package/dist/packages/data/src/trade/copy/stats.js +120 -0
- package/dist/packages/data/src/trade/copy/tracker.js +158 -0
- package/dist/packages/data/src/trade/copy/trades.js +87 -0
- package/dist/packages/data/src/trade/copy/types.js +6 -0
- package/dist/packages/data/src/trade/executor.js +933 -0
- package/dist/packages/data/src/trade/kill-switch.js +47 -0
- package/dist/packages/data/src/trade/link-store.js +52 -0
- package/dist/packages/data/src/trade/plans/confirm.js +157 -0
- package/dist/packages/data/src/trade/plans/engine.js +784 -0
- package/dist/packages/data/src/trade/plans/executor-live.js +220 -0
- package/dist/packages/data/src/trade/plans/executor-paper.js +44 -0
- package/dist/packages/data/src/trade/plans/executor.js +11 -0
- package/dist/packages/data/src/trade/plans/index.js +12 -0
- package/dist/packages/data/src/trade/plans/journal.js +77 -0
- package/dist/packages/data/src/trade/plans/mandate.js +129 -0
- package/dist/packages/data/src/trade/plans/plan.js +179 -0
- package/dist/packages/data/src/trade/plans/service.js +168 -0
- package/dist/packages/data/src/trade/plans/types.js +6 -0
- package/dist/packages/data/src/trade/rpc-exec.js +229 -0
- package/dist/packages/data/src/trade/signer/client.js +197 -0
- package/dist/packages/data/src/trade/signer/fast-wallet.js +154 -0
- package/dist/packages/data/src/trade/signer/host.js +97 -0
- package/dist/packages/data/src/trade/signer/main.js +121 -0
- package/dist/packages/data/src/trade/signer/probe-builder.js +85 -0
- package/dist/packages/data/src/trade/signer/protocol.js +92 -0
- package/dist/packages/data/src/trade/signer/service.js +400 -0
- package/dist/packages/data/src/trade/signer/sol-usd-feed.js +98 -0
- package/dist/packages/data/src/trade/sol-usd.js +86 -0
- package/dist/packages/data/src/trade/swap-build.js +108 -0
- package/dist/packages/data/src/trade/token-delta.js +201 -0
- package/dist/packages/data/src/trade/tx-decode.js +632 -0
- package/dist/packages/data/src/util.js +24 -0
- package/dist/packages/data/src/watchlist.js +176 -0
- package/dist/packages/data/src/x/api.js +256 -0
- package/dist/packages/data/src/x/budget.js +66 -0
- package/dist/packages/data/src/x/config.js +49 -0
- package/dist/packages/data/src/x/index.js +10 -0
- package/dist/packages/data/src/x/mock-server.js +217 -0
- package/dist/packages/data/src/x/oauth.js +242 -0
- package/dist/packages/data/src/x/pkce.js +21 -0
- package/dist/packages/data/src/x/source.js +285 -0
- package/dist/packages/data/src/x/token-store.js +134 -0
- package/dist/packages/data/src/x/transport.js +71 -0
- package/dist/packages/data/vendor/solana-web3/LICENSE +20 -0
- package/dist/packages/data/vendor/solana-web3/NOTICE.md +13 -0
- package/dist/packages/data/vendor/solana-web3/index.iife.min.js +20 -0
- package/dist/packages/mcp/assets/cases.json +1194 -0
- package/dist/packages/mcp/assets/kb/entries/cabal-and-coordinated-launches.md +28 -0
- package/dist/packages/mcp/assets/kb/entries/common-rug-patterns.md +40 -0
- package/dist/packages/mcp/assets/kb/entries/dbc-mechanics.md +30 -0
- package/dist/packages/mcp/assets/kb/entries/dev-wallet-behaviour.md +33 -0
- package/dist/packages/mcp/assets/kb/entries/glossary.md +35 -0
- package/dist/packages/mcp/assets/kb/entries/holder-concentration.md +30 -0
- package/dist/packages/mcp/assets/kb/entries/jito-bundles.md +29 -0
- package/dist/packages/mcp/assets/kb/entries/launchlab-curve.md +31 -0
- package/dist/packages/mcp/assets/kb/entries/lp-burn-vs-lock.md +31 -0
- package/dist/packages/mcp/assets/kb/entries/metadata-and-copycats.md +29 -0
- package/dist/packages/mcp/assets/kb/entries/mint-freeze-authority.md +31 -0
- package/dist/packages/mcp/assets/kb/entries/narrative-lifecycle.md +32 -0
- package/dist/packages/mcp/assets/kb/entries/pump-bonding-curve.md +33 -0
- package/dist/packages/mcp/assets/kb/entries/pumpswap-migration.md +29 -0
- package/dist/packages/mcp/assets/kb/entries/sniping-and-snipers.md +29 -0
- package/dist/packages/mcp/assets/kb/entries/token2022-extensions-risk.md +32 -0
- package/dist/packages/mcp/assets/kb/entries/wash-trading-fake-volume.md +33 -0
- package/dist/packages/mcp/assets/skills/rug-check.md +52 -0
- package/dist/packages/mcp/assets/skills/token-dossier.md +57 -0
- package/dist/packages/mcp/assets/skills/trench-scan.md +40 -0
- package/dist/packages/mcp/bin/dexterm-mcp.mjs +16 -0
- package/dist/packages/mcp/src/dossier.js +78 -0
- package/dist/packages/mcp/src/firewall.js +21 -0
- package/dist/packages/mcp/src/index.js +22 -0
- package/dist/packages/mcp/src/server.js +849 -0
- package/dist/packages/mcp/src/user-content.js +434 -0
- package/dist/packages/packs/src/backtest.js +183 -0
- package/dist/packages/packs/src/digest.js +95 -0
- package/dist/packages/packs/src/firewall.js +57 -0
- package/dist/packages/packs/src/index.js +19 -0
- package/dist/packages/packs/src/larp-eval.js +100 -0
- package/dist/packages/packs/src/narrative.js +309 -0
- package/dist/packages/packs/src/reason.js +135 -0
- package/dist/packages/packs/src/rug-model.js +137 -0
- package/dist/packages/packs/src/schema.js +54 -0
- package/dist/packages/packs/src/token-dossier.js +174 -0
- package/dist/packages/packs/src/verdicts.js +17 -0
- package/dist/packages/packs/src/wallet-dossier.js +105 -0
- package/dist/packages/popup/src/config.js +71 -0
- package/dist/packages/popup/src/dock.js +271 -0
- package/dist/packages/popup/src/host.js +210 -0
- package/dist/packages/popup/src/index.js +12 -0
- package/dist/packages/popup/src/loopback.js +53 -0
- package/dist/packages/popup/src/open.js +183 -0
- package/dist/packages/popup/src/pages/bubbles-lib.js +160 -0
- package/dist/packages/popup/src/pages/bubbles.css +23 -0
- package/dist/packages/popup/src/pages/bubbles.html +36 -0
- package/dist/packages/popup/src/pages/bubbles.js +214 -0
- package/dist/packages/popup/src/pages/chart-lib.js +262 -0
- package/dist/packages/popup/src/pages/chart.css +50 -0
- package/dist/packages/popup/src/pages/chart.html +56 -0
- package/dist/packages/popup/src/pages/chart.js +554 -0
- package/dist/packages/popup/src/pages/common.css +34 -0
- package/dist/packages/popup/src/pages/common.js +187 -0
- package/dist/packages/popup/src/pages/feeds.html +20 -0
- package/dist/packages/popup/src/pages/feeds.js +77 -0
- package/dist/packages/popup/src/pages/narrative.html +20 -0
- package/dist/packages/popup/src/pages/narrative.js +61 -0
- package/dist/packages/popup/src/pages/plans.css +30 -0
- package/dist/packages/popup/src/pages/plans.html +26 -0
- package/dist/packages/popup/src/pages/plans.js +82 -0
- package/dist/packages/popup/src/pages/roster.css +41 -0
- package/dist/packages/popup/src/pages/roster.html +25 -0
- package/dist/packages/popup/src/pages/roster.js +75 -0
- package/dist/packages/popup/src/pages/token.html +20 -0
- package/dist/packages/popup/src/pages/token.js +112 -0
- package/dist/packages/popup/src/pages/widget-sdk.js +224 -0
- package/dist/packages/popup/src/pages/widget.css +5 -0
- package/dist/packages/popup/src/pages/widget.html +21 -0
- package/dist/packages/popup/src/pages/widget.js +85 -0
- package/dist/packages/popup/src/protocol.js +31 -0
- package/dist/packages/popup/src/selftest.js +156 -0
- package/dist/packages/popup/src/server.js +582 -0
- package/dist/packages/popup/src/sinkproxy.js +167 -0
- package/dist/packages/popup/src/widget-html.js +231 -0
- package/dist/packages/popup/vendor/lightweight-charts/LICENSE +201 -0
- package/dist/packages/popup/vendor/lightweight-charts/NOTICE +2 -0
- package/dist/packages/popup/vendor/lightweight-charts/README.md +30 -0
- package/dist/packages/popup/vendor/lightweight-charts/VERSION +1 -0
- package/dist/packages/popup/vendor/lightweight-charts/lightweight-charts.standalone.production.js +7 -0
- package/dist/packages/recorder/bin/dexterm-recorder.mjs +11 -0
- package/dist/packages/recorder/src/cli.js +113 -0
- package/dist/packages/recorder/src/export.js +71 -0
- package/dist/packages/recorder/src/index.js +20 -0
- package/dist/packages/recorder/src/labels.js +151 -0
- package/dist/packages/recorder/src/open.js +29 -0
- package/dist/packages/recorder/src/recorder.js +553 -0
- package/dist/packages/recorder/src/schedule.js +43 -0
- package/dist/packages/recorder/src/schema.js +135 -0
- package/dist/packages/recorder/src/snapshot.js +191 -0
- package/dist/packages/recorder/src/sweep.js +52 -0
- package/dist/packages/recorder/src/verdicts.js +52 -0
- package/dist/packages/tui/src/bubbles.js +415 -0
- package/dist/packages/tui/src/canvas.js +370 -0
- package/dist/packages/tui/src/chrome.js +85 -0
- package/dist/packages/tui/src/color.js +133 -0
- package/dist/packages/tui/src/flush.js +62 -0
- package/dist/packages/tui/src/fmt.js +188 -0
- package/dist/packages/tui/src/fx-trade.js +278 -0
- package/dist/packages/tui/src/fx.js +527 -0
- package/dist/packages/tui/src/images/blocks.js +141 -0
- package/dist/packages/tui/src/images/coin.js +22 -0
- package/dist/packages/tui/src/images/detect.js +140 -0
- package/dist/packages/tui/src/images/index.js +7 -0
- package/dist/packages/tui/src/images/layer.js +146 -0
- package/dist/packages/tui/src/images/pixels.js +157 -0
- package/dist/packages/tui/src/images/protocols.js +87 -0
- package/dist/packages/tui/src/index.js +15 -0
- package/dist/packages/tui/src/keymap.js +355 -0
- package/dist/packages/tui/src/layout.js +11 -0
- package/dist/packages/tui/src/sound.js +177 -0
- package/dist/packages/tui/src/theme.js +182 -0
- package/dist/packages/tui/src/widgets.js +598 -0
- package/dist/packages/voice/src/controller.js +395 -0
- package/dist/packages/voice/src/doctor.js +63 -0
- package/dist/packages/voice/src/engines.js +44 -0
- package/dist/packages/voice/src/index.js +16 -0
- package/dist/packages/voice/src/router.js +502 -0
- package/dist/packages/voice/src/stt/apple-helper.swift.js +103 -0
- package/dist/packages/voice/src/stt/apple.js +179 -0
- package/dist/packages/voice/src/stt/fake.js +34 -0
- package/dist/packages/voice/src/stt/whisper.js +102 -0
- package/dist/packages/voice/src/summary.js +44 -0
- package/dist/packages/voice/src/tts/fake.js +30 -0
- package/dist/packages/voice/src/tts/say.js +96 -0
- package/dist/packages/voice/src/types.js +3 -0
- package/package.json +42 -4
- package/README.md +0 -3
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// Persisted watchlist + alert proposals (task-1298, SPEC §4.5). `node:sqlite`, same file as the cache
|
|
2
|
+
// (default XDG path) so one `--db` controls everything. Tables:
|
|
3
|
+
// watchlist(mint PK, symbol, name, launchpad, added_at, entry_price, note)
|
|
4
|
+
// alert_rules(id, mint, kind, op, value, status proposed|armed|fired|dismissed, created_at, note)
|
|
5
|
+
// alert_events(id, t, mint, kind, level, text)
|
|
6
|
+
// Rules: proposals are only ever *proposed* by code (or later by the Analyst); a user keypress arms
|
|
7
|
+
// them. Firing a rule writes an event and marks the rule `fired` (one shot). Nothing here trades.
|
|
8
|
+
import { mkdirSync } from 'node:fs';
|
|
9
|
+
import { dirname } from 'node:path';
|
|
10
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
11
|
+
import { systemClock } from "./clock.js";
|
|
12
|
+
export class WatchlistStore {
|
|
13
|
+
db;
|
|
14
|
+
clock;
|
|
15
|
+
constructor(opts = {}) {
|
|
16
|
+
const path = opts.path ?? ':memory:';
|
|
17
|
+
if (path !== ':memory:')
|
|
18
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
19
|
+
this.db = new DatabaseSync(path);
|
|
20
|
+
this.clock = opts.clock ?? systemClock;
|
|
21
|
+
this.db.exec(`
|
|
22
|
+
PRAGMA journal_mode = WAL;
|
|
23
|
+
CREATE TABLE IF NOT EXISTS watchlist (
|
|
24
|
+
mint TEXT PRIMARY KEY, symbol TEXT NOT NULL, name TEXT NOT NULL, launchpad TEXT NOT NULL,
|
|
25
|
+
added_at INTEGER NOT NULL, entry_price REAL, note TEXT NOT NULL DEFAULT ''
|
|
26
|
+
) WITHOUT ROWID;
|
|
27
|
+
CREATE TABLE IF NOT EXISTS alert_rules (
|
|
28
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT, mint TEXT NOT NULL, kind TEXT NOT NULL, op TEXT NOT NULL, value REAL NOT NULL,
|
|
29
|
+
status TEXT NOT NULL, created_at INTEGER NOT NULL, note TEXT NOT NULL DEFAULT ''
|
|
30
|
+
);
|
|
31
|
+
CREATE INDEX IF NOT EXISTS alert_rules_mint ON alert_rules(mint, status);
|
|
32
|
+
CREATE TABLE IF NOT EXISTS alert_events (
|
|
33
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT, t INTEGER NOT NULL, mint TEXT, kind TEXT NOT NULL, level TEXT NOT NULL, text TEXT NOT NULL
|
|
34
|
+
);
|
|
35
|
+
`);
|
|
36
|
+
}
|
|
37
|
+
// ---------------------------------------------------------------- watchlist
|
|
38
|
+
list() {
|
|
39
|
+
const rows = this.db.prepare('SELECT mint, symbol, name, launchpad, added_at, entry_price, note FROM watchlist ORDER BY added_at DESC').all();
|
|
40
|
+
return rows.map(rowToEntry);
|
|
41
|
+
}
|
|
42
|
+
get(mint) {
|
|
43
|
+
const row = this.db.prepare('SELECT mint, symbol, name, launchpad, added_at, entry_price, note FROM watchlist WHERE mint = ?').get(mint);
|
|
44
|
+
return row ? rowToEntry(row) : undefined;
|
|
45
|
+
}
|
|
46
|
+
has(mint) {
|
|
47
|
+
return this.get(mint) !== undefined;
|
|
48
|
+
}
|
|
49
|
+
/** Insert (or update identity fields of) a watched token. Returns true when it was new. */
|
|
50
|
+
add(e) {
|
|
51
|
+
const existed = this.has(e.mint);
|
|
52
|
+
if (existed) {
|
|
53
|
+
this.db.prepare('UPDATE watchlist SET symbol = ?, name = ?, launchpad = ? WHERE mint = ?').run(e.symbol, e.name, e.launchpad, e.mint);
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
this.db
|
|
57
|
+
.prepare('INSERT INTO watchlist (mint, symbol, name, launchpad, added_at, entry_price, note) VALUES (?, ?, ?, ?, ?, ?, ?)')
|
|
58
|
+
.run(e.mint, e.symbol, e.name, e.launchpad, this.clock.now(), e.entryPrice, e.note ?? '');
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
remove(mint) {
|
|
62
|
+
const r = this.db.prepare('DELETE FROM watchlist WHERE mint = ?').run(mint);
|
|
63
|
+
this.db.prepare("UPDATE alert_rules SET status = 'dismissed' WHERE mint = ? AND status IN ('proposed', 'armed')").run(mint);
|
|
64
|
+
return Number(r.changes) > 0;
|
|
65
|
+
}
|
|
66
|
+
setNote(mint, note) {
|
|
67
|
+
this.db.prepare('UPDATE watchlist SET note = ? WHERE mint = ?').run(note, mint);
|
|
68
|
+
}
|
|
69
|
+
setEntry(mint, entryPrice) {
|
|
70
|
+
this.db.prepare('UPDATE watchlist SET entry_price = ? WHERE mint = ?').run(entryPrice, mint);
|
|
71
|
+
}
|
|
72
|
+
// ---------------------------------------------------------------- alert rules
|
|
73
|
+
/** Record a proposal. Nothing fires until confirm() arms it. */
|
|
74
|
+
propose(r) {
|
|
75
|
+
const res = this.db
|
|
76
|
+
.prepare("INSERT INTO alert_rules (mint, kind, op, value, status, created_at, note) VALUES (?, ?, ?, ?, 'proposed', ?, ?)")
|
|
77
|
+
.run(r.mint, r.kind, r.op, r.value, this.clock.now(), r.note ?? '');
|
|
78
|
+
return this.rule(Number(res.lastInsertRowid));
|
|
79
|
+
}
|
|
80
|
+
confirm(id) {
|
|
81
|
+
this.db.prepare("UPDATE alert_rules SET status = 'armed' WHERE id = ? AND status = 'proposed'").run(id);
|
|
82
|
+
return this.rule(id);
|
|
83
|
+
}
|
|
84
|
+
dismiss(id) {
|
|
85
|
+
this.db.prepare("UPDATE alert_rules SET status = 'dismissed' WHERE id = ? AND status IN ('proposed', 'armed')").run(id);
|
|
86
|
+
return this.rule(id);
|
|
87
|
+
}
|
|
88
|
+
rule(id) {
|
|
89
|
+
const row = this.db.prepare('SELECT id, mint, kind, op, value, status, created_at, note FROM alert_rules WHERE id = ?').get(id);
|
|
90
|
+
return row ? rowToRule(row) : undefined;
|
|
91
|
+
}
|
|
92
|
+
rules(status, mint) {
|
|
93
|
+
const statuses = status === undefined ? null : Array.isArray(status) ? status : [status];
|
|
94
|
+
const where = [];
|
|
95
|
+
const args = [];
|
|
96
|
+
if (statuses) {
|
|
97
|
+
where.push(`status IN (${statuses.map(() => '?').join(',')})`);
|
|
98
|
+
args.push(...statuses);
|
|
99
|
+
}
|
|
100
|
+
if (mint) {
|
|
101
|
+
where.push('mint = ?');
|
|
102
|
+
args.push(mint);
|
|
103
|
+
}
|
|
104
|
+
const sql = `SELECT id, mint, kind, op, value, status, created_at, note FROM alert_rules${where.length ? ' WHERE ' + where.join(' AND ') : ''} ORDER BY id ASC`;
|
|
105
|
+
return this.db.prepare(sql).all(...args).map(rowToRule);
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Evaluate the armed rules against current values; each rule that trips fires once (status `fired`)
|
|
109
|
+
* and writes an event. `values` maps mint -> {price, liq, holders, mcap} with nulls for unknowns.
|
|
110
|
+
*/
|
|
111
|
+
evaluate(values, symbolOf = (m) => m.slice(0, 6)) {
|
|
112
|
+
const fired = [];
|
|
113
|
+
for (const r of this.rules('armed')) {
|
|
114
|
+
const v = values.get(r.mint)?.[r.kind];
|
|
115
|
+
if (v === null || v === undefined || !Number.isFinite(v))
|
|
116
|
+
continue;
|
|
117
|
+
const hit = r.op === '>' ? v > r.value : v < r.value;
|
|
118
|
+
if (!hit)
|
|
119
|
+
continue;
|
|
120
|
+
this.db.prepare("UPDATE alert_rules SET status = 'fired' WHERE id = ?").run(r.id);
|
|
121
|
+
const level = r.kind === 'price' || r.kind === 'mcap' ? (r.op === '>' ? 'up' : 'down') : r.op === '<' ? 'down' : 'info';
|
|
122
|
+
fired.push(this.log({ mint: r.mint, kind: r.kind, level, text: `${symbolOf(r.mint)} ${r.kind} ${r.op} ${fmtValue(r.kind, r.value)} (now ${fmtValue(r.kind, v)})` }));
|
|
123
|
+
}
|
|
124
|
+
return fired;
|
|
125
|
+
}
|
|
126
|
+
// ---------------------------------------------------------------- events
|
|
127
|
+
log(e) {
|
|
128
|
+
const t = this.clock.now();
|
|
129
|
+
const res = this.db.prepare('INSERT INTO alert_events (t, mint, kind, level, text) VALUES (?, ?, ?, ?, ?)').run(t, e.mint, e.kind, e.level, e.text);
|
|
130
|
+
return { id: Number(res.lastInsertRowid), t, mint: e.mint, kind: e.kind, level: e.level, text: e.text };
|
|
131
|
+
}
|
|
132
|
+
events(limit = 50) {
|
|
133
|
+
const rows = this.db.prepare('SELECT id, t, mint, kind, level, text FROM alert_events ORDER BY id DESC LIMIT ?').all(limit);
|
|
134
|
+
return rows.map((r) => ({ id: Number(r.id), t: Number(r.t), mint: r.mint ?? null, kind: String(r.kind), level: r.level, text: String(r.text) }));
|
|
135
|
+
}
|
|
136
|
+
close() {
|
|
137
|
+
this.db.close();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
function rowToEntry(r) {
|
|
141
|
+
return {
|
|
142
|
+
mint: String(r.mint),
|
|
143
|
+
symbol: String(r.symbol),
|
|
144
|
+
name: String(r.name),
|
|
145
|
+
launchpad: r.launchpad,
|
|
146
|
+
addedAt: Number(r.added_at),
|
|
147
|
+
entryPrice: r.entry_price === null || r.entry_price === undefined ? null : Number(r.entry_price),
|
|
148
|
+
note: String(r.note ?? ''),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
function rowToRule(r) {
|
|
152
|
+
return {
|
|
153
|
+
id: Number(r.id),
|
|
154
|
+
mint: String(r.mint),
|
|
155
|
+
kind: r.kind,
|
|
156
|
+
op: r.op,
|
|
157
|
+
value: Number(r.value),
|
|
158
|
+
status: r.status,
|
|
159
|
+
createdAt: Number(r.created_at),
|
|
160
|
+
note: String(r.note ?? ''),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
export function fmtValue(kind, v) {
|
|
164
|
+
if (kind === 'holders')
|
|
165
|
+
return String(Math.round(v));
|
|
166
|
+
if (kind === 'price')
|
|
167
|
+
return v >= 1 ? `$${v.toFixed(2)}` : `$${v.toPrecision(3)}`;
|
|
168
|
+
const a = Math.abs(v);
|
|
169
|
+
if (a >= 1e9)
|
|
170
|
+
return `$${(v / 1e9).toFixed(1)}B`;
|
|
171
|
+
if (a >= 1e6)
|
|
172
|
+
return `$${(v / 1e6).toFixed(1)}M`;
|
|
173
|
+
if (a >= 1e3)
|
|
174
|
+
return `$${(v / 1e3).toFixed(0)}K`;
|
|
175
|
+
return `$${v.toFixed(0)}`;
|
|
176
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// @dexterm/data — the user-authorized X v2 read adapter (task-1357, lane I-02; INTEGRATIONS-DESIGN §4).
|
|
2
|
+
//
|
|
3
|
+
// Only these official endpoints, only in USER context (the user's own login, their rate windows, their bill):
|
|
4
|
+
// GET /2/users/me identity (75 / 15 min)
|
|
5
|
+
// GET /2/tweets/search/recent per-token panel, `from:` KOL queries (300 / 15 min per user, 7 days)
|
|
6
|
+
// GET /2/lists/:id/tweets the user's own X lists as KOL feeds (900 / 15 min)
|
|
7
|
+
// GET /2/users/:id/owned_lists import own lists (15 / 15 min)
|
|
8
|
+
// GET /2/lists/:id/members the list's handles (900 / 15 min)
|
|
9
|
+
// Never: posting, liking, following, DMs, streams (economically unbounded), scraping of x.com pages.
|
|
10
|
+
//
|
|
11
|
+
// Every call: health gate + limiter bucket `x-api` → bearer from the OAuth client (refreshed near expiry) →
|
|
12
|
+
// node transport → the X rate-limit headers update a per-endpoint window → the spend meter counts NEW post ids
|
|
13
|
+
// → 15-minute query cache in the shared DB so re-opening a token is free. 401 = logged out (the user revoked
|
|
14
|
+
// at x.com); 429 = rate-limited until `x-rate-limit-reset`; 403 client-not-enrolled = the app's tier cannot
|
|
15
|
+
// read this endpoint (X Free tier: no search/list timelines). Each is a typed XApiError the poller turns into
|
|
16
|
+
// a VISIBLE state — never a silent empty feed. Raw tweet text leaves this module only as a RawPost, which the
|
|
17
|
+
// social feed firewalls on ingest; no tweet text is ever logged.
|
|
18
|
+
import { systemClock } from "../clock.js";
|
|
19
|
+
import { X_LIVE_ENDPOINTS, redactXSecrets } from "./config.js";
|
|
20
|
+
import { XAuthError } from "./oauth.js";
|
|
21
|
+
import { nodeXTransport } from "./transport.js";
|
|
22
|
+
export const X_BUCKET = 'x-api';
|
|
23
|
+
export const X_QUERY_NS = 'x:query';
|
|
24
|
+
export const X_QUERY_TTL_MS = 15 * 60_000;
|
|
25
|
+
export const X_SEARCH_MAX = 100;
|
|
26
|
+
export const X_TWEET_FIELDS = 'created_at,public_metrics,lang,author_id';
|
|
27
|
+
export const X_USER_FIELDS = 'created_at,username,name';
|
|
28
|
+
export class XApiError extends Error {
|
|
29
|
+
kind;
|
|
30
|
+
status;
|
|
31
|
+
/** Epoch ms when a rate-limited window resets (429 only). */
|
|
32
|
+
resetAt;
|
|
33
|
+
constructor(kind, message, status = null, resetAt = null) {
|
|
34
|
+
super(redactXSecrets(message));
|
|
35
|
+
this.name = 'XApiError';
|
|
36
|
+
this.kind = kind;
|
|
37
|
+
this.status = status;
|
|
38
|
+
this.resetAt = resetAt;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export class XApi {
|
|
42
|
+
endpoints;
|
|
43
|
+
oauth;
|
|
44
|
+
transport;
|
|
45
|
+
limiter;
|
|
46
|
+
health;
|
|
47
|
+
cache;
|
|
48
|
+
spend;
|
|
49
|
+
clock;
|
|
50
|
+
env;
|
|
51
|
+
queryTtlMs;
|
|
52
|
+
onRequest;
|
|
53
|
+
/** Per-endpoint windows from the last response headers (`search`, `list-tweets`, `me`, …). */
|
|
54
|
+
windows = new Map();
|
|
55
|
+
constructor(opts) {
|
|
56
|
+
this.oauth = opts.oauth;
|
|
57
|
+
this.endpoints = opts.endpoints ?? opts.oauth.endpoints ?? X_LIVE_ENDPOINTS;
|
|
58
|
+
this.transport = opts.transport ?? nodeXTransport();
|
|
59
|
+
this.limiter = opts.limiter ?? null;
|
|
60
|
+
this.health = opts.health ?? null;
|
|
61
|
+
this.cache = opts.cache ?? null;
|
|
62
|
+
this.spend = opts.spend ?? null;
|
|
63
|
+
this.clock = opts.clock ?? systemClock;
|
|
64
|
+
this.env = opts.env ?? process.env;
|
|
65
|
+
this.queryTtlMs = opts.queryTtlMs ?? X_QUERY_TTL_MS;
|
|
66
|
+
this.onRequest = opts.onRequest;
|
|
67
|
+
}
|
|
68
|
+
/** The window for an endpoint key, or null when never seen / already reset. */
|
|
69
|
+
window(key) {
|
|
70
|
+
const w = this.windows.get(key);
|
|
71
|
+
if (!w)
|
|
72
|
+
return null;
|
|
73
|
+
if (w.resetAt !== null && this.clock.now() >= w.resetAt)
|
|
74
|
+
return null;
|
|
75
|
+
return w;
|
|
76
|
+
}
|
|
77
|
+
noteWindow(key, res) {
|
|
78
|
+
const num = (h) => { const v = Number(res.headers[h]); return Number.isFinite(v) ? v : null; };
|
|
79
|
+
const reset = num('x-rate-limit-reset');
|
|
80
|
+
this.windows.set(key, { limit: num('x-rate-limit-limit'), remaining: num('x-rate-limit-remaining'), resetAt: reset !== null ? reset * 1000 : null, seenAt: this.clock.now() });
|
|
81
|
+
}
|
|
82
|
+
/** One authenticated GET. Throws XApiError; never logs a token or a tweet. */
|
|
83
|
+
async get(key, path, params) {
|
|
84
|
+
if (this.health && !this.health.isAvailable('x-api'))
|
|
85
|
+
throw new XApiError('unavailable', `x-api is ${this.health.state('x-api')}`);
|
|
86
|
+
if (this.spend && !this.spend.allow())
|
|
87
|
+
throw new XApiError('no-credit', `daily X budget reached ($${this.spend.budgetUsd} — DEXTERM_X_DAILY_USD); reads resume at the next UTC day`);
|
|
88
|
+
const w = this.window(key);
|
|
89
|
+
if (w && w.remaining !== null && w.remaining <= 0 && w.resetAt !== null)
|
|
90
|
+
throw new XApiError('rate-limited', `${key}: window exhausted`, 429, w.resetAt);
|
|
91
|
+
let bearer;
|
|
92
|
+
try {
|
|
93
|
+
bearer = await this.oauth.bearer(this.env);
|
|
94
|
+
}
|
|
95
|
+
catch (e) {
|
|
96
|
+
if (e instanceof XAuthError && (e.kind === 'not-logged-in'))
|
|
97
|
+
throw new XApiError('unauthorized', 'not logged in', 401);
|
|
98
|
+
throw new XApiError('unauthorized', `token refresh failed: ${e instanceof Error ? e.message : String(e)}`, 401);
|
|
99
|
+
}
|
|
100
|
+
if (this.limiter)
|
|
101
|
+
await this.limiter.acquire(X_BUCKET);
|
|
102
|
+
const url = `${this.endpoints.api}${path}?${new URLSearchParams(params).toString()}`;
|
|
103
|
+
let res;
|
|
104
|
+
try {
|
|
105
|
+
res = await this.transport({ method: 'GET', url, headers: { authorization: `Bearer ${bearer}` } });
|
|
106
|
+
}
|
|
107
|
+
catch (e) {
|
|
108
|
+
const msg = redactXSecrets(e instanceof Error ? e.message : String(e));
|
|
109
|
+
this.health?.record('x-api', { ok: false, error: msg });
|
|
110
|
+
this.onRequest?.({ endpoint: key, status: null, latencyMs: 0, cached: false });
|
|
111
|
+
throw new XApiError('transport', msg);
|
|
112
|
+
}
|
|
113
|
+
this.onRequest?.({ endpoint: key, status: res.status, latencyMs: res.latencyMs, cached: false });
|
|
114
|
+
this.noteWindow(key, res);
|
|
115
|
+
let body = {};
|
|
116
|
+
try {
|
|
117
|
+
body = JSON.parse(res.body);
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
body = {};
|
|
121
|
+
}
|
|
122
|
+
if (res.status === 401) {
|
|
123
|
+
this.health?.record('x-api', { ok: false, status: 401, latencyMs: res.latencyMs });
|
|
124
|
+
throw new XApiError('unauthorized', 'X answered 401: the login was revoked or expired — run `dexterm x login`', 401);
|
|
125
|
+
}
|
|
126
|
+
if (res.status === 429) {
|
|
127
|
+
const reset = Number(res.headers['x-rate-limit-reset']);
|
|
128
|
+
const resetAt = Number.isFinite(reset) && reset > 0 ? reset * 1000 : this.clock.now() + 15 * 60_000;
|
|
129
|
+
if (this.limiter)
|
|
130
|
+
this.limiter.pause(X_BUCKET, Math.max(1000, Math.min(resetAt - this.clock.now(), 15 * 60_000)));
|
|
131
|
+
this.health?.record('x-api', { ok: false, status: 429, latencyMs: res.latencyMs });
|
|
132
|
+
throw new XApiError('rate-limited', `${key}: X rate limit (resets ${new Date(resetAt).toISOString()})`, 429, resetAt);
|
|
133
|
+
}
|
|
134
|
+
if (res.status === 403) {
|
|
135
|
+
this.health?.record('x-api', { ok: false, status: 403, latencyMs: res.latencyMs, error: String(body.reason ?? body.title ?? '') });
|
|
136
|
+
if (body.reason === 'client-not-enrolled' || /enrollment|attached to a Project/i.test(String(body.detail ?? '')))
|
|
137
|
+
throw new XApiError('not-enrolled', `${key}: your X app's tier cannot read this endpoint (Free tier has no search / list timelines; see docs: Login with X)`, 403);
|
|
138
|
+
throw new XApiError('forbidden', `${key}: X answered 403 ${String(body.title ?? '')}`.trim(), 403);
|
|
139
|
+
}
|
|
140
|
+
if (res.status < 200 || res.status >= 300) {
|
|
141
|
+
this.health?.record('x-api', { ok: false, status: res.status, latencyMs: res.latencyMs });
|
|
142
|
+
throw new XApiError('http', `${key}: X answered ${res.status} ${String(body.title ?? body.detail ?? '')}`.trim(), res.status);
|
|
143
|
+
}
|
|
144
|
+
this.health?.record('x-api', { ok: true, status: res.status, latencyMs: res.latencyMs });
|
|
145
|
+
return { data: body, res };
|
|
146
|
+
}
|
|
147
|
+
cacheKey(key, params) {
|
|
148
|
+
return `${key}:${new URLSearchParams(params).toString()}`;
|
|
149
|
+
}
|
|
150
|
+
parsePage(env, fetchedAt, cached) {
|
|
151
|
+
const users = new Map();
|
|
152
|
+
for (const u of env.includes?.users ?? []) {
|
|
153
|
+
const r = u;
|
|
154
|
+
if (typeof r.id === 'string' && typeof r.username === 'string')
|
|
155
|
+
users.set(r.id, { id: r.id, username: r.username, name: typeof r.name === 'string' ? r.name : null, createdAt: typeof r.created_at === 'string' ? Date.parse(r.created_at) || null : null });
|
|
156
|
+
}
|
|
157
|
+
const tweets = [];
|
|
158
|
+
for (const t of env.data ?? []) {
|
|
159
|
+
const r = t;
|
|
160
|
+
if (typeof r.id !== 'string' || typeof r.text !== 'string')
|
|
161
|
+
continue;
|
|
162
|
+
const m = r.public_metrics ?? {};
|
|
163
|
+
const n = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
|
|
164
|
+
tweets.push({ id: r.id, text: r.text, authorId: typeof r.author_id === 'string' ? r.author_id : '?', createdAt: typeof r.created_at === 'string' ? Date.parse(r.created_at) || fetchedAt : fetchedAt, lang: typeof r.lang === 'string' ? r.lang : null, metrics: { likes: n(m.like_count), reposts: n(m.retweet_count), replies: n(m.reply_count), views: typeof m.impression_count === 'number' ? m.impression_count : null } });
|
|
165
|
+
}
|
|
166
|
+
return { tweets, users, newestId: typeof env.meta?.newest_id === 'string' ? env.meta.newest_id : (tweets[0]?.id ?? null), fetchedAt, cached };
|
|
167
|
+
}
|
|
168
|
+
async page(key, path, params) {
|
|
169
|
+
const ck = this.cacheKey(key, params);
|
|
170
|
+
const hit = this.cache?.get(X_QUERY_NS, ck);
|
|
171
|
+
if (hit && !hit.stale) {
|
|
172
|
+
this.onRequest?.({ endpoint: key, status: 200, latencyMs: 0, cached: true });
|
|
173
|
+
return this.parsePage(hit.value, hit.fetchedAt, true);
|
|
174
|
+
}
|
|
175
|
+
const { data } = await this.get(key, path, params);
|
|
176
|
+
const fetchedAt = this.clock.now();
|
|
177
|
+
this.cache?.set(X_QUERY_NS, ck, data, { ttlMs: this.queryTtlMs, fetchedAt });
|
|
178
|
+
const page = this.parsePage(data, fetchedAt, false);
|
|
179
|
+
this.spend?.record(page.tweets.map((t) => t.id), 0);
|
|
180
|
+
return page;
|
|
181
|
+
}
|
|
182
|
+
async me() {
|
|
183
|
+
const { data } = await this.get('me', '/2/users/me', { 'user.fields': X_USER_FIELDS });
|
|
184
|
+
const d = data.data;
|
|
185
|
+
if (!d || typeof d.id !== 'string' || typeof d.username !== 'string')
|
|
186
|
+
throw new XApiError('http', 'users/me answered without a user');
|
|
187
|
+
this.spend?.record([], 1);
|
|
188
|
+
return { id: d.id, username: d.username, name: typeof d.name === 'string' ? d.name : null, createdAt: typeof d.created_at === 'string' ? Date.parse(d.created_at) || null : null };
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* search/recent. `query` is built by the caller from mints/handles/symbols (never from post text);
|
|
192
|
+
* `-is:retweet -is:reply` are appended so reposts/replies are never billed.
|
|
193
|
+
*/
|
|
194
|
+
searchRecent(query, opts = {}) {
|
|
195
|
+
const q = /-is:retweet/.test(query) ? query : `${query} -is:retweet -is:reply`;
|
|
196
|
+
const params = { query: q.slice(0, 512), max_results: String(Math.max(10, Math.min(X_SEARCH_MAX, opts.maxResults ?? 50))), 'tweet.fields': X_TWEET_FIELDS, expansions: 'author_id', 'user.fields': X_USER_FIELDS };
|
|
197
|
+
if (opts.sinceId)
|
|
198
|
+
params.since_id = opts.sinceId;
|
|
199
|
+
return this.page('search', '/2/tweets/search/recent', params);
|
|
200
|
+
}
|
|
201
|
+
listTweets(listId, opts = {}) {
|
|
202
|
+
if (!/^\d{1,30}$/.test(listId))
|
|
203
|
+
return Promise.reject(new XApiError('http', 'list id must be numeric'));
|
|
204
|
+
return this.page('list-tweets', `/2/lists/${listId}/tweets`, { max_results: String(Math.max(1, Math.min(100, opts.maxResults ?? 50))), 'tweet.fields': X_TWEET_FIELDS, expansions: 'author_id', 'user.fields': X_USER_FIELDS });
|
|
205
|
+
}
|
|
206
|
+
async ownedLists(userId) {
|
|
207
|
+
if (!/^\d{1,30}$/.test(userId))
|
|
208
|
+
throw new XApiError('http', 'user id must be numeric');
|
|
209
|
+
const { data } = await this.get('owned-lists', `/2/users/${userId}/owned_lists`, { 'list.fields': 'member_count,name', max_results: '100' });
|
|
210
|
+
return (data.data ?? []).filter((l) => typeof l.id === 'string' && typeof l.name === 'string').map((l) => ({ id: l.id, name: l.name, memberCount: typeof l.member_count === 'number' ? l.member_count : null }));
|
|
211
|
+
}
|
|
212
|
+
async listMembers(listId) {
|
|
213
|
+
if (!/^\d{1,30}$/.test(listId))
|
|
214
|
+
throw new XApiError('http', 'list id must be numeric');
|
|
215
|
+
const { data } = await this.get('list-members', `/2/lists/${listId}/members`, { 'user.fields': X_USER_FIELDS, max_results: '100' });
|
|
216
|
+
const out = (data.data ?? []).filter((u) => typeof u.id === 'string' && typeof u.username === 'string').map((u) => ({ id: u.id, username: u.username, name: typeof u.name === 'string' ? u.name : null, createdAt: typeof u.created_at === 'string' ? Date.parse(u.created_at) || null : null }));
|
|
217
|
+
this.spend?.record([], out.length);
|
|
218
|
+
return out;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
/** Build a per-token search query from RESOLVED facts (symbol, mint, project handle), never from post text. */
|
|
222
|
+
export function tokenQuery(opts) {
|
|
223
|
+
const parts = [];
|
|
224
|
+
const sym = opts.symbol.replace(/[^A-Za-z0-9_]/g, '');
|
|
225
|
+
if (sym.length >= 2)
|
|
226
|
+
parts.push(`$${sym}`);
|
|
227
|
+
if (/^[1-9A-HJ-NP-Za-km-z]{32,44}$/.test(opts.mint))
|
|
228
|
+
parts.push(opts.mint);
|
|
229
|
+
const h = (opts.handle ?? '').replace(/^@/, '');
|
|
230
|
+
if (/^[A-Za-z0-9_]{1,15}$/.test(h))
|
|
231
|
+
parts.push(`from:${h}`);
|
|
232
|
+
return `(${parts.join(' OR ')}) -is:retweet -is:reply`;
|
|
233
|
+
}
|
|
234
|
+
/** `from:` query for up to 20 handles (X caps queries at 512 chars on Basic/Pro). */
|
|
235
|
+
export function handlesQuery(handles) {
|
|
236
|
+
const hs = handles.filter((h) => /^[A-Za-z0-9_]{1,15}$/.test(h)).slice(0, 20);
|
|
237
|
+
return `(${hs.map((h) => `from:${h}`).join(' OR ')}) -is:retweet -is:reply`;
|
|
238
|
+
}
|
|
239
|
+
/** Tweet → RawPost for the social feed's ingest (which firewalls the text). `mints` are the CALLER's resolution. */
|
|
240
|
+
export function tweetToRaw(t, users, opts) {
|
|
241
|
+
const u = users.get(t.authorId);
|
|
242
|
+
const handle = u ? `@${u.username}` : `@user${t.authorId.slice(-4)}`;
|
|
243
|
+
return {
|
|
244
|
+
id: `x:${t.id}`,
|
|
245
|
+
t: t.createdAt,
|
|
246
|
+
platform: 'x',
|
|
247
|
+
author: { id: t.authorId, handle, tier: 'none', createdAt: u?.createdAt ?? null },
|
|
248
|
+
text: t.text,
|
|
249
|
+
url: u ? `https://x.com/${u.username}/status/${t.id}` : `https://x.com/i/status/${t.id}`,
|
|
250
|
+
lang: t.lang,
|
|
251
|
+
metrics: t.metrics,
|
|
252
|
+
mints: opts.mints,
|
|
253
|
+
source: 'x-api',
|
|
254
|
+
fetchedAt: opts.fetchedAt,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// @dexterm/data — the X spend meter (task-1357; INTEGRATIONS-DESIGN §4/§6). X bills the DEVELOPER account
|
|
2
|
+
// per post read ($0.005) and per user read ($0.01), deduplicated per UTC day — and with Login with X each
|
|
3
|
+
// dexterm user IS the developer of their own app, so this is the user's own money. The meter keeps a per-day
|
|
4
|
+
// estimate in the shared cache DB, counts a post only the first time its id is seen in a UTC day (24 h local
|
|
5
|
+
// dedup table), and hard-stops reads at the daily budget (default $10, `DEXTERM_X_DAILY_USD`). The numbers are
|
|
6
|
+
// ESTIMATES from the published list prices; the X console is the bill.
|
|
7
|
+
export const X_USD_PER_POST = 0.005;
|
|
8
|
+
export const X_USD_PER_USER_READ = 0.01;
|
|
9
|
+
export const X_DEFAULT_DAILY_USD = 10;
|
|
10
|
+
export const X_BUDGET_NS = 'x:budget';
|
|
11
|
+
export const X_SEEN_NS = 'x:seen';
|
|
12
|
+
const DAY_MS = 86_400_000;
|
|
13
|
+
export function utcDayKey(now) {
|
|
14
|
+
return new Date(now).toISOString().slice(0, 10);
|
|
15
|
+
}
|
|
16
|
+
export function dailyBudgetFrom(env = process.env) {
|
|
17
|
+
const v = Number(env.DEXTERM_X_DAILY_USD);
|
|
18
|
+
return Number.isFinite(v) && v >= 0 ? v : X_DEFAULT_DAILY_USD;
|
|
19
|
+
}
|
|
20
|
+
export class XSpendMeter {
|
|
21
|
+
cache;
|
|
22
|
+
now;
|
|
23
|
+
budgetUsd;
|
|
24
|
+
constructor(opts) {
|
|
25
|
+
this.cache = opts.cache;
|
|
26
|
+
this.now = opts.now ?? (() => Date.now());
|
|
27
|
+
this.budgetUsd = opts.budgetUsd ?? dailyBudgetFrom(opts.env ?? process.env);
|
|
28
|
+
}
|
|
29
|
+
read(day) {
|
|
30
|
+
return this.cache.get(X_BUDGET_NS, day)?.value ?? { day, requests: 0, posts: 0, dedupedPosts: 0, userReads: 0, usd: 0 };
|
|
31
|
+
}
|
|
32
|
+
write(d) {
|
|
33
|
+
this.cache.set(X_BUDGET_NS, d.day, d, { ttlMs: 3 * DAY_MS });
|
|
34
|
+
}
|
|
35
|
+
status() {
|
|
36
|
+
const now = this.now();
|
|
37
|
+
const d = this.read(utcDayKey(now));
|
|
38
|
+
const pct = this.budgetUsd > 0 ? d.usd / this.budgetUsd : 1;
|
|
39
|
+
const dayStart = Date.UTC(new Date(now).getUTCFullYear(), new Date(now).getUTCMonth(), new Date(now).getUTCDate());
|
|
40
|
+
return { ...d, budgetUsd: this.budgetUsd, pct, level: pct >= 1 ? 'stop' : pct >= 0.8 ? 'warn' : 'ok', resetsAt: dayStart + DAY_MS };
|
|
41
|
+
}
|
|
42
|
+
/** True while another read is allowed (a zero budget means "never read X"). */
|
|
43
|
+
allow() { return this.status().level !== 'stop'; }
|
|
44
|
+
/** Record one request that returned `postIds` (new ids are billed) and `userReads` user objects. */
|
|
45
|
+
record(postIds, userReads = 0) {
|
|
46
|
+
const now = this.now();
|
|
47
|
+
const d = this.read(utcDayKey(now));
|
|
48
|
+
d.requests += 1;
|
|
49
|
+
let fresh = 0;
|
|
50
|
+
for (const id of postIds) {
|
|
51
|
+
if (this.cache.get(X_SEEN_NS, id)) {
|
|
52
|
+
d.dedupedPosts += 1;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
this.cache.set(X_SEEN_NS, id, 1, { ttlMs: DAY_MS, fetchedAt: now });
|
|
56
|
+
fresh += 1;
|
|
57
|
+
}
|
|
58
|
+
d.posts += fresh;
|
|
59
|
+
d.userReads += userReads;
|
|
60
|
+
d.usd = Math.round((d.posts * X_USD_PER_POST + d.userReads * X_USD_PER_USER_READ) * 1000) / 1000;
|
|
61
|
+
this.write(d);
|
|
62
|
+
return d;
|
|
63
|
+
}
|
|
64
|
+
/** Drop stale `seen` rows (called by the poller once a minute; cheap). */
|
|
65
|
+
prune() { return this.cache.prune(0); }
|
|
66
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// @dexterm/data — Login with X: constants, endpoints, local paths and secret redaction (task-1357, lane I-02;
|
|
2
|
+
// INTEGRATIONS-DESIGN §3, research/task-1289/xlogin).
|
|
3
|
+
//
|
|
4
|
+
// Facts this encodes (docs.x.com, fetched 2026-10-09 by the spike):
|
|
5
|
+
// * authorize https://x.com/i/oauth2/authorize (response_type=code, client_id, redirect_uri, scope, state,
|
|
6
|
+
// code_challenge, code_challenge_method=S256); the code must be exchanged within 30 s
|
|
7
|
+
// * token POST https://api.x.com/2/oauth2/token (x-www-form-urlencoded; a Native App is a PUBLIC client:
|
|
8
|
+
// client_id in the body, no secret). Access tokens live 2 h; refresh needs `offline.access`
|
|
9
|
+
// * revoke POST https://api.x.com/2/oauth2/revoke (token + client_id)
|
|
10
|
+
// * callback must match EXACTLY and must be http://127.0.0.1 (not localhost) for a local app → FIXED port
|
|
11
|
+
// * rate limits are per USER for user-context tokens; pay-per-use credits bill the DEVELOPER account that owns
|
|
12
|
+
// the app — each dexterm user brings their OWN app (client id), so each user pays for their own reads.
|
|
13
|
+
//
|
|
14
|
+
// Every endpoint is injectable so tests run the whole flow against the loopback mock server (./mock-server.ts)
|
|
15
|
+
// and never touch x.com. Nothing in this directory ever logs a token: `redactXSecrets()` is applied to every
|
|
16
|
+
// error string that could reach a log, the screen or the health registry.
|
|
17
|
+
import { homedir } from 'node:os';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
/** Fixed loopback port: X requires an exact-match callback URL, so the port is part of the app registration. */
|
|
20
|
+
export const X_CALLBACK_PORT = 41287;
|
|
21
|
+
export const X_CALLBACK_PATH = '/callback';
|
|
22
|
+
/** Scopes dexterm asks for. Never any `.write` or `dm.*` — dexterm never posts, likes, follows or DMs. */
|
|
23
|
+
export const X_SCOPES = ['tweet.read', 'users.read', 'list.read', 'offline.access'];
|
|
24
|
+
export const X_FORBIDDEN_SCOPE_RE = /\.write$|^dm\./;
|
|
25
|
+
export const X_LIVE_ENDPOINTS = {
|
|
26
|
+
authorize: 'https://x.com/i/oauth2/authorize',
|
|
27
|
+
token: 'https://api.x.com/2/oauth2/token',
|
|
28
|
+
revoke: 'https://api.x.com/2/oauth2/revoke',
|
|
29
|
+
api: 'https://api.x.com',
|
|
30
|
+
};
|
|
31
|
+
export function callbackUri(port = X_CALLBACK_PORT) {
|
|
32
|
+
return `http://127.0.0.1:${port}${X_CALLBACK_PATH}`;
|
|
33
|
+
}
|
|
34
|
+
/** `$DEXTERM_HOME/x` (default `~/.dexterm/x`): client id + tokens, mode 0700. */
|
|
35
|
+
export function xDir(env = process.env) {
|
|
36
|
+
const home = env.DEXTERM_HOME && env.DEXTERM_HOME.trim() ? env.DEXTERM_HOME : join(homedir(), '.dexterm');
|
|
37
|
+
return join(home, 'x');
|
|
38
|
+
}
|
|
39
|
+
const BEARER_RE = /(bearer\s+)[A-Za-z0-9._~+/=-]{8,}/gi;
|
|
40
|
+
const TOKEN_PARAM_RE = /((?:access_token|refresh_token|token|code|code_verifier|client_secret)=)[^&\s"'<>]+/gi;
|
|
41
|
+
const TOKEN_JSON_RE = /("(?:access_token|refresh_token|token|code_verifier|client_secret)"\s*:\s*")[^"]*(")/gi;
|
|
42
|
+
/** Strip bearer tokens, OAuth form fields and token-bearing JSON fields from any string. Safe on any input. */
|
|
43
|
+
export function redactXSecrets(text) {
|
|
44
|
+
return String(text).replace(BEARER_RE, '$1REDACTED').replace(TOKEN_PARAM_RE, '$1REDACTED').replace(TOKEN_JSON_RE, '$1REDACTED$2');
|
|
45
|
+
}
|
|
46
|
+
/** A client id is one token of URL-safe characters (X ids are base64url-ish and end in `:1:ci`). */
|
|
47
|
+
export function isPlausibleClientId(id) {
|
|
48
|
+
return /^[A-Za-z0-9._:~-]{8,128}$/.test(id);
|
|
49
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// @dexterm/data — Login with X + the user-authorized X read adapter (task-1357, lane I-02).
|
|
2
|
+
export * from "./config.js";
|
|
3
|
+
export * from "./pkce.js";
|
|
4
|
+
export * from "./token-store.js";
|
|
5
|
+
export * from "./transport.js";
|
|
6
|
+
export * from "./oauth.js";
|
|
7
|
+
export * from "./budget.js";
|
|
8
|
+
export * from "./api.js";
|
|
9
|
+
export * from "./source.js";
|
|
10
|
+
export * from "./mock-server.js";
|