birdclaw 0.8.0 → 0.8.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # CHANGELOG
2
2
 
3
+ ## 0.8.1 - 2026-06-13
4
+
5
+ ### Changed
6
+
7
+ - Refresh dependency backstop updates for `@steipete/sweet-cookie`, `@types/node`, the adopted TypeScript native-preview toolchain, and the pnpm 10 package-manager pin.
8
+ - Remove the separate public read-only web profile; deployments can expose the full private app behind an external authentication boundary.
9
+ - Show recent web timestamps as live relative time, then switch to calendar dates with exact local time on hover.
10
+
11
+ ### Fixed
12
+
13
+ - Open long-running AI streams immediately, keep the Today digest within proxy limits by using the locally synchronized archive, and show actionable retry errors when a connection is interrupted.
14
+ - Reuse freshly generated Today reports across reloads while background sync updates the archive, and hydrate locally stored cited tweets so source hovercards remain available outside the selected time window.
15
+
3
16
  ## 0.8.0 - 2026-06-10
4
17
 
5
18
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "birdclaw",
3
- "version": "0.8.0",
3
+ "version": "0.8.1",
4
4
  "description": "Local Twitter memory in SQLite for archives, DMs, likes, bookmarks, and moderation",
5
5
  "homepage": "https://github.com/steipete/birdclaw#readme",
6
6
  "license": "MIT",
@@ -52,7 +52,7 @@
52
52
  "docs:site": "node scripts/build-docs-site.mjs"
53
53
  },
54
54
  "dependencies": {
55
- "@steipete/sweet-cookie": "^0.3.0",
55
+ "@steipete/sweet-cookie": "^0.4.0",
56
56
  "@tailwindcss/vite": "^4.3.0",
57
57
  "@tanstack/devtools-vite": "0.7.0",
58
58
  "@tanstack/react-devtools": "0.10.5",
@@ -83,11 +83,11 @@
83
83
  "@testing-library/jest-dom": "^6.9.1",
84
84
  "@testing-library/react": "^16.3.2",
85
85
  "@types/geojson": "^7946.0.16",
86
- "@types/node": "^25.9.2",
86
+ "@types/node": "^25.9.3",
87
87
  "@types/react": "^19.2.17",
88
88
  "@types/react-dom": "^19.2.3",
89
89
  "@types/supercluster": "^7.1.3",
90
- "@typescript/native-preview": "7.0.0-dev.20260609.1",
90
+ "@typescript/native-preview": "7.0.0-dev.20260611.2",
91
91
  "@vitest/coverage-v8": "^4.1.8",
92
92
  "jsdom": "^29.1.1",
93
93
  "oxfmt": "^0.54.0",
@@ -98,5 +98,5 @@
98
98
  "engines": {
99
99
  "node": ">=25.8.1 <27"
100
100
  },
101
- "packageManager": "pnpm@10.31.0"
101
+ "packageManager": "pnpm@10.34.3"
102
102
  }
@@ -1,5 +1,4 @@
1
1
  import { MessageCircle } from "lucide-react";
2
- import { formatShortTimestamp } from "#/lib/present";
3
2
  import type { EmbeddedTweet } from "#/lib/types";
4
3
  import {
5
4
  cx,
@@ -11,6 +10,7 @@ import {
11
10
  import { AvatarChip } from "./AvatarChip";
12
11
  import { BirdclawEmpty, BirdclawLoading } from "./BrandMark";
13
12
  import { ProfilePreview } from "./ProfilePreview";
13
+ import { SmartTimestamp } from "./SmartTimestamp";
14
14
  import { TweetMediaGrid } from "./TweetMediaGrid";
15
15
  import { TweetRichText } from "./TweetRichText";
16
16
 
@@ -101,9 +101,10 @@ export function ConversationThread({
101
101
  </span>
102
102
  </ProfilePreview>
103
103
  <span className="text-[var(--ink-soft)]">·</span>
104
- <span className={feedRowTimestampClass}>
105
- {formatShortTimestamp(tweet.createdAt)}
106
- </span>
104
+ <SmartTimestamp
105
+ className={feedRowTimestampClass}
106
+ value={tweet.createdAt}
107
+ />
107
108
  {isAnchor ? (
108
109
  <span className="ml-auto rounded-full bg-[var(--accent)] px-2 py-0.5 text-[11px] font-bold text-white">
109
110
  selected
@@ -1,5 +1,5 @@
1
1
  import { CheckCircle2, Circle } from "lucide-react";
2
- import { formatCompactNumber, formatShortTimestamp } from "#/lib/present";
2
+ import { formatCompactNumber } from "#/lib/present";
3
3
  import type { DmConversationItem, DmMessageItem } from "#/lib/types";
4
4
  import {
5
5
  composerBarClass,
@@ -39,6 +39,7 @@ import {
39
39
  } from "#/lib/ui";
40
40
  import { AvatarChip } from "./AvatarChip";
41
41
  import { BirdclawEmpty } from "./BrandMark";
42
+ import { SmartTimestamp } from "./SmartTimestamp";
42
43
 
43
44
  function MessageBubble({ message }: { message: DmMessageItem }) {
44
45
  const outbound = message.direction === "outbound";
@@ -57,7 +58,7 @@ function MessageBubble({ message }: { message: DmMessageItem }) {
57
58
  <div className={dmMessageMetaClass}>
58
59
  <span>{message.sender.displayName}</span>
59
60
  <span>·</span>
60
- <span>{formatShortTimestamp(message.createdAt)}</span>
61
+ <SmartTimestamp value={message.createdAt} />
61
62
  </div>
62
63
  </div>
63
64
  );
@@ -81,9 +82,13 @@ export function DmWorkspace({
81
82
  onReplySend: (conversationId: string) => void;
82
83
  }) {
83
84
  const participant = selectedConversation?.participant ?? null;
84
- const subtitle = selectedConversation
85
- ? `${selectedConversation.isMessageRequest ? "Message request" : selectedConversation.needsReply ? "Reply open" : "We replied"} · last message ${formatShortTimestamp(selectedConversation.lastMessageAt)}`
86
- : "No conversation selected";
85
+ const selectedStatus = selectedConversation
86
+ ? selectedConversation.isMessageRequest
87
+ ? "Message request"
88
+ : selectedConversation.needsReply
89
+ ? "Reply open"
90
+ : "We replied"
91
+ : null;
87
92
 
88
93
  return (
89
94
  <section aria-label="DM workspace" className={dmShellClass}>
@@ -119,9 +124,10 @@ export function DmWorkspace({
119
124
  @{conversation.participant.handle}
120
125
  </span>
121
126
  </div>
122
- <span className={dmListTimestampClass}>
123
- {formatShortTimestamp(conversation.lastMessageAt)}
124
- </span>
127
+ <SmartTimestamp
128
+ className={dmListTimestampClass}
129
+ value={conversation.lastMessageAt}
130
+ />
125
131
  </div>
126
132
  <p className={dmListPreviewClass}>
127
133
  {conversation.lastMessagePreview}
@@ -176,7 +182,18 @@ export function DmWorkspace({
176
182
  <div className={dmThreadNameClass}>
177
183
  {selectedConversation.participant.displayName}
178
184
  </div>
179
- <div className={dmThreadSubtitleClass}>{subtitle}</div>
185
+ <div className={dmThreadSubtitleClass}>
186
+ {selectedStatus && selectedConversation ? (
187
+ <>
188
+ {selectedStatus} · last message{" "}
189
+ <SmartTimestamp
190
+ value={selectedConversation.lastMessageAt}
191
+ />
192
+ </>
193
+ ) : (
194
+ "No conversation selected"
195
+ )}
196
+ </div>
180
197
  </div>
181
198
  </div>
182
199
  <button
@@ -1,4 +1,3 @@
1
- import { formatShortTimestamp } from "#/lib/present";
2
1
  import type { EmbeddedTweet } from "#/lib/types";
3
2
  import {
4
3
  embeddedCardBodyClass,
@@ -10,6 +9,7 @@ import {
10
9
  feedRowTimestampClass,
11
10
  } from "#/lib/ui";
12
11
  import { ProfilePreview } from "./ProfilePreview";
12
+ import { SmartTimestamp } from "./SmartTimestamp";
13
13
  import { TweetMediaGrid } from "./TweetMediaGrid";
14
14
  import { TweetRichText } from "./TweetRichText";
15
15
 
@@ -35,9 +35,10 @@ export function EmbeddedTweetCard({
35
35
  </span>
36
36
  </ProfilePreview>
37
37
  <span className="text-[var(--ink-soft)]">·</span>
38
- <span className={feedRowTimestampClass}>
39
- {formatShortTimestamp(item.createdAt)}
40
- </span>
38
+ <SmartTimestamp
39
+ className={feedRowTimestampClass}
40
+ value={item.createdAt}
41
+ />
41
42
  </header>
42
43
  <TweetRichText
43
44
  className={embeddedCardCopyClass}
@@ -5,7 +5,7 @@ import {
5
5
  ExternalLink,
6
6
  MessageCircle,
7
7
  } from "lucide-react";
8
- import { formatCompactNumber, formatShortTimestamp } from "#/lib/present";
8
+ import { formatCompactNumber } from "#/lib/present";
9
9
  import type { InboxItem } from "#/lib/types";
10
10
  import {
11
11
  composerBarClass,
@@ -30,6 +30,7 @@ import {
30
30
  timestampClass,
31
31
  } from "#/lib/ui";
32
32
  import { AvatarChip } from "./AvatarChip";
33
+ import { SmartTimestamp } from "./SmartTimestamp";
33
34
 
34
35
  export function InboxCard({
35
36
  item,
@@ -65,9 +66,10 @@ export function InboxCard({
65
66
  </span>
66
67
  </span>
67
68
  <span className={feedRowDotClass}>·</span>
68
- <span className={feedRowTimestampClass}>
69
- {formatShortTimestamp(item.createdAt)}
70
- </span>
69
+ <SmartTimestamp
70
+ className={feedRowTimestampClass}
71
+ value={item.createdAt}
72
+ />
71
73
  <span className="ml-auto flex items-center gap-1.5">
72
74
  <span className={cx(pillClass, pillSoftClass)}>
73
75
  {item.entityKind}
@@ -6,7 +6,7 @@ import {
6
6
  useRef,
7
7
  useState,
8
8
  } from "react";
9
- import { formatCompactNumber, formatShortTimestamp } from "#/lib/present";
9
+ import { formatCompactNumber } from "#/lib/present";
10
10
  import type { PeriodDigestContext } from "#/lib/period-digest";
11
11
  import type { ProfileAnalysisContext } from "#/lib/profile-analysis";
12
12
  import { renderTweetPlainText } from "#/lib/tweet-render";
@@ -15,6 +15,7 @@ import { cx, tweetLinkClass, tweetMentionClass } from "#/lib/ui";
15
15
  import { safeHttpUrl } from "#/lib/url-safety";
16
16
  import { AvatarChip } from "./AvatarChip";
17
17
  import { ProfilePreview } from "./ProfilePreview";
18
+ import { SmartTimestamp } from "./SmartTimestamp";
18
19
 
19
20
  type CitationTweet = PeriodDigestContext["tweets"][number];
20
21
  type CitationContext = PeriodDigestContext | ProfileAnalysisContext;
@@ -231,7 +232,7 @@ function TweetPreviewToken({
231
232
  <span className="min-w-0">
232
233
  <span className="block truncate font-bold">{tweet.name}</span>
233
234
  <span className="block truncate text-[12px] text-[var(--ink-soft)]">
234
- @{tweet.author} · {formatShortTimestamp(tweet.createdAt)}
235
+ @{tweet.author} · <SmartTimestamp value={tweet.createdAt} />
235
236
  </span>
236
237
  </span>
237
238
  </span>
@@ -0,0 +1,72 @@
1
+ import { useSyncExternalStore } from "react";
2
+ import {
3
+ formatExactTimestamp,
4
+ formatShortTimestamp,
5
+ formatSmartTimestamp,
6
+ } from "#/lib/present";
7
+
8
+ const CLOCK_INTERVAL_MS = 30_000;
9
+
10
+ type ClockSnapshot = number | null;
11
+
12
+ const listeners = new Set<() => void>();
13
+ let currentNow = Date.now();
14
+ let clockInterval: ReturnType<typeof setInterval> | null = null;
15
+
16
+ function updateClock() {
17
+ currentNow = Date.now();
18
+ for (const listener of listeners) listener();
19
+ }
20
+
21
+ function subscribeToClock(listener: () => void) {
22
+ listeners.add(listener);
23
+
24
+ if (clockInterval === null) {
25
+ currentNow = Date.now();
26
+ clockInterval = setInterval(updateClock, CLOCK_INTERVAL_MS);
27
+ }
28
+
29
+ return () => {
30
+ listeners.delete(listener);
31
+ if (listeners.size === 0 && clockInterval !== null) {
32
+ clearInterval(clockInterval);
33
+ clockInterval = null;
34
+ }
35
+ };
36
+ }
37
+
38
+ function getClockSnapshot(): ClockSnapshot {
39
+ return currentNow;
40
+ }
41
+
42
+ function getServerClockSnapshot(): ClockSnapshot {
43
+ return null;
44
+ }
45
+
46
+ export function SmartTimestamp({
47
+ className,
48
+ value,
49
+ }: {
50
+ className?: string;
51
+ value: string;
52
+ }) {
53
+ const now = useSyncExternalStore(
54
+ subscribeToClock,
55
+ getClockSnapshot,
56
+ getServerClockSnapshot,
57
+ );
58
+ const exactTimestamp = formatExactTimestamp(value);
59
+
60
+ return (
61
+ <time
62
+ aria-label={exactTimestamp}
63
+ className={className}
64
+ dateTime={value}
65
+ title={exactTimestamp}
66
+ >
67
+ {now === null
68
+ ? formatShortTimestamp(value)
69
+ : formatSmartTimestamp(value, now)}
70
+ </time>
71
+ );
72
+ }
@@ -8,7 +8,7 @@ import {
8
8
  Repeat2,
9
9
  UserSearch,
10
10
  } from "lucide-react";
11
- import { formatCompactNumber, formatShortTimestamp } from "#/lib/present";
11
+ import { formatCompactNumber } from "#/lib/present";
12
12
  import { normalizeTweetUrlEntityRangeForText } from "#/lib/tweet-render";
13
13
  import type {
14
14
  TimelineItem,
@@ -41,6 +41,7 @@ import { ConversationThread } from "./ConversationThread";
41
41
  import { EmbeddedTweetCard } from "./EmbeddedTweetCard";
42
42
  import { LinkPreviewCard } from "./LinkPreviewCard";
43
43
  import { ProfilePreview } from "./ProfilePreview";
44
+ import { SmartTimestamp } from "./SmartTimestamp";
44
45
  import { TweetMediaGrid } from "./TweetMediaGrid";
45
46
  import { TweetRichText } from "./TweetRichText";
46
47
 
@@ -312,9 +313,10 @@ export function TimelineCard({
312
313
  </span>
313
314
  </ProfilePreview>
314
315
  <span className={feedRowDotClass}>·</span>
315
- <span className={feedRowTimestampClass}>
316
- {formatShortTimestamp(displayTweet.createdAt)}
317
- </span>
316
+ <SmartTimestamp
317
+ className={feedRowTimestampClass}
318
+ value={displayTweet.createdAt}
319
+ />
318
320
  {canReply || hasConversation ? (
319
321
  <span className="ml-auto inline-flex items-center gap-1">
320
322
  {hasConversation ? (
@@ -0,0 +1,106 @@
1
+ import { Effect } from "effect";
2
+ import { runEffectBackground } from "./effect-runtime";
3
+
4
+ const encoder = new TextEncoder();
5
+ const HEARTBEAT_INTERVAL_MS = 15_000;
6
+ const STREAM_START_DELAY_MS = 25;
7
+ const FLUSH_PADDING = `${" ".repeat(16_384)}\n`;
8
+
9
+ export function createEffectNdjsonResponse<Event>({
10
+ request,
11
+ initialEvents = [],
12
+ run,
13
+ errorEvent,
14
+ }: {
15
+ request: Request;
16
+ initialEvents?: Event[];
17
+ run: (context: {
18
+ signal: AbortSignal;
19
+ emit: (event: Event) => void;
20
+ }) => Effect.Effect<unknown, unknown>;
21
+ errorEvent: (error: unknown) => Event;
22
+ }) {
23
+ let abortStream: (() => void) | undefined;
24
+
25
+ return new Response(
26
+ new ReadableStream<Uint8Array>({
27
+ cancel() {
28
+ abortStream?.();
29
+ },
30
+ start(controller) {
31
+ const abortController = new AbortController();
32
+ let closed = false;
33
+ let heartbeat: ReturnType<typeof setInterval> | undefined;
34
+ let startTimer: ReturnType<typeof setTimeout> | undefined;
35
+
36
+ const cleanup = () => {
37
+ request.signal.removeEventListener("abort", abort);
38
+ if (heartbeat !== undefined) clearInterval(heartbeat);
39
+ if (startTimer !== undefined) clearTimeout(startTimer);
40
+ };
41
+ const abort = () => {
42
+ if (closed) return;
43
+ closed = true;
44
+ cleanup();
45
+ abortController.abort();
46
+ };
47
+ const close = () => {
48
+ if (closed) return;
49
+ closed = true;
50
+ cleanup();
51
+ abortController.abort();
52
+ controller.close();
53
+ };
54
+ const enqueue = (value: string) => {
55
+ if (closed) return;
56
+ try {
57
+ controller.enqueue(encoder.encode(value));
58
+ } catch {
59
+ abort();
60
+ }
61
+ };
62
+ const emit = (event: Event) => {
63
+ enqueue(`${JSON.stringify(event)}\n`);
64
+ };
65
+
66
+ request.signal.addEventListener("abort", abort, { once: true });
67
+ abortStream = abort;
68
+ if (request.signal.aborted) {
69
+ abort();
70
+ controller.close();
71
+ return;
72
+ }
73
+ for (const event of initialEvents) emit(event);
74
+ enqueue(FLUSH_PADDING);
75
+ heartbeat = setInterval(
76
+ () => enqueue(FLUSH_PADDING),
77
+ HEARTBEAT_INTERVAL_MS,
78
+ );
79
+
80
+ startTimer = setTimeout(() => {
81
+ if (closed) return;
82
+ try {
83
+ runEffectBackground(run({ signal: abortController.signal, emit }), {
84
+ onSuccess: close,
85
+ onFailure: (error) => {
86
+ emit(errorEvent(error));
87
+ close();
88
+ },
89
+ });
90
+ } catch (error) {
91
+ emit(errorEvent(error));
92
+ close();
93
+ }
94
+ }, STREAM_START_DELAY_MS);
95
+ },
96
+ }),
97
+ {
98
+ headers: {
99
+ "cache-control": "no-store, no-transform",
100
+ "content-encoding": "identity",
101
+ "content-type": "application/x-ndjson; charset=utf-8",
102
+ "x-accel-buffering": "no",
103
+ },
104
+ },
105
+ );
106
+ }