birdclaw 0.1.0 → 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.
@@ -12,6 +12,7 @@ import type {
12
12
  QueryEnvelope,
13
13
  QueryResponse,
14
14
  ReplyFilter,
15
+ TimelineQualityFilter,
15
16
  TimelineItem,
16
17
  TimelineQuery,
17
18
  TweetEntities,
@@ -124,6 +125,36 @@ function buildReplyClause(replyFilter: ReplyFilter) {
124
125
  return "";
125
126
  }
126
127
 
128
+ function buildTimelineQualityClause(qualityFilter: TimelineQualityFilter) {
129
+ if (qualityFilter === "all") {
130
+ return "";
131
+ }
132
+
133
+ return `
134
+ and not (
135
+ t.text like 'RT @%'
136
+ or (
137
+ t.like_count < 50
138
+ and (
139
+ (
140
+ length(trim(replace(t.text, 'https://t.co/', ''))) < 16
141
+ and t.media_count = 0
142
+ )
143
+ or (
144
+ t.text like '@%'
145
+ and length(trim(t.text)) < 60
146
+ )
147
+ or (
148
+ t.text glob '*https://t.co/*'
149
+ and t.media_count = 0
150
+ and length(trim(replace(t.text, 'https://t.co/', ''))) < 45
151
+ )
152
+ )
153
+ )
154
+ )
155
+ `;
156
+ }
157
+
127
158
  export async function getQueryEnvelope(): Promise<QueryEnvelope> {
128
159
  const db = getDb();
129
160
  const counts = await Promise.all([
@@ -168,6 +199,7 @@ export async function getQueryEnvelope(): Promise<QueryEnvelope> {
168
199
  id: row.id,
169
200
  name: row.name,
170
201
  handle: row.handle,
202
+ externalUserId: row.external_user_id,
171
203
  transport: row.transport,
172
204
  isDefault: row.is_default,
173
205
  createdAt: row.created_at,
@@ -182,6 +214,12 @@ export function listTimelineItems({
182
214
  account,
183
215
  search,
184
216
  replyFilter = "all",
217
+ since,
218
+ until,
219
+ includeReplies = true,
220
+ qualityFilter = "all",
221
+ likedOnly = false,
222
+ bookmarkedOnly = false,
185
223
  limit = 18,
186
224
  }: TimelineQuery): TimelineItem[] {
187
225
  const db = getNativeDb();
@@ -190,6 +228,11 @@ export function listTimelineItems({
190
228
  let join = "";
191
229
  let where = "where t.kind = ?";
192
230
 
231
+ if (likedOnly || bookmarkedOnly) {
232
+ params.length = 0;
233
+ where = "where 1 = 1";
234
+ }
235
+
193
236
  if (account && account !== "all") {
194
237
  where += " and a.id = ?";
195
238
  params.push(account);
@@ -199,6 +242,21 @@ export function listTimelineItems({
199
242
  "is_replied",
200
243
  "t.is_replied",
201
244
  );
245
+ where += buildTimelineQualityClause(qualityFilter);
246
+
247
+ if (!includeReplies) {
248
+ where += " and t.text not like '@%'";
249
+ }
250
+
251
+ if (since?.trim()) {
252
+ where += " and t.created_at >= ?";
253
+ params.push(since.trim());
254
+ }
255
+
256
+ if (until?.trim()) {
257
+ where += " and t.created_at < ?";
258
+ params.push(until.trim());
259
+ }
202
260
 
203
261
  if (search?.trim()) {
204
262
  join += " join tweets_fts fts on fts.tweet_id = t.id ";
@@ -206,6 +264,14 @@ export function listTimelineItems({
206
264
  params.push(search.trim());
207
265
  }
208
266
 
267
+ if (likedOnly) {
268
+ where += " and t.liked = 1";
269
+ }
270
+
271
+ if (bookmarkedOnly) {
272
+ where += " and t.bookmarked = 1";
273
+ }
274
+
209
275
  params.push(limit);
210
276
 
211
277
  const rows = db
@@ -580,11 +646,14 @@ export function queryResource(
580
646
  };
581
647
  }
582
648
 
649
+ const { resource: _filterResource, ...timelineFilters } =
650
+ filters as TimelineQuery;
651
+
583
652
  return {
584
653
  resource,
585
654
  items: listTimelineItems({
586
655
  resource,
587
- ...(filters as TimelineQuery),
656
+ ...timelineFilters,
588
657
  }),
589
658
  };
590
659
  }
package/src/lib/seed.ts CHANGED
@@ -34,8 +34,8 @@ export function seedDemoData(db: Database.Database) {
34
34
  }
35
35
 
36
36
  const insertAccount = db.prepare(`
37
- insert into accounts (id, name, handle, transport, is_default, created_at)
38
- values (@id, @name, @handle, @transport, @isDefault, @createdAt)
37
+ insert into accounts (id, name, handle, external_user_id, transport, is_default, created_at)
38
+ values (@id, @name, @handle, @externalUserId, @transport, @isDefault, @createdAt)
39
39
  `);
40
40
 
41
41
  const insertProfile = db.prepare(`
@@ -81,6 +81,7 @@ export function seedDemoData(db: Database.Database) {
81
81
  id: "acct_primary",
82
82
  name: "Peter",
83
83
  handle: "@steipete",
84
+ externalUserId: "25401953",
84
85
  transport: "xurl",
85
86
  isDefault: 1,
86
87
  createdAt: now.toISOString(),
@@ -89,6 +90,7 @@ export function seedDemoData(db: Database.Database) {
89
90
  id: "acct_studio",
90
91
  name: "Studio",
91
92
  handle: "@birdclaw_lab",
93
+ externalUserId: null,
92
94
  transport: "xurl",
93
95
  isDefault: 0,
94
96
  createdAt: now.toISOString(),
@@ -7,12 +7,6 @@ export interface ThemeTransitionContext {
7
7
  pointerClientY?: number;
8
8
  }
9
9
 
10
- interface DocumentWithViewTransition extends Document {
11
- startViewTransition?: (callback: () => void) => {
12
- finished: Promise<void>;
13
- };
14
- }
15
-
16
10
  const clamp01 = (value: number) => {
17
11
  if (Number.isNaN(value)) return 0.5;
18
12
  if (value <= 0) return 0;
@@ -58,10 +52,12 @@ export function startThemeTransition({
58
52
  }
59
53
 
60
54
  const root = document.documentElement;
61
- const documentWithTransition = document as DocumentWithViewTransition;
55
+ const startViewTransition =
56
+ typeof document.startViewTransition === "function"
57
+ ? document.startViewTransition.bind(document)
58
+ : undefined;
62
59
  const canUseViewTransition =
63
- Boolean(documentWithTransition.startViewTransition) &&
64
- !hasReducedMotionPreference();
60
+ Boolean(startViewTransition) && !hasReducedMotionPreference();
65
61
 
66
62
  const applyTheme = () => {
67
63
  flushSync(() => {
@@ -98,7 +94,7 @@ export function startThemeTransition({
98
94
  root.classList.add("theme-transition");
99
95
 
100
96
  try {
101
- const transition = documentWithTransition.startViewTransition?.(() => {
97
+ const transition = startViewTransition?.(() => {
102
98
  applyTheme();
103
99
  });
104
100
 
package/src/lib/theme.tsx CHANGED
@@ -111,7 +111,15 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
111
111
  return;
112
112
  }
113
113
 
114
- const media = window.matchMedia("(prefers-color-scheme: dark)");
114
+ const media = window.matchMedia(
115
+ "(prefers-color-scheme: dark)",
116
+ ) as unknown as {
117
+ matches: boolean;
118
+ addEventListener?: (type: "change", listener: () => void) => void;
119
+ removeEventListener?: (type: "change", listener: () => void) => void;
120
+ addListener?: (listener: () => void) => void;
121
+ removeListener?: (listener: () => void) => void;
122
+ };
115
123
  const onChange = () => {
116
124
  const nextResolvedTheme = media.matches ? "dark" : "light";
117
125
  setResolvedTheme(nextResolvedTheme);
@@ -119,16 +127,16 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
119
127
  document.documentElement.style.colorScheme = nextResolvedTheme;
120
128
  };
121
129
 
122
- if ("addEventListener" in media) {
130
+ if (typeof media.addEventListener === "function") {
123
131
  media.addEventListener("change", onChange);
124
132
  } else {
125
- media.addListener(onChange);
133
+ media.addListener?.(onChange);
126
134
  }
127
135
  return () => {
128
- if ("removeEventListener" in media) {
136
+ if (typeof media.removeEventListener === "function") {
129
137
  media.removeEventListener("change", onChange);
130
138
  } else {
131
- media.removeListener(onChange);
139
+ media.removeListener?.(onChange);
132
140
  }
133
141
  };
134
142
  }, [isReady, theme]);
@@ -0,0 +1,406 @@
1
+ import type Database from "better-sqlite3";
2
+ import { listBookmarkedTweetsViaBird, listLikedTweetsViaBird } from "./bird";
3
+ import { getNativeDb } from "./db";
4
+ import { readSyncCache, writeSyncCache } from "./sync-cache";
5
+ import type {
6
+ XurlMentionData,
7
+ XurlMentionsResponse,
8
+ XurlMentionUser,
9
+ } from "./types";
10
+ import { ensureStubProfileForXUser, upsertProfileFromXUser } from "./x-profile";
11
+ import {
12
+ listBookmarkedTweetsViaXurl,
13
+ listLikedTweetsViaXurl,
14
+ lookupUsersByHandles,
15
+ } from "./xurl";
16
+
17
+ export type TimelineCollectionKind = "likes" | "bookmarks";
18
+ export type TimelineCollectionMode = "auto" | "xurl" | "bird";
19
+
20
+ const DEFAULT_COLLECTION_CACHE_TTL_MS = 2 * 60_000;
21
+ const MIN_XURL_LIMIT = 5;
22
+ const MAX_XURL_LIMIT = 100;
23
+
24
+ function parseCacheTtlMs(value?: number) {
25
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
26
+ return DEFAULT_COLLECTION_CACHE_TTL_MS;
27
+ }
28
+ return Math.floor(value);
29
+ }
30
+
31
+ function parseMaxPages(value?: number) {
32
+ if (value === undefined) {
33
+ return null;
34
+ }
35
+ if (!Number.isFinite(value) || value < 1) {
36
+ throw new Error("--max-pages must be at least 1");
37
+ }
38
+ return Math.floor(value);
39
+ }
40
+
41
+ function assertLimit(limit: number) {
42
+ if (!Number.isFinite(limit) || limit < 1) {
43
+ throw new Error("--limit must be at least 1");
44
+ }
45
+ }
46
+
47
+ function assertXurlLimit(limit: number) {
48
+ if (limit < MIN_XURL_LIMIT || limit > MAX_XURL_LIMIT) {
49
+ throw new Error("xurl mode requires --limit between 5 and 100");
50
+ }
51
+ }
52
+
53
+ function resolveAccount(db: Database.Database, accountId?: string) {
54
+ const row = accountId
55
+ ? (db
56
+ .prepare(
57
+ "select id, handle, external_user_id from accounts where id = ?",
58
+ )
59
+ .get(accountId) as
60
+ | { id: string; handle: string; external_user_id: string | null }
61
+ | undefined)
62
+ : (db
63
+ .prepare(
64
+ `
65
+ select id, handle, external_user_id
66
+ from accounts
67
+ order by is_default desc, created_at asc
68
+ limit 1
69
+ `,
70
+ )
71
+ .get() as
72
+ | { id: string; handle: string; external_user_id: string | null }
73
+ | undefined);
74
+
75
+ if (!row) {
76
+ throw new Error(`Unknown account: ${accountId ?? "default"}`);
77
+ }
78
+
79
+ return {
80
+ accountId: row.id,
81
+ username: row.handle.replace(/^@/, ""),
82
+ externalUserId:
83
+ typeof row.external_user_id === "string" &&
84
+ row.external_user_id.length > 0
85
+ ? row.external_user_id
86
+ : undefined,
87
+ };
88
+ }
89
+
90
+ function getMediaCount(tweet: XurlMentionData) {
91
+ const urls = Array.isArray(tweet.entities?.urls) ? tweet.entities.urls : [];
92
+ return urls.filter(
93
+ (url) =>
94
+ url &&
95
+ typeof url === "object" &&
96
+ typeof (url as Record<string, unknown>).media_key === "string",
97
+ ).length;
98
+ }
99
+
100
+ function replaceTweetFts(db: Database.Database, tweetId: string, text: string) {
101
+ db.prepare("delete from tweets_fts where tweet_id = ?").run(tweetId);
102
+ db.prepare("insert into tweets_fts (tweet_id, text) values (?, ?)").run(
103
+ tweetId,
104
+ text,
105
+ );
106
+ }
107
+
108
+ function mergePayloads(pages: XurlMentionsResponse[]): XurlMentionsResponse {
109
+ const tweets: XurlMentionData[] = [];
110
+ const seenTweetIds = new Set<string>();
111
+ const users: XurlMentionUser[] = [];
112
+ const seenUserIds = new Set<string>();
113
+
114
+ for (const page of pages) {
115
+ for (const tweet of page.data) {
116
+ if (seenTweetIds.has(tweet.id)) {
117
+ continue;
118
+ }
119
+ seenTweetIds.add(tweet.id);
120
+ tweets.push(tweet);
121
+ }
122
+
123
+ for (const user of page.includes?.users ?? []) {
124
+ if (seenUserIds.has(user.id)) {
125
+ continue;
126
+ }
127
+ seenUserIds.add(user.id);
128
+ users.push(user);
129
+ }
130
+ }
131
+
132
+ const lastPage = pages.at(-1);
133
+ return {
134
+ data: tweets,
135
+ includes: users.length > 0 ? { users } : undefined,
136
+ meta: {
137
+ result_count: tweets.length,
138
+ page_count: pages.length,
139
+ next_token: lastPage?.meta?.next_token ?? null,
140
+ ...(tweets[0] ? { newest_id: tweets[0].id } : {}),
141
+ ...(tweets.at(-1) ? { oldest_id: tweets.at(-1)?.id } : {}),
142
+ },
143
+ };
144
+ }
145
+
146
+ function mergeTimelineCollectionIntoLocalStore(
147
+ db: Database.Database,
148
+ accountId: string,
149
+ kind: TimelineCollectionKind,
150
+ payload: XurlMentionsResponse,
151
+ source: "xurl" | "bird",
152
+ ) {
153
+ const usersById = new Map(
154
+ (payload.includes?.users ?? []).map((user) => [user.id, user]),
155
+ );
156
+ const tweetKind = kind === "likes" ? "like" : "bookmark";
157
+ const liked = kind === "likes" ? 1 : 0;
158
+ const bookmarked = kind === "bookmarks" ? 1 : 0;
159
+ const upsertTweet = db.prepare(
160
+ `
161
+ insert into tweets (
162
+ id, account_id, author_profile_id, kind, text, created_at,
163
+ is_replied, reply_to_id, like_count, media_count, bookmarked, liked,
164
+ entities_json, media_json, quoted_tweet_id
165
+ ) values (?, ?, ?, ?, ?, ?, 0, null, ?, ?, ?, ?, ?, '[]', null)
166
+ on conflict(id) do update set
167
+ account_id = excluded.account_id,
168
+ author_profile_id = excluded.author_profile_id,
169
+ kind = case
170
+ when tweets.kind in ('home', 'mention') then tweets.kind
171
+ else excluded.kind
172
+ end,
173
+ text = excluded.text,
174
+ created_at = excluded.created_at,
175
+ like_count = excluded.like_count,
176
+ media_count = excluded.media_count,
177
+ entities_json = excluded.entities_json,
178
+ media_json = excluded.media_json,
179
+ bookmarked = max(tweets.bookmarked, excluded.bookmarked),
180
+ liked = max(tweets.liked, excluded.liked)
181
+ `,
182
+ );
183
+ const upsertCollection = db.prepare(`
184
+ insert into tweet_collections (
185
+ account_id, tweet_id, kind, collected_at, source, raw_json, updated_at
186
+ ) values (?, ?, ?, null, ?, ?, ?)
187
+ on conflict(account_id, tweet_id, kind) do update set
188
+ source = excluded.source,
189
+ raw_json = excluded.raw_json,
190
+ updated_at = excluded.updated_at
191
+ `);
192
+
193
+ db.transaction(() => {
194
+ const updatedAt = new Date().toISOString();
195
+ for (const tweet of payload.data) {
196
+ const author =
197
+ usersById.get(tweet.author_id) ??
198
+ ({
199
+ id: tweet.author_id,
200
+ username: `user_${tweet.author_id}`,
201
+ name: `user_${tweet.author_id}`,
202
+ } as const);
203
+ const profile = usersById.has(tweet.author_id)
204
+ ? upsertProfileFromXUser(db, author)
205
+ : ensureStubProfileForXUser(db, tweet.author_id);
206
+ upsertTweet.run(
207
+ tweet.id,
208
+ accountId,
209
+ profile.profile.id,
210
+ tweetKind,
211
+ tweet.text,
212
+ tweet.created_at,
213
+ Number(tweet.public_metrics?.like_count ?? 0),
214
+ getMediaCount(tweet),
215
+ bookmarked,
216
+ liked,
217
+ JSON.stringify(tweet.entities ?? {}),
218
+ );
219
+ upsertCollection.run(
220
+ accountId,
221
+ tweet.id,
222
+ kind,
223
+ source,
224
+ JSON.stringify(tweet),
225
+ updatedAt,
226
+ );
227
+ replaceTweetFts(db, tweet.id, tweet.text);
228
+ }
229
+ })();
230
+ }
231
+
232
+ async function fetchXurlCollection({
233
+ kind,
234
+ username,
235
+ userId,
236
+ limit,
237
+ all,
238
+ maxPages,
239
+ }: {
240
+ kind: TimelineCollectionKind;
241
+ username: string;
242
+ userId?: string;
243
+ limit: number;
244
+ all: boolean;
245
+ maxPages: number | null;
246
+ }) {
247
+ let resolvedUserId = userId;
248
+ if (!resolvedUserId) {
249
+ const [accountUser] = await lookupUsersByHandles([username]);
250
+ if (!accountUser?.id) {
251
+ throw new Error(`Could not resolve X user id for @${username}`);
252
+ }
253
+ resolvedUserId = String(accountUser.id);
254
+ }
255
+
256
+ const pages: XurlMentionsResponse[] = [];
257
+ let nextToken: string | undefined;
258
+ let pageCount = 0;
259
+ do {
260
+ const payload =
261
+ kind === "likes"
262
+ ? await listLikedTweetsViaXurl({
263
+ maxResults: limit,
264
+ username,
265
+ userId: resolvedUserId,
266
+ paginationToken: nextToken,
267
+ })
268
+ : await listBookmarkedTweetsViaXurl({
269
+ maxResults: limit,
270
+ username,
271
+ userId: resolvedUserId,
272
+ paginationToken: nextToken,
273
+ });
274
+ pages.push(payload);
275
+ nextToken =
276
+ typeof payload.meta?.next_token === "string"
277
+ ? payload.meta.next_token
278
+ : undefined;
279
+ pageCount += 1;
280
+ } while (all && nextToken && (maxPages === null || pageCount < maxPages));
281
+
282
+ return mergePayloads(pages);
283
+ }
284
+
285
+ async function fetchBirdCollection({
286
+ kind,
287
+ limit,
288
+ all,
289
+ maxPages,
290
+ }: {
291
+ kind: TimelineCollectionKind;
292
+ limit: number;
293
+ all: boolean;
294
+ maxPages: number | null;
295
+ }) {
296
+ return kind === "likes"
297
+ ? listLikedTweetsViaBird({
298
+ maxResults: limit,
299
+ all,
300
+ maxPages: maxPages ?? undefined,
301
+ })
302
+ : listBookmarkedTweetsViaBird({
303
+ maxResults: limit,
304
+ all,
305
+ maxPages: maxPages ?? undefined,
306
+ });
307
+ }
308
+
309
+ export async function syncTimelineCollection({
310
+ kind,
311
+ account,
312
+ mode = "auto",
313
+ limit = 20,
314
+ all = false,
315
+ maxPages,
316
+ refresh = false,
317
+ cacheTtlMs,
318
+ }: {
319
+ kind: TimelineCollectionKind;
320
+ account?: string;
321
+ mode?: TimelineCollectionMode;
322
+ limit?: number;
323
+ all?: boolean;
324
+ maxPages?: number;
325
+ refresh?: boolean;
326
+ cacheTtlMs?: number;
327
+ }) {
328
+ assertLimit(limit);
329
+ const parsedMaxPages = parseMaxPages(maxPages);
330
+ if (mode === "xurl" || mode === "auto") {
331
+ assertXurlLimit(limit);
332
+ }
333
+
334
+ const db = getNativeDb();
335
+ const resolvedAccount = resolveAccount(db, account);
336
+ const cacheKey = `${kind}:${mode}:${resolvedAccount.accountId}:${String(limit)}:${all ? "all" : "single"}:${parsedMaxPages === null ? "all-pages" : String(parsedMaxPages)}`;
337
+ const ttlMs = parseCacheTtlMs(cacheTtlMs);
338
+ const cached = readSyncCache<XurlMentionsResponse>(cacheKey, db);
339
+ const cacheAgeMs = cached
340
+ ? Date.now() - new Date(cached.updatedAt).getTime()
341
+ : Number.POSITIVE_INFINITY;
342
+
343
+ if (!refresh && cached && cacheAgeMs <= ttlMs) {
344
+ return {
345
+ ok: true,
346
+ source: "cache",
347
+ kind,
348
+ accountId: resolvedAccount.accountId,
349
+ count: cached.value.data.length,
350
+ payload: cached.value,
351
+ };
352
+ }
353
+
354
+ let source: "xurl" | "bird";
355
+ let payload: XurlMentionsResponse;
356
+ if (mode === "bird") {
357
+ payload = await fetchBirdCollection({
358
+ kind,
359
+ limit,
360
+ all,
361
+ maxPages: parsedMaxPages,
362
+ });
363
+ source = "bird";
364
+ } else {
365
+ try {
366
+ payload = await fetchXurlCollection({
367
+ kind,
368
+ username: resolvedAccount.username,
369
+ userId: resolvedAccount.externalUserId,
370
+ limit,
371
+ all,
372
+ maxPages: parsedMaxPages,
373
+ });
374
+ source = "xurl";
375
+ } catch (error) {
376
+ if (mode === "xurl") {
377
+ throw error;
378
+ }
379
+ payload = await fetchBirdCollection({
380
+ kind,
381
+ limit,
382
+ all,
383
+ maxPages: parsedMaxPages,
384
+ });
385
+ source = "bird";
386
+ }
387
+ }
388
+
389
+ mergeTimelineCollectionIntoLocalStore(
390
+ db,
391
+ resolvedAccount.accountId,
392
+ kind,
393
+ payload,
394
+ source,
395
+ );
396
+ writeSyncCache(cacheKey, payload, db);
397
+
398
+ return {
399
+ ok: true,
400
+ source,
401
+ kind,
402
+ accountId: resolvedAccount.accountId,
403
+ count: payload.data.length,
404
+ payload,
405
+ };
406
+ }
package/src/lib/types.ts CHANGED
@@ -2,11 +2,13 @@ export type ResourceKind = "home" | "mentions" | "dms";
2
2
  export type InboxKind = "mixed" | "mentions" | "dms";
3
3
 
4
4
  export type ReplyFilter = "all" | "replied" | "unreplied";
5
+ export type TimelineQualityFilter = "all" | "summary";
5
6
 
6
7
  export interface AccountRecord {
7
8
  id: string;
8
9
  name: string;
9
10
  handle: string;
11
+ externalUserId?: string | null;
10
12
  transport: string;
11
13
  isDefault: number;
12
14
  createdAt: string;
@@ -94,7 +96,7 @@ export interface TimelineItem {
94
96
  id: string;
95
97
  accountId: string;
96
98
  accountHandle: string;
97
- kind: Exclude<ResourceKind, "dms">;
99
+ kind: "home" | "mention" | "like" | "bookmark";
98
100
  text: string;
99
101
  createdAt: string;
100
102
  isReplied: boolean;
@@ -139,6 +141,12 @@ export interface TimelineQuery {
139
141
  account?: string;
140
142
  search?: string;
141
143
  replyFilter?: ReplyFilter;
144
+ since?: string;
145
+ until?: string;
146
+ includeReplies?: boolean;
147
+ qualityFilter?: TimelineQualityFilter;
148
+ likedOnly?: boolean;
149
+ bookmarkedOnly?: boolean;
142
150
  limit?: number;
143
151
  }
144
152