@mepuka/skygent 0.2.0

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.
Files changed (114) hide show
  1. package/README.md +59 -0
  2. package/index.ts +146 -0
  3. package/package.json +56 -0
  4. package/src/cli/app.ts +75 -0
  5. package/src/cli/config-command.ts +140 -0
  6. package/src/cli/config.ts +91 -0
  7. package/src/cli/derive.ts +205 -0
  8. package/src/cli/doc/annotation.ts +36 -0
  9. package/src/cli/doc/filter.ts +69 -0
  10. package/src/cli/doc/index.ts +9 -0
  11. package/src/cli/doc/post.ts +155 -0
  12. package/src/cli/doc/primitives.ts +25 -0
  13. package/src/cli/doc/render.ts +18 -0
  14. package/src/cli/doc/table.ts +114 -0
  15. package/src/cli/doc/thread.ts +46 -0
  16. package/src/cli/doc/tree.ts +126 -0
  17. package/src/cli/errors.ts +59 -0
  18. package/src/cli/exit-codes.ts +52 -0
  19. package/src/cli/feed.ts +177 -0
  20. package/src/cli/filter-dsl.ts +1411 -0
  21. package/src/cli/filter-errors.ts +208 -0
  22. package/src/cli/filter-help.ts +70 -0
  23. package/src/cli/filter-input.ts +54 -0
  24. package/src/cli/filter.ts +435 -0
  25. package/src/cli/graph.ts +472 -0
  26. package/src/cli/help.ts +14 -0
  27. package/src/cli/interval.ts +35 -0
  28. package/src/cli/jetstream.ts +173 -0
  29. package/src/cli/layers.ts +180 -0
  30. package/src/cli/logging.ts +136 -0
  31. package/src/cli/output-format.ts +26 -0
  32. package/src/cli/output.ts +82 -0
  33. package/src/cli/parse.ts +80 -0
  34. package/src/cli/post.ts +193 -0
  35. package/src/cli/preferences.ts +11 -0
  36. package/src/cli/query-fields.ts +247 -0
  37. package/src/cli/query.ts +415 -0
  38. package/src/cli/range.ts +44 -0
  39. package/src/cli/search.ts +465 -0
  40. package/src/cli/shared-options.ts +169 -0
  41. package/src/cli/shared.ts +20 -0
  42. package/src/cli/store-errors.ts +80 -0
  43. package/src/cli/store-tree.ts +392 -0
  44. package/src/cli/store.ts +395 -0
  45. package/src/cli/sync-factory.ts +107 -0
  46. package/src/cli/sync.ts +366 -0
  47. package/src/cli/view-thread.ts +196 -0
  48. package/src/cli/view.ts +47 -0
  49. package/src/cli/watch.ts +344 -0
  50. package/src/db/migrations/store-catalog/001_init.ts +14 -0
  51. package/src/db/migrations/store-index/001_init.ts +34 -0
  52. package/src/db/migrations/store-index/002_event_log.ts +24 -0
  53. package/src/db/migrations/store-index/003_fts_and_derived.ts +52 -0
  54. package/src/db/migrations/store-index/004_query_indexes.ts +9 -0
  55. package/src/db/migrations/store-index/005_post_lang.ts +15 -0
  56. package/src/db/migrations/store-index/006_has_embed.ts +10 -0
  57. package/src/db/migrations/store-index/007_event_seq_and_checkpoints.ts +68 -0
  58. package/src/domain/bsky.ts +467 -0
  59. package/src/domain/config.ts +11 -0
  60. package/src/domain/credentials.ts +6 -0
  61. package/src/domain/defaults.ts +8 -0
  62. package/src/domain/derivation.ts +55 -0
  63. package/src/domain/errors.ts +71 -0
  64. package/src/domain/events.ts +55 -0
  65. package/src/domain/extract.ts +64 -0
  66. package/src/domain/filter-describe.ts +551 -0
  67. package/src/domain/filter-explain.ts +9 -0
  68. package/src/domain/filter.ts +797 -0
  69. package/src/domain/format.ts +91 -0
  70. package/src/domain/index.ts +13 -0
  71. package/src/domain/indexes.ts +17 -0
  72. package/src/domain/policies.ts +16 -0
  73. package/src/domain/post.ts +88 -0
  74. package/src/domain/primitives.ts +50 -0
  75. package/src/domain/raw.ts +140 -0
  76. package/src/domain/store.ts +103 -0
  77. package/src/domain/sync.ts +211 -0
  78. package/src/domain/text-width.ts +56 -0
  79. package/src/services/app-config.ts +278 -0
  80. package/src/services/bsky-client.ts +2113 -0
  81. package/src/services/credential-store.ts +408 -0
  82. package/src/services/derivation-engine.ts +502 -0
  83. package/src/services/derivation-settings.ts +61 -0
  84. package/src/services/derivation-validator.ts +68 -0
  85. package/src/services/filter-compiler.ts +269 -0
  86. package/src/services/filter-library.ts +371 -0
  87. package/src/services/filter-runtime.ts +821 -0
  88. package/src/services/filter-settings.ts +30 -0
  89. package/src/services/identity-resolver.ts +563 -0
  90. package/src/services/jetstream-sync.ts +636 -0
  91. package/src/services/lineage-store.ts +89 -0
  92. package/src/services/link-validator.ts +244 -0
  93. package/src/services/output-manager.ts +274 -0
  94. package/src/services/post-parser.ts +62 -0
  95. package/src/services/profile-resolver.ts +223 -0
  96. package/src/services/resource-monitor.ts +106 -0
  97. package/src/services/shared.ts +69 -0
  98. package/src/services/store-cleaner.ts +43 -0
  99. package/src/services/store-commit.ts +168 -0
  100. package/src/services/store-db.ts +248 -0
  101. package/src/services/store-event-log.ts +285 -0
  102. package/src/services/store-index-sql.ts +289 -0
  103. package/src/services/store-index.ts +1152 -0
  104. package/src/services/store-keys.ts +4 -0
  105. package/src/services/store-manager.ts +358 -0
  106. package/src/services/store-stats.ts +522 -0
  107. package/src/services/store-writer.ts +200 -0
  108. package/src/services/sync-checkpoint-store.ts +169 -0
  109. package/src/services/sync-engine.ts +547 -0
  110. package/src/services/sync-reporter.ts +16 -0
  111. package/src/services/sync-settings.ts +72 -0
  112. package/src/services/trending-topics.ts +226 -0
  113. package/src/services/view-checkpoint-store.ts +238 -0
  114. package/src/typeclass/chunk.ts +84 -0
@@ -0,0 +1,821 @@
1
+ /**
2
+ * Filter runtime service for compiling and evaluating filter expressions against posts.
3
+ *
4
+ * This service provides the core filtering logic for Skygent. It compiles filter
5
+ * expressions into executable predicates and supports both synchronous and effectful
6
+ * filters. Effectful filters (like `HasValidLinks` and `Trending`) can perform
7
+ * external operations such as HTTP requests.
8
+ *
9
+ * ## Features
10
+ *
11
+ * - **Filter compilation**: Converts FilterExpr AST into executable predicates
12
+ * - **Effectful filters**: Supports filters requiring async operations with retry policies
13
+ * - **Batch evaluation**: Efficiently evaluates filters against multiple posts
14
+ * - **Explanation mode**: Provides detailed reasoning for filter decisions
15
+ * - **Error policies**: Configurable handling of filter evaluation errors (Include/Exclude/Retry)
16
+ *
17
+ * ## Filter Types
18
+ *
19
+ * ### Simple Filters
20
+ * - `All`, `None`: Identity filters
21
+ * - `Author`, `AuthorIn`: Match by author handle
22
+ * - `Hashtag`, `HashtagIn`: Match by hashtag
23
+ * - `Contains`: Text substring matching
24
+ * - `IsReply`, `IsQuote`, `IsRepost`, `IsOriginal`: Post type matching
25
+ * - `HasImages`, `HasVideo`, `HasLinks`, `HasMedia`, `HasEmbed`: Media detection
26
+ * - `Engagement`: Threshold-based engagement matching
27
+ * - `Language`: Language code matching
28
+ * - `Regex`: Regular expression pattern matching
29
+ * - `DateRange`: Creation date range matching
30
+ *
31
+ * ### Effectful Filters
32
+ * - `HasValidLinks`: Validates external links via HTTP requests
33
+ * - `Trending`: Checks hashtag trending status via Bluesky API
34
+ *
35
+ * ### Composite Filters
36
+ * - `And`, `Or`: Logical composition
37
+ * - `Not`: Logical negation
38
+ *
39
+ * ## Error Handling
40
+ *
41
+ * Effectful filters use `FilterErrorPolicy` to determine behavior on failure:
42
+ * - `Include`: Treat errors as matching (include the post)
43
+ * - `Exclude`: Treat errors as non-matching (exclude the post)
44
+ * - `Retry`: Retry with exponential backoff
45
+ *
46
+ * ## Dependencies
47
+ *
48
+ * - `LinkValidator`: For validating external links
49
+ * - `TrendingTopics`: For checking trending hashtag status
50
+ *
51
+ * @example
52
+ * ```ts
53
+ * import { Effect } from "effect";
54
+ * import { FilterRuntime } from "./services/filter-runtime.js";
55
+ * import { and, hashtag, author } from "./domain/filter.js";
56
+ *
57
+ * const program = Effect.gen(function* () {
58
+ * const runtime = yield* FilterRuntime;
59
+ *
60
+ * // Compile a filter expression
61
+ * const predicate = yield* runtime.evaluate(
62
+ * and(hashtag("tech"), author("@alice.bsky.social"))
63
+ * );
64
+ *
65
+ * // Evaluate against a post
66
+ * const matches = yield* predicate(post);
67
+ * });
68
+ * ```
69
+ *
70
+ * @module services/filter-runtime
71
+ */
72
+
73
+ import { Chunk, Context, Duration, Effect, Layer, Schedule } from "effect";
74
+ import { FilterCompileError, FilterEvalError } from "../domain/errors.js";
75
+ import type { FilterExpr } from "../domain/filter.js";
76
+ import type { FilterErrorPolicy } from "../domain/policies.js";
77
+ import type { Post } from "../domain/post.js";
78
+ import type { FilterExplanation } from "../domain/filter-explain.js";
79
+ import type { LinkValidatorService } from "./link-validator.js";
80
+ import type { TrendingTopicsService } from "./trending-topics.js";
81
+
82
+ const regexMatches = (regex: RegExp, text: string) => {
83
+ if (regex.global || regex.sticky) {
84
+ return new RegExp(regex.source, regex.flags).test(text);
85
+ }
86
+ return regex.test(text);
87
+ };
88
+ import { LinkValidator } from "./link-validator.js";
89
+ import { TrendingTopics } from "./trending-topics.js";
90
+ import { FilterSettings } from "./filter-settings.js";
91
+
92
+ type Predicate = (post: Post) => Effect.Effect<boolean, FilterEvalError>;
93
+ type Explainer = (post: Post) => Effect.Effect<FilterExplanation, FilterEvalError>;
94
+
95
+ const embedTag = (embed: Post["embed"]): string | undefined => {
96
+ if (!embed || typeof embed !== "object" || !("_tag" in embed)) {
97
+ return undefined;
98
+ }
99
+ const tag = (embed as { readonly _tag?: unknown })._tag;
100
+ return typeof tag === "string" ? tag : undefined;
101
+ };
102
+
103
+ const embedMediaTag = (embed: Post["embed"]): string | undefined => {
104
+ if (!embed || typeof embed !== "object" || !("_tag" in embed)) {
105
+ return undefined;
106
+ }
107
+ const tag = (embed as { readonly _tag?: unknown })._tag;
108
+ if (tag !== "RecordWithMedia") {
109
+ return undefined;
110
+ }
111
+ const media = (embed as { readonly media?: unknown }).media;
112
+ if (!media || typeof media !== "object" || !("_tag" in media)) {
113
+ return undefined;
114
+ }
115
+ const mediaTag = (media as { readonly _tag?: unknown })._tag;
116
+ return typeof mediaTag === "string" ? mediaTag : undefined;
117
+ };
118
+
119
+ const hasExternalLink = (post: Post) => {
120
+ if (post.links.length > 0) {
121
+ return true;
122
+ }
123
+ const tag = embedTag(post.embed);
124
+ if (tag === "External") {
125
+ return true;
126
+ }
127
+ return embedMediaTag(post.embed) === "External";
128
+ };
129
+
130
+ const hasImages = (post: Post) => {
131
+ const tag = embedTag(post.embed);
132
+ if (tag === "Images") {
133
+ return true;
134
+ }
135
+ return embedMediaTag(post.embed) === "Images";
136
+ };
137
+
138
+ const hasVideo = (post: Post) => {
139
+ const tag = embedTag(post.embed);
140
+ if (tag === "Video") {
141
+ return true;
142
+ }
143
+ return embedMediaTag(post.embed) === "Video";
144
+ };
145
+
146
+ const hasMedia = (post: Post) =>
147
+ hasImages(post) || hasVideo(post) || hasExternalLink(post);
148
+
149
+ const hasEmbed = (post: Post) =>
150
+ post.embed != null || post.recordEmbed != null;
151
+
152
+ const isRepost = (post: Post) => {
153
+ const reason = post.feed?.reason;
154
+ if (!reason || typeof reason !== "object") {
155
+ return false;
156
+ }
157
+ const tag = (reason as { readonly _tag?: unknown })._tag;
158
+ return tag === "ReasonRepost";
159
+ };
160
+
161
+ const isQuote = (post: Post) => {
162
+ const tag = embedTag(post.embed);
163
+ return tag === "Record" || tag === "RecordWithMedia";
164
+ };
165
+
166
+ const withPolicy = (
167
+ policy: FilterErrorPolicy,
168
+ effect: Effect.Effect<boolean, FilterEvalError>
169
+ ): Effect.Effect<boolean, FilterEvalError> => {
170
+ switch (policy._tag) {
171
+ case "Include":
172
+ return effect.pipe(Effect.catchAll(() => Effect.succeed(true)));
173
+ case "Exclude":
174
+ return effect.pipe(Effect.catchAll(() => Effect.succeed(false)));
175
+ case "Retry": {
176
+ if (!Duration.isFinite(policy.baseDelay)) {
177
+ return Effect.fail(
178
+ FilterEvalError.make({ message: "Retry baseDelay must be finite" })
179
+ );
180
+ }
181
+ const delay = policy.baseDelay;
182
+ const schedule = Schedule.addDelay(
183
+ Schedule.recurs(policy.maxRetries),
184
+ () => delay
185
+ );
186
+ return effect.pipe(Effect.retry(schedule));
187
+ }
188
+ }
189
+ };
190
+
191
+ const messageFromError = (error: unknown) => {
192
+ if (error && typeof error === "object" && "message" in error) {
193
+ const message = (error as { readonly message?: unknown }).message;
194
+ if (typeof message === "string") return message;
195
+ }
196
+ return String(error);
197
+ };
198
+
199
+ const retryScheduleFor = (policy: Extract<FilterErrorPolicy, { _tag: "Retry" }>) => {
200
+ if (!Duration.isFinite(policy.baseDelay)) {
201
+ return FilterEvalError.make({ message: "Retry baseDelay must be finite" });
202
+ }
203
+ return Schedule.addDelay(Schedule.recurs(policy.maxRetries), () => policy.baseDelay);
204
+ };
205
+
206
+ const explainPolicy = <A>(
207
+ policy: FilterErrorPolicy,
208
+ effect: Effect.Effect<A, FilterEvalError>,
209
+ onSuccess: (value: A) => FilterExplanation,
210
+ onError: (error: FilterEvalError, policyTag: "Include" | "Exclude") => FilterExplanation
211
+ ): Effect.Effect<FilterExplanation, FilterEvalError> => {
212
+ switch (policy._tag) {
213
+ case "Include":
214
+ case "Exclude":
215
+ return effect.pipe(
216
+ Effect.match({
217
+ onSuccess,
218
+ onFailure: (error) => onError(error, policy._tag)
219
+ })
220
+ );
221
+ case "Retry": {
222
+ const schedule = retryScheduleFor(policy);
223
+ if (schedule instanceof FilterEvalError) {
224
+ return Effect.fail(schedule);
225
+ }
226
+ return effect.pipe(Effect.retry(schedule), Effect.map(onSuccess));
227
+ }
228
+ }
229
+ };
230
+
231
+ const skippedNode = (expr: FilterExpr, reason: string): FilterExplanation => ({
232
+ _tag: expr._tag,
233
+ ok: false,
234
+ skipped: true,
235
+ detail: reason
236
+ });
237
+
238
+ const buildExplanation = (
239
+ links: LinkValidatorService,
240
+ trending: TrendingTopicsService
241
+ ): ((expr: FilterExpr) => Effect.Effect<Explainer, FilterCompileError>) =>
242
+ Effect.fn("FilterRuntime.buildExplanation")(function* (expr: FilterExpr) {
243
+ switch (expr._tag) {
244
+ case "All":
245
+ return (_post: Post) => Effect.succeed({ _tag: "All", ok: true });
246
+ case "None":
247
+ return (_post: Post) => Effect.succeed({ _tag: "None", ok: false });
248
+ case "Author":
249
+ return (post: Post) =>
250
+ Effect.succeed({
251
+ _tag: "Author",
252
+ ok: post.author === expr.handle,
253
+ detail: `author=${post.author}, expected=${expr.handle}`
254
+ });
255
+ case "Hashtag":
256
+ return (post: Post) => {
257
+ const matched = post.hashtags.find((tag) => tag === expr.tag);
258
+ return Effect.succeed({
259
+ _tag: "Hashtag",
260
+ ok: matched !== undefined,
261
+ detail: matched
262
+ ? `matched=${matched}`
263
+ : `hashtags=${post.hashtags.join(",") || "none"}`
264
+ });
265
+ };
266
+ case "AuthorIn": {
267
+ const handles = new Set(expr.handles);
268
+ return (post: Post) =>
269
+ Effect.succeed({
270
+ _tag: "AuthorIn",
271
+ ok: handles.has(post.author),
272
+ detail: `author=${post.author}`
273
+ });
274
+ }
275
+ case "HashtagIn": {
276
+ const tags = new Set(expr.tags);
277
+ return (post: Post) => {
278
+ const matched = post.hashtags.find((tag) => tags.has(tag));
279
+ return Effect.succeed({
280
+ _tag: "HashtagIn",
281
+ ok: matched !== undefined,
282
+ detail: matched
283
+ ? `matched=${matched}`
284
+ : `hashtags=${post.hashtags.join(",") || "none"}`
285
+ });
286
+ };
287
+ }
288
+ case "Contains": {
289
+ const needle = expr.caseSensitive ? expr.text : expr.text.toLowerCase();
290
+ return (post: Post) => {
291
+ const haystack = expr.caseSensitive ? post.text : post.text.toLowerCase();
292
+ const ok = haystack.includes(needle);
293
+ return Effect.succeed({
294
+ _tag: "Contains",
295
+ ok,
296
+ detail: `caseSensitive=${expr.caseSensitive ?? false}`
297
+ });
298
+ };
299
+ }
300
+ case "IsReply":
301
+ return (post: Post) =>
302
+ Effect.succeed({
303
+ _tag: "IsReply",
304
+ ok: !!post.reply,
305
+ detail: `reply=${Boolean(post.reply)}`
306
+ });
307
+ case "IsQuote":
308
+ return (post: Post) =>
309
+ Effect.succeed({
310
+ _tag: "IsQuote",
311
+ ok: isQuote(post),
312
+ detail: `quote=${isQuote(post)}`
313
+ });
314
+ case "IsRepost":
315
+ return (post: Post) =>
316
+ Effect.succeed({
317
+ _tag: "IsRepost",
318
+ ok: isRepost(post),
319
+ detail: `repost=${isRepost(post)}`
320
+ });
321
+ case "IsOriginal":
322
+ return (post: Post) => {
323
+ const ok = !post.reply && !isQuote(post) && !isRepost(post);
324
+ return Effect.succeed({
325
+ _tag: "IsOriginal",
326
+ ok,
327
+ detail: `reply=${Boolean(post.reply)}, quote=${isQuote(post)}, repost=${isRepost(post)}`
328
+ });
329
+ };
330
+ case "Engagement":
331
+ return (post: Post) => {
332
+ const metrics = post.metrics;
333
+ const likes = metrics?.likeCount ?? 0;
334
+ const reposts = metrics?.repostCount ?? 0;
335
+ const replies = metrics?.replyCount ?? 0;
336
+ const passes = (min: number | undefined, value: number) =>
337
+ min === undefined || value >= min;
338
+ const ok =
339
+ passes(expr.minLikes, likes) &&
340
+ passes(expr.minReposts, reposts) &&
341
+ passes(expr.minReplies, replies);
342
+ return Effect.succeed({
343
+ _tag: "Engagement",
344
+ ok,
345
+ detail: `likes=${likes}, reposts=${reposts}, replies=${replies}`
346
+ });
347
+ };
348
+ case "HasImages":
349
+ return (post: Post) =>
350
+ Effect.succeed({
351
+ _tag: "HasImages",
352
+ ok: hasImages(post),
353
+ detail: `hasImages=${hasImages(post)}`
354
+ });
355
+ case "HasVideo":
356
+ return (post: Post) =>
357
+ Effect.succeed({
358
+ _tag: "HasVideo",
359
+ ok: hasVideo(post),
360
+ detail: `hasVideo=${hasVideo(post)}`
361
+ });
362
+ case "HasLinks":
363
+ return (post: Post) =>
364
+ Effect.succeed({
365
+ _tag: "HasLinks",
366
+ ok: hasExternalLink(post),
367
+ detail: `links=${post.links.length}`
368
+ });
369
+ case "HasMedia":
370
+ return (post: Post) =>
371
+ Effect.succeed({
372
+ _tag: "HasMedia",
373
+ ok: hasMedia(post),
374
+ detail: `hasMedia=${hasMedia(post)}`
375
+ });
376
+ case "HasEmbed":
377
+ return (post: Post) =>
378
+ Effect.succeed({
379
+ _tag: "HasEmbed",
380
+ ok: hasEmbed(post),
381
+ detail: `hasEmbed=${hasEmbed(post)}`
382
+ });
383
+ case "Language": {
384
+ const langs = new Set(expr.langs.map((lang) => lang.toLowerCase()));
385
+ return (post: Post) => {
386
+ if (!post.langs || post.langs.length === 0) {
387
+ return Effect.succeed({
388
+ _tag: "Language",
389
+ ok: false,
390
+ detail: "langs=none"
391
+ });
392
+ }
393
+ const matched = post.langs.find((lang) => langs.has(lang.toLowerCase()));
394
+ return Effect.succeed({
395
+ _tag: "Language",
396
+ ok: matched !== undefined,
397
+ detail: matched
398
+ ? `matched=${matched}`
399
+ : `langs=${post.langs.join(",")}`
400
+ });
401
+ };
402
+ }
403
+ case "Regex": {
404
+ if (expr.patterns.length === 0) {
405
+ return yield* FilterCompileError.make({
406
+ message: "Regex patterns must contain at least one entry"
407
+ });
408
+ }
409
+ const compiled = yield* Effect.forEach(
410
+ expr.patterns,
411
+ (pattern) =>
412
+ Effect.try({
413
+ try: () => new RegExp(pattern, expr.flags),
414
+ catch: (error) =>
415
+ FilterCompileError.make({
416
+ message: `Invalid regex "${pattern}": ${messageFromError(error)}`
417
+ })
418
+ })
419
+ );
420
+ return (post: Post) => {
421
+ const matched = compiled.find((regex) => regexMatches(regex, post.text));
422
+ return Effect.succeed({
423
+ _tag: "Regex",
424
+ ok: matched !== undefined,
425
+ detail: matched
426
+ ? `matched=${matched.source}`
427
+ : `patterns=${expr.patterns.join(",")}`
428
+ });
429
+ };
430
+ }
431
+ case "DateRange":
432
+ return (post: Post) => {
433
+ const created = post.createdAt.getTime();
434
+ const ok =
435
+ created >= expr.start.getTime() && created <= expr.end.getTime();
436
+ return Effect.succeed({
437
+ _tag: "DateRange",
438
+ ok,
439
+ detail: `createdAt=${post.createdAt.toISOString()}`
440
+ });
441
+ };
442
+ case "And": {
443
+ const left = yield* buildExplanation(links, trending)(expr.left);
444
+ const right = yield* buildExplanation(links, trending)(expr.right);
445
+ return (post: Post) =>
446
+ left(post).pipe(
447
+ Effect.flatMap((leftResult) => {
448
+ if (!leftResult.ok) {
449
+ return Effect.succeed({
450
+ _tag: "And",
451
+ ok: false,
452
+ children: [
453
+ leftResult,
454
+ skippedNode(expr.right, "Skipped because left side was false.")
455
+ ]
456
+ });
457
+ }
458
+ return right(post).pipe(
459
+ Effect.map((rightResult) => ({
460
+ _tag: "And",
461
+ ok: rightResult.ok,
462
+ children: [leftResult, rightResult]
463
+ }))
464
+ );
465
+ })
466
+ );
467
+ }
468
+ case "Or": {
469
+ const left = yield* buildExplanation(links, trending)(expr.left);
470
+ const right = yield* buildExplanation(links, trending)(expr.right);
471
+ return (post: Post) =>
472
+ left(post).pipe(
473
+ Effect.flatMap((leftResult) => {
474
+ if (leftResult.ok) {
475
+ return Effect.succeed({
476
+ _tag: "Or",
477
+ ok: true,
478
+ children: [
479
+ leftResult,
480
+ skippedNode(expr.right, "Skipped because left side was true.")
481
+ ]
482
+ });
483
+ }
484
+ return right(post).pipe(
485
+ Effect.map((rightResult) => ({
486
+ _tag: "Or",
487
+ ok: rightResult.ok,
488
+ children: [leftResult, rightResult]
489
+ }))
490
+ );
491
+ })
492
+ );
493
+ }
494
+ case "Not": {
495
+ const inner = yield* buildExplanation(links, trending)(expr.expr);
496
+ return (post: Post) =>
497
+ inner(post).pipe(
498
+ Effect.map((innerResult) => ({
499
+ _tag: "Not",
500
+ ok: !innerResult.ok,
501
+ children: [innerResult]
502
+ }))
503
+ );
504
+ }
505
+ case "HasValidLinks": {
506
+ return (post: Post) => {
507
+ const urls = post.links.map((link) => link.toString());
508
+ return explainPolicy(
509
+ expr.onError,
510
+ links.hasValidLink(urls),
511
+ (ok) => ({
512
+ _tag: "HasValidLinks",
513
+ ok,
514
+ detail: `links=${urls.length}, policy=${expr.onError._tag}`
515
+ }),
516
+ (error, policyTag) => ({
517
+ _tag: "HasValidLinks",
518
+ ok: policyTag === "Include",
519
+ detail: `error=${messageFromError(error)}, policy=${policyTag}`
520
+ })
521
+ );
522
+ };
523
+ }
524
+ case "Trending": {
525
+ return (_post: Post) =>
526
+ explainPolicy(
527
+ expr.onError,
528
+ trending.isTrending(expr.tag),
529
+ (ok) => ({
530
+ _tag: "Trending",
531
+ ok,
532
+ detail: `tag=${expr.tag}, policy=${expr.onError._tag}`
533
+ }),
534
+ (error, policyTag) => ({
535
+ _tag: "Trending",
536
+ ok: policyTag === "Include",
537
+ detail: `error=${messageFromError(error)}, policy=${policyTag}`
538
+ })
539
+ );
540
+ }
541
+ default:
542
+ return yield* FilterCompileError.make({
543
+ message: `Unknown filter tag: ${(expr as { _tag: string })._tag}`
544
+ });
545
+ }
546
+ });
547
+
548
+ const buildPredicate = (
549
+ links: LinkValidatorService,
550
+ trending: TrendingTopicsService
551
+ ): ((expr: FilterExpr) => Effect.Effect<Predicate, FilterCompileError>) =>
552
+ Effect.fn("FilterRuntime.buildPredicate")(function* (expr: FilterExpr) {
553
+ switch (expr._tag) {
554
+ case "All":
555
+ return (_post: Post) => Effect.succeed(true);
556
+ case "None":
557
+ return (_post: Post) => Effect.succeed(false);
558
+ case "Author":
559
+ return (post: Post) =>
560
+ Effect.succeed(post.author === expr.handle);
561
+ case "Hashtag":
562
+ return (post: Post) =>
563
+ Effect.succeed(post.hashtags.some((tag) => tag === expr.tag));
564
+ case "AuthorIn": {
565
+ const handles = new Set(expr.handles);
566
+ return (post: Post) =>
567
+ Effect.succeed(handles.has(post.author));
568
+ }
569
+ case "HashtagIn": {
570
+ const tags = new Set(expr.tags);
571
+ return (post: Post) =>
572
+ Effect.succeed(post.hashtags.some((tag) => tags.has(tag)));
573
+ }
574
+ case "Contains": {
575
+ const needle = expr.caseSensitive ? expr.text : expr.text.toLowerCase();
576
+ return (post: Post) => {
577
+ const haystack = expr.caseSensitive ? post.text : post.text.toLowerCase();
578
+ return Effect.succeed(haystack.includes(needle));
579
+ };
580
+ }
581
+ case "IsReply":
582
+ return (post: Post) => Effect.succeed(!!post.reply);
583
+ case "IsQuote":
584
+ return (post: Post) => Effect.succeed(isQuote(post));
585
+ case "IsRepost":
586
+ return (post: Post) => Effect.succeed(isRepost(post));
587
+ case "IsOriginal":
588
+ return (post: Post) =>
589
+ Effect.succeed(!post.reply && !isQuote(post) && !isRepost(post));
590
+ case "Engagement":
591
+ return (post: Post) => {
592
+ const metrics = post.metrics;
593
+ const likes = metrics?.likeCount ?? 0;
594
+ const reposts = metrics?.repostCount ?? 0;
595
+ const replies = metrics?.replyCount ?? 0;
596
+ const passes = (min: number | undefined, value: number) =>
597
+ min === undefined || value >= min;
598
+ return Effect.succeed(
599
+ passes(expr.minLikes, likes) &&
600
+ passes(expr.minReposts, reposts) &&
601
+ passes(expr.minReplies, replies)
602
+ );
603
+ };
604
+ case "HasImages":
605
+ return (post: Post) => Effect.succeed(hasImages(post));
606
+ case "HasVideo":
607
+ return (post: Post) => Effect.succeed(hasVideo(post));
608
+ case "HasLinks":
609
+ return (post: Post) =>
610
+ Effect.succeed(hasExternalLink(post));
611
+ case "HasMedia":
612
+ return (post: Post) => Effect.succeed(hasMedia(post));
613
+ case "HasEmbed":
614
+ return (post: Post) => Effect.succeed(hasEmbed(post));
615
+ case "Language": {
616
+ const langs = new Set(expr.langs.map((lang) => lang.toLowerCase()));
617
+ return (post: Post) => {
618
+ if (!post.langs || post.langs.length === 0) {
619
+ return Effect.succeed(false);
620
+ }
621
+ return Effect.succeed(
622
+ post.langs.some((lang) => langs.has(lang.toLowerCase()))
623
+ );
624
+ };
625
+ }
626
+ case "Regex": {
627
+ if (expr.patterns.length === 0) {
628
+ return yield* FilterCompileError.make({
629
+ message: "Regex patterns must contain at least one entry"
630
+ });
631
+ }
632
+ const compiled = yield* Effect.forEach(
633
+ expr.patterns,
634
+ (pattern) =>
635
+ Effect.try({
636
+ try: () => new RegExp(pattern, expr.flags),
637
+ catch: (error) =>
638
+ FilterCompileError.make({
639
+ message: `Invalid regex "${pattern}": ${messageFromError(error)}`
640
+ })
641
+ })
642
+ );
643
+ return (post: Post) =>
644
+ Effect.succeed(
645
+ compiled.some((regex) => regexMatches(regex, post.text))
646
+ );
647
+ }
648
+ case "DateRange":
649
+ return (post: Post) => {
650
+ const created = post.createdAt.getTime();
651
+ return Effect.succeed(
652
+ created >= expr.start.getTime() && created <= expr.end.getTime()
653
+ );
654
+ };
655
+ case "And": {
656
+ const left = yield* buildPredicate(links, trending)(expr.left);
657
+ const right = yield* buildPredicate(links, trending)(expr.right);
658
+ return (post: Post) =>
659
+ left(post).pipe(
660
+ Effect.flatMap((ok) =>
661
+ ok ? right(post) : Effect.succeed(false)
662
+ )
663
+ );
664
+ }
665
+ case "Or": {
666
+ const left = yield* buildPredicate(links, trending)(expr.left);
667
+ const right = yield* buildPredicate(links, trending)(expr.right);
668
+ return (post: Post) =>
669
+ left(post).pipe(
670
+ Effect.flatMap((ok) =>
671
+ ok ? Effect.succeed(true) : right(post)
672
+ )
673
+ );
674
+ }
675
+ case "Not": {
676
+ const inner = yield* buildPredicate(links, trending)(expr.expr);
677
+ return (post: Post) =>
678
+ inner(post).pipe(Effect.map((ok) => !ok));
679
+ }
680
+ case "HasValidLinks": {
681
+ return (post: Post) =>
682
+ withPolicy(
683
+ expr.onError,
684
+ links.hasValidLink(post.links.map((link) => link.toString()))
685
+ );
686
+ }
687
+ case "Trending": {
688
+ return (_post: Post) =>
689
+ withPolicy(expr.onError, trending.isTrending(expr.tag));
690
+ }
691
+ default:
692
+ return yield* FilterCompileError.make({
693
+ message: `Unknown filter tag: ${(expr as { _tag: string })._tag}`
694
+ });
695
+ }
696
+ });
697
+
698
+ /**
699
+ * Service for compiling and evaluating filter expressions.
700
+ *
701
+ * Provides methods to compile FilterExpr AST into executable predicates,
702
+ * with support for batch evaluation and explanation mode.
703
+ *
704
+ * ## Methods
705
+ *
706
+ * - `evaluate`: Compile a filter into a predicate function
707
+ * - `evaluateWithMetadata`: Like evaluate but returns detailed match results
708
+ * - `evaluateBatch`: Efficiently evaluate a filter against multiple posts
709
+ * - `explain`: Get detailed explanations for why posts match or don't match
710
+ *
711
+ * @example
712
+ * ```ts
713
+ * const runtime = yield* FilterRuntime;
714
+ *
715
+ * // Simple evaluation
716
+ * const predicate = yield* runtime.evaluate(hashtag("tech"));
717
+ * const matches = yield* predicate(post);
718
+ *
719
+ * // Batch evaluation for performance
720
+ * const batchPredicate = yield* runtime.evaluateBatch(filter);
721
+ * const results = yield* batchPredicate(Chunk.fromIterable(posts));
722
+ * ```
723
+ */
724
+ export class FilterRuntime extends Context.Tag("@skygent/FilterRuntime")<
725
+ FilterRuntime,
726
+ {
727
+ /**
728
+ * Compiles a filter expression into an executable predicate.
729
+ *
730
+ * @param expr - The filter expression to compile
731
+ * @returns Effect that yields a predicate function
732
+ */
733
+ readonly evaluate: (
734
+ expr: FilterExpr
735
+ ) => Effect.Effect<Predicate, FilterCompileError>;
736
+
737
+ /**
738
+ * Like evaluate, but returns detailed match results with metadata.
739
+ *
740
+ * @param expr - The filter expression to compile
741
+ * @returns Effect that yields a predicate returning { ok: boolean }
742
+ */
743
+ readonly evaluateWithMetadata: (
744
+ expr: FilterExpr
745
+ ) => Effect.Effect<
746
+ (post: Post) => Effect.Effect<
747
+ { readonly ok: boolean },
748
+ FilterEvalError
749
+ >,
750
+ FilterCompileError
751
+ >;
752
+
753
+ /**
754
+ * Compiles a filter for efficient batch evaluation.
755
+ *
756
+ * Batch evaluation processes multiple posts concurrently with
757
+ * automatic request batching for effectful filters.
758
+ *
759
+ * @param expr - The filter expression to compile
760
+ * @returns Effect that yields a batch predicate
761
+ */
762
+ readonly evaluateBatch: (
763
+ expr: FilterExpr
764
+ ) => Effect.Effect<
765
+ (posts: Chunk.Chunk<Post>) => Effect.Effect<Chunk.Chunk<boolean>, FilterEvalError>,
766
+ FilterCompileError
767
+ >;
768
+
769
+ /**
770
+ * Compiles a filter into an explainer function.
771
+ *
772
+ * Explainer functions provide detailed reasoning for filter decisions,
773
+ * useful for debugging and user feedback.
774
+ *
775
+ * @param expr - The filter expression to compile
776
+ * @returns Effect that yields an explainer function
777
+ */
778
+ readonly explain: (
779
+ expr: FilterExpr
780
+ ) => Effect.Effect<Explainer, FilterCompileError>;
781
+ }
782
+ >() {
783
+ static readonly layer = Layer.effect(
784
+ FilterRuntime,
785
+ Effect.gen(function* () {
786
+ const links = yield* LinkValidator;
787
+ const trending = yield* TrendingTopics;
788
+ const settings = yield* FilterSettings;
789
+ const evaluate = Effect.fn("FilterRuntime.evaluate")((expr: FilterExpr) =>
790
+ buildPredicate(links, trending)(expr)
791
+ );
792
+ const evaluateWithMetadata = Effect.fn(
793
+ "FilterRuntime.evaluateWithMetadata"
794
+ )((expr: FilterExpr) =>
795
+ buildPredicate(links, trending)(expr).pipe(
796
+ Effect.map((predicate) => (post: Post) =>
797
+ predicate(post).pipe(Effect.map((ok) => ({ ok })))
798
+ )
799
+ )
800
+ );
801
+ const evaluateBatch = Effect.fn("FilterRuntime.evaluateBatch")((expr: FilterExpr) =>
802
+ buildPredicate(links, trending)(expr).pipe(
803
+ Effect.map((predicate) => (posts: Chunk.Chunk<Post>) =>
804
+ Effect.all(Array.from(posts, (post) => predicate(post)), {
805
+ batching: true,
806
+ concurrency: settings.concurrency
807
+ }).pipe(
808
+ Effect.map(Chunk.fromIterable),
809
+ Effect.withRequestBatching(true)
810
+ )
811
+ )
812
+ )
813
+ );
814
+ const explain = Effect.fn("FilterRuntime.explain")((expr: FilterExpr) =>
815
+ buildExplanation(links, trending)(expr)
816
+ );
817
+
818
+ return FilterRuntime.of({ evaluate, evaluateWithMetadata, evaluateBatch, explain });
819
+ })
820
+ );
821
+ }