@mepuka/skygent 0.2.0 → 0.3.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.
Files changed (62) hide show
  1. package/README.md +269 -31
  2. package/index.ts +18 -3
  3. package/package.json +1 -1
  4. package/src/cli/app.ts +4 -2
  5. package/src/cli/compact-output.ts +52 -0
  6. package/src/cli/config.ts +46 -4
  7. package/src/cli/doc/table-renderers.ts +29 -0
  8. package/src/cli/doc/thread.ts +2 -4
  9. package/src/cli/exit-codes.ts +2 -0
  10. package/src/cli/feed.ts +78 -61
  11. package/src/cli/filter-dsl.ts +146 -11
  12. package/src/cli/filter-errors.ts +13 -11
  13. package/src/cli/filter-help.ts +7 -0
  14. package/src/cli/filter-input.ts +3 -2
  15. package/src/cli/filter.ts +83 -5
  16. package/src/cli/graph.ts +297 -169
  17. package/src/cli/input.ts +45 -0
  18. package/src/cli/interval.ts +4 -33
  19. package/src/cli/jetstream.ts +2 -0
  20. package/src/cli/layers.ts +10 -0
  21. package/src/cli/logging.ts +8 -0
  22. package/src/cli/option-schemas.ts +22 -0
  23. package/src/cli/output-format.ts +11 -0
  24. package/src/cli/output-render.ts +14 -0
  25. package/src/cli/pagination.ts +17 -0
  26. package/src/cli/parse-errors.ts +30 -0
  27. package/src/cli/parse.ts +1 -47
  28. package/src/cli/pipe-input.ts +18 -0
  29. package/src/cli/pipe.ts +154 -0
  30. package/src/cli/post.ts +88 -66
  31. package/src/cli/query-fields.ts +13 -3
  32. package/src/cli/query.ts +354 -100
  33. package/src/cli/search.ts +93 -136
  34. package/src/cli/shared-options.ts +11 -63
  35. package/src/cli/shared.ts +1 -20
  36. package/src/cli/store-errors.ts +28 -21
  37. package/src/cli/store-tree.ts +6 -4
  38. package/src/cli/store.ts +41 -2
  39. package/src/cli/stream-merge.ts +105 -0
  40. package/src/cli/sync-factory.ts +24 -7
  41. package/src/cli/sync.ts +46 -67
  42. package/src/cli/thread-options.ts +25 -0
  43. package/src/cli/time.ts +171 -0
  44. package/src/cli/view-thread.ts +29 -32
  45. package/src/cli/watch.ts +55 -26
  46. package/src/domain/errors.ts +6 -1
  47. package/src/domain/format.ts +21 -0
  48. package/src/domain/order.ts +24 -0
  49. package/src/domain/primitives.ts +20 -3
  50. package/src/graph/relationships.ts +129 -0
  51. package/src/services/bsky-client.ts +11 -5
  52. package/src/services/jetstream-sync.ts +4 -4
  53. package/src/services/lineage-store.ts +15 -1
  54. package/src/services/shared.ts +48 -1
  55. package/src/services/store-cleaner.ts +5 -2
  56. package/src/services/store-commit.ts +60 -0
  57. package/src/services/store-manager.ts +69 -2
  58. package/src/services/store-renamer.ts +288 -0
  59. package/src/services/store-stats.ts +7 -5
  60. package/src/services/sync-engine.ts +149 -89
  61. package/src/services/sync-reporter.ts +3 -1
  62. package/src/services/sync-settings.ts +24 -0
package/src/cli/feed.ts CHANGED
@@ -1,36 +1,38 @@
1
1
  import { Args, Command, Options } from "@effect/cli";
2
- import { Effect, Option, Stream } from "effect";
2
+ import { Effect, Option, Schema, 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 { decodeActor, parseLimit } from "./shared-options.js";
7
+ import type { FeedGeneratorView } from "../domain/bsky.js";
8
+ import { AtUri } from "../domain/primitives.js";
9
+ import { CliPreferences } from "./preferences.js";
10
+ import { compactFeedGeneratorView } from "./compact-output.js";
11
+ import { actorArg } from "./shared-options.js";
8
12
  import { CliInputError } from "./errors.js";
9
13
  import { withExamples } from "./help.js";
10
14
  import { writeJson, writeJsonStream, writeText } from "./output.js";
11
- import { jsonNdjsonTableFormats, resolveOutputFormat } from "./output-format.js";
15
+ import { jsonNdjsonTableFormats } from "./output-format.js";
16
+ import { emitWithFormat } from "./output-render.js";
17
+ import { cursorOption as baseCursorOption, limitOption as baseLimitOption, parsePagination } from "./pagination.js";
12
18
 
13
19
  const feedUriArg = Args.text({ name: "uri" }).pipe(
20
+ Args.withSchema(AtUri),
14
21
  Args.withDescription("Bluesky feed URI (at://...)")
15
22
  );
16
23
 
17
24
  const feedUrisArg = Args.text({ name: "uri" }).pipe(
18
25
  Args.repeated,
26
+ Args.withSchema(Schema.mutable(Schema.Array(AtUri))),
19
27
  Args.withDescription("Feed URIs to fetch")
20
28
  );
21
29
 
22
- const actorArg = Args.text({ name: "actor" }).pipe(
23
- Args.withDescription("Bluesky handle or DID")
24
- );
25
-
26
- const limitOption = Options.integer("limit").pipe(
27
- Options.withDescription("Maximum number of results"),
28
- Options.optional
30
+ const limitOption = baseLimitOption.pipe(
31
+ Options.withDescription("Maximum number of results")
29
32
  );
30
33
 
31
- const cursorOption = Options.text("cursor").pipe(
32
- Options.withDescription("Pagination cursor"),
33
- Options.optional
34
+ const cursorOption = baseCursorOption.pipe(
35
+ Options.withDescription("Pagination cursor")
34
36
  );
35
37
 
36
38
  const formatOption = Options.choice("format", jsonNdjsonTableFormats).pipe(
@@ -38,19 +40,17 @@ const formatOption = Options.choice("format", jsonNdjsonTableFormats).pipe(
38
40
  Options.optional
39
41
  );
40
42
 
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
- };
43
+ const ensureSupportedFormat = (
44
+ format: Option.Option<typeof jsonNdjsonTableFormats[number]>,
45
+ configFormat: string
46
+ ) =>
47
+ Option.isNone(format) && configFormat === "markdown"
48
+ ? CliInputError.make({
49
+ message: 'Output format "markdown" is not supported for feed commands. Use --format json|ndjson|table.',
50
+ cause: { format: configFormat }
51
+ })
52
+ : Effect.void;
53
+
54
54
 
55
55
  const renderFeedInfoTable = (
56
56
  view: FeedGeneratorView,
@@ -68,19 +68,27 @@ const showCommand = Command.make(
68
68
  ({ uri, format }) =>
69
69
  Effect.gen(function* () {
70
70
  const appConfig = yield* AppConfigService;
71
+ yield* ensureSupportedFormat(format, appConfig.outputFormat);
72
+ const preferences = yield* CliPreferences;
71
73
  const client = yield* BskyClient;
72
74
  const result = yield* client.getFeedGenerator(uri);
73
- const outputFormat = resolveOutputFormat(
75
+ const payload = preferences.compact
76
+ ? {
77
+ ...result,
78
+ view: compactFeedGeneratorView(result.view)
79
+ }
80
+ : result;
81
+ yield* emitWithFormat(
74
82
  format,
75
83
  appConfig.outputFormat,
76
84
  jsonNdjsonTableFormats,
77
- "json"
85
+ "json",
86
+ {
87
+ json: writeJson(payload),
88
+ ndjson: writeJson(payload),
89
+ table: writeText(renderFeedInfoTable(result.view, result.isOnline, result.isValid))
90
+ }
78
91
  );
79
- if (outputFormat === "table") {
80
- yield* writeText(renderFeedInfoTable(result.view, result.isOnline, result.isValid));
81
- return;
82
- }
83
- yield* writeJson(result);
84
92
  })
85
93
  ).pipe(
86
94
  Command.withDescription(
@@ -96,6 +104,8 @@ const batchCommand = Command.make(
96
104
  ({ uris, format }) =>
97
105
  Effect.gen(function* () {
98
106
  const appConfig = yield* AppConfigService;
107
+ yield* ensureSupportedFormat(format, appConfig.outputFormat);
108
+ const preferences = yield* CliPreferences;
99
109
  const client = yield* BskyClient;
100
110
  if (uris.length === 0) {
101
111
  return yield* CliInputError.make({
@@ -104,21 +114,24 @@ const batchCommand = Command.make(
104
114
  });
105
115
  }
106
116
  const result = yield* client.getFeedGenerators(uris);
107
- const outputFormat = resolveOutputFormat(
117
+ const feeds = preferences.compact
118
+ ? result.feeds.map(compactFeedGeneratorView)
119
+ : result.feeds;
120
+ const payload = { ...result, feeds };
121
+ const feedsStream = Stream.fromIterable(
122
+ feeds as ReadonlyArray<FeedGeneratorView | ReturnType<typeof compactFeedGeneratorView>>
123
+ );
124
+ yield* emitWithFormat(
108
125
  format,
109
126
  appConfig.outputFormat,
110
127
  jsonNdjsonTableFormats,
111
- "json"
128
+ "json",
129
+ {
130
+ json: writeJson(payload),
131
+ ndjson: writeJsonStream(feedsStream),
132
+ table: writeText(renderFeedTable(result.feeds, undefined))
133
+ }
112
134
  );
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
135
  })
123
136
  ).pipe(
124
137
  Command.withDescription(
@@ -134,28 +147,32 @@ const byActorCommand = Command.make(
134
147
  ({ actor, limit, cursor, format }) =>
135
148
  Effect.gen(function* () {
136
149
  const appConfig = yield* AppConfigService;
150
+ yield* ensureSupportedFormat(format, appConfig.outputFormat);
151
+ const preferences = yield* CliPreferences;
137
152
  const client = yield* BskyClient;
138
- const parsedLimit = yield* parseLimit(limit);
139
- const resolvedActor = yield* decodeActor(actor);
140
- const result = yield* client.getActorFeeds(resolvedActor, {
141
- ...(Option.isSome(parsedLimit) ? { limit: parsedLimit.value } : {}),
142
- ...(Option.isSome(cursor) ? { cursor: cursor.value } : {})
153
+ const { limit: limitValue, cursor: cursorValue } = parsePagination(limit, cursor);
154
+ const result = yield* client.getActorFeeds(actor, {
155
+ ...(limitValue !== undefined ? { limit: limitValue } : {}),
156
+ ...(cursorValue !== undefined ? { cursor: cursorValue } : {})
143
157
  });
144
- const outputFormat = resolveOutputFormat(
158
+ const feeds = preferences.compact
159
+ ? result.feeds.map(compactFeedGeneratorView)
160
+ : result.feeds;
161
+ const payload = { ...result, feeds };
162
+ const feedsStream = Stream.fromIterable(
163
+ feeds as ReadonlyArray<FeedGeneratorView | ReturnType<typeof compactFeedGeneratorView>>
164
+ );
165
+ yield* emitWithFormat(
145
166
  format,
146
167
  appConfig.outputFormat,
147
168
  jsonNdjsonTableFormats,
148
- "json"
169
+ "json",
170
+ {
171
+ json: writeJson(payload),
172
+ ndjson: writeJsonStream(feedsStream),
173
+ table: writeText(renderFeedTable(result.feeds, result.cursor))
174
+ }
149
175
  );
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
176
  })
160
177
  ).pipe(
161
178
  Command.withDescription(
@@ -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
- "Expected a filter expression like 'hashtag:#ai' or 'author:handle'.",
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(`Unknown has: filter "${baseValue}".`, token.position);
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
  );
@@ -1,5 +1,5 @@
1
1
  import { ParseResult } from "effect";
2
- import { safeParseJson, issueDetails } from "./shared.js";
2
+ import { safeParseJson, issueDetails, findJsonParseIssue, jsonParseTip } 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 => {
@@ -70,19 +76,15 @@ export const formatFilterParseError = (error: ParseResult.ParseError, raw: strin
70
76
  const received = safeParseJson(raw);
71
77
  const receivedValue = received === undefined ? raw : received;
72
78
 
73
- const jsonParseIssue = issues.find(
74
- (issue) =>
75
- issue._tag === "Transformation" &&
76
- typeof issue.message === "string" &&
77
- issue.message.startsWith("JSON Parse error")
78
- );
79
+ const jsonParseIssue = findJsonParseIssue(issues);
79
80
  if (jsonParseIssue) {
81
+ const jsonMessage = jsonParseIssue.message ?? "Invalid JSON input.";
80
82
  return jsonParseError({
81
83
  message: "Invalid JSON in --filter-json.",
82
84
  received: raw,
83
85
  details: [
84
- jsonParseIssue.message,
85
- "Tip: wrap JSON in single quotes to avoid shell escaping issues."
86
+ jsonMessage,
87
+ jsonParseTip
86
88
  ]
87
89
  });
88
90
  }
@@ -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");
@@ -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, {