@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,435 @@
1
+ import { Args, Command, Options } from "@effect/cli";
2
+ import { formatSchemaError } from "./shared.js";
3
+ import { Chunk, Clock, Effect, Option, Stream } from "effect";
4
+ import { StoreQuery } from "../domain/events.js";
5
+ import { RawPost } from "../domain/raw.js";
6
+ import type { Post } from "../domain/post.js";
7
+ import { StoreName } from "../domain/primitives.js";
8
+ import { BskyClient } from "../services/bsky-client.js";
9
+ import { FilterCompiler } from "../services/filter-compiler.js";
10
+ import { FilterLibrary } from "../services/filter-library.js";
11
+ import { FilterRuntime } from "../services/filter-runtime.js";
12
+ import { PostParser } from "../services/post-parser.js";
13
+ import { AppConfigService } from "../services/app-config.js";
14
+ import { StoreIndex } from "../services/store-index.js";
15
+ import { parseFilterExpr } from "./filter-input.js";
16
+ import { decodeJson } from "./parse.js";
17
+ import { writeJson, writeText } from "./output.js";
18
+ import { CliInputError, CliJsonError } from "./errors.js";
19
+ import { storeOptions } from "./store.js";
20
+ import { describeFilter, formatFilterExpr } from "../domain/filter-describe.js";
21
+ import { renderFilterDescriptionDoc } from "./doc/filter.js";
22
+ import { renderPlain, renderAnsi } from "./doc/render.js";
23
+ import { renderTableLegacy } from "./doc/table.js";
24
+ import { withExamples } from "./help.js";
25
+ import { filterOption, filterJsonOption } from "./shared-options.js";
26
+ import { jsonTableFormats, resolveOutputFormat, textJsonFormats } from "./output-format.js";
27
+
28
+ const filterNameArg = Args.text({ name: "name" }).pipe(
29
+ Args.withSchema(StoreName),
30
+ Args.withDescription("Filter name")
31
+ );
32
+
33
+ const postJsonOption = Options.text("post-json").pipe(
34
+ Options.withDescription("Raw post JSON (app.bsky.feed.getPosts result)."),
35
+ Options.optional
36
+ );
37
+ const postUriOption = Options.text("post-uri").pipe(
38
+ Options.withDescription("Bluesky post URI (at://...)."),
39
+ Options.optional
40
+ );
41
+ const storeOption = Options.text("store").pipe(
42
+ Options.withSchema(StoreName),
43
+ Options.withDescription("Store name to sample for benchmarking")
44
+ );
45
+ const sampleSizeOption = Options.integer("sample-size").pipe(
46
+ Options.withDescription("Number of posts to evaluate (default: 1000)"),
47
+ Options.optional
48
+ );
49
+ const describeFormatOption = Options.choice("format", textJsonFormats).pipe(
50
+ Options.withDescription("Output format for filter descriptions (default: text)"),
51
+ Options.optional
52
+ );
53
+ const testFormatOption = Options.choice("format", textJsonFormats).pipe(
54
+ Options.withDescription("Output format for filter tests (default: text)"),
55
+ Options.optional
56
+ );
57
+ const listFormatOption = Options.choice("format", jsonTableFormats).pipe(
58
+ Options.withDescription("Output format (default: json)"),
59
+ Options.optional
60
+ );
61
+ const describeAnsiOption = Options.boolean("ansi").pipe(
62
+ Options.withDescription("Enable ANSI colors in output")
63
+ );
64
+
65
+ const requireFilterExpr = (
66
+ filter: Option.Option<string>,
67
+ filterJson: Option.Option<string>
68
+ ) =>
69
+ Option.match(filter, {
70
+ onNone: () =>
71
+ Option.match(filterJson, {
72
+ onNone: () =>
73
+ Effect.fail(
74
+ CliInputError.make({
75
+ message: "Provide --filter or --filter-json.",
76
+ cause: { filter: null, filterJson: null }
77
+ })
78
+ ),
79
+ onSome: () => Effect.void
80
+ }),
81
+ onSome: () => Effect.void
82
+ });
83
+
84
+
85
+ const requireSinglePostInput = (
86
+ postJson: Option.Option<string>,
87
+ postUri: Option.Option<string>
88
+ ) => {
89
+ if (Option.isSome(postJson) && Option.isSome(postUri)) {
90
+ return Effect.fail(
91
+ CliInputError.make({
92
+ message: "Use only one of --post-json or --post-uri.",
93
+ cause: { postJson: true, postUri: true }
94
+ })
95
+ );
96
+ }
97
+ if (Option.isNone(postJson) && Option.isNone(postUri)) {
98
+ return Effect.fail(
99
+ CliInputError.make({
100
+ message: "Provide --post-json or --post-uri.",
101
+ cause: { postJson: null, postUri: null }
102
+ })
103
+ );
104
+ }
105
+ return Effect.void;
106
+ };
107
+
108
+ const parseRawPost = (raw: RawPost) =>
109
+ Effect.gen(function* () {
110
+ const parser = yield* PostParser;
111
+ return yield* parser.parsePost(raw).pipe(
112
+ Effect.mapError((error) =>
113
+ CliInputError.make({
114
+ message: `Invalid post payload: ${formatSchemaError(error)}`,
115
+ cause: error
116
+ })
117
+ )
118
+ );
119
+ });
120
+
121
+ const loadPost = (
122
+ postJson: Option.Option<string>,
123
+ postUri: Option.Option<string>
124
+ ): Effect.Effect<Post, CliInputError | CliJsonError, BskyClient | PostParser> =>
125
+ Effect.gen(function* () {
126
+ yield* requireSinglePostInput(postJson, postUri);
127
+ if (Option.isSome(postJson)) {
128
+ const raw = yield* decodeJson(RawPost, postJson.value);
129
+ return yield* parseRawPost(raw);
130
+ }
131
+ if (Option.isSome(postUri)) {
132
+ const client = yield* BskyClient;
133
+ const raw = yield* client.getPost(postUri.value).pipe(
134
+ Effect.mapError((error) =>
135
+ CliInputError.make({
136
+ message: `Failed to fetch post: ${error.message}`,
137
+ cause: error
138
+ })
139
+ )
140
+ );
141
+ return yield* parseRawPost(raw);
142
+ }
143
+ return yield* CliInputError.make({
144
+ message: "Provide --post-json or --post-uri.",
145
+ cause: { postJson: null, postUri: null }
146
+ });
147
+ });
148
+
149
+ export const filterList = Command.make("list", { format: listFormatOption }, ({ format }) =>
150
+ Effect.gen(function* () {
151
+ const appConfig = yield* AppConfigService;
152
+ const library = yield* FilterLibrary;
153
+ const names = yield* library.list();
154
+ const outputFormat = resolveOutputFormat(
155
+ format,
156
+ appConfig.outputFormat,
157
+ jsonTableFormats,
158
+ "json"
159
+ );
160
+
161
+ if (outputFormat === "table") {
162
+ const filters = yield* Effect.forEach(names, (name) =>
163
+ library.get(name as StoreName).pipe(Effect.map((expr) => ({ name, expr: formatFilterExpr(expr) })))
164
+ );
165
+ const rows = filters.map((f) => [f.name, f.expr]);
166
+ const table = renderTableLegacy(["NAME", "EXPRESSION"], rows);
167
+ yield* writeText(table);
168
+ return;
169
+ }
170
+
171
+ yield* writeJson(names);
172
+ })
173
+ ).pipe(
174
+ Command.withDescription(
175
+ withExamples("List saved filters", ["skygent filter list"])
176
+ )
177
+ );
178
+
179
+ export const filterShow = Command.make(
180
+ "show",
181
+ { name: filterNameArg },
182
+ ({ name }) =>
183
+ Effect.gen(function* () {
184
+ const library = yield* FilterLibrary;
185
+ const expr = yield* library.get(name);
186
+ yield* writeJson(expr);
187
+ })
188
+ ).pipe(
189
+ Command.withDescription(
190
+ withExamples("Show a saved filter", ["skygent filter show tech"])
191
+ )
192
+ );
193
+
194
+ export const filterCreate = Command.make(
195
+ "create",
196
+ { name: filterNameArg, filter: filterOption, filterJson: filterJsonOption },
197
+ ({ name, filter, filterJson }) =>
198
+ Effect.gen(function* () {
199
+ yield* requireFilterExpr(filter, filterJson);
200
+ const expr = yield* parseFilterExpr(filter, filterJson);
201
+ const library = yield* FilterLibrary;
202
+ yield* library.save(name, expr);
203
+ yield* writeJson({ name, saved: true });
204
+ })
205
+ ).pipe(
206
+ Command.withDescription(
207
+ withExamples("Create or update a saved filter", [
208
+ "skygent filter create tech --filter 'hashtag:#tech'"
209
+ ])
210
+ )
211
+ );
212
+
213
+ export const filterDelete = Command.make(
214
+ "delete",
215
+ { name: filterNameArg },
216
+ ({ name }) =>
217
+ Effect.gen(function* () {
218
+ const library = yield* FilterLibrary;
219
+ yield* library.remove(name);
220
+ yield* writeJson({ name, deleted: true });
221
+ })
222
+ ).pipe(
223
+ Command.withDescription(
224
+ withExamples("Delete a saved filter", ["skygent filter delete tech"])
225
+ )
226
+ );
227
+
228
+ export const filterValidateAll = Command.make("validate-all", {}, () =>
229
+ Effect.gen(function* () {
230
+ const library = yield* FilterLibrary;
231
+ const results = yield* library.validateAll();
232
+ const summary = {
233
+ ok: results.filter((entry) => entry.ok).length,
234
+ failed: results.filter((entry) => !entry.ok).length
235
+ };
236
+ yield* writeJson({ summary, results });
237
+ })
238
+ ).pipe(
239
+ Command.withDescription(
240
+ withExamples("Validate all saved filters", ["skygent filter validate-all"])
241
+ )
242
+ );
243
+
244
+ export const filterValidate = Command.make(
245
+ "validate",
246
+ { filter: filterOption, filterJson: filterJsonOption },
247
+ ({ filter, filterJson }) =>
248
+ Effect.gen(function* () {
249
+ yield* requireFilterExpr(filter, filterJson);
250
+ const expr = yield* parseFilterExpr(filter, filterJson);
251
+ const compiler = yield* FilterCompiler;
252
+ yield* compiler.validate(expr);
253
+ yield* writeJson({ ok: true });
254
+ })
255
+ ).pipe(
256
+ Command.withDescription(
257
+ withExamples("Validate a filter expression", [
258
+ "skygent filter validate --filter 'author:alice.bsky.social'"
259
+ ])
260
+ )
261
+ );
262
+
263
+ export const filterTest = Command.make(
264
+ "test",
265
+ {
266
+ filter: filterOption,
267
+ filterJson: filterJsonOption,
268
+ postJson: postJsonOption,
269
+ postUri: postUriOption,
270
+ format: testFormatOption
271
+ },
272
+ ({ filter, filterJson, postJson, postUri, format }) =>
273
+ Effect.gen(function* () {
274
+ yield* requireFilterExpr(filter, filterJson);
275
+ const expr = yield* parseFilterExpr(filter, filterJson);
276
+ const runtime = yield* FilterRuntime;
277
+ const predicate = yield* runtime.evaluate(expr);
278
+ const post = yield* loadPost(postJson, postUri);
279
+ const ok = yield* predicate(post);
280
+ const outputFormat = Option.getOrElse(format, () => "text" as const);
281
+ if (outputFormat === "json") {
282
+ yield* writeJson({
283
+ ok,
284
+ post: { uri: post.uri, author: post.author },
285
+ filter: expr,
286
+ filterText: formatFilterExpr(expr)
287
+ });
288
+ return;
289
+ }
290
+ const author = post.author ? ` by ${post.author}` : "";
291
+ const lines = [
292
+ `Match: ${ok ? "yes" : "no"}`,
293
+ `Post: ${post.uri}${author}`,
294
+ `Filter: ${formatFilterExpr(expr)}`
295
+ ];
296
+ yield* writeText(lines.join("\n"));
297
+ })
298
+ ).pipe(
299
+ Command.withDescription(
300
+ withExamples("Test a filter against a single post", [
301
+ "skygent filter test --filter 'hashtag:#ai' --post-uri at://did:plc:example/app.bsky.feed.post/xyz"
302
+ ])
303
+ )
304
+ );
305
+
306
+ export const filterExplain = Command.make(
307
+ "explain",
308
+ {
309
+ filter: filterOption,
310
+ filterJson: filterJsonOption,
311
+ postJson: postJsonOption,
312
+ postUri: postUriOption
313
+ },
314
+ ({ filter, filterJson, postJson, postUri }) =>
315
+ Effect.gen(function* () {
316
+ yield* requireFilterExpr(filter, filterJson);
317
+ const expr = yield* parseFilterExpr(filter, filterJson);
318
+ const runtime = yield* FilterRuntime;
319
+ const explainer = yield* runtime.explain(expr);
320
+ const post = yield* loadPost(postJson, postUri);
321
+ const explanation = yield* explainer(post);
322
+ yield* writeJson({
323
+ ok: explanation.ok,
324
+ post: { uri: post.uri, author: post.author },
325
+ explanation
326
+ });
327
+ })
328
+ ).pipe(
329
+ Command.withDescription(
330
+ withExamples("Explain why a post matches a filter", [
331
+ "skygent filter explain --filter 'hashtag:#ai' --post-uri at://did:plc:example/app.bsky.feed.post/xyz"
332
+ ])
333
+ )
334
+ );
335
+
336
+ export const filterBenchmark = Command.make(
337
+ "benchmark",
338
+ {
339
+ store: storeOption,
340
+ filter: filterOption,
341
+ filterJson: filterJsonOption,
342
+ sampleSize: sampleSizeOption
343
+ },
344
+ ({ store, filter, filterJson, sampleSize }) =>
345
+ Effect.gen(function* () {
346
+ yield* requireFilterExpr(filter, filterJson);
347
+ const expr = yield* parseFilterExpr(filter, filterJson);
348
+ const runtime = yield* FilterRuntime;
349
+ const index = yield* StoreIndex;
350
+ const storeRef = yield* storeOptions.loadStoreRef(store);
351
+ const limit = Option.getOrElse(sampleSize, () => 1000);
352
+ const evaluateBatch = yield* runtime.evaluateBatch(expr);
353
+ const query = StoreQuery.make({ scanLimit: limit, order: "desc" });
354
+ const stream = index.query(storeRef, query);
355
+ const start = yield* Clock.currentTimeMillis;
356
+ const result = yield* stream.pipe(
357
+ Stream.grouped(50),
358
+ Stream.runFoldEffect(
359
+ { processed: 0, matched: 0 },
360
+ (state, batch) =>
361
+ evaluateBatch(batch).pipe(
362
+ Effect.map((results) => {
363
+ const processed = state.processed + Chunk.size(batch);
364
+ const matched =
365
+ state.matched +
366
+ Chunk.toReadonlyArray(results).filter(Boolean).length;
367
+ return { processed, matched };
368
+ })
369
+ )
370
+ )
371
+ );
372
+ const end = yield* Clock.currentTimeMillis;
373
+ const durationMs = end - start;
374
+ const avgMs = result.processed > 0 ? durationMs / result.processed : 0;
375
+ yield* writeJson({
376
+ store: storeRef.name,
377
+ processed: result.processed,
378
+ matched: result.matched,
379
+ durationMs,
380
+ avgMsPerPost: avgMs,
381
+ sampleSize: limit
382
+ });
383
+ })
384
+ ).pipe(
385
+ Command.withDescription(
386
+ withExamples("Benchmark filter performance over stored posts", [
387
+ "skygent filter benchmark --store my-store --filter 'hashtag:#ai' --sample-size 500"
388
+ ])
389
+ )
390
+ );
391
+
392
+ export const filterDescribe = Command.make(
393
+ "describe",
394
+ { filter: filterOption, filterJson: filterJsonOption, format: describeFormatOption, ansi: describeAnsiOption },
395
+ ({ filter, filterJson, format, ansi }) =>
396
+ Effect.gen(function* () {
397
+ yield* requireFilterExpr(filter, filterJson);
398
+ const expr = yield* parseFilterExpr(filter, filterJson);
399
+ const description = describeFilter(expr);
400
+ const outputFormat = Option.getOrElse(format, () => "text" as const);
401
+ if (outputFormat === "json") {
402
+ yield* writeJson(description);
403
+ return;
404
+ }
405
+ const doc = renderFilterDescriptionDoc(description);
406
+ yield* writeText(ansi ? renderAnsi(doc) : renderPlain(doc));
407
+ })
408
+ ).pipe(
409
+ Command.withDescription(
410
+ withExamples("Describe a filter in human-readable form", [
411
+ "skygent filter describe --filter 'hashtag:#ai'",
412
+ "skygent filter describe --filter 'hashtag:#ai' --format json"
413
+ ])
414
+ )
415
+ );
416
+
417
+ export const filterCommand = Command.make("filter", {}).pipe(
418
+ Command.withSubcommands([
419
+ filterList,
420
+ filterShow,
421
+ filterCreate,
422
+ filterDelete,
423
+ filterValidateAll,
424
+ filterValidate,
425
+ filterTest,
426
+ filterExplain,
427
+ filterBenchmark,
428
+ filterDescribe
429
+ ]),
430
+ Command.withDescription(
431
+ withExamples("Manage saved filters and filter tooling", [
432
+ "skygent filter list"
433
+ ])
434
+ )
435
+ );