@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,11 @@
1
+ import { Context } from "effect";
2
+
3
+ export type CliPreferencesValue = {
4
+ readonly compact: boolean;
5
+ readonly logFormat?: "json" | "human";
6
+ };
7
+
8
+ export class CliPreferences extends Context.Tag("@skygent/CliPreferences")<
9
+ CliPreferences,
10
+ CliPreferencesValue
11
+ >() {}
@@ -0,0 +1,247 @@
1
+ import { Effect, Option } from "effect";
2
+ import { CliInputError } from "./errors.js";
3
+
4
+ type FieldSelector = {
5
+ readonly path: ReadonlyArray<string>;
6
+ readonly wildcard: boolean;
7
+ readonly raw: string;
8
+ };
9
+
10
+ const fieldPresets: Record<string, ReadonlyArray<string>> = {
11
+ minimal: ["uri", "author", "text", "createdAt"],
12
+ social: ["uri", "author", "text", "metrics", "hashtags"],
13
+ full: []
14
+ };
15
+
16
+ const isObject = (value: unknown): value is Record<string, unknown> =>
17
+ typeof value === "object" && value !== null && !Array.isArray(value);
18
+
19
+ const ensureNonEmpty = (value: string, message: string, cause: unknown) => {
20
+ if (value.length === 0) {
21
+ return Effect.fail(CliInputError.make({ message, cause }));
22
+ }
23
+ return Effect.void;
24
+ };
25
+
26
+ const scalarFieldHeads = new Set([
27
+ "uri",
28
+ "cid",
29
+ "author",
30
+ "authorDid",
31
+ "text",
32
+ "createdAt",
33
+ "hashtags",
34
+ "mentions",
35
+ "mentionDids",
36
+ "links",
37
+ "facets",
38
+ "langs",
39
+ "tags",
40
+ "selfLabels",
41
+ "labels",
42
+ "indexedAt"
43
+ ]);
44
+
45
+ const parseFieldToken = (token: string) =>
46
+ Effect.gen(function* () {
47
+ const parts = token.split(".").map((segment) => segment.trim());
48
+ if (parts.length === 0) {
49
+ return yield* CliInputError.make({
50
+ message: "Field token cannot be empty.",
51
+ cause: token
52
+ });
53
+ }
54
+ for (const part of parts) {
55
+ yield* ensureNonEmpty(
56
+ part,
57
+ `Invalid field token "${token}".`,
58
+ token
59
+ );
60
+ }
61
+ if (parts.length > 1) {
62
+ const head = parts[0] ?? "";
63
+ if (scalarFieldHeads.has(head)) {
64
+ const suggestion =
65
+ head === "author"
66
+ ? ' Use "author" or "authorProfile.handle".'
67
+ : " Remove the dot path.";
68
+ return yield* CliInputError.make({
69
+ message: `Field "${token}" is not a valid path. "${head}" is a scalar field.${suggestion}`,
70
+ cause: token
71
+ });
72
+ }
73
+ }
74
+ const wildcardIndex = parts.indexOf("*");
75
+ if (wildcardIndex >= 0 && wildcardIndex !== parts.length - 1) {
76
+ return yield* CliInputError.make({
77
+ message: `Wildcard "*" must be the last segment in "${token}".`,
78
+ cause: token
79
+ });
80
+ }
81
+ const wildcardCount = parts.filter((part) => part === "*").length;
82
+ if (wildcardCount > 1) {
83
+ return yield* CliInputError.make({
84
+ message: `Wildcard "*" can only appear once in "${token}".`,
85
+ cause: token
86
+ });
87
+ }
88
+ const wildcard = wildcardIndex === parts.length - 1;
89
+ const path = wildcard ? parts.slice(0, -1) : parts;
90
+ return { path, wildcard, raw: token } satisfies FieldSelector;
91
+ });
92
+
93
+ const normalizeTokens = (raw: string) =>
94
+ raw
95
+ .split(",")
96
+ .map((token) => token.trim())
97
+ .filter((token) => token.length > 0);
98
+
99
+ const expandPresets = (tokens: ReadonlyArray<string>) =>
100
+ Effect.gen(function* () {
101
+ const expanded: string[] = [];
102
+ let fullRequested = false;
103
+
104
+ for (const token of tokens) {
105
+ if (token.startsWith("@")) {
106
+ const name = token.slice(1);
107
+ const preset = fieldPresets[name];
108
+ if (!preset) {
109
+ return yield* CliInputError.make({
110
+ message: `Unknown fields preset "${token}".`,
111
+ cause: token
112
+ });
113
+ }
114
+ if (name === "full") {
115
+ fullRequested = true;
116
+ } else {
117
+ expanded.push(...preset);
118
+ }
119
+ } else {
120
+ expanded.push(token);
121
+ }
122
+ }
123
+
124
+ if (fullRequested) {
125
+ if (expanded.length > 0) {
126
+ return yield* CliInputError.make({
127
+ message: "Preset @full cannot be combined with other fields.",
128
+ cause: tokens
129
+ });
130
+ }
131
+ return { kind: "full" as const, tokens: [] as ReadonlyArray<string> };
132
+ }
133
+
134
+ if (expanded.length === 0) {
135
+ return yield* CliInputError.make({
136
+ message: "Fields list cannot be empty.",
137
+ cause: tokens
138
+ });
139
+ }
140
+
141
+ return { kind: "partial" as const, tokens: expanded };
142
+ });
143
+
144
+ export const parseFieldSelectors = (
145
+ raw: string
146
+ ): Effect.Effect<Option.Option<ReadonlyArray<FieldSelector>>, CliInputError> =>
147
+ Effect.gen(function* () {
148
+ const tokens = normalizeTokens(raw);
149
+ yield* ensureNonEmpty(raw.trim(), "Fields list cannot be empty.", raw);
150
+ const expanded = yield* expandPresets(tokens);
151
+ if (expanded.kind === "full") {
152
+ return Option.none();
153
+ }
154
+ const selectors = yield* Effect.forEach(
155
+ expanded.tokens,
156
+ (token) => parseFieldToken(token),
157
+ { discard: false }
158
+ );
159
+ return Option.some(selectors);
160
+ });
161
+
162
+ export const resolveFieldSelectors = (
163
+ fields: Option.Option<string>,
164
+ compact: boolean
165
+ ): Effect.Effect<Option.Option<ReadonlyArray<FieldSelector>>, CliInputError> =>
166
+ Option.match(fields, {
167
+ onNone: () =>
168
+ compact ? parseFieldSelectors("@minimal") : Effect.succeed(Option.none()),
169
+ onSome: (raw) => parseFieldSelectors(raw)
170
+ });
171
+
172
+ const getPathValue = (source: unknown, path: ReadonlyArray<string>): unknown => {
173
+ let current: unknown = source;
174
+ for (const segment of path) {
175
+ if (!isObject(current)) {
176
+ return undefined;
177
+ }
178
+ current = current[segment];
179
+ }
180
+ return current;
181
+ };
182
+
183
+ const ensureObjectPath = (target: Record<string, unknown>, path: string[]) => {
184
+ let current: Record<string, unknown> = target;
185
+ for (const segment of path) {
186
+ const existing = current[segment];
187
+ if (!isObject(existing)) {
188
+ current[segment] = {};
189
+ }
190
+ current = current[segment] as Record<string, unknown>;
191
+ }
192
+ return current;
193
+ };
194
+
195
+ const setPathValue = (
196
+ target: Record<string, unknown>,
197
+ path: ReadonlyArray<string>,
198
+ value: unknown
199
+ ) => {
200
+ if (path.length === 0) {
201
+ return;
202
+ }
203
+ const head = path.slice(0, -1);
204
+ const tail = path[path.length - 1]!;
205
+ const container = ensureObjectPath(target, head);
206
+ container[tail] = value;
207
+ };
208
+
209
+ const applySelector = (
210
+ target: Record<string, unknown>,
211
+ source: unknown,
212
+ selector: FieldSelector
213
+ ) => {
214
+ if (selector.wildcard) {
215
+ const value = getPathValue(source, selector.path);
216
+ if (value === undefined) return;
217
+ if (Array.isArray(value)) {
218
+ setPathValue(target, selector.path, value);
219
+ return;
220
+ }
221
+ if (!isObject(value)) {
222
+ return;
223
+ }
224
+ for (const [key, child] of Object.entries(value)) {
225
+ if (child !== undefined) {
226
+ setPathValue(target, [...selector.path, key], child);
227
+ }
228
+ }
229
+ return;
230
+ }
231
+
232
+ const value = getPathValue(source, selector.path);
233
+ if (value !== undefined) {
234
+ setPathValue(target, selector.path, value);
235
+ }
236
+ };
237
+
238
+ export const projectFields = (
239
+ source: unknown,
240
+ selectors: ReadonlyArray<FieldSelector>
241
+ ): Record<string, unknown> => {
242
+ const target: Record<string, unknown> = {};
243
+ for (const selector of selectors) {
244
+ applySelector(target, source, selector);
245
+ }
246
+ return target;
247
+ };
@@ -0,0 +1,415 @@
1
+ import { Args, Command, Options } from "@effect/cli";
2
+ import { Chunk, Clock, Effect, Option, Ref, Stream } from "effect";
3
+ import * as Doc from "@effect/printer/Doc";
4
+ import { all } from "../domain/filter.js";
5
+ import type { FilterExpr } from "../domain/filter.js";
6
+ import { StoreQuery } from "../domain/events.js";
7
+ import { StoreName } from "../domain/primitives.js";
8
+ import type { Post } from "../domain/post.js";
9
+ import { FilterRuntime } from "../services/filter-runtime.js";
10
+ import { AppConfigService } from "../services/app-config.js";
11
+ import { StoreIndex } from "../services/store-index.js";
12
+ import { renderPostsMarkdown, renderPostsTable } from "../domain/format.js";
13
+ import { renderPostCompact, renderPostCard } from "./doc/post.js";
14
+ import { renderThread } from "./doc/thread.js";
15
+ import { renderPlain, renderAnsi } from "./doc/render.js";
16
+ import { parseOptionalFilterExpr } from "./filter-input.js";
17
+ import { CliOutput, writeJson, writeJsonStream, writeText } from "./output.js";
18
+ import { parseRange } from "./range.js";
19
+ import { storeOptions } from "./store.js";
20
+ import { CliPreferences } from "./preferences.js";
21
+ import { projectFields, resolveFieldSelectors } from "./query-fields.js";
22
+ import { CliInputError } from "./errors.js";
23
+ import { withExamples } from "./help.js";
24
+ import { filterOption, filterJsonOption } from "./shared-options.js";
25
+ import { filterByFlags } from "../typeclass/chunk.js";
26
+
27
+ const storeNameArg = Args.text({ name: "store" }).pipe(
28
+ Args.withSchema(StoreName),
29
+ Args.withDescription("Store name to query")
30
+ );
31
+ const rangeOption = Options.text("range").pipe(
32
+ Options.withDescription("ISO range as <start>..<end>"),
33
+ Options.optional
34
+ );
35
+ const limitOption = Options.integer("limit").pipe(
36
+ Options.withDescription("Maximum number of posts to return"),
37
+ Options.optional
38
+ );
39
+ const scanLimitOption = Options.integer("scan-limit").pipe(
40
+ Options.withDescription("Maximum rows to scan before filtering (advanced)"),
41
+ Options.optional
42
+ );
43
+ const sortOption = Options.choice("sort", ["asc", "desc"]).pipe(
44
+ Options.withDescription("Sort order for results (default: asc)"),
45
+ Options.optional
46
+ );
47
+ const newestFirstOption = Options.boolean("newest-first").pipe(
48
+ Options.withDescription("Sort newest posts first (alias for --sort desc)")
49
+ );
50
+ const formatOption = Options.choice("format", [
51
+ "json",
52
+ "ndjson",
53
+ "markdown",
54
+ "table",
55
+ "compact",
56
+ "card",
57
+ "thread"
58
+ ]).pipe(
59
+ Options.optional,
60
+ Options.withDescription("Output format (default: config output format)")
61
+ );
62
+ const ansiOption = Options.boolean("ansi").pipe(
63
+ Options.withDescription("Enable ANSI colors in output")
64
+ );
65
+ const widthOption = Options.integer("width").pipe(
66
+ Options.withDescription("Line width for terminal output"),
67
+ Options.optional
68
+ );
69
+ const fieldsOption = Options.text("fields").pipe(
70
+ Options.withDescription(
71
+ "Comma-separated fields to include (supports dot notation and presets: @minimal, @social, @full). Use author or authorProfile.handle for handles."
72
+ ),
73
+ Options.optional
74
+ );
75
+ const progressOption = Options.boolean("progress").pipe(
76
+ Options.withDescription("Show progress for filtered queries")
77
+ );
78
+
79
+ const DEFAULT_FILTER_SCAN_LIMIT = 5000;
80
+
81
+ const isAscii = (value: string) => /^[\x00-\x7F]*$/.test(value);
82
+
83
+ const hasUnicodeInsensitiveContains = (expr: FilterExpr): boolean => {
84
+ switch (expr._tag) {
85
+ case "Contains":
86
+ return !expr.caseSensitive && expr.text.length > 0 && !isAscii(expr.text);
87
+ case "And":
88
+ return (
89
+ hasUnicodeInsensitiveContains(expr.left) ||
90
+ hasUnicodeInsensitiveContains(expr.right)
91
+ );
92
+ case "Or":
93
+ return (
94
+ hasUnicodeInsensitiveContains(expr.left) ||
95
+ hasUnicodeInsensitiveContains(expr.right)
96
+ );
97
+ case "Not":
98
+ return hasUnicodeInsensitiveContains(expr.expr);
99
+ default:
100
+ return false;
101
+ }
102
+ };
103
+
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))
108
+ });
109
+
110
+
111
+ export const queryCommand = Command.make(
112
+ "query",
113
+ {
114
+ store: storeNameArg,
115
+ range: rangeOption,
116
+ filter: filterOption,
117
+ filterJson: filterJsonOption,
118
+ limit: limitOption,
119
+ scanLimit: scanLimitOption,
120
+ sort: sortOption,
121
+ newestFirst: newestFirstOption,
122
+ format: formatOption,
123
+ ansi: ansiOption,
124
+ width: widthOption,
125
+ fields: fieldsOption,
126
+ progress: progressOption
127
+ },
128
+ ({ store, range, filter, filterJson, limit, scanLimit, sort, newestFirst, format, ansi, width, fields, progress }) =>
129
+ Effect.gen(function* () {
130
+ const appConfig = yield* AppConfigService;
131
+ const index = yield* StoreIndex;
132
+ const runtime = yield* FilterRuntime;
133
+ const output = yield* CliOutput;
134
+ const preferences = yield* CliPreferences;
135
+ const storeRef = yield* storeOptions.loadStoreRef(store);
136
+ const parsedRange = yield* parseRangeOption(range);
137
+ const parsedFilter = yield* parseOptionalFilterExpr(filter, filterJson);
138
+ const expr = Option.getOrElse(parsedFilter, () => all());
139
+ const outputFormat = Option.getOrElse(format, () => appConfig.outputFormat);
140
+ const compact = preferences.compact;
141
+ const selectorsOption = yield* resolveFieldSelectors(fields, compact);
142
+ const project = (post: Post) =>
143
+ Option.match(selectorsOption, {
144
+ onNone: () => post,
145
+ onSome: (selectors) => projectFields(post, selectors)
146
+ });
147
+ if (Option.isSome(selectorsOption) && outputFormat !== "json" && outputFormat !== "ndjson") {
148
+ return yield* CliInputError.make({
149
+ message: "--fields is only supported with json or ndjson output.",
150
+ cause: { format: outputFormat }
151
+ });
152
+ }
153
+
154
+ const w = Option.getOrUndefined(width);
155
+
156
+ if (Option.isSome(limit) && limit.value <= 0) {
157
+ return yield* CliInputError.make({
158
+ message: "--limit must be a positive integer.",
159
+ cause: { limit: limit.value }
160
+ });
161
+ }
162
+ if (Option.isSome(scanLimit) && scanLimit.value <= 0) {
163
+ return yield* CliInputError.make({
164
+ message: "--scan-limit must be a positive integer.",
165
+ cause: { scanLimit: scanLimit.value }
166
+ });
167
+ }
168
+ const sortValue = Option.getOrUndefined(sort);
169
+ const order =
170
+ newestFirst
171
+ ? "desc"
172
+ : sortValue;
173
+ if (newestFirst && sortValue === "asc") {
174
+ return yield* CliInputError.make({
175
+ message: "--newest-first conflicts with --sort asc.",
176
+ cause: { newestFirst, sort: sortValue }
177
+ });
178
+ }
179
+
180
+ const hasFilter = Option.isSome(parsedFilter);
181
+ if (hasFilter && hasUnicodeInsensitiveContains(expr)) {
182
+ yield* output
183
+ .writeStderr(
184
+ "Warning: Unicode case-insensitive contains filters cannot be pushed down; query may scan in-memory.\n"
185
+ )
186
+ .pipe(Effect.catchAll(() => Effect.void));
187
+ }
188
+ const userLimit = Option.getOrUndefined(limit);
189
+ const userScanLimit = Option.getOrUndefined(scanLimit);
190
+ const defaultScanLimit =
191
+ hasFilter && userScanLimit === undefined
192
+ ? Math.max(userLimit !== undefined ? userLimit * 50 : 0, DEFAULT_FILTER_SCAN_LIMIT)
193
+ : undefined;
194
+ const resolvedScanLimit =
195
+ hasFilter
196
+ ? userScanLimit ?? defaultScanLimit
197
+ : userScanLimit ?? userLimit;
198
+ if (defaultScanLimit !== undefined) {
199
+ yield* output
200
+ .writeStderr(
201
+ `ℹ️ Scanning up to ${defaultScanLimit} posts (filtered query). Use --scan-limit to scan more.`
202
+ )
203
+ .pipe(Effect.catchAll(() => Effect.void));
204
+ }
205
+
206
+ if (
207
+ hasFilter &&
208
+ Option.isNone(limit) &&
209
+ (outputFormat === "thread" || outputFormat === "table")
210
+ ) {
211
+ yield* output
212
+ .writeStderr(
213
+ "Warning: thread/table output collects all matched posts in memory. Consider adding --limit."
214
+ )
215
+ .pipe(Effect.catchAll(() => Effect.void));
216
+ }
217
+
218
+ const query = StoreQuery.make({
219
+ range: Option.getOrUndefined(parsedRange),
220
+ filter: Option.getOrUndefined(parsedFilter),
221
+ scanLimit: resolvedScanLimit,
222
+ order
223
+ });
224
+
225
+ const baseStream = index.query(storeRef, query);
226
+ const progressEnabled = hasFilter && progress;
227
+ const trackScanLimit = hasFilter && resolvedScanLimit !== undefined;
228
+ const scanRef = trackScanLimit
229
+ ? yield* Ref.make({ scanned: 0, matched: 0 })
230
+ : undefined;
231
+ let startTime = 0;
232
+ let progressRef: Ref.Ref<{ scanned: number; matched: number; lastReportAt: number }> | undefined;
233
+ if (progressEnabled) {
234
+ startTime = yield* Clock.currentTimeMillis;
235
+ progressRef = yield* Ref.make({ scanned: 0, matched: 0, lastReportAt: startTime });
236
+ }
237
+
238
+ const reportProgress =
239
+ progressEnabled && progressRef
240
+ ? (scanned: number, matched: number, now: number) =>
241
+ output
242
+ .writeStderr(
243
+ `Query progress: scanned=${scanned} matched=${matched} elapsedMs=${now - startTime}`
244
+ )
245
+ .pipe(Effect.catchAll(() => Effect.void))
246
+ : undefined;
247
+
248
+ const onBatch = (scannedDelta: number, matchedDelta: number) =>
249
+ Effect.gen(function* () {
250
+ if (scanRef) {
251
+ yield* Ref.update(scanRef, (state) => ({
252
+ scanned: state.scanned + scannedDelta,
253
+ matched: state.matched + matchedDelta
254
+ }));
255
+ }
256
+ if (progressEnabled && progressRef && reportProgress) {
257
+ const now = yield* Clock.currentTimeMillis;
258
+ const state = yield* Ref.get(progressRef);
259
+ const scanned = state.scanned + scannedDelta;
260
+ const matched = state.matched + matchedDelta;
261
+ const shouldReport =
262
+ scanned % 1000 === 0 || now - state.lastReportAt >= 1000;
263
+ if (shouldReport) {
264
+ yield* reportProgress(scanned, matched, now);
265
+ }
266
+ yield* Ref.set(progressRef, {
267
+ scanned,
268
+ matched,
269
+ lastReportAt: shouldReport ? now : state.lastReportAt
270
+ });
271
+ }
272
+ });
273
+
274
+ const evaluateBatch = hasFilter ? yield* runtime.evaluateBatch(expr) : undefined;
275
+
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)
291
+ )
292
+ ),
293
+ Stream.mapConcat((chunk) => Chunk.toReadonlyArray(chunk))
294
+ )
295
+ : baseStream;
296
+
297
+ const stream = Option.match(limit, {
298
+ onNone: () => filtered,
299
+ onSome: (value) => filtered.pipe(Stream.take(value))
300
+ });
301
+
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
+ )
314
+ )
315
+ : () => Effect.void;
316
+
317
+ if (outputFormat === "ndjson") {
318
+ yield* writeJsonStream(stream.pipe(Stream.map(project)));
319
+ yield* warnIfScanLimitReached();
320
+ return;
321
+ }
322
+ if (outputFormat === "json") {
323
+ const writeChunk = (value: string) =>
324
+ Stream.fromIterable([value]).pipe(Stream.run(output.stdout));
325
+ let isFirst = true;
326
+ yield* writeChunk("[");
327
+ yield* Stream.runForEach(stream.pipe(Stream.map(project)), (post) => {
328
+ const json = JSON.stringify(post);
329
+ const prefix = isFirst ? "" : ",\n";
330
+ isFirst = false;
331
+ return writeChunk(`${prefix}${json}`);
332
+ });
333
+ const suffix = isFirst ? "]\n" : "\n]\n";
334
+ yield* writeChunk(suffix);
335
+ yield* warnIfScanLimitReached();
336
+ return;
337
+ }
338
+
339
+ switch (outputFormat) {
340
+ 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)));
346
+ yield* warnIfScanLimitReached();
347
+ return;
348
+ }
349
+ case "card": {
350
+ const rendered = stream.pipe(
351
+ Stream.map((post) => {
352
+ const doc = Doc.vsep(renderPostCard(post));
353
+ return ansi ? renderAnsi(doc, w) : renderPlain(doc, w);
354
+ }),
355
+ Stream.mapAccum(true, (isFirst, text) => {
356
+ const output = isFirst ? text : `\\n${text}`;
357
+ return [false, output] as const;
358
+ })
359
+ );
360
+ yield* Stream.runForEach(rendered, (text) => writeText(text));
361
+ yield* warnIfScanLimitReached();
362
+ return;
363
+ }
364
+ }
365
+
366
+ const collected = yield* Stream.runCollect(stream);
367
+ const posts = Chunk.toReadonlyArray(collected);
368
+ const projectedPosts = Option.isSome(selectorsOption) ? posts.map(project) : posts;
369
+ yield* warnIfScanLimitReached();
370
+
371
+ switch (outputFormat) {
372
+ case "markdown":
373
+ yield* writeText(renderPostsMarkdown(posts));
374
+ return;
375
+ case "table":
376
+ yield* writeText(renderPostsTable(posts));
377
+ return;
378
+ case "thread": {
379
+ // B3: Warn if query doesn't have thread relationships
380
+ if (!hasFilter) {
381
+ yield* output
382
+ .writeStderr(
383
+ "ℹ️ Query results don't have thread relationships. Posts will display in chronological order.\n"
384
+ )
385
+ .pipe(Effect.catchAll(() => Effect.void));
386
+ }
387
+ const doc = renderThread(
388
+ posts,
389
+ w === undefined ? { compact: false } : { compact: false, lineWidth: w }
390
+ );
391
+ yield* writeText(ansi ? renderAnsi(doc, w) : renderPlain(doc, w));
392
+ return;
393
+ }
394
+ default:
395
+ yield* writeJson(projectedPosts);
396
+ }
397
+ })
398
+ ).pipe(
399
+ Command.withDescription(
400
+ withExamples(
401
+ "Query a store with optional range and filter",
402
+ [
403
+ "skygent query my-store --limit 25 --format table",
404
+ "skygent query my-store --range 2024-01-01T00:00:00Z..2024-01-31T00:00:00Z --filter 'hashtag:#ai'",
405
+ "skygent query my-store --format card --ansi",
406
+ "skygent query my-store --format thread --ansi --width 120",
407
+ "skygent query my-store --format compact --limit 50",
408
+ "skygent query my-store --sort desc --limit 25"
409
+ ],
410
+ [
411
+ "Tip: use --fields @minimal or --compact to reduce JSON output size."
412
+ ]
413
+ )
414
+ )
415
+ );