@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,205 @@
1
+ import { Args, Command, Options } from "@effect/cli";
2
+ import { Clock, Effect, Option } from "effect";
3
+ import { filterExprSignature, isEffectfulFilter } from "../domain/filter.js";
4
+ import { defaultStoreConfig } from "../domain/defaults.js";
5
+ import { StoreName } from "../domain/primitives.js";
6
+ import { DerivationEngine } from "../services/derivation-engine.js";
7
+ import { StoreManager } from "../services/store-manager.js";
8
+ import { ViewCheckpointStore } from "../services/view-checkpoint-store.js";
9
+ import { OutputManager } from "../services/output-manager.js";
10
+ import { filterJsonDescription } from "./filter-help.js";
11
+ import { parseFilterExpr } from "./filter-input.js";
12
+ import { writeJson } from "./output.js";
13
+ import { storeOptions } from "./store.js";
14
+ import { CliInputError } from "./errors.js";
15
+ import { logInfo } from "./logging.js";
16
+ import type { FilterEvaluationMode } from "../domain/derivation.js";
17
+ import { CliPreferences } from "./preferences.js";
18
+ import { withExamples } from "./help.js";
19
+ import { filterOption } from "./shared-options.js";
20
+
21
+ const sourceArg = Args.text({ name: "source" }).pipe(
22
+ Args.withSchema(StoreName),
23
+ Args.withDescription("Source store name")
24
+ );
25
+ const targetArg = Args.text({ name: "target" }).pipe(
26
+ Args.withSchema(StoreName),
27
+ Args.withDescription("Target (derived) store name")
28
+ );
29
+
30
+ const filterJsonOption = Options.text("filter-json").pipe(
31
+ Options.withDescription(
32
+ filterJsonDescription("EventTime mode supports pure filters only.")
33
+ ),
34
+ Options.optional
35
+ );
36
+
37
+ const modeOption = Options.choice("mode", ["event-time", "derive-time"]).pipe(
38
+ Options.withDescription("Filter evaluation mode (default: event-time)"),
39
+ Options.withDefault("event-time" as const)
40
+ );
41
+
42
+ const resetFlag = Options.boolean("reset").pipe(
43
+ Options.withDescription("Reset the target store before deriving")
44
+ );
45
+
46
+ const yesFlag = Options.boolean("yes").pipe(
47
+ Options.withAlias("y"),
48
+ Options.withDescription("Confirm destructive operations")
49
+ );
50
+
51
+ const mapMode = (mode: "event-time" | "derive-time"): FilterEvaluationMode => {
52
+ return mode === "event-time" ? "EventTime" : "DeriveTime";
53
+ };
54
+
55
+ export const deriveCommand = Command.make(
56
+ "derive",
57
+ {
58
+ source: sourceArg,
59
+ target: targetArg,
60
+ filter: filterOption,
61
+ filterJson: filterJsonOption,
62
+ mode: modeOption,
63
+ reset: resetFlag,
64
+ yes: yesFlag
65
+ },
66
+ ({ source, target, filter, filterJson, mode, reset, yes }) =>
67
+ Effect.gen(function* () {
68
+ const startTime = yield* Clock.currentTimeMillis;
69
+ const engine = yield* DerivationEngine;
70
+ const checkpoints = yield* ViewCheckpointStore;
71
+ const manager = yield* StoreManager;
72
+ const outputManager = yield* OutputManager;
73
+ const preferences = yield* CliPreferences;
74
+
75
+ // Parse filter expression
76
+ const filterExpr = yield* parseFilterExpr(filter, filterJson);
77
+
78
+ // Validation 1: EventTime mode guard for effectful filters
79
+ // Defense-in-depth: CLI validates for UX (user-friendly errors),
80
+ // service validates for safety (in case called from other contexts)
81
+ const evaluationMode = mapMode(mode);
82
+ if (evaluationMode === "EventTime" && isEffectfulFilter(filterExpr)) {
83
+ return yield* CliInputError.make({
84
+ message:
85
+ "EventTime mode does not allow Trending/HasValidLinks filters. Use --mode derive-time for effectful filters.",
86
+ cause: { filterExpr, mode: evaluationMode }
87
+ });
88
+ }
89
+
90
+ // Validation 2: Reset requires --yes confirmation
91
+ if (reset && !yes) {
92
+ return yield* CliInputError.make({
93
+ message: "--reset is destructive. Re-run with --yes to confirm.",
94
+ cause: { reset: true, yes: false }
95
+ });
96
+ }
97
+
98
+ // Validation 3: Source and target must be different
99
+ if (source === target) {
100
+ return yield* CliInputError.make({
101
+ message: "Source and target stores must be different.",
102
+ cause: { source, target }
103
+ });
104
+ }
105
+
106
+ // Validation 4: Filter or mode change detection (only if not resetting)
107
+ if (!reset) {
108
+ const checkpointOption = yield* checkpoints.load(target, source);
109
+ if (Option.isSome(checkpointOption)) {
110
+ const checkpoint = checkpointOption.value;
111
+ const newFilterHash = filterExprSignature(filterExpr);
112
+ if (checkpoint.filterHash !== newFilterHash || checkpoint.evaluationMode !== evaluationMode) {
113
+ const message = [
114
+ "Derivation settings have changed since last derivation.",
115
+ `Previous filter hash: ${checkpoint.filterHash}`,
116
+ `New filter hash: ${newFilterHash}`,
117
+ `Previous mode: ${checkpoint.evaluationMode}`,
118
+ `New mode: ${evaluationMode}`,
119
+ "",
120
+ "This would result in inconsistent data. Options:",
121
+ " 1. Use --reset --yes to discard existing data and start fresh",
122
+ " 2. Use the same filter expression as before",
123
+ " 3. Derive into a new target store"
124
+ ].join("\n");
125
+
126
+ return yield* CliInputError.make({
127
+ message,
128
+ cause: {
129
+ oldHash: checkpoint.filterHash,
130
+ newHash: newFilterHash,
131
+ oldMode: checkpoint.evaluationMode,
132
+ newMode: evaluationMode
133
+ }
134
+ });
135
+ }
136
+ }
137
+ }
138
+
139
+ // Load store references
140
+ const sourceRef = yield* storeOptions.loadStoreRef(source);
141
+ const targetOption = yield* manager.getStore(target);
142
+ const targetRef = yield* Option.match(targetOption, {
143
+ onNone: () =>
144
+ manager.createStore(target, defaultStoreConfig).pipe(
145
+ Effect.tap(() => logInfo("Auto-created target store", { target }))
146
+ ),
147
+ onSome: Effect.succeed
148
+ });
149
+
150
+ // Execute derivation
151
+ const result = yield* engine.derive(sourceRef, targetRef, filterExpr, {
152
+ mode: evaluationMode,
153
+ reset
154
+ });
155
+
156
+ const materialized = yield* outputManager.materializeStore(targetRef);
157
+ if (materialized.filters.length > 0) {
158
+ yield* logInfo("Materialized filter outputs", {
159
+ store: targetRef.name,
160
+ filters: materialized.filters.map((spec) => spec.name)
161
+ });
162
+ }
163
+
164
+ // Calculate duration and percentage
165
+ const endTime = yield* Clock.currentTimeMillis;
166
+ const duration = (endTime - startTime) / 1000;
167
+ const percentage = result.eventsProcessed > 0
168
+ ? ((result.eventsMatched / result.eventsProcessed) * 100).toFixed(1)
169
+ : "0.0";
170
+
171
+ // Human-friendly summary (always shown)
172
+ yield* logInfo(
173
+ `Derived ${result.eventsMatched} posts (${percentage}%) from ${sourceRef.name} → ${targetRef.name} in ${duration.toFixed(1)}s`
174
+ );
175
+
176
+ // Output result with context
177
+ if (preferences.compact) {
178
+ yield* writeJson({
179
+ source: sourceRef.name,
180
+ target: targetRef.name,
181
+ mode: evaluationMode,
182
+ ...result
183
+ });
184
+ return;
185
+ }
186
+
187
+ yield* writeJson({
188
+ source: sourceRef.name,
189
+ target: targetRef.name,
190
+ mode: evaluationMode,
191
+ result
192
+ });
193
+ })
194
+ ).pipe(
195
+ Command.withDescription(
196
+ withExamples(
197
+ "Derive a target store from a source store by applying a filter",
198
+ [
199
+ "skygent derive source-store derived-store --filter 'hashtag:#ai'",
200
+ "skygent derive source-store derived-store --filter 'hashtag:#ai' --mode derive-time"
201
+ ],
202
+ ["Tip: use --reset --yes if you need to rebuild with a new filter or mode."]
203
+ )
204
+ )
205
+ );
@@ -0,0 +1,36 @@
1
+ import * as Ansi from "@effect/printer-ansi/Ansi";
2
+
3
+ export type Annotation =
4
+ | "label" | "value" | "dim" | "accent" | "muted" | "connector"
5
+ | "error" | "warning" | "metric" | "badge"
6
+ | "storeName" | "storeName:root" | "storeName:derived"
7
+ | "status:ready" | "status:stale" | "status:unknown" | "status:source"
8
+ | "sync:current" | "sync:stale" | "sync:empty" | "sync:unknown"
9
+ | "author" | "hashtag" | "link" | "timestamp" | "embed" | "cycle";
10
+
11
+ export const toAnsi = (a: Annotation): Ansi.Ansi => {
12
+ switch (a) {
13
+ case "label": case "dim": case "muted": case "connector": return Ansi.blackBright;
14
+ case "value": return Ansi.white;
15
+ case "storeName": return Ansi.cyan;
16
+ case "storeName:root": return Ansi.combine(Ansi.cyan, Ansi.bold);
17
+ case "storeName:derived": return Ansi.magenta;
18
+ case "status:ready": return Ansi.green;
19
+ case "status:stale": return Ansi.red;
20
+ case "status:unknown": return Ansi.yellow;
21
+ case "status:source": return Ansi.cyan;
22
+ case "sync:current": return Ansi.green;
23
+ case "sync:stale": case "sync:unknown": return Ansi.yellow;
24
+ case "sync:empty": return Ansi.blackBright;
25
+ case "accent": return Ansi.cyan;
26
+ case "error": return Ansi.red;
27
+ case "warning": case "cycle": return Ansi.yellow;
28
+ case "metric": return Ansi.whiteBright;
29
+ case "badge": return Ansi.bold;
30
+ case "author": return Ansi.combine(Ansi.cyan, Ansi.bold);
31
+ case "hashtag": return Ansi.blue;
32
+ case "link": return Ansi.underlined;
33
+ case "timestamp": return Ansi.blackBright;
34
+ case "embed": return Ansi.magenta;
35
+ }
36
+ };
@@ -0,0 +1,69 @@
1
+ import * as Doc from "@effect/printer/Doc";
2
+ import type { Annotation } from "./annotation.js";
3
+ import { ann, field } from "./primitives.js";
4
+ import type { FilterCondition, FilterDescription } from "../../domain/filter-describe.js";
5
+
6
+ type SDoc = Doc.Doc<Annotation>;
7
+
8
+ const titleCase = (value: string) => value.replace(/\b\w/g, (char) => char.toUpperCase());
9
+
10
+ const conditionLine = (condition: FilterCondition): string => {
11
+ const prefix = condition.negated ? "Must NOT " : "Must ";
12
+ switch (condition.type) {
13
+ case "Hashtag": return `${prefix}have hashtag: ${condition.value}`;
14
+ case "Author": return `${prefix}be from: ${condition.value}`;
15
+ case "AuthorIn": return `${prefix}be from one of: ${condition.value}`;
16
+ case "HashtagIn": return `${prefix}have hashtag in: ${condition.value}`;
17
+ case "Contains": return `${prefix}contain: ${condition.value}`;
18
+ case "IsReply": return `${prefix}be a reply`;
19
+ case "IsQuote": return `${prefix}be a quote`;
20
+ case "IsRepost": return `${prefix}be a repost`;
21
+ case "IsOriginal": return `${prefix}be an original post`;
22
+ case "Engagement": return `${prefix}meet engagement: ${condition.value}`;
23
+ case "HasImages": return `${prefix}include images`;
24
+ case "HasVideo": return `${prefix}include video`;
25
+ case "HasLinks": return `${prefix}include links`;
26
+ case "HasMedia": return `${prefix}include media`;
27
+ case "HasEmbed": return `${prefix}include embeds`;
28
+ case "Language": return `${prefix}be in: ${condition.value}`;
29
+ case "Regex": return `${prefix}match regex: ${condition.value}`;
30
+ case "DateRange": return `${prefix}be in date range: ${condition.value}`;
31
+ case "HasValidLinks": return `${prefix}have valid links`;
32
+ case "Trending": return `${prefix}match trending: ${condition.value}`;
33
+ default: return `${prefix}match ${condition.type}: ${condition.value}`;
34
+ }
35
+ };
36
+
37
+ export const renderFilterDescriptionDoc = (description: FilterDescription): SDoc => {
38
+ const lines: SDoc[] = [];
39
+ lines.push(ann("value", Doc.text(description.summary)));
40
+
41
+ if (description.conditions.length > 0) {
42
+ lines.push(Doc.empty);
43
+ lines.push(ann("label", Doc.text("Breakdown:")));
44
+ for (const condition of description.conditions) {
45
+ lines.push(Doc.hsep([ann("dim", Doc.text("-")), Doc.text(conditionLine(condition))]));
46
+ }
47
+ }
48
+
49
+ lines.push(Doc.empty);
50
+ lines.push(ann("label", Doc.text("Mode compatibility:")));
51
+ lines.push(Doc.hsep([
52
+ ann("dim", Doc.text("-")),
53
+ Doc.text("EventTime:"),
54
+ ann(description.eventTimeCompatible ? "status:ready" : "status:stale",
55
+ Doc.text(description.eventTimeCompatible ? "YES" : "NO"))
56
+ ]));
57
+ lines.push(Doc.hsep([
58
+ ann("dim", Doc.text("-")),
59
+ Doc.text("DeriveTime:"),
60
+ ann("status:ready", Doc.text("YES"))
61
+ ]));
62
+
63
+ lines.push(Doc.empty);
64
+ lines.push(field("Effectful", description.effectful ? "Yes" : "No"));
65
+ lines.push(field("Estimated cost", titleCase(description.estimatedCost)));
66
+ lines.push(field("Complexity", `${titleCase(description.complexity)} (${description.conditionCount} conditions, ${description.negationCount} negations)`));
67
+
68
+ return Doc.vsep(lines);
69
+ };
@@ -0,0 +1,9 @@
1
+ export type { Annotation } from "./annotation.js";
2
+ export { toAnsi } from "./annotation.js";
3
+ export { renderPlain, renderAnsi } from "./render.js";
4
+ export { ann, label, value, field, badge, connector, metric } from "./primitives.js";
5
+ export type { RenderContext, TreeConfig } from "./tree.js";
6
+ export { renderTree } from "./tree.js";
7
+ export { renderPostCompact, renderPostCard } from "./post.js";
8
+ export { renderThread } from "./thread.js";
9
+ export { renderFilterDescriptionDoc } from "./filter.js";
@@ -0,0 +1,155 @@
1
+ import * as Doc from "@effect/printer/Doc";
2
+ import type { Annotation } from "./annotation.js";
3
+ import { ann, metric } from "./primitives.js";
4
+ import { collapseWhitespace, normalizeWhitespace, truncate } from "../../domain/format.js";
5
+ import type { Post } from "../../domain/post.js";
6
+ import type { PostEmbed } from "../../domain/bsky.js";
7
+
8
+ type SDoc = Doc.Doc<Annotation>;
9
+
10
+ const renderEmbedSummary = (embed: PostEmbed): SDoc => {
11
+ switch (embed._tag) {
12
+ case "Images": return Doc.text(`[Images: ${embed.images.length}]`);
13
+ case "External": return Doc.text(`[Link: ${truncate(embed.title || embed.uri, 40)}]`);
14
+ case "Video": return Doc.text("[Video]");
15
+ case "Record":
16
+ return Doc.text(
17
+ embed.record._tag === "RecordView"
18
+ ? `[Quote: @${embed.record.author.handle}]`
19
+ : "[Quote]"
20
+ );
21
+ case "RecordWithMedia":
22
+ return Doc.text(
23
+ embed.record._tag === "RecordView"
24
+ ? `[Quote: @${embed.record.author.handle} + media]`
25
+ : "[Quote + media]"
26
+ );
27
+ default: return Doc.text("[Embed]");
28
+ }
29
+ };
30
+
31
+ const wrapText = (text: string, maxWidth?: number): ReadonlyArray<string> => {
32
+ const normalized = normalizeWhitespace(text);
33
+ if (!maxWidth || maxWidth <= 0) {
34
+ const lines = normalized.split("\n");
35
+ return lines.length > 0 ? lines : [normalized];
36
+ }
37
+ const lines: string[] = [];
38
+ const paragraphs = normalized.split("\n");
39
+ for (const paragraph of paragraphs) {
40
+ if (paragraph.length === 0) {
41
+ lines.push("");
42
+ continue;
43
+ }
44
+ const words = paragraph.split(" ");
45
+ let current = "";
46
+ const flushCurrent = () => {
47
+ if (current.length > 0) {
48
+ lines.push(current);
49
+ current = "";
50
+ }
51
+ };
52
+ for (let word of words) {
53
+ while (word.length > maxWidth && maxWidth > 1) {
54
+ const chunk = word.slice(0, maxWidth - 1);
55
+ flushCurrent();
56
+ lines.push(`${chunk}-`);
57
+ word = word.slice(maxWidth - 1);
58
+ }
59
+ if (current.length === 0) {
60
+ current = word;
61
+ continue;
62
+ }
63
+ if (current.length + 1 + word.length <= maxWidth) {
64
+ current = `${current} ${word}`;
65
+ } else {
66
+ lines.push(current);
67
+ current = word;
68
+ }
69
+ }
70
+ flushCurrent();
71
+ }
72
+ return lines.length > 0 ? lines : [normalized];
73
+ };
74
+
75
+ export const renderPostCompact = (post: Post): SDoc => {
76
+ const text = post.text ?? "";
77
+ const parts: SDoc[] = [
78
+ ann("author", Doc.text(`@${post.author}`)),
79
+ ann("dim", Doc.text("·")),
80
+ ann("timestamp", Doc.text(post.createdAt.toISOString().slice(0, 10))),
81
+ Doc.text(truncate(collapseWhitespace(text), 60))
82
+ ];
83
+ if (post.metrics) {
84
+ const m = post.metrics;
85
+ if (m.likeCount != null && m.likeCount > 0) parts.push(metric("♥", m.likeCount));
86
+ if (m.repostCount != null && m.repostCount > 0) parts.push(metric("↻", m.repostCount));
87
+ if (m.replyCount != null && m.replyCount > 0) parts.push(metric("💬", m.replyCount));
88
+ }
89
+ return Doc.hsep(parts);
90
+ };
91
+
92
+ /** Returns an array of Doc lines suitable for multi-line tree rendering.
93
+ * When used standalone, combine with `Doc.vsep(renderPostCard(post))`. */
94
+ export const renderPostCard = (post: Post): ReadonlyArray<SDoc> => {
95
+ const text = post.text ?? "";
96
+ const lines: SDoc[] = [];
97
+
98
+ lines.push(Doc.hsep([
99
+ ann("author", Doc.text(`@${post.author}`)),
100
+ ann("dim", Doc.text("·")),
101
+ ann("timestamp", Doc.text(post.createdAt.toISOString()))
102
+ ]));
103
+
104
+ const paragraphs = normalizeWhitespace(text).split("\n");
105
+ lines.push(Doc.vsep(paragraphs.map((paragraph) => Doc.reflow(paragraph))));
106
+
107
+ if (post.embed) lines.push(ann("embed", renderEmbedSummary(post.embed)));
108
+
109
+ if (post.metrics) {
110
+ const parts: SDoc[] = [];
111
+ const m = post.metrics;
112
+ if (m.likeCount != null && m.likeCount > 0) parts.push(metric("♥", m.likeCount));
113
+ if (m.repostCount != null && m.repostCount > 0) parts.push(metric("↻", m.repostCount));
114
+ if (m.replyCount != null && m.replyCount > 0) parts.push(metric("💬", m.replyCount));
115
+ if (m.quoteCount != null && m.quoteCount > 0) parts.push(metric("❝", m.quoteCount));
116
+ if (parts.length > 0) lines.push(Doc.hsep(parts));
117
+ }
118
+
119
+ return lines;
120
+ };
121
+
122
+ export const renderPostCardLines = (
123
+ post: Post,
124
+ options?: { lineWidth?: number }
125
+ ): ReadonlyArray<SDoc> => {
126
+ const text = post.text ?? "";
127
+ const lines: SDoc[] = [];
128
+
129
+ lines.push(Doc.hsep([
130
+ ann("author", Doc.text(`@${post.author}`)),
131
+ ann("dim", Doc.text("·")),
132
+ ann("timestamp", Doc.text(post.createdAt.toISOString()))
133
+ ]));
134
+
135
+ const textLines = wrapText(text, options?.lineWidth);
136
+ for (const line of textLines) {
137
+ lines.push(Doc.text(line));
138
+ }
139
+
140
+ if (post.embed) {
141
+ lines.push(ann("embed", renderEmbedSummary(post.embed)));
142
+ }
143
+
144
+ if (post.metrics) {
145
+ const parts: SDoc[] = [];
146
+ const m = post.metrics;
147
+ if (m.likeCount != null && m.likeCount > 0) parts.push(metric("♥", m.likeCount));
148
+ if (m.repostCount != null && m.repostCount > 0) parts.push(metric("↻", m.repostCount));
149
+ if (m.replyCount != null && m.replyCount > 0) parts.push(metric("💬", m.replyCount));
150
+ if (m.quoteCount != null && m.quoteCount > 0) parts.push(metric("❝", m.quoteCount));
151
+ if (parts.length > 0) lines.push(Doc.hsep(parts));
152
+ }
153
+
154
+ return lines;
155
+ };
@@ -0,0 +1,25 @@
1
+ import * as Doc from "@effect/printer/Doc";
2
+ import { pipe } from "effect/Function";
3
+ import type { Annotation } from "./annotation.js";
4
+
5
+ type SDoc = Doc.Doc<Annotation>;
6
+
7
+ export const ann = (a: Annotation, doc: SDoc): SDoc => Doc.annotate(doc, a);
8
+
9
+ export const label = (text: string): SDoc => ann("label", Doc.text(text));
10
+ export const value = (text: string): SDoc => ann("value", Doc.text(text));
11
+
12
+ export const field = (key: string, val: string, keyWidth?: number): SDoc =>
13
+ Doc.hsep([
14
+ keyWidth ? pipe(label(key + ":"), Doc.fillBreak(keyWidth)) : label(key + ":"),
15
+ value(val)
16
+ ]);
17
+
18
+ export const badge = (text: string, annotation: Annotation): SDoc =>
19
+ ann(annotation, Doc.text(`[${text}]`));
20
+
21
+ export const connector = (text: string): SDoc =>
22
+ ann("connector", Doc.text(text));
23
+
24
+ export const metric = (icon: string, count: number): SDoc =>
25
+ ann("metric", Doc.text(`${icon} ${new Intl.NumberFormat("en-US").format(count)}`));
@@ -0,0 +1,18 @@
1
+ import * as Doc from "@effect/printer/Doc";
2
+ import * as AnsiDoc from "@effect/printer-ansi/AnsiDoc";
3
+ import type { Annotation } from "./annotation.js";
4
+ import { toAnsi } from "./annotation.js";
5
+
6
+ export const renderPlain = (doc: Doc.Doc<Annotation>, width?: number): string => {
7
+ const plain = Doc.unAnnotate(doc);
8
+ return width
9
+ ? Doc.render(plain, { style: "pretty", options: { lineWidth: width } })
10
+ : Doc.render(plain, { style: "pretty" });
11
+ };
12
+
13
+ export const renderAnsi = (doc: Doc.Doc<Annotation>, width?: number): string => {
14
+ const ansiDoc = Doc.reAnnotate(doc, toAnsi);
15
+ return width
16
+ ? AnsiDoc.render(ansiDoc, { style: "pretty", options: { lineWidth: width } })
17
+ : AnsiDoc.render(ansiDoc, { style: "pretty" });
18
+ };
@@ -0,0 +1,114 @@
1
+ import * as Doc from "@effect/printer/Doc";
2
+ import { pipe } from "effect/Function";
3
+ import type { Annotation } from "./annotation.js";
4
+ import { renderAnsi, renderPlain } from "./render.js";
5
+ import { ann, label, value } from "./primitives.js";
6
+ import { displayWidth } from "../../domain/text-width.js";
7
+
8
+ export type SDoc = Doc.Doc<Annotation>;
9
+
10
+ export interface TableColumn {
11
+ readonly header: string;
12
+ readonly width?: number;
13
+ }
14
+
15
+ export interface TableConfig {
16
+ readonly columns: ReadonlyArray<TableColumn>;
17
+ readonly rows: ReadonlyArray<ReadonlyArray<string>>;
18
+ readonly trimEnd?: boolean;
19
+ }
20
+
21
+ export const calculateColumnWidths = (
22
+ columns: ReadonlyArray<TableColumn>,
23
+ rows: ReadonlyArray<ReadonlyArray<string>>
24
+ ): ReadonlyArray<number> => {
25
+ return columns.map((column, index) => {
26
+ const contentWidths = rows.map((row) => displayWidth(row[index] ?? ""));
27
+ return Math.max(displayWidth(column.header), ...contentWidths);
28
+ });
29
+ };
30
+
31
+ export const buildCell = (
32
+ content: string,
33
+ width: number,
34
+ style?: "label" | "value" | "dim"
35
+ ): SDoc => {
36
+ const doc = style === "label"
37
+ ? label(content)
38
+ : style === "dim"
39
+ ? ann("dim", Doc.text(content))
40
+ : value(content);
41
+
42
+ return pipe(doc, Doc.fillBreak(width));
43
+ };
44
+
45
+ export const buildRow = (
46
+ cells: ReadonlyArray<string>,
47
+ widths: ReadonlyArray<number>,
48
+ styles?: ReadonlyArray<"label" | "value" | "dim" | undefined>,
49
+ trimEnd = false
50
+ ): SDoc => {
51
+ const cellDocs = cells.map((cell, i) => {
52
+ const style = styles?.[i];
53
+ return buildCell(cell, widths[i] ?? 0, style);
54
+ });
55
+
56
+ const row = Doc.hsep(cellDocs);
57
+
58
+ if (trimEnd) {
59
+ return pipe(
60
+ row,
61
+ Doc.render({ style: "pretty" }),
62
+ (str: string) => str.trimEnd(),
63
+ Doc.text
64
+ );
65
+ }
66
+
67
+ return row;
68
+ };
69
+
70
+ export const buildTableDoc = (config: TableConfig): SDoc => {
71
+ const { columns, rows, trimEnd = false } = config;
72
+
73
+ const widths = calculateColumnWidths(columns, rows);
74
+
75
+ const headerCells = columns.map((col, i) =>
76
+ buildCell(col.header, widths[i] ?? 0, "label")
77
+ );
78
+ const header = Doc.hsep(headerCells);
79
+
80
+ const separatorCells = widths.map((w) => ann("dim", Doc.text("-".repeat(w))));
81
+ const separator = Doc.hsep(separatorCells);
82
+
83
+ const dataRows = rows.map((row) => {
84
+ const dataCells = row.map((cell, i) => buildCell(cell, widths[i] ?? 0, "value"));
85
+ const dataRow = Doc.hsep(dataCells);
86
+
87
+ if (trimEnd) {
88
+ return pipe(
89
+ dataRow,
90
+ Doc.render({ style: "pretty" }),
91
+ (str: string) => str.trimEnd(),
92
+ Doc.text
93
+ );
94
+ }
95
+
96
+ return dataRow;
97
+ });
98
+
99
+ return Doc.vsep([header, separator, ...dataRows]);
100
+ };
101
+
102
+ export const renderTable = (config: TableConfig, ansi = false): string => {
103
+ const doc = buildTableDoc(config);
104
+ return ansi ? renderAnsi(doc) : renderPlain(doc);
105
+ };
106
+
107
+ export const renderTableLegacy = (
108
+ headers: ReadonlyArray<string>,
109
+ rows: ReadonlyArray<ReadonlyArray<string>>,
110
+ trimEnd = true
111
+ ): string => {
112
+ const columns = headers.map((header) => ({ header }));
113
+ return renderTable({ columns, rows, trimEnd });
114
+ };
@@ -0,0 +1,46 @@
1
+ import * as Doc from "@effect/printer/Doc";
2
+ import type { Annotation } from "./annotation.js";
3
+ import { renderTree } from "./tree.js";
4
+ import { renderPostCompact, renderPostCardLines } from "./post.js";
5
+ import type { Post } from "../../domain/post.js";
6
+
7
+ export const renderThread = (
8
+ posts: ReadonlyArray<Post>,
9
+ options?: { compact?: boolean; lineWidth?: number }
10
+ ): Doc.Doc<Annotation> => {
11
+ const byUri = new Map(posts.map((p) => [String(p.uri), p]));
12
+ const childMap = new Map<string, Post[]>();
13
+ const roots: Post[] = [];
14
+
15
+ for (const post of posts) {
16
+ const parentUri = post.reply?.parent.uri ? String(post.reply.parent.uri) : undefined;
17
+ if (parentUri && byUri.has(parentUri)) {
18
+ const siblings = childMap.get(parentUri) ?? [];
19
+ siblings.push(post);
20
+ childMap.set(parentUri, siblings);
21
+ } else {
22
+ roots.push(post);
23
+ }
24
+ }
25
+
26
+ const sortPosts = (arr: Post[]) =>
27
+ arr.sort((a, b) =>
28
+ a.createdAt.getTime() - b.createdAt.getTime() || a.uri.localeCompare(b.uri)
29
+ );
30
+
31
+ sortPosts(roots);
32
+ for (const children of childMap.values()) sortPosts(children);
33
+
34
+ const cardOptions =
35
+ options?.lineWidth === undefined ? undefined : { lineWidth: options.lineWidth };
36
+ const render = options?.compact
37
+ ? renderPostCompact
38
+ : (post: Post) => renderPostCardLines(post, cardOptions);
39
+
40
+ return renderTree<Post, undefined>(roots, {
41
+ children: (post) =>
42
+ (childMap.get(String(post.uri)) ?? []).map((p) => ({ node: p, edge: undefined })),
43
+ renderNode: (post) => render(post),
44
+ key: (post) => String(post.uri),
45
+ });
46
+ };