@mepuka/skygent 0.2.0 → 0.3.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.
- package/README.md +269 -31
- package/index.ts +18 -3
- package/package.json +1 -1
- package/src/cli/app.ts +4 -2
- package/src/cli/config.ts +20 -3
- package/src/cli/doc/table-renderers.ts +29 -0
- package/src/cli/doc/thread.ts +2 -4
- package/src/cli/exit-codes.ts +2 -0
- package/src/cli/feed.ts +35 -55
- package/src/cli/filter-dsl.ts +146 -11
- package/src/cli/filter-errors.ts +9 -3
- package/src/cli/filter-help.ts +7 -0
- package/src/cli/filter-input.ts +3 -2
- package/src/cli/filter.ts +84 -4
- package/src/cli/graph.ts +193 -156
- package/src/cli/input.ts +45 -0
- package/src/cli/layers.ts +10 -0
- package/src/cli/logging.ts +8 -0
- package/src/cli/output-render.ts +14 -0
- package/src/cli/pagination.ts +18 -0
- package/src/cli/parse-errors.ts +18 -0
- package/src/cli/pipe.ts +157 -0
- package/src/cli/post.ts +43 -66
- package/src/cli/query.ts +349 -74
- package/src/cli/search.ts +92 -118
- package/src/cli/shared.ts +0 -19
- package/src/cli/store-errors.ts +24 -13
- package/src/cli/store-tree.ts +6 -4
- package/src/cli/store.ts +35 -2
- package/src/cli/stream-merge.ts +105 -0
- package/src/cli/sync-factory.ts +28 -3
- package/src/cli/sync.ts +16 -18
- package/src/cli/thread-options.ts +33 -0
- package/src/cli/time.ts +171 -0
- package/src/cli/view-thread.ts +12 -18
- package/src/cli/watch.ts +61 -19
- package/src/domain/errors.ts +6 -1
- package/src/domain/format.ts +21 -0
- package/src/domain/order.ts +24 -0
- package/src/graph/relationships.ts +129 -0
- package/src/services/jetstream-sync.ts +4 -4
- package/src/services/lineage-store.ts +15 -1
- package/src/services/store-commit.ts +60 -0
- package/src/services/store-manager.ts +69 -2
- package/src/services/store-renamer.ts +286 -0
- package/src/services/store-stats.ts +7 -5
- package/src/services/sync-engine.ts +136 -85
- package/src/services/sync-reporter.ts +3 -1
- package/src/services/sync-settings.ts +24 -0
package/src/cli/feed.ts
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import { Args, Command, Options } from "@effect/cli";
|
|
2
|
-
import { Effect,
|
|
2
|
+
import { Effect, Stream } from "effect";
|
|
3
3
|
import { renderTableLegacy } from "./doc/table.js";
|
|
4
|
+
import { renderFeedTable } from "./doc/table-renderers.js";
|
|
4
5
|
import { BskyClient } from "../services/bsky-client.js";
|
|
5
|
-
import type { FeedGeneratorView } from "../domain/bsky.js";
|
|
6
6
|
import { AppConfigService } from "../services/app-config.js";
|
|
7
|
-
import {
|
|
7
|
+
import type { FeedGeneratorView } from "../domain/bsky.js";
|
|
8
|
+
import { decodeActor } from "./shared-options.js";
|
|
8
9
|
import { CliInputError } from "./errors.js";
|
|
9
10
|
import { withExamples } from "./help.js";
|
|
10
11
|
import { writeJson, writeJsonStream, writeText } from "./output.js";
|
|
11
|
-
import { jsonNdjsonTableFormats
|
|
12
|
+
import { jsonNdjsonTableFormats } from "./output-format.js";
|
|
13
|
+
import { emitWithFormat } from "./output-render.js";
|
|
14
|
+
import { cursorOption as baseCursorOption, limitOption as baseLimitOption, parsePagination } from "./pagination.js";
|
|
12
15
|
|
|
13
16
|
const feedUriArg = Args.text({ name: "uri" }).pipe(
|
|
14
17
|
Args.withDescription("Bluesky feed URI (at://...)")
|
|
@@ -23,14 +26,12 @@ const actorArg = Args.text({ name: "actor" }).pipe(
|
|
|
23
26
|
Args.withDescription("Bluesky handle or DID")
|
|
24
27
|
);
|
|
25
28
|
|
|
26
|
-
const limitOption =
|
|
27
|
-
Options.withDescription("Maximum number of results")
|
|
28
|
-
Options.optional
|
|
29
|
+
const limitOption = baseLimitOption.pipe(
|
|
30
|
+
Options.withDescription("Maximum number of results")
|
|
29
31
|
);
|
|
30
32
|
|
|
31
|
-
const cursorOption =
|
|
32
|
-
Options.withDescription("Pagination cursor")
|
|
33
|
-
Options.optional
|
|
33
|
+
const cursorOption = baseCursorOption.pipe(
|
|
34
|
+
Options.withDescription("Pagination cursor")
|
|
34
35
|
);
|
|
35
36
|
|
|
36
37
|
const formatOption = Options.choice("format", jsonNdjsonTableFormats).pipe(
|
|
@@ -38,19 +39,6 @@ const formatOption = Options.choice("format", jsonNdjsonTableFormats).pipe(
|
|
|
38
39
|
Options.optional
|
|
39
40
|
);
|
|
40
41
|
|
|
41
|
-
const renderFeedTable = (
|
|
42
|
-
feeds: ReadonlyArray<FeedGeneratorView>,
|
|
43
|
-
cursor: string | undefined
|
|
44
|
-
) => {
|
|
45
|
-
const rows = feeds.map((feed) => [
|
|
46
|
-
feed.displayName,
|
|
47
|
-
feed.creator.handle,
|
|
48
|
-
feed.uri,
|
|
49
|
-
typeof feed.likeCount === "number" ? String(feed.likeCount) : ""
|
|
50
|
-
]);
|
|
51
|
-
const table = renderTableLegacy(["NAME", "CREATOR", "URI", "LIKES"], rows);
|
|
52
|
-
return cursor ? `${table}\n\nCursor: ${cursor}` : table;
|
|
53
|
-
};
|
|
54
42
|
|
|
55
43
|
const renderFeedInfoTable = (
|
|
56
44
|
view: FeedGeneratorView,
|
|
@@ -70,17 +58,17 @@ const showCommand = Command.make(
|
|
|
70
58
|
const appConfig = yield* AppConfigService;
|
|
71
59
|
const client = yield* BskyClient;
|
|
72
60
|
const result = yield* client.getFeedGenerator(uri);
|
|
73
|
-
|
|
61
|
+
yield* emitWithFormat(
|
|
74
62
|
format,
|
|
75
63
|
appConfig.outputFormat,
|
|
76
64
|
jsonNdjsonTableFormats,
|
|
77
|
-
"json"
|
|
65
|
+
"json",
|
|
66
|
+
{
|
|
67
|
+
json: writeJson(result),
|
|
68
|
+
ndjson: writeJson(result),
|
|
69
|
+
table: writeText(renderFeedInfoTable(result.view, result.isOnline, result.isValid))
|
|
70
|
+
}
|
|
78
71
|
);
|
|
79
|
-
if (outputFormat === "table") {
|
|
80
|
-
yield* writeText(renderFeedInfoTable(result.view, result.isOnline, result.isValid));
|
|
81
|
-
return;
|
|
82
|
-
}
|
|
83
|
-
yield* writeJson(result);
|
|
84
72
|
})
|
|
85
73
|
).pipe(
|
|
86
74
|
Command.withDescription(
|
|
@@ -104,21 +92,17 @@ const batchCommand = Command.make(
|
|
|
104
92
|
});
|
|
105
93
|
}
|
|
106
94
|
const result = yield* client.getFeedGenerators(uris);
|
|
107
|
-
|
|
95
|
+
yield* emitWithFormat(
|
|
108
96
|
format,
|
|
109
97
|
appConfig.outputFormat,
|
|
110
98
|
jsonNdjsonTableFormats,
|
|
111
|
-
"json"
|
|
99
|
+
"json",
|
|
100
|
+
{
|
|
101
|
+
json: writeJson(result),
|
|
102
|
+
ndjson: writeJsonStream(Stream.fromIterable(result.feeds)),
|
|
103
|
+
table: writeText(renderFeedTable(result.feeds, undefined))
|
|
104
|
+
}
|
|
112
105
|
);
|
|
113
|
-
if (outputFormat === "ndjson") {
|
|
114
|
-
yield* writeJsonStream(Stream.fromIterable(result.feeds));
|
|
115
|
-
return;
|
|
116
|
-
}
|
|
117
|
-
if (outputFormat === "table") {
|
|
118
|
-
yield* writeText(renderFeedTable(result.feeds, undefined));
|
|
119
|
-
return;
|
|
120
|
-
}
|
|
121
|
-
yield* writeJson(result);
|
|
122
106
|
})
|
|
123
107
|
).pipe(
|
|
124
108
|
Command.withDescription(
|
|
@@ -135,27 +119,23 @@ const byActorCommand = Command.make(
|
|
|
135
119
|
Effect.gen(function* () {
|
|
136
120
|
const appConfig = yield* AppConfigService;
|
|
137
121
|
const client = yield* BskyClient;
|
|
138
|
-
const
|
|
122
|
+
const { limit: limitValue, cursor: cursorValue } = yield* parsePagination(limit, cursor);
|
|
139
123
|
const resolvedActor = yield* decodeActor(actor);
|
|
140
124
|
const result = yield* client.getActorFeeds(resolvedActor, {
|
|
141
|
-
...(
|
|
142
|
-
...(
|
|
125
|
+
...(limitValue !== undefined ? { limit: limitValue } : {}),
|
|
126
|
+
...(cursorValue !== undefined ? { cursor: cursorValue } : {})
|
|
143
127
|
});
|
|
144
|
-
|
|
128
|
+
yield* emitWithFormat(
|
|
145
129
|
format,
|
|
146
130
|
appConfig.outputFormat,
|
|
147
131
|
jsonNdjsonTableFormats,
|
|
148
|
-
"json"
|
|
132
|
+
"json",
|
|
133
|
+
{
|
|
134
|
+
json: writeJson(result),
|
|
135
|
+
ndjson: writeJsonStream(Stream.fromIterable(result.feeds)),
|
|
136
|
+
table: writeText(renderFeedTable(result.feeds, result.cursor))
|
|
137
|
+
}
|
|
149
138
|
);
|
|
150
|
-
if (outputFormat === "ndjson") {
|
|
151
|
-
yield* writeJsonStream(Stream.fromIterable(result.feeds));
|
|
152
|
-
return;
|
|
153
|
-
}
|
|
154
|
-
if (outputFormat === "table") {
|
|
155
|
-
yield* writeText(renderFeedTable(result.feeds, result.cursor));
|
|
156
|
-
return;
|
|
157
|
-
}
|
|
158
|
-
yield* writeJson(result);
|
|
159
139
|
})
|
|
160
140
|
).pipe(
|
|
161
141
|
Command.withDescription(
|
package/src/cli/filter-dsl.ts
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
import { Context, Duration, Effect, Schema } from "effect";
|
|
1
|
+
import { Clock, Context, Duration, Effect, Schema } from "effect";
|
|
2
2
|
import { formatSchemaError } from "./shared.js";
|
|
3
3
|
import type { FilterEngagement, FilterExpr } from "../domain/filter.js";
|
|
4
4
|
import { all, and, none, not, or } from "../domain/filter.js";
|
|
5
5
|
import type { FilterErrorPolicy } from "../domain/policies.js";
|
|
6
6
|
import { ExcludeOnError, IncludeOnError, RetryOnError } from "../domain/policies.js";
|
|
7
|
-
import { Handle, Hashtag, StoreName } from "../domain/primitives.js";
|
|
7
|
+
import { Handle, Hashtag, StoreName, Timestamp } from "../domain/primitives.js";
|
|
8
8
|
import { FilterLibrary } from "../services/filter-library.js";
|
|
9
9
|
import { FilterLibraryError, FilterNotFound } from "../domain/errors.js";
|
|
10
10
|
import { CliInputError } from "./errors.js";
|
|
11
11
|
import { parseRange } from "./range.js";
|
|
12
|
+
import { parseDurationInput, parseTimeInput } from "./time.js";
|
|
12
13
|
|
|
13
14
|
type Token =
|
|
14
15
|
| { readonly _tag: "Word"; readonly value: string; readonly position: number }
|
|
@@ -212,7 +213,8 @@ const normalizeOptionKey = (key: string) =>
|
|
|
212
213
|
const normalizeFilterKey = (key: string) => key.trim().toLowerCase();
|
|
213
214
|
|
|
214
215
|
const unsupportedFilterKeys = new Map<string, string>([
|
|
215
|
-
["label", "Label filters are not supported yet."]
|
|
216
|
+
["label", "Label filters are not supported yet."],
|
|
217
|
+
["labels", "Label filters are not supported yet."]
|
|
216
218
|
]);
|
|
217
219
|
|
|
218
220
|
const filterKeyHints = new Map<string, string>([
|
|
@@ -235,7 +237,10 @@ const filterKeyHints = new Map<string, string>([
|
|
|
235
237
|
["hashtagin", "hashtagin:#ai,#ml"],
|
|
236
238
|
["tags", "hashtagin:#ai,#ml"],
|
|
237
239
|
["hashtags", "hashtagin:#ai,#ml"],
|
|
238
|
-
["engagement", "engagement:minLikes=100"]
|
|
240
|
+
["engagement", "engagement:minLikes=100"],
|
|
241
|
+
["since", "since:24h"],
|
|
242
|
+
["until", "until:2026-01-01T00:00:00Z"],
|
|
243
|
+
["age", "age:<24h"]
|
|
239
244
|
]);
|
|
240
245
|
|
|
241
246
|
type FilterSuggestion = {
|
|
@@ -296,6 +301,18 @@ const filterSuggestions: ReadonlyArray<FilterSuggestion> = [
|
|
|
296
301
|
keys: ["engagement"],
|
|
297
302
|
suggestions: ["engagement:minLikes=100"]
|
|
298
303
|
},
|
|
304
|
+
{
|
|
305
|
+
keys: ["since"],
|
|
306
|
+
suggestions: ["since:24h"]
|
|
307
|
+
},
|
|
308
|
+
{
|
|
309
|
+
keys: ["until"],
|
|
310
|
+
suggestions: ["until:2026-01-01T00:00:00Z"]
|
|
311
|
+
},
|
|
312
|
+
{
|
|
313
|
+
keys: ["age"],
|
|
314
|
+
suggestions: ["age:<24h"]
|
|
315
|
+
},
|
|
299
316
|
{
|
|
300
317
|
keys: ["authorin", "authors"],
|
|
301
318
|
suggestions: ["authorin:alice,bob"]
|
|
@@ -315,6 +332,7 @@ const defaultFilterExamples = [
|
|
|
315
332
|
"hashtag:#ai",
|
|
316
333
|
"text:\"hello\""
|
|
317
334
|
];
|
|
335
|
+
const filterHelpHint = " Tip: run \"skygent filter help\" for all predicates.";
|
|
318
336
|
|
|
319
337
|
const uniqueSuggestions = (items: ReadonlyArray<string>) =>
|
|
320
338
|
Array.from(new Set(items));
|
|
@@ -826,7 +844,8 @@ class Parser {
|
|
|
826
844
|
constructor(
|
|
827
845
|
private readonly input: string,
|
|
828
846
|
private readonly tokens: ReadonlyArray<Token>,
|
|
829
|
-
private readonly library: FilterLibraryService
|
|
847
|
+
private readonly library: FilterLibraryService,
|
|
848
|
+
private readonly now: Date
|
|
830
849
|
) {}
|
|
831
850
|
|
|
832
851
|
parse = (): Effect.Effect<FilterExpr, CliInputError> => {
|
|
@@ -1031,7 +1050,7 @@ class Parser {
|
|
|
1031
1050
|
const unsupported = unsupportedFilterKeys.get(lower);
|
|
1032
1051
|
if (unsupported) {
|
|
1033
1052
|
return yield* self.fail(
|
|
1034
|
-
`Unknown filter type "${value}". ${unsupported}`,
|
|
1053
|
+
`Unknown filter type "${value}". ${unsupported}${filterHelpHint}`,
|
|
1035
1054
|
token.position
|
|
1036
1055
|
);
|
|
1037
1056
|
}
|
|
@@ -1043,7 +1062,7 @@ class Parser {
|
|
|
1043
1062
|
);
|
|
1044
1063
|
}
|
|
1045
1064
|
return yield* self.fail(
|
|
1046
|
-
|
|
1065
|
+
`Expected a filter expression like 'hashtag:#ai' or 'author:handle'.${filterHelpHint}`,
|
|
1047
1066
|
token.position
|
|
1048
1067
|
);
|
|
1049
1068
|
}
|
|
@@ -1111,6 +1130,8 @@ class Parser {
|
|
|
1111
1130
|
const { value: baseValueRaw, valuePosition: basePosition, options } =
|
|
1112
1131
|
yield* parseValueOptions(self.input, rawValue, valuePosition, optionMode);
|
|
1113
1132
|
const baseValue = stripQuotes(baseValueRaw);
|
|
1133
|
+
const timeError = (message: string, cause?: unknown) =>
|
|
1134
|
+
failAt(self.input, basePosition, message);
|
|
1114
1135
|
|
|
1115
1136
|
switch (key) {
|
|
1116
1137
|
case "author":
|
|
@@ -1197,7 +1218,10 @@ class Parser {
|
|
|
1197
1218
|
case "embeds":
|
|
1198
1219
|
return { _tag: "HasEmbed" };
|
|
1199
1220
|
default:
|
|
1200
|
-
return yield* self.fail(
|
|
1221
|
+
return yield* self.fail(
|
|
1222
|
+
`Unknown has: filter "${baseValue}". Use images|video|links|media|embed.`,
|
|
1223
|
+
token.position
|
|
1224
|
+
);
|
|
1201
1225
|
}
|
|
1202
1226
|
}
|
|
1203
1227
|
case "engagement": {
|
|
@@ -1296,6 +1320,105 @@ class Parser {
|
|
|
1296
1320
|
yield* ensureNoUnknownOptions(options, self.input);
|
|
1297
1321
|
return { _tag: "DateRange", start: range.start, end: range.end };
|
|
1298
1322
|
}
|
|
1323
|
+
case "since": {
|
|
1324
|
+
if (baseValue.length === 0) {
|
|
1325
|
+
return yield* self.fail(`Missing value for "${key}".`, token.position);
|
|
1326
|
+
}
|
|
1327
|
+
const start = yield* parseTimeInput(baseValue, self.now, {
|
|
1328
|
+
label: "since",
|
|
1329
|
+
onError: timeError
|
|
1330
|
+
});
|
|
1331
|
+
const end = self.now;
|
|
1332
|
+
if (start.getTime() >= end.getTime()) {
|
|
1333
|
+
return yield* self.fail(
|
|
1334
|
+
"Since value must be before now.",
|
|
1335
|
+
basePosition
|
|
1336
|
+
);
|
|
1337
|
+
}
|
|
1338
|
+
yield* ensureNoUnknownOptions(options, self.input);
|
|
1339
|
+
return {
|
|
1340
|
+
_tag: "DateRange",
|
|
1341
|
+
start: yield* self.asTimestamp(start, basePosition),
|
|
1342
|
+
end: yield* self.asTimestamp(end, basePosition)
|
|
1343
|
+
};
|
|
1344
|
+
}
|
|
1345
|
+
case "until": {
|
|
1346
|
+
if (baseValue.length === 0) {
|
|
1347
|
+
return yield* self.fail(`Missing value for "${key}".`, token.position);
|
|
1348
|
+
}
|
|
1349
|
+
const end = yield* parseTimeInput(baseValue, self.now, {
|
|
1350
|
+
label: "until",
|
|
1351
|
+
onError: timeError
|
|
1352
|
+
});
|
|
1353
|
+
const start = new Date(0);
|
|
1354
|
+
if (start.getTime() >= end.getTime()) {
|
|
1355
|
+
return yield* self.fail(
|
|
1356
|
+
"Until value must be after the epoch (1970-01-01T00:00:00Z).",
|
|
1357
|
+
basePosition
|
|
1358
|
+
);
|
|
1359
|
+
}
|
|
1360
|
+
yield* ensureNoUnknownOptions(options, self.input);
|
|
1361
|
+
return {
|
|
1362
|
+
_tag: "DateRange",
|
|
1363
|
+
start: yield* self.asTimestamp(start, basePosition),
|
|
1364
|
+
end: yield* self.asTimestamp(end, basePosition)
|
|
1365
|
+
};
|
|
1366
|
+
}
|
|
1367
|
+
case "age": {
|
|
1368
|
+
if (baseValue.length === 0) {
|
|
1369
|
+
return yield* self.fail(`Missing value for "${key}".`, token.position);
|
|
1370
|
+
}
|
|
1371
|
+
const comparatorMatch = /^(<=|>=|<|>)/.exec(baseValue.trim());
|
|
1372
|
+
const comparator = comparatorMatch?.[1];
|
|
1373
|
+
const durationRaw = comparator
|
|
1374
|
+
? baseValue.trim().slice(comparator.length).trim()
|
|
1375
|
+
: baseValue.trim();
|
|
1376
|
+
if (durationRaw.length === 0) {
|
|
1377
|
+
return yield* self.fail("Age filter requires a duration.", basePosition);
|
|
1378
|
+
}
|
|
1379
|
+
const duration = yield* parseDurationInput(durationRaw, {
|
|
1380
|
+
label: "age",
|
|
1381
|
+
onError: timeError
|
|
1382
|
+
});
|
|
1383
|
+
const durationMillis = Duration.toMillis(duration);
|
|
1384
|
+
if (durationMillis <= 0) {
|
|
1385
|
+
return yield* self.fail(
|
|
1386
|
+
"Age duration must be greater than zero.",
|
|
1387
|
+
basePosition
|
|
1388
|
+
);
|
|
1389
|
+
}
|
|
1390
|
+
const now = self.now.getTime();
|
|
1391
|
+
if (comparator === ">" || comparator === ">=") {
|
|
1392
|
+
const end = new Date(now - durationMillis);
|
|
1393
|
+
const start = new Date(0);
|
|
1394
|
+
if (start.getTime() >= end.getTime()) {
|
|
1395
|
+
return yield* self.fail(
|
|
1396
|
+
"Age duration is larger than the available timeline.",
|
|
1397
|
+
basePosition
|
|
1398
|
+
);
|
|
1399
|
+
}
|
|
1400
|
+
yield* ensureNoUnknownOptions(options, self.input);
|
|
1401
|
+
return {
|
|
1402
|
+
_tag: "DateRange",
|
|
1403
|
+
start: yield* self.asTimestamp(start, basePosition),
|
|
1404
|
+
end: yield* self.asTimestamp(end, basePosition)
|
|
1405
|
+
};
|
|
1406
|
+
}
|
|
1407
|
+
const start = new Date(now - durationMillis);
|
|
1408
|
+
const end = self.now;
|
|
1409
|
+
if (start.getTime() >= end.getTime()) {
|
|
1410
|
+
return yield* self.fail(
|
|
1411
|
+
"Age duration is larger than the available timeline.",
|
|
1412
|
+
basePosition
|
|
1413
|
+
);
|
|
1414
|
+
}
|
|
1415
|
+
yield* ensureNoUnknownOptions(options, self.input);
|
|
1416
|
+
return {
|
|
1417
|
+
_tag: "DateRange",
|
|
1418
|
+
start: yield* self.asTimestamp(start, basePosition),
|
|
1419
|
+
end: yield* self.asTimestamp(end, basePosition)
|
|
1420
|
+
};
|
|
1421
|
+
}
|
|
1299
1422
|
case "links":
|
|
1300
1423
|
case "validlinks":
|
|
1301
1424
|
case "hasvalidlinks": {
|
|
@@ -1344,7 +1467,7 @@ class Parser {
|
|
|
1344
1467
|
const unsupported = unsupportedFilterKeys.get(key);
|
|
1345
1468
|
if (unsupported) {
|
|
1346
1469
|
return yield* self.fail(
|
|
1347
|
-
`Unknown filter type "${rawKey}". ${unsupported}`,
|
|
1470
|
+
`Unknown filter type "${rawKey}". ${unsupported}${filterHelpHint}`,
|
|
1348
1471
|
token.position
|
|
1349
1472
|
);
|
|
1350
1473
|
}
|
|
@@ -1353,7 +1476,7 @@ class Parser {
|
|
|
1353
1476
|
? formatSuggestionHint(suggestions)
|
|
1354
1477
|
: ` Try "${defaultFilterExamples[0]}", "${defaultFilterExamples[1]}", or "${defaultFilterExamples[2]}".`;
|
|
1355
1478
|
return yield* self.fail(
|
|
1356
|
-
`Unknown filter type "${rawKey}".${hint}`,
|
|
1479
|
+
`Unknown filter type "${rawKey}".${hint}${filterHelpHint}`,
|
|
1357
1480
|
token.position
|
|
1358
1481
|
);
|
|
1359
1482
|
}
|
|
@@ -1365,6 +1488,17 @@ class Parser {
|
|
|
1365
1488
|
return Effect.fail(failAt(this.input, position, message));
|
|
1366
1489
|
}
|
|
1367
1490
|
|
|
1491
|
+
private asTimestamp(
|
|
1492
|
+
date: Date,
|
|
1493
|
+
position: number
|
|
1494
|
+
): Effect.Effect<Timestamp, CliInputError> {
|
|
1495
|
+
return Schema.decodeUnknown(Timestamp)(date).pipe(
|
|
1496
|
+
Effect.mapError((error) =>
|
|
1497
|
+
failAt(this.input, position, `Invalid timestamp: ${formatSchemaError(error)}`)
|
|
1498
|
+
)
|
|
1499
|
+
);
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1368
1502
|
private peek(): Token | undefined {
|
|
1369
1503
|
return this.tokens[this.index];
|
|
1370
1504
|
}
|
|
@@ -1405,7 +1539,8 @@ class Parser {
|
|
|
1405
1539
|
export const parseFilterDsl = Effect.fn("FilterDsl.parse")((input: string) =>
|
|
1406
1540
|
Effect.gen(function* () {
|
|
1407
1541
|
const library = yield* FilterLibrary;
|
|
1542
|
+
const nowMillis = yield* Clock.currentTimeMillis;
|
|
1408
1543
|
const tokens = yield* tokenize(input);
|
|
1409
|
-
return yield* new Parser(input, tokens, library).parse();
|
|
1544
|
+
return yield* new Parser(input, tokens, library, new Date(nowMillis)).parse();
|
|
1410
1545
|
})
|
|
1411
1546
|
);
|
package/src/cli/filter-errors.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { ParseResult } from "effect";
|
|
2
|
-
import { safeParseJson, issueDetails } from "./
|
|
2
|
+
import { safeParseJson, issueDetails } from "./parse-errors.js";
|
|
3
3
|
import { formatAgentError, type AgentErrorPayload } from "./errors.js";
|
|
4
4
|
|
|
5
5
|
const validFilterTags = [
|
|
@@ -31,6 +31,7 @@ const validFilterTags = [
|
|
|
31
31
|
];
|
|
32
32
|
|
|
33
33
|
const filterDocs = "docs/filters/README.md";
|
|
34
|
+
const filterHelpHint = "Tip: run \"skygent filter help\" for examples and aliases.";
|
|
34
35
|
|
|
35
36
|
|
|
36
37
|
const getTag = (raw: string): string | undefined => {
|
|
@@ -48,13 +49,18 @@ const getTag = (raw: string): string | undefined => {
|
|
|
48
49
|
const hasPath = (issue: { readonly path: ReadonlyArray<unknown> }, key: string) =>
|
|
49
50
|
issue.path.length === 1 && issue.path[0] === key;
|
|
50
51
|
|
|
52
|
+
const withHelpHint = (payload: Omit<AgentErrorPayload, "error">) => ({
|
|
53
|
+
...payload,
|
|
54
|
+
details: payload.details ? [...payload.details, filterHelpHint] : [filterHelpHint]
|
|
55
|
+
});
|
|
56
|
+
|
|
51
57
|
const validationError = (
|
|
52
58
|
payload: Omit<AgentErrorPayload, "error">
|
|
53
|
-
) => formatAgentError({ error: "FilterValidationError", ...payload });
|
|
59
|
+
) => formatAgentError({ error: "FilterValidationError", ...withHelpHint(payload) });
|
|
54
60
|
|
|
55
61
|
const jsonParseError = (
|
|
56
62
|
payload: Omit<AgentErrorPayload, "error">
|
|
57
|
-
) => formatAgentError({ error: "FilterJsonParseError", ...payload });
|
|
63
|
+
) => formatAgentError({ error: "FilterJsonParseError", ...withHelpHint(payload) });
|
|
58
64
|
|
|
59
65
|
|
|
60
66
|
export const formatFilterParseError = (error: ParseResult.ParseError, raw: string): string => {
|
package/src/cli/filter-help.ts
CHANGED
|
@@ -24,6 +24,7 @@ export const filterJsonDescription = (extra?: string) =>
|
|
|
24
24
|
"Filter expression as JSON string.",
|
|
25
25
|
"Sync/query filters run at ingestion or query time; store config filters are materialized views.",
|
|
26
26
|
...(extra ? [extra] : []),
|
|
27
|
+
"Tip: run \"skygent filter help\" for all predicates and aliases.",
|
|
27
28
|
"",
|
|
28
29
|
filterJsonExamples
|
|
29
30
|
].join("\n");
|
|
@@ -31,6 +32,7 @@ export const filterJsonDescription = (extra?: string) =>
|
|
|
31
32
|
const filterDslExamples = [
|
|
32
33
|
"Examples:",
|
|
33
34
|
" hashtag:#ai AND author:user.bsky.social",
|
|
35
|
+
" from:alice.bsky.social",
|
|
34
36
|
" authorin:alice.bsky.social,bob.bsky.social",
|
|
35
37
|
" hashtagin:#tech,#coding",
|
|
36
38
|
" contains:\"typescript\",caseSensitive=false",
|
|
@@ -40,9 +42,13 @@ const filterDslExamples = [
|
|
|
40
42
|
" engagement:minLikes=100,minReplies=5",
|
|
41
43
|
" hasmedia",
|
|
42
44
|
" hasembed",
|
|
45
|
+
" has:images",
|
|
43
46
|
" language:en,es",
|
|
44
47
|
" @tech AND author:user.bsky.social",
|
|
45
48
|
" date:2024-01-01T00:00:00Z..2024-01-31T00:00:00Z",
|
|
49
|
+
" since:24h",
|
|
50
|
+
" until:2024-01-15",
|
|
51
|
+
" age:<72h",
|
|
46
52
|
" links:onError=exclude",
|
|
47
53
|
" trending:#ai,onError=include",
|
|
48
54
|
" (hashtag:#ai OR hashtag:#ml) AND author:user.bsky.social",
|
|
@@ -65,6 +71,7 @@ export const filterDslDescription = () =>
|
|
|
65
71
|
"Options are comma-separated (no spaces); quote values with spaces.",
|
|
66
72
|
"Lists use commas (e.g. authorin:alice,bob). Named filters use @name.",
|
|
67
73
|
"Defaults: onError defaults to include for trending and exclude for links.",
|
|
74
|
+
"Tip: run \"skygent filter help\" for all predicates and aliases.",
|
|
68
75
|
"",
|
|
69
76
|
filterDslExamples
|
|
70
77
|
].join("\n");
|
package/src/cli/filter-input.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Effect, Option } from "effect";
|
|
2
|
+
import type { Clock as ClockService } from "effect/Clock";
|
|
2
3
|
import { FilterExprSchema, all } from "../domain/filter.js";
|
|
3
4
|
import type { FilterExpr } from "../domain/filter.js";
|
|
4
5
|
import type { FilterLibrary } from "../services/filter-library.js";
|
|
@@ -16,7 +17,7 @@ const conflictError = (filter: boolean, filterJson: boolean) =>
|
|
|
16
17
|
export const parseFilterExpr = (
|
|
17
18
|
filter: Option.Option<string>,
|
|
18
19
|
filterJson: Option.Option<string>
|
|
19
|
-
): Effect.Effect<FilterExpr, CliInputError | CliJsonError, FilterLibrary> =>
|
|
20
|
+
): Effect.Effect<FilterExpr, CliInputError | CliJsonError, FilterLibrary | ClockService> =>
|
|
20
21
|
Option.match(filter, {
|
|
21
22
|
onNone: () =>
|
|
22
23
|
Option.match(filterJson, {
|
|
@@ -36,7 +37,7 @@ export const parseFilterExpr = (
|
|
|
36
37
|
export const parseOptionalFilterExpr = (
|
|
37
38
|
filter: Option.Option<string>,
|
|
38
39
|
filterJson: Option.Option<string>
|
|
39
|
-
): Effect.Effect<Option.Option<FilterExpr>, CliInputError | CliJsonError, FilterLibrary> =>
|
|
40
|
+
): Effect.Effect<Option.Option<FilterExpr>, CliInputError | CliJsonError, FilterLibrary | ClockService> =>
|
|
40
41
|
Option.match(filter, {
|
|
41
42
|
onNone: () =>
|
|
42
43
|
Option.match(filterJson, {
|
package/src/cli/filter.ts
CHANGED
|
@@ -24,6 +24,7 @@ import { renderTableLegacy } from "./doc/table.js";
|
|
|
24
24
|
import { withExamples } from "./help.js";
|
|
25
25
|
import { filterOption, filterJsonOption } from "./shared-options.js";
|
|
26
26
|
import { jsonTableFormats, resolveOutputFormat, textJsonFormats } from "./output-format.js";
|
|
27
|
+
import { filterDslDescription, filterJsonDescription } from "./filter-help.js";
|
|
27
28
|
|
|
28
29
|
const filterNameArg = Args.text({ name: "name" }).pipe(
|
|
29
30
|
Args.withSchema(StoreName),
|
|
@@ -42,6 +43,15 @@ const storeOption = Options.text("store").pipe(
|
|
|
42
43
|
Options.withSchema(StoreName),
|
|
43
44
|
Options.withDescription("Store name to sample for benchmarking")
|
|
44
45
|
);
|
|
46
|
+
const storeTestOption = Options.text("store").pipe(
|
|
47
|
+
Options.withSchema(StoreName),
|
|
48
|
+
Options.withDescription("Store name to sample for filter testing"),
|
|
49
|
+
Options.optional
|
|
50
|
+
);
|
|
51
|
+
const testLimitOption = Options.integer("limit").pipe(
|
|
52
|
+
Options.withDescription("Number of posts to evaluate (default: 100)"),
|
|
53
|
+
Options.optional
|
|
54
|
+
);
|
|
45
55
|
const sampleSizeOption = Options.integer("sample-size").pipe(
|
|
46
56
|
Options.withDescription("Number of posts to evaluate (default: 1000)"),
|
|
47
57
|
Options.optional
|
|
@@ -267,17 +277,74 @@ export const filterTest = Command.make(
|
|
|
267
277
|
filterJson: filterJsonOption,
|
|
268
278
|
postJson: postJsonOption,
|
|
269
279
|
postUri: postUriOption,
|
|
280
|
+
store: storeTestOption,
|
|
281
|
+
limit: testLimitOption,
|
|
270
282
|
format: testFormatOption
|
|
271
283
|
},
|
|
272
|
-
({ filter, filterJson, postJson, postUri, format }) =>
|
|
284
|
+
({ filter, filterJson, postJson, postUri, store, limit, format }) =>
|
|
273
285
|
Effect.gen(function* () {
|
|
274
286
|
yield* requireFilterExpr(filter, filterJson);
|
|
275
287
|
const expr = yield* parseFilterExpr(filter, filterJson);
|
|
276
288
|
const runtime = yield* FilterRuntime;
|
|
289
|
+
const outputFormat = Option.getOrElse(format, () => "text" as const);
|
|
290
|
+
if (Option.isSome(store)) {
|
|
291
|
+
if (Option.isSome(postJson) || Option.isSome(postUri)) {
|
|
292
|
+
return yield* CliInputError.make({
|
|
293
|
+
message: "Use either --store or --post-json/--post-uri, not both.",
|
|
294
|
+
cause: { store: store.value, postJson, postUri }
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
if (Option.isSome(limit) && limit.value <= 0) {
|
|
298
|
+
return yield* CliInputError.make({
|
|
299
|
+
message: "--limit must be a positive integer.",
|
|
300
|
+
cause: { limit: limit.value }
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
const storeRef = yield* storeOptions.loadStoreRef(store.value);
|
|
304
|
+
const index = yield* StoreIndex;
|
|
305
|
+
const evaluateBatch = yield* runtime.evaluateBatch(expr);
|
|
306
|
+
const sampleLimit = Option.getOrElse(limit, () => 100);
|
|
307
|
+
const query = StoreQuery.make({ scanLimit: sampleLimit, order: "desc" });
|
|
308
|
+
const stream = index.query(storeRef, query);
|
|
309
|
+
const result = yield* stream.pipe(
|
|
310
|
+
Stream.grouped(50),
|
|
311
|
+
Stream.runFoldEffect(
|
|
312
|
+
{ processed: 0, matched: 0 },
|
|
313
|
+
(state, batch) =>
|
|
314
|
+
evaluateBatch(batch).pipe(
|
|
315
|
+
Effect.map((results) => {
|
|
316
|
+
const processed = state.processed + Chunk.size(batch);
|
|
317
|
+
const matched =
|
|
318
|
+
state.matched +
|
|
319
|
+
Chunk.toReadonlyArray(results).filter(Boolean).length;
|
|
320
|
+
return { processed, matched };
|
|
321
|
+
})
|
|
322
|
+
)
|
|
323
|
+
)
|
|
324
|
+
);
|
|
325
|
+
if (outputFormat === "json") {
|
|
326
|
+
yield* writeJson({
|
|
327
|
+
store: storeRef.name,
|
|
328
|
+
processed: result.processed,
|
|
329
|
+
matched: result.matched,
|
|
330
|
+
limit: sampleLimit,
|
|
331
|
+
filter: expr,
|
|
332
|
+
filterText: formatFilterExpr(expr)
|
|
333
|
+
});
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
const lines = [
|
|
337
|
+
`Matched: ${result.matched}/${result.processed}`,
|
|
338
|
+
`Store: ${storeRef.name}`,
|
|
339
|
+
`Filter: ${formatFilterExpr(expr)}`
|
|
340
|
+
];
|
|
341
|
+
yield* writeText(lines.join("\n"));
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
|
|
277
345
|
const predicate = yield* runtime.evaluate(expr);
|
|
278
346
|
const post = yield* loadPost(postJson, postUri);
|
|
279
347
|
const ok = yield* predicate(post);
|
|
280
|
-
const outputFormat = Option.getOrElse(format, () => "text" as const);
|
|
281
348
|
if (outputFormat === "json") {
|
|
282
349
|
yield* writeJson({
|
|
283
350
|
ok,
|
|
@@ -297,8 +364,9 @@ export const filterTest = Command.make(
|
|
|
297
364
|
})
|
|
298
365
|
).pipe(
|
|
299
366
|
Command.withDescription(
|
|
300
|
-
withExamples("Test a filter against a
|
|
301
|
-
"skygent filter test --filter 'hashtag:#ai' --post-uri at://did:plc:example/app.bsky.feed.post/xyz"
|
|
367
|
+
withExamples("Test a filter against a post or store sample", [
|
|
368
|
+
"skygent filter test --filter 'hashtag:#ai' --post-uri at://did:plc:example/app.bsky.feed.post/xyz",
|
|
369
|
+
"skygent filter test --filter 'engagement:minLikes=10' --store my-store --limit 100"
|
|
302
370
|
])
|
|
303
371
|
)
|
|
304
372
|
);
|
|
@@ -414,8 +482,20 @@ export const filterDescribe = Command.make(
|
|
|
414
482
|
)
|
|
415
483
|
);
|
|
416
484
|
|
|
485
|
+
export const filterHelp = Command.make("help", {}, () =>
|
|
486
|
+
Effect.gen(function* () {
|
|
487
|
+
const body = [filterDslDescription(), "", filterJsonDescription()].join("\n");
|
|
488
|
+
yield* writeText(body);
|
|
489
|
+
})
|
|
490
|
+
).pipe(
|
|
491
|
+
Command.withDescription(
|
|
492
|
+
withExamples("Show filter DSL and JSON help", ["skygent filter help"])
|
|
493
|
+
)
|
|
494
|
+
);
|
|
495
|
+
|
|
417
496
|
export const filterCommand = Command.make("filter", {}).pipe(
|
|
418
497
|
Command.withSubcommands([
|
|
498
|
+
filterHelp,
|
|
419
499
|
filterList,
|
|
420
500
|
filterShow,
|
|
421
501
|
filterCreate,
|