birdclaw 0.1.0 → 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/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## 0.1.1 - 2026-04-27
6
+
7
+ ### Added
8
+
9
+ - Add opt-in low-quality timeline filtering for year-scale tweet review, including date windows, originals-only mode, and CLI/API flags for hiding retweets, tiny replies, and link-only noise.
10
+
11
+ ### Fixed
12
+
13
+ - Fix fresh npm installs so the packaged `birdclaw` binary includes its TypeScript runtime dependency.
14
+
5
15
  ## 0.1.0 - 2026-04-27
6
16
 
7
17
  ### Added
package/README.md CHANGED
@@ -179,6 +179,7 @@ pnpm cli blocks import ~/triage/blocklist.txt --account acct_primary --json
179
179
  ```bash
180
180
  pnpm cli search tweets "local-first" --json
181
181
  pnpm cli search tweets "sync engine" --limit 20 --json
182
+ pnpm cli search tweets --since 2020-01-01 --until 2021-01-01 --originals-only --hide-low-quality --limit 500 --json
182
183
  ```
183
184
 
184
185
  ### Export mentions for agents
package/bin/birdclaw.mjs CHANGED
@@ -1,10 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawnSync } from "node:child_process";
3
+ import { createRequire } from "node:module";
3
4
  import { dirname, join } from "node:path";
4
5
  import { fileURLToPath } from "node:url";
5
6
 
6
7
  const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
7
- const tsxCli = join(packageRoot, "node_modules", "tsx", "dist", "cli.mjs");
8
+ const require = createRequire(import.meta.url);
9
+ const tsxCli = require.resolve("tsx/cli");
8
10
  const birdclawCli = join(packageRoot, "src", "cli.ts");
9
11
 
10
12
  const result = spawnSync(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "birdclaw",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Local-first X workspace for archives, DMs, mentions, and moderation",
5
5
  "homepage": "https://github.com/steipete/birdclaw#readme",
6
6
  "license": "MIT",
package/src/cli.ts CHANGED
@@ -131,10 +131,14 @@ const searchCommand = program
131
131
  .description("Search local data");
132
132
 
133
133
  searchCommand
134
- .command("tweets <query>")
134
+ .command("tweets [query]")
135
135
  .option("--resource <resource>", "home or mentions", "home")
136
136
  .option("--replied", "Only replied items")
137
137
  .option("--unreplied", "Only unreplied items")
138
+ .option("--since <date>", "Include tweets created at or after this date")
139
+ .option("--until <date>", "Include tweets created before this date")
140
+ .option("--originals-only", "Exclude authored replies that start with @")
141
+ .option("--hide-low-quality", "Hide RTs, tiny replies, and link-only noise")
138
142
  .option("--limit <n>", "Limit results", "20")
139
143
  .action((query, options) => {
140
144
  const replyFilter = options.replied
@@ -146,6 +150,10 @@ searchCommand
146
150
  resource: options.resource === "mentions" ? "mentions" : "home",
147
151
  search: query,
148
152
  replyFilter,
153
+ since: options.since,
154
+ until: options.until,
155
+ includeReplies: !options.originalsOnly,
156
+ qualityFilter: options.hideLowQuality ? "summary" : "all",
149
157
  limit: Number(options.limit),
150
158
  });
151
159
  print(items, program.opts().json ?? false);
@@ -12,6 +12,7 @@ import type {
12
12
  QueryEnvelope,
13
13
  QueryResponse,
14
14
  ReplyFilter,
15
+ TimelineQualityFilter,
15
16
  TimelineItem,
16
17
  TimelineQuery,
17
18
  TweetEntities,
@@ -124,6 +125,36 @@ function buildReplyClause(replyFilter: ReplyFilter) {
124
125
  return "";
125
126
  }
126
127
 
128
+ function buildTimelineQualityClause(qualityFilter: TimelineQualityFilter) {
129
+ if (qualityFilter === "all") {
130
+ return "";
131
+ }
132
+
133
+ return `
134
+ and not (
135
+ t.text like 'RT @%'
136
+ or (
137
+ t.like_count < 50
138
+ and (
139
+ (
140
+ length(trim(replace(t.text, 'https://t.co/', ''))) < 16
141
+ and t.media_count = 0
142
+ )
143
+ or (
144
+ t.text like '@%'
145
+ and length(trim(t.text)) < 60
146
+ )
147
+ or (
148
+ t.text glob '*https://t.co/*'
149
+ and t.media_count = 0
150
+ and length(trim(replace(t.text, 'https://t.co/', ''))) < 45
151
+ )
152
+ )
153
+ )
154
+ )
155
+ `;
156
+ }
157
+
127
158
  export async function getQueryEnvelope(): Promise<QueryEnvelope> {
128
159
  const db = getDb();
129
160
  const counts = await Promise.all([
@@ -182,6 +213,10 @@ export function listTimelineItems({
182
213
  account,
183
214
  search,
184
215
  replyFilter = "all",
216
+ since,
217
+ until,
218
+ includeReplies = true,
219
+ qualityFilter = "all",
185
220
  limit = 18,
186
221
  }: TimelineQuery): TimelineItem[] {
187
222
  const db = getNativeDb();
@@ -199,6 +234,21 @@ export function listTimelineItems({
199
234
  "is_replied",
200
235
  "t.is_replied",
201
236
  );
237
+ where += buildTimelineQualityClause(qualityFilter);
238
+
239
+ if (!includeReplies) {
240
+ where += " and t.text not like '@%'";
241
+ }
242
+
243
+ if (since?.trim()) {
244
+ where += " and t.created_at >= ?";
245
+ params.push(since.trim());
246
+ }
247
+
248
+ if (until?.trim()) {
249
+ where += " and t.created_at < ?";
250
+ params.push(until.trim());
251
+ }
202
252
 
203
253
  if (search?.trim()) {
204
254
  join += " join tweets_fts fts on fts.tweet_id = t.id ";
package/src/lib/types.ts CHANGED
@@ -2,6 +2,7 @@ export type ResourceKind = "home" | "mentions" | "dms";
2
2
  export type InboxKind = "mixed" | "mentions" | "dms";
3
3
 
4
4
  export type ReplyFilter = "all" | "replied" | "unreplied";
5
+ export type TimelineQualityFilter = "all" | "summary";
5
6
 
6
7
  export interface AccountRecord {
7
8
  id: string;
@@ -139,6 +140,10 @@ export interface TimelineQuery {
139
140
  account?: string;
140
141
  search?: string;
141
142
  replyFilter?: ReplyFilter;
143
+ since?: string;
144
+ until?: string;
145
+ includeReplies?: boolean;
146
+ qualityFilter?: TimelineQualityFilter;
142
147
  limit?: number;
143
148
  }
144
149
 
@@ -1,6 +1,10 @@
1
1
  import { createFileRoute } from "@tanstack/react-router";
2
2
  import { queryResource } from "#/lib/queries";
3
- import type { ReplyFilter, ResourceKind } from "#/lib/types";
3
+ import type {
4
+ ReplyFilter,
5
+ ResourceKind,
6
+ TimelineQualityFilter,
7
+ } from "#/lib/types";
4
8
 
5
9
  function json(data: unknown) {
6
10
  return new Response(JSON.stringify(data), {
@@ -23,6 +27,10 @@ function parseNumber(value: string | null) {
23
27
  return Number.isFinite(parsed) ? parsed : undefined;
24
28
  }
25
29
 
30
+ function parseQualityFilter(value: string | null): TimelineQualityFilter {
31
+ return value === "summary" ? "summary" : "all";
32
+ }
33
+
26
34
  export const Route = createFileRoute("/api/query")({
27
35
  server: {
28
36
  handlers: {
@@ -34,6 +42,12 @@ export const Route = createFileRoute("/api/query")({
34
42
  account: url.searchParams.get("account") ?? undefined,
35
43
  search: url.searchParams.get("search") ?? undefined,
36
44
  replyFilter: parseReplyFilter(url.searchParams.get("replyFilter")),
45
+ since: url.searchParams.get("since") ?? undefined,
46
+ until: url.searchParams.get("until") ?? undefined,
47
+ includeReplies: url.searchParams.get("originalsOnly") !== "true",
48
+ qualityFilter: parseQualityFilter(
49
+ url.searchParams.get("qualityFilter"),
50
+ ),
37
51
  limit: parseNumber(url.searchParams.get("limit")) ?? undefined,
38
52
  };
39
53