@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.
Files changed (49) 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/config.ts +20 -3
  6. package/src/cli/doc/table-renderers.ts +29 -0
  7. package/src/cli/doc/thread.ts +2 -4
  8. package/src/cli/exit-codes.ts +2 -0
  9. package/src/cli/feed.ts +35 -55
  10. package/src/cli/filter-dsl.ts +146 -11
  11. package/src/cli/filter-errors.ts +9 -3
  12. package/src/cli/filter-help.ts +7 -0
  13. package/src/cli/filter-input.ts +3 -2
  14. package/src/cli/filter.ts +84 -4
  15. package/src/cli/graph.ts +193 -156
  16. package/src/cli/input.ts +45 -0
  17. package/src/cli/layers.ts +10 -0
  18. package/src/cli/logging.ts +8 -0
  19. package/src/cli/output-render.ts +14 -0
  20. package/src/cli/pagination.ts +18 -0
  21. package/src/cli/parse-errors.ts +18 -0
  22. package/src/cli/pipe.ts +157 -0
  23. package/src/cli/post.ts +43 -66
  24. package/src/cli/query.ts +349 -74
  25. package/src/cli/search.ts +92 -118
  26. package/src/cli/shared.ts +0 -19
  27. package/src/cli/store-errors.ts +24 -13
  28. package/src/cli/store-tree.ts +6 -4
  29. package/src/cli/store.ts +35 -2
  30. package/src/cli/stream-merge.ts +105 -0
  31. package/src/cli/sync-factory.ts +28 -3
  32. package/src/cli/sync.ts +16 -18
  33. package/src/cli/thread-options.ts +33 -0
  34. package/src/cli/time.ts +171 -0
  35. package/src/cli/view-thread.ts +12 -18
  36. package/src/cli/watch.ts +61 -19
  37. package/src/domain/errors.ts +6 -1
  38. package/src/domain/format.ts +21 -0
  39. package/src/domain/order.ts +24 -0
  40. package/src/graph/relationships.ts +129 -0
  41. package/src/services/jetstream-sync.ts +4 -4
  42. package/src/services/lineage-store.ts +15 -1
  43. package/src/services/store-commit.ts +60 -0
  44. package/src/services/store-manager.ts +69 -2
  45. package/src/services/store-renamer.ts +286 -0
  46. package/src/services/store-stats.ts +7 -5
  47. package/src/services/sync-engine.ts +136 -85
  48. package/src/services/sync-reporter.ts +3 -1
  49. package/src/services/sync-settings.ts +24 -0
package/src/cli/query.ts CHANGED
@@ -1,37 +1,60 @@
1
1
  import { Args, Command, Options } from "@effect/cli";
2
- import { Chunk, Clock, Effect, Option, Ref, Stream } from "effect";
2
+ import { Chunk, Clock, Effect, Option, Order, Ref, Schema, Stream } from "effect";
3
3
  import * as Doc from "@effect/printer/Doc";
4
4
  import { all } from "../domain/filter.js";
5
5
  import type { FilterExpr } from "../domain/filter.js";
6
6
  import { StoreQuery } from "../domain/events.js";
7
- import { StoreName } from "../domain/primitives.js";
7
+ import { StoreName, Timestamp } from "../domain/primitives.js";
8
8
  import type { Post } from "../domain/post.js";
9
+ import type { StoreRef } from "../domain/store.js";
9
10
  import { FilterRuntime } from "../services/filter-runtime.js";
10
11
  import { AppConfigService } from "../services/app-config.js";
11
12
  import { StoreIndex } from "../services/store-index.js";
12
- import { renderPostsMarkdown, renderPostsTable } from "../domain/format.js";
13
+ import {
14
+ renderPostsMarkdown,
15
+ renderPostsTable,
16
+ renderStorePostsMarkdown,
17
+ renderStorePostsTable
18
+ } from "../domain/format.js";
13
19
  import { renderPostCompact, renderPostCard } from "./doc/post.js";
14
20
  import { renderThread } from "./doc/thread.js";
15
21
  import { renderPlain, renderAnsi } from "./doc/render.js";
16
22
  import { parseOptionalFilterExpr } from "./filter-input.js";
17
23
  import { CliOutput, writeJson, writeJsonStream, writeText } from "./output.js";
18
24
  import { parseRange } from "./range.js";
19
- import { storeOptions } from "./store.js";
25
+ import { parseTimeInput } from "./time.js";
20
26
  import { CliPreferences } from "./preferences.js";
21
27
  import { projectFields, resolveFieldSelectors } from "./query-fields.js";
22
28
  import { CliInputError } from "./errors.js";
23
29
  import { withExamples } from "./help.js";
24
30
  import { filterOption, filterJsonOption } from "./shared-options.js";
25
31
  import { filterByFlags } from "../typeclass/chunk.js";
32
+ import { StoreManager } from "../services/store-manager.js";
33
+ import { StoreNotFound } from "../domain/errors.js";
34
+ import { StorePostOrder } from "../domain/order.js";
35
+ import { formatSchemaError } from "./shared.js";
36
+ import { mergeOrderedStreams } from "./stream-merge.js";
26
37
 
27
- const storeNameArg = Args.text({ name: "store" }).pipe(
28
- Args.withSchema(StoreName),
29
- Args.withDescription("Store name to query")
38
+ const storeNamesArg = Args.text({ name: "store" }).pipe(
39
+ Args.repeated,
40
+ Args.withDescription("Store name(s) to query (repeatable or comma-separated)")
30
41
  );
31
42
  const rangeOption = Options.text("range").pipe(
32
43
  Options.withDescription("ISO range as <start>..<end>"),
33
44
  Options.optional
34
45
  );
46
+ const sinceOption = Options.text("since").pipe(
47
+ Options.withDescription(
48
+ "Start time (ISO timestamp, date, relative duration like 24h, or now/today/yesterday)"
49
+ ),
50
+ Options.optional
51
+ );
52
+ const untilOption = Options.text("until").pipe(
53
+ Options.withDescription(
54
+ "End time (ISO timestamp, date, relative duration like 24h, or now/today/yesterday)"
55
+ ),
56
+ Options.optional
57
+ );
35
58
  const limitOption = Options.integer("limit").pipe(
36
59
  Options.withDescription("Maximum number of posts to return"),
37
60
  Options.optional
@@ -59,6 +82,9 @@ const formatOption = Options.choice("format", [
59
82
  Options.optional,
60
83
  Options.withDescription("Output format (default: config output format)")
61
84
  );
85
+ const includeStoreOption = Options.boolean("include-store").pipe(
86
+ Options.withDescription("Include store name in output")
87
+ );
62
88
  const ansiOption = Options.boolean("ansi").pipe(
63
89
  Options.withDescription("Enable ANSI colors in output")
64
90
  );
@@ -75,9 +101,17 @@ const fieldsOption = Options.text("fields").pipe(
75
101
  const progressOption = Options.boolean("progress").pipe(
76
102
  Options.withDescription("Show progress for filtered queries")
77
103
  );
104
+ const countOption = Options.boolean("count").pipe(
105
+ Options.withDescription("Only output the count of matching posts")
106
+ );
78
107
 
79
108
  const DEFAULT_FILTER_SCAN_LIMIT = 5000;
80
109
 
110
+ type StorePost = {
111
+ readonly store: StoreRef;
112
+ readonly post: Post;
113
+ };
114
+
81
115
  const isAscii = (value: string) => /^[\x00-\x7F]*$/.test(value);
82
116
 
83
117
  const hasUnicodeInsensitiveContains = (expr: FilterExpr): boolean => {
@@ -101,18 +135,127 @@ const hasUnicodeInsensitiveContains = (expr: FilterExpr): boolean => {
101
135
  }
102
136
  };
103
137
 
104
- const parseRangeOption = (range: Option.Option<string>) =>
105
- Option.match(range, {
106
- onNone: () => Effect.succeed(Option.none()),
107
- onSome: (raw) => parseRange(raw).pipe(Effect.map(Option.some))
138
+ const parseRangeOptions = (
139
+ range: Option.Option<string>,
140
+ since: Option.Option<string>,
141
+ until: Option.Option<string>
142
+ ) =>
143
+ Effect.gen(function* () {
144
+ const toTimestamp = (date: Date, label: string) =>
145
+ Schema.decodeUnknown(Timestamp)(date).pipe(
146
+ Effect.mapError((cause) =>
147
+ CliInputError.make({
148
+ message: `Computed ${label} timestamp is invalid.`,
149
+ cause
150
+ })
151
+ )
152
+ );
153
+ const hasRange = Option.isSome(range);
154
+ const hasSince = Option.isSome(since);
155
+ const hasUntil = Option.isSome(until);
156
+
157
+ if (hasRange && (hasSince || hasUntil)) {
158
+ return yield* CliInputError.make({
159
+ message: "Use either --range or --since/--until, not both.",
160
+ cause: { range: range.value, since: Option.getOrUndefined(since), until: Option.getOrUndefined(until) }
161
+ });
162
+ }
163
+
164
+ if (hasRange) {
165
+ const parsed = yield* parseRange(range.value);
166
+ return Option.some(parsed);
167
+ }
168
+
169
+ if (!hasSince && !hasUntil) {
170
+ return Option.none();
171
+ }
172
+
173
+ const nowMillis = yield* Clock.currentTimeMillis;
174
+ const now = new Date(nowMillis);
175
+
176
+ const start = hasSince
177
+ ? yield* parseTimeInput(since.value, now, { label: "--since" })
178
+ : new Date(0);
179
+ const end = hasUntil
180
+ ? yield* parseTimeInput(until.value, now, { label: "--until" })
181
+ : now;
182
+
183
+ if (start.getTime() > end.getTime()) {
184
+ return yield* CliInputError.make({
185
+ message: `Invalid time range: start ${start.toISOString()} must be before end ${end.toISOString()}.`,
186
+ cause: { start, end }
187
+ });
188
+ }
189
+
190
+ const startTimestamp = yield* toTimestamp(start, "start");
191
+ const endTimestamp = yield* toTimestamp(end, "end");
192
+ return Option.some({ start: startTimestamp, end: endTimestamp });
193
+ });
194
+
195
+ const splitStoreNames = (raw: ReadonlyArray<string>) =>
196
+ raw.flatMap((value) =>
197
+ value
198
+ .split(",")
199
+ .map((entry) => entry.trim())
200
+ .filter((entry) => entry.length > 0)
201
+ );
202
+
203
+ const parseStoreNames = (raw: ReadonlyArray<string>) =>
204
+ Effect.gen(function* () {
205
+ const names = splitStoreNames(raw);
206
+ if (names.length === 0) {
207
+ return yield* CliInputError.make({
208
+ message: "Provide at least one store name.",
209
+ cause: { stores: raw }
210
+ });
211
+ }
212
+ return yield* Effect.forEach(
213
+ names,
214
+ (name) =>
215
+ Schema.decodeUnknown(StoreName)(name).pipe(
216
+ Effect.mapError((error) =>
217
+ CliInputError.make({
218
+ message: `Invalid store name "${name}": ${formatSchemaError(error)}`,
219
+ cause: { name }
220
+ })
221
+ )
222
+ ),
223
+ { discard: false }
224
+ );
225
+ });
226
+
227
+ const loadStoreRefs = (names: ReadonlyArray<StoreName>) =>
228
+ Effect.gen(function* () {
229
+ const manager = yield* StoreManager;
230
+ const results = yield* Effect.forEach(
231
+ names,
232
+ (name) => manager.getStore(name),
233
+ { discard: false }
234
+ );
235
+ const missing = names.filter((_, index) => Option.isNone(results[index]!));
236
+ if (missing.length > 0) {
237
+ if (missing.length === 1 && names.length === 1) {
238
+ return yield* StoreNotFound.make({ name: missing[0]! });
239
+ }
240
+ return yield* CliInputError.make({
241
+ message: `Unknown stores: ${missing.join(", ")}`,
242
+ cause: { missing }
243
+ });
244
+ }
245
+ const stores = results
246
+ .map((option) => (Option.isSome(option) ? option.value : undefined))
247
+ .filter((value): value is NonNullable<typeof value> => value !== undefined);
248
+ return stores;
108
249
  });
109
250
 
110
251
 
111
252
  export const queryCommand = Command.make(
112
253
  "query",
113
254
  {
114
- store: storeNameArg,
255
+ stores: storeNamesArg,
115
256
  range: rangeOption,
257
+ since: sinceOption,
258
+ until: untilOption,
116
259
  filter: filterOption,
117
260
  filterJson: filterJsonOption,
118
261
  limit: limitOption,
@@ -120,23 +263,36 @@ export const queryCommand = Command.make(
120
263
  sort: sortOption,
121
264
  newestFirst: newestFirstOption,
122
265
  format: formatOption,
266
+ includeStore: includeStoreOption,
123
267
  ansi: ansiOption,
124
268
  width: widthOption,
125
269
  fields: fieldsOption,
126
- progress: progressOption
270
+ progress: progressOption,
271
+ count: countOption
127
272
  },
128
- ({ store, range, filter, filterJson, limit, scanLimit, sort, newestFirst, format, ansi, width, fields, progress }) =>
273
+ ({ stores, range, since, until, filter, filterJson, limit, scanLimit, sort, newestFirst, format, includeStore, ansi, width, fields, progress, count }) =>
129
274
  Effect.gen(function* () {
130
275
  const appConfig = yield* AppConfigService;
131
276
  const index = yield* StoreIndex;
132
277
  const runtime = yield* FilterRuntime;
133
278
  const output = yield* CliOutput;
134
279
  const preferences = yield* CliPreferences;
135
- const storeRef = yield* storeOptions.loadStoreRef(store);
136
- const parsedRange = yield* parseRangeOption(range);
280
+ const storeNames = yield* parseStoreNames(stores);
281
+ const storeRefs = yield* loadStoreRefs(storeNames);
282
+ const multiStore = storeRefs.length > 1;
283
+ const includeStoreLabel = includeStore || multiStore;
284
+ const parsedRange = yield* parseRangeOptions(range, since, until);
137
285
  const parsedFilter = yield* parseOptionalFilterExpr(filter, filterJson);
138
286
  const expr = Option.getOrElse(parsedFilter, () => all());
139
- const outputFormat = Option.getOrElse(format, () => appConfig.outputFormat);
287
+ const outputFormat = Option.getOrElse(format, () =>
288
+ appConfig.outputFormat === "ndjson" ? "compact" : appConfig.outputFormat
289
+ );
290
+ if (multiStore && outputFormat === "thread") {
291
+ return yield* CliInputError.make({
292
+ message: "Thread output is only supported for single-store queries.",
293
+ cause: { format: outputFormat }
294
+ });
295
+ }
140
296
  const compact = preferences.compact;
141
297
  const selectorsOption = yield* resolveFieldSelectors(fields, compact);
142
298
  const project = (post: Post) =>
@@ -150,6 +306,12 @@ export const queryCommand = Command.make(
150
306
  cause: { format: outputFormat }
151
307
  });
152
308
  }
309
+ if (count && Option.isSome(selectorsOption)) {
310
+ return yield* CliInputError.make({
311
+ message: "--count cannot be combined with --fields.",
312
+ cause: { count, fields }
313
+ });
314
+ }
153
315
 
154
316
  const w = Option.getOrUndefined(width);
155
317
 
@@ -198,7 +360,7 @@ export const queryCommand = Command.make(
198
360
  if (defaultScanLimit !== undefined) {
199
361
  yield* output
200
362
  .writeStderr(
201
- `ℹ️ Scanning up to ${defaultScanLimit} posts (filtered query). Use --scan-limit to scan more.`
363
+ `ℹ️ Scanning up to ${defaultScanLimit} posts${multiStore ? " per store" : ""} (filtered query). Use --scan-limit to scan more.`
202
364
  )
203
365
  .pipe(Effect.catchAll(() => Effect.void));
204
366
  }
@@ -222,12 +384,8 @@ export const queryCommand = Command.make(
222
384
  order
223
385
  });
224
386
 
225
- const baseStream = index.query(storeRef, query);
226
387
  const progressEnabled = hasFilter && progress;
227
388
  const trackScanLimit = hasFilter && resolvedScanLimit !== undefined;
228
- const scanRef = trackScanLimit
229
- ? yield* Ref.make({ scanned: 0, matched: 0 })
230
- : undefined;
231
389
  let startTime = 0;
232
390
  let progressRef: Ref.Ref<{ scanned: number; matched: number; lastReportAt: number }> | undefined;
233
391
  if (progressEnabled) {
@@ -245,7 +403,11 @@ export const queryCommand = Command.make(
245
403
  .pipe(Effect.catchAll(() => Effect.void))
246
404
  : undefined;
247
405
 
248
- const onBatch = (scannedDelta: number, matchedDelta: number) =>
406
+ const onBatch = (
407
+ scanRef: Ref.Ref<{ scanned: number; matched: number }> | undefined,
408
+ scannedDelta: number,
409
+ matchedDelta: number
410
+ ) =>
249
411
  Effect.gen(function* () {
250
412
  if (scanRef) {
251
413
  yield* Ref.update(scanRef, (state) => ({
@@ -273,49 +435,119 @@ export const queryCommand = Command.make(
273
435
 
274
436
  const evaluateBatch = hasFilter ? yield* runtime.evaluateBatch(expr) : undefined;
275
437
 
276
- const filtered = hasFilter && evaluateBatch
277
- ? baseStream.pipe(
278
- Stream.grouped(50),
279
- Stream.mapEffect((batch) =>
280
- evaluateBatch(batch).pipe(
281
- Effect.map((flags) => {
282
- const matched = filterByFlags(batch, flags);
283
- return {
284
- matched,
285
- scanned: Chunk.size(batch),
286
- matchedCount: Chunk.size(matched)
287
- };
288
- }),
289
- Effect.tap(({ scanned, matchedCount }) => onBatch(scanned, matchedCount)),
290
- Effect.map(({ matched }) => matched)
438
+ const buildStoreStream = (storeRef: StoreRef) =>
439
+ Effect.gen(function* () {
440
+ const scanRef = trackScanLimit
441
+ ? yield* Ref.make({ scanned: 0, matched: 0 })
442
+ : undefined;
443
+ const baseStream = index.query(storeRef, query);
444
+ const filtered = hasFilter && evaluateBatch
445
+ ? baseStream.pipe(
446
+ Stream.grouped(50),
447
+ Stream.mapEffect((batch) =>
448
+ evaluateBatch(batch).pipe(
449
+ Effect.map((flags) => {
450
+ const matched = filterByFlags(batch, flags);
451
+ return {
452
+ matched,
453
+ scanned: Chunk.size(batch),
454
+ matchedCount: Chunk.size(matched)
455
+ };
456
+ }),
457
+ Effect.tap(({ scanned, matchedCount }) =>
458
+ onBatch(scanRef, scanned, matchedCount)
459
+ ),
460
+ Effect.map(({ matched }) => matched)
461
+ )
462
+ ),
463
+ Stream.mapConcat((chunk) => Chunk.toReadonlyArray(chunk))
291
464
  )
292
- ),
293
- Stream.mapConcat((chunk) => Chunk.toReadonlyArray(chunk))
294
- )
295
- : baseStream;
465
+ : baseStream;
466
+ const storeStream = filtered.pipe(
467
+ Stream.map((post) => ({ store: storeRef, post }))
468
+ );
469
+ return { store: storeRef, stream: storeStream, scanRef };
470
+ });
471
+
472
+ const storeStreams = yield* Effect.forEach(storeRefs, buildStoreStream, {
473
+ discard: false
474
+ });
475
+ const scanRefs = storeStreams
476
+ .map((entry) =>
477
+ entry.scanRef ? { store: entry.store, ref: entry.scanRef } : undefined
478
+ )
479
+ .filter((entry): entry is { store: StoreRef; ref: Ref.Ref<{ scanned: number; matched: number }> } => entry !== undefined);
480
+
481
+ const storePostOrder =
482
+ order === "desc" ? Order.reverse(StorePostOrder) : StorePostOrder;
483
+
484
+ const merged = mergeOrderedStreams(
485
+ storeStreams.map((entry) => entry.stream),
486
+ storePostOrder
487
+ );
296
488
 
297
489
  const stream = Option.match(limit, {
298
- onNone: () => filtered,
299
- onSome: (value) => filtered.pipe(Stream.take(value))
490
+ onNone: () => merged,
491
+ onSome: (value) => merged.pipe(Stream.take(value))
300
492
  });
301
493
 
302
- const warnIfScanLimitReached = scanRef && resolvedScanLimit !== undefined
303
- ? () =>
304
- Ref.get(scanRef).pipe(
305
- Effect.flatMap((state) =>
306
- state.scanned >= resolvedScanLimit
307
- ? output
308
- .writeStderr(
309
- `Warning: scan limit ${resolvedScanLimit} reached. Results may be truncated.\n`
310
- )
311
- .pipe(Effect.catchAll(() => Effect.void))
312
- : Effect.void
313
- )
494
+ const warnIfScanLimitReached = () =>
495
+ resolvedScanLimit === undefined || scanRefs.length === 0
496
+ ? Effect.void
497
+ : Effect.forEach(
498
+ scanRefs,
499
+ ({ store, ref }) =>
500
+ Ref.get(ref).pipe(
501
+ Effect.flatMap((state) =>
502
+ state.scanned >= resolvedScanLimit
503
+ ? output
504
+ .writeStderr(
505
+ multiStore
506
+ ? `Warning: scan limit ${resolvedScanLimit} reached for ${store.name}. Results may be truncated.\n`
507
+ : `Warning: scan limit ${resolvedScanLimit} reached. Results may be truncated.\n`
508
+ )
509
+ .pipe(Effect.catchAll(() => Effect.void))
510
+ : Effect.void
511
+ )
512
+ ),
513
+ { discard: true }
514
+ );
515
+
516
+ const toOutput = (entry: StorePost) => {
517
+ const projected = project(entry.post);
518
+ return includeStoreLabel ? { store: entry.store.name, post: projected } : projected;
519
+ };
520
+
521
+ if (count) {
522
+ const canUseIndexCount =
523
+ !hasFilter && Option.isNone(parsedRange);
524
+ const total = canUseIndexCount
525
+ ? yield* Effect.forEach(storeRefs, (store) => index.count(store), {
526
+ discard: false
527
+ }).pipe(
528
+ Effect.map((counts) => counts.reduce((sum, value) => sum + value, 0))
314
529
  )
315
- : () => Effect.void;
530
+ : yield* Stream.runFold(stream, 0, (acc) => acc + 1);
531
+ const limited = Option.match(limit, {
532
+ onNone: () => total,
533
+ onSome: (value) => Math.min(total, value)
534
+ });
535
+ yield* writeJson(limited);
536
+ yield* warnIfScanLimitReached();
537
+ return;
538
+ }
316
539
 
317
540
  if (outputFormat === "ndjson") {
318
- yield* writeJsonStream(stream.pipe(Stream.map(project)));
541
+ const countRef = yield* Ref.make(0);
542
+ const counted = stream.pipe(
543
+ Stream.map(toOutput),
544
+ Stream.tap(() => Ref.update(countRef, (count) => count + 1))
545
+ );
546
+ yield* writeJsonStream(counted);
547
+ const count = yield* Ref.get(countRef);
548
+ if (count === 0) {
549
+ yield* writeText("[]");
550
+ }
319
551
  yield* warnIfScanLimitReached();
320
552
  return;
321
553
  }
@@ -324,8 +556,8 @@ export const queryCommand = Command.make(
324
556
  Stream.fromIterable([value]).pipe(Stream.run(output.stdout));
325
557
  let isFirst = true;
326
558
  yield* writeChunk("[");
327
- yield* Stream.runForEach(stream.pipe(Stream.map(project)), (post) => {
328
- const json = JSON.stringify(post);
559
+ yield* Stream.runForEach(stream.pipe(Stream.map(toOutput)), (value) => {
560
+ const json = JSON.stringify(value);
329
561
  const prefix = isFirst ? "" : ",\n";
330
562
  isFirst = false;
331
563
  return writeChunk(`${prefix}${json}`);
@@ -338,44 +570,83 @@ export const queryCommand = Command.make(
338
570
 
339
571
  switch (outputFormat) {
340
572
  case "compact": {
341
- const render = (post: Post) =>
342
- ansi
343
- ? renderAnsi(renderPostCompact(post), w)
344
- : renderPlain(renderPostCompact(post), w);
345
- yield* Stream.runForEach(stream, (post) => writeText(render(post)));
573
+ const countRef = yield* Ref.make(0);
574
+ const render = (entry: StorePost) => {
575
+ const doc = includeStoreLabel
576
+ ? Doc.hsep([Doc.text(`[${entry.store.name}]`), renderPostCompact(entry.post)])
577
+ : renderPostCompact(entry.post);
578
+ return ansi ? renderAnsi(doc, w) : renderPlain(doc, w);
579
+ };
580
+ yield* Stream.runForEach(stream, (entry) =>
581
+ Ref.update(countRef, (count) => count + 1).pipe(
582
+ Effect.zipRight(writeText(render(entry)))
583
+ )
584
+ );
585
+ const count = yield* Ref.get(countRef);
586
+ if (count === 0) {
587
+ yield* writeText("No posts found.");
588
+ }
346
589
  yield* warnIfScanLimitReached();
347
590
  return;
348
591
  }
349
592
  case "card": {
593
+ const countRef = yield* Ref.make(0);
350
594
  const rendered = stream.pipe(
351
- Stream.map((post) => {
352
- const doc = Doc.vsep(renderPostCard(post));
595
+ Stream.map((entry) => {
596
+ const lines = renderPostCard(entry.post);
597
+ const doc = includeStoreLabel
598
+ ? Doc.vsep([Doc.text(`[${entry.store.name}]`), ...lines])
599
+ : Doc.vsep(lines);
353
600
  return ansi ? renderAnsi(doc, w) : renderPlain(doc, w);
354
601
  }),
355
602
  Stream.mapAccum(true, (isFirst, text) => {
356
603
  const output = isFirst ? text : `\\n${text}`;
357
604
  return [false, output] as const;
358
- })
605
+ }),
606
+ Stream.tap(() => Ref.update(countRef, (count) => count + 1))
359
607
  );
360
608
  yield* Stream.runForEach(rendered, (text) => writeText(text));
609
+ const count = yield* Ref.get(countRef);
610
+ if (count === 0) {
611
+ yield* writeText("No posts found.");
612
+ }
361
613
  yield* warnIfScanLimitReached();
362
614
  return;
363
615
  }
364
616
  }
365
617
 
366
618
  const collected = yield* Stream.runCollect(stream);
367
- const posts = Chunk.toReadonlyArray(collected);
368
- const projectedPosts = Option.isSome(selectorsOption) ? posts.map(project) : posts;
619
+ const entries = Chunk.toReadonlyArray(collected);
620
+ const posts = entries.map((entry) => entry.post);
621
+ const projectedPosts = entries.map(toOutput);
369
622
  yield* warnIfScanLimitReached();
370
623
 
371
624
  switch (outputFormat) {
372
625
  case "markdown":
373
- yield* writeText(renderPostsMarkdown(posts));
626
+ yield* writeText(
627
+ includeStoreLabel
628
+ ? renderStorePostsMarkdown(entries.map((entry) => ({
629
+ store: entry.store.name,
630
+ post: entry.post
631
+ })))
632
+ : renderPostsMarkdown(posts)
633
+ );
374
634
  return;
375
635
  case "table":
376
- yield* writeText(renderPostsTable(posts));
636
+ yield* writeText(
637
+ includeStoreLabel
638
+ ? renderStorePostsTable(entries.map((entry) => ({
639
+ store: entry.store.name,
640
+ post: entry.post
641
+ })))
642
+ : renderPostsTable(posts)
643
+ );
377
644
  return;
378
645
  case "thread": {
646
+ if (posts.length === 0) {
647
+ yield* writeText("No posts found.");
648
+ return;
649
+ }
379
650
  // B3: Warn if query doesn't have thread relationships
380
651
  if (!hasFilter) {
381
652
  yield* output
@@ -398,14 +669,18 @@ export const queryCommand = Command.make(
398
669
  ).pipe(
399
670
  Command.withDescription(
400
671
  withExamples(
401
- "Query a store with optional range and filter",
672
+ "Query a store with optional time range and filter",
402
673
  [
403
674
  "skygent query my-store --limit 25 --format table",
404
675
  "skygent query my-store --range 2024-01-01T00:00:00Z..2024-01-31T00:00:00Z --filter 'hashtag:#ai'",
676
+ "skygent query my-store --since 24h --filter 'hashtag:#ai'",
677
+ "skygent query my-store --until 2024-01-15 --format compact",
405
678
  "skygent query my-store --format card --ansi",
406
679
  "skygent query my-store --format thread --ansi --width 120",
407
680
  "skygent query my-store --format compact --limit 50",
408
- "skygent query my-store --sort desc --limit 25"
681
+ "skygent query my-store --sort desc --limit 25",
682
+ "skygent query my-store --filter 'contains:ai' --count",
683
+ "skygent query store-a,store-b --format ndjson"
409
684
  ],
410
685
  [
411
686
  "Tip: use --fields @minimal or --compact to reduce JSON output size."