fieldtheory 1.0.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.
package/dist/cli.js ADDED
@@ -0,0 +1,381 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import { syncTwitterBookmarks } from './bookmarks.js';
4
+ import { getBookmarkStatusView, formatBookmarkStatus } from './bookmarks-service.js';
5
+ import { runTwitterOAuthFlow } from './xauth.js';
6
+ import { syncBookmarksGraphQL } from './graphql-bookmarks.js';
7
+ import { fetchBookmarkMediaBatch } from './bookmark-media.js';
8
+ import { buildIndex, searchBookmarks, formatSearchResults, getStats, classifyAndRebuild, getCategoryCounts, sampleByCategory, getDomainCounts, listBookmarks, getBookmarkById, } from './bookmarks-db.js';
9
+ import { formatClassificationSummary } from './bookmark-classify.js';
10
+ import { classifyWithLlm, classifyDomainsWithLlm } from './bookmark-classify-llm.js';
11
+ import { renderViz } from './bookmarks-viz.js';
12
+ import { dataDir, ensureDataDir, isFirstRun } from './paths.js';
13
+ // ── Progress rendering ──────────────────────────────────────────────────────
14
+ const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
15
+ let spinnerIdx = 0;
16
+ function renderProgress(status, startTime) {
17
+ const elapsed = Math.round((Date.now() - startTime) / 1000);
18
+ const spin = SPINNER[spinnerIdx++ % SPINNER.length];
19
+ const line = ` ${spin} Syncing bookmarks... ${status.newAdded} new \u2502 page ${status.page} \u2502 ${elapsed}s`;
20
+ process.stderr.write(`\r\x1b[K${line}`);
21
+ }
22
+ const FRIENDLY_STOP_REASONS = {
23
+ 'caught up to newest stored bookmark': 'All caught up \u2014 no new bookmarks since last sync.',
24
+ 'no new bookmarks (stale)': 'Sync complete \u2014 reached the end of new bookmarks.',
25
+ 'end of bookmarks': 'Sync complete \u2014 all bookmarks fetched.',
26
+ 'max runtime reached': 'Paused after 30 minutes. Run again to continue.',
27
+ 'max pages reached': 'Paused after reaching page limit. Run again to continue.',
28
+ 'target additions reached': 'Reached target bookmark count.',
29
+ };
30
+ function friendlyStopReason(raw) {
31
+ if (!raw)
32
+ return 'Sync complete.';
33
+ return FRIENDLY_STOP_REASONS[raw] ?? `Sync complete \u2014 ${raw}`;
34
+ }
35
+ // ── First-run welcome ───────────────────────────────────────────────────────
36
+ function showWelcome() {
37
+ process.stderr.write(`
38
+ Field Theory CLI \u2014 self-custody for your bookmarks.
39
+
40
+ This tool syncs your X/Twitter bookmarks to a local
41
+ SQLite database on your machine. Your data never leaves
42
+ your computer.
43
+
44
+ Requirements:
45
+ \u2022 Google Chrome with an active X login
46
+
47
+ Data will be stored at: ${dataDir()}
48
+
49
+ `);
50
+ }
51
+ // ── CLI ─────────────────────────────────────────────────────────────────────
52
+ export function buildCli() {
53
+ const program = new Command();
54
+ async function rebuildAndClassify(added) {
55
+ if (added <= 0)
56
+ return;
57
+ process.stderr.write(' Indexing and classifying...\n');
58
+ const idx = await classifyAndRebuild();
59
+ process.stderr.write(` \u2713 ${idx.recordCount} bookmarks indexed, ${Object.keys(idx.summary).length} categories\n`);
60
+ }
61
+ program
62
+ .name('ft')
63
+ .description('Self-custody for your X/Twitter bookmarks. Sync, search, classify, and explore locally.')
64
+ .version('1.0.0')
65
+ .showHelpAfterError();
66
+ // ── sync ────────────────────────────────────────────────────────────────
67
+ program
68
+ .command('sync')
69
+ .description('Sync bookmarks from X into your local database')
70
+ .option('--api', 'Use OAuth v2 API instead of Chrome session', false)
71
+ .option('--full', 'Full crawl instead of incremental sync', false)
72
+ .option('--max-pages <n>', 'Max pages to fetch', (v) => Number(v), 500)
73
+ .option('--target-adds <n>', 'Stop after N new bookmarks', (v) => Number(v))
74
+ .option('--delay-ms <n>', 'Delay between requests in ms', (v) => Number(v), 600)
75
+ .option('--max-minutes <n>', 'Max runtime in minutes', (v) => Number(v), 30)
76
+ .option('--chrome-user-data-dir <path>', 'Chrome user-data directory')
77
+ .option('--chrome-profile-directory <name>', 'Chrome profile name')
78
+ .action(async (options) => {
79
+ const firstRun = isFirstRun();
80
+ if (firstRun)
81
+ showWelcome();
82
+ ensureDataDir();
83
+ const useApi = Boolean(options.api);
84
+ const mode = Boolean(options.full) ? 'full' : 'incremental';
85
+ if (useApi) {
86
+ const result = await syncTwitterBookmarks(mode, {
87
+ targetAdds: typeof options.targetAdds === 'number' && !Number.isNaN(options.targetAdds) ? options.targetAdds : undefined,
88
+ });
89
+ console.log(`\n \u2713 ${result.added} new bookmarks synced (${result.totalBookmarks} total)`);
90
+ console.log(` \u2713 Data: ${dataDir()}\n`);
91
+ await rebuildAndClassify(result.added);
92
+ }
93
+ else {
94
+ const startTime = Date.now();
95
+ const result = await syncBookmarksGraphQL({
96
+ incremental: !Boolean(options.full),
97
+ maxPages: Number(options.maxPages) || 500,
98
+ targetAdds: typeof options.targetAdds === 'number' && !Number.isNaN(options.targetAdds) ? options.targetAdds : undefined,
99
+ delayMs: Number(options.delayMs) || 600,
100
+ maxMinutes: Number(options.maxMinutes) || 30,
101
+ chromeUserDataDir: options.chromeUserDataDir ? String(options.chromeUserDataDir) : undefined,
102
+ chromeProfileDirectory: options.chromeProfileDirectory ? String(options.chromeProfileDirectory) : undefined,
103
+ onProgress: (status) => {
104
+ renderProgress(status, startTime);
105
+ if (status.done)
106
+ process.stderr.write('\n');
107
+ },
108
+ });
109
+ console.log(`\n \u2713 ${result.added} new bookmarks synced (${result.totalBookmarks} total)`);
110
+ console.log(` ${friendlyStopReason(result.stopReason)}`);
111
+ console.log(` \u2713 Data: ${dataDir()}\n`);
112
+ await rebuildAndClassify(result.added);
113
+ }
114
+ if (firstRun) {
115
+ console.log(`\n Try: ft search "machine learning"`);
116
+ console.log(` ft viz`);
117
+ console.log(` ft categories\n`);
118
+ }
119
+ });
120
+ // ── search ──────────────────────────────────────────────────────────────
121
+ program
122
+ .command('search')
123
+ .description('Full-text search across bookmarks')
124
+ .argument('<query>', 'Search query (supports FTS5 syntax: AND, OR, NOT, "exact phrase")')
125
+ .option('--author <handle>', 'Filter by author handle')
126
+ .option('--after <date>', 'Bookmarks posted after this date (YYYY-MM-DD)')
127
+ .option('--before <date>', 'Bookmarks posted before this date (YYYY-MM-DD)')
128
+ .option('--limit <n>', 'Max results', (v) => Number(v), 20)
129
+ .action(async (query, options) => {
130
+ const results = await searchBookmarks({
131
+ query,
132
+ author: options.author ? String(options.author) : undefined,
133
+ after: options.after ? String(options.after) : undefined,
134
+ before: options.before ? String(options.before) : undefined,
135
+ limit: Number(options.limit) || 20,
136
+ });
137
+ console.log(formatSearchResults(results));
138
+ });
139
+ // ── list ────────────────────────────────────────────────────────────────
140
+ program
141
+ .command('list')
142
+ .description('List bookmarks with filters')
143
+ .option('--query <query>', 'Text query (FTS5 syntax)')
144
+ .option('--author <handle>', 'Filter by author handle')
145
+ .option('--after <date>', 'Posted after (YYYY-MM-DD)')
146
+ .option('--before <date>', 'Posted before (YYYY-MM-DD)')
147
+ .option('--category <category>', 'Filter by category')
148
+ .option('--domain <domain>', 'Filter by domain')
149
+ .option('--limit <n>', 'Max results', (v) => Number(v), 30)
150
+ .option('--offset <n>', 'Offset into results', (v) => Number(v), 0)
151
+ .option('--json', 'JSON output')
152
+ .action(async (options) => {
153
+ const items = await listBookmarks({
154
+ query: options.query ? String(options.query) : undefined,
155
+ author: options.author ? String(options.author) : undefined,
156
+ after: options.after ? String(options.after) : undefined,
157
+ before: options.before ? String(options.before) : undefined,
158
+ category: options.category ? String(options.category) : undefined,
159
+ domain: options.domain ? String(options.domain) : undefined,
160
+ limit: Number(options.limit) || 30,
161
+ offset: Number(options.offset) || 0,
162
+ });
163
+ if (options.json) {
164
+ console.log(JSON.stringify(items, null, 2));
165
+ return;
166
+ }
167
+ for (const item of items) {
168
+ const tags = [item.primaryCategory, item.primaryDomain].filter(Boolean).join(' \u00b7 ');
169
+ const summary = item.text.length > 120 ? `${item.text.slice(0, 117)}...` : item.text;
170
+ console.log(`${item.id} ${item.authorHandle ? `@${item.authorHandle}` : '@?'} ${item.postedAt?.slice(0, 10) ?? '?'}${tags ? ` ${tags}` : ''}`);
171
+ console.log(` ${summary}`);
172
+ console.log(` ${item.url}`);
173
+ console.log();
174
+ }
175
+ });
176
+ // ── show ─────────────────────────────────────────────────────────────────
177
+ program
178
+ .command('show')
179
+ .description('Show one bookmark in detail')
180
+ .argument('<id>', 'Bookmark id')
181
+ .option('--json', 'JSON output')
182
+ .action(async (id, options) => {
183
+ const item = await getBookmarkById(String(id));
184
+ if (!item) {
185
+ console.error(`Unknown bookmark: ${String(id)}`);
186
+ process.exitCode = 1;
187
+ return;
188
+ }
189
+ if (options.json) {
190
+ console.log(JSON.stringify(item, null, 2));
191
+ return;
192
+ }
193
+ console.log(`${item.id} \u00b7 ${item.authorHandle ? `@${item.authorHandle}` : '@?'}`);
194
+ console.log(item.url);
195
+ console.log(item.text);
196
+ if (item.links.length)
197
+ console.log(`links: ${item.links.join(', ')}`);
198
+ if (item.categories)
199
+ console.log(`categories: ${item.categories}`);
200
+ if (item.domains)
201
+ console.log(`domains: ${item.domains}`);
202
+ });
203
+ // ── stats ───────────────────────────────────────────────────────────────
204
+ program
205
+ .command('stats')
206
+ .description('Aggregate statistics from your bookmarks')
207
+ .action(async () => {
208
+ const stats = await getStats();
209
+ console.log(`Bookmarks: ${stats.totalBookmarks}`);
210
+ console.log(`Unique authors: ${stats.uniqueAuthors}`);
211
+ console.log(`Date range: ${stats.dateRange.earliest?.slice(0, 10) ?? '?'} to ${stats.dateRange.latest?.slice(0, 10) ?? '?'}`);
212
+ console.log(`\nTop authors:`);
213
+ for (const a of stats.topAuthors)
214
+ console.log(` @${a.handle}: ${a.count}`);
215
+ console.log(`\nLanguages:`);
216
+ for (const l of stats.languageBreakdown)
217
+ console.log(` ${l.language}: ${l.count}`);
218
+ });
219
+ // ── viz ─────────────────────────────────────────────────────────────────
220
+ program
221
+ .command('viz')
222
+ .description('Visual dashboard of your bookmarking patterns')
223
+ .action(async () => {
224
+ console.log(await renderViz());
225
+ });
226
+ // ── classify ────────────────────────────────────────────────────────────
227
+ program
228
+ .command('classify')
229
+ .description('Classify bookmarks by category')
230
+ .option('--deep', 'Use LLM classification (requires claude or codex CLI)')
231
+ .action(async (options) => {
232
+ if (options.deep) {
233
+ process.stderr.write('Classifying bookmarks with LLM...\n');
234
+ const result = await classifyWithLlm({
235
+ onBatch: (done, total) => {
236
+ process.stderr.write(` Processing ${done}/${total} bookmarks...\n`);
237
+ },
238
+ });
239
+ console.log(`Engine: ${result.engine}`);
240
+ console.log(`Classified ${result.classified}/${result.totalUnclassified} (${result.batches} batches, ${result.failed} failed)`);
241
+ }
242
+ else {
243
+ process.stderr.write('Classifying bookmarks (regex)...\n');
244
+ const result = await classifyAndRebuild();
245
+ console.log(`Indexed ${result.recordCount} bookmarks \u2192 ${result.dbPath}`);
246
+ console.log(formatClassificationSummary(result.summary));
247
+ }
248
+ });
249
+ // ── classify-domains ────────────────────────────────────────────────────
250
+ program
251
+ .command('classify-domains')
252
+ .description('Classify bookmarks by subject domain using LLM (ai, finance, etc.)')
253
+ .option('--all', 'Re-classify all bookmarks, not just missing')
254
+ .action(async (options) => {
255
+ process.stderr.write('Classifying bookmark domains with LLM...\n');
256
+ const result = await classifyDomainsWithLlm({
257
+ all: options.all ?? false,
258
+ onBatch: (done, total) => {
259
+ process.stderr.write(` Processing ${done}/${total} bookmarks...\n`);
260
+ },
261
+ });
262
+ console.log(`Engine: ${result.engine}`);
263
+ console.log(`Classified ${result.classified}/${result.totalUnclassified} (${result.batches} batches, ${result.failed} failed)`);
264
+ });
265
+ // ── categories ──────────────────────────────────────────────────────────
266
+ program
267
+ .command('categories')
268
+ .description('Show category distribution')
269
+ .action(async () => {
270
+ const counts = await getCategoryCounts();
271
+ if (Object.keys(counts).length === 0) {
272
+ console.log('No categories found. Run: ft classify');
273
+ return;
274
+ }
275
+ const total = Object.values(counts).reduce((a, b) => a + b, 0);
276
+ for (const [cat, count] of Object.entries(counts).sort((a, b) => b[1] - a[1])) {
277
+ const pct = ((count / total) * 100).toFixed(1);
278
+ console.log(` ${cat.padEnd(14)} ${String(count).padStart(5)} (${pct}%)`);
279
+ }
280
+ });
281
+ // ── domains ─────────────────────────────────────────────────────────────
282
+ program
283
+ .command('domains')
284
+ .description('Show domain distribution')
285
+ .action(async () => {
286
+ const counts = await getDomainCounts();
287
+ if (Object.keys(counts).length === 0) {
288
+ console.log('No domains found. Run: ft classify-domains');
289
+ return;
290
+ }
291
+ const total = Object.values(counts).reduce((a, b) => a + b, 0);
292
+ for (const [dom, count] of Object.entries(counts).sort((a, b) => b[1] - a[1])) {
293
+ const pct = ((count / total) * 100).toFixed(1);
294
+ console.log(` ${dom.padEnd(20)} ${String(count).padStart(5)} (${pct}%)`);
295
+ }
296
+ });
297
+ // ── index ───────────────────────────────────────────────────────────────
298
+ program
299
+ .command('index')
300
+ .description('Rebuild the SQLite search index from the JSONL cache')
301
+ .action(async () => {
302
+ process.stderr.write('Building search index...\n');
303
+ const result = await buildIndex();
304
+ console.log(`Indexed ${result.recordCount} bookmarks \u2192 ${result.dbPath}`);
305
+ });
306
+ // ── auth ────────────────────────────────────────────────────────────────
307
+ program
308
+ .command('auth')
309
+ .description('Set up OAuth for API-based sync (needed for ft sync --api)')
310
+ .action(async () => {
311
+ const result = await runTwitterOAuthFlow();
312
+ console.log(`Saved token to ${result.tokenPath}`);
313
+ if (result.scope)
314
+ console.log(`Scope: ${result.scope}`);
315
+ });
316
+ // ── status ──────────────────────────────────────────────────────────────
317
+ program
318
+ .command('status')
319
+ .description('Show sync status and data location')
320
+ .action(async () => {
321
+ const view = await getBookmarkStatusView();
322
+ console.log(formatBookmarkStatus(view));
323
+ });
324
+ // ── path ────────────────────────────────────────────────────────────────
325
+ program
326
+ .command('path')
327
+ .description('Print the data directory path')
328
+ .action(() => { console.log(dataDir()); });
329
+ // ── sample ──────────────────────────────────────────────────────────────
330
+ program
331
+ .command('sample')
332
+ .description('Sample bookmarks by category')
333
+ .argument('<category>', 'Category: tool, security, technique, launch, research, opinion, commerce')
334
+ .option('--limit <n>', 'Max results', (v) => Number(v), 10)
335
+ .action(async (category, options) => {
336
+ const results = await sampleByCategory(category, Number(options.limit) || 10);
337
+ if (results.length === 0) {
338
+ console.log(`No bookmarks found with category "${category}". Run: ft classify`);
339
+ return;
340
+ }
341
+ for (const r of results) {
342
+ const text = r.text.length > 120 ? r.text.slice(0, 120) + '...' : r.text;
343
+ console.log(`[@${r.authorHandle ?? '?'}] ${text}`);
344
+ console.log(` ${r.url} [${r.categories}]`);
345
+ if (r.githubUrls)
346
+ console.log(` github: ${r.githubUrls}`);
347
+ console.log();
348
+ }
349
+ });
350
+ // ── fetch-media ─────────────────────────────────────────────────────────
351
+ program
352
+ .command('fetch-media')
353
+ .description('Download media assets for bookmarks')
354
+ .option('--limit <n>', 'Max bookmarks to process', (v) => Number(v), 100)
355
+ .option('--max-bytes <n>', 'Per-asset byte limit', (v) => Number(v), 50 * 1024 * 1024)
356
+ .action(async (options) => {
357
+ const result = await fetchBookmarkMediaBatch({
358
+ limit: Number(options.limit) || 100,
359
+ maxBytes: Number(options.maxBytes) || 50 * 1024 * 1024,
360
+ });
361
+ console.log(JSON.stringify(result, null, 2));
362
+ });
363
+ // ── hidden backward-compat aliases ────────────────────────────────────
364
+ const bookmarksAlias = program.command('bookmarks').description('(alias) Bookmark commands').helpOption(false);
365
+ for (const cmd of ['sync', 'search', 'list', 'show', 'stats', 'viz', 'classify', 'classify-domains',
366
+ 'categories', 'domains', 'index', 'auth', 'status', 'path', 'sample', 'fetch-media']) {
367
+ bookmarksAlias.command(cmd).description(`Alias for: ft ${cmd}`).allowUnknownOption(true)
368
+ .action(async () => {
369
+ const args = ['node', 'ft', cmd, ...process.argv.slice(4)];
370
+ await program.parseAsync(args);
371
+ });
372
+ }
373
+ bookmarksAlias.command('enable').description('Alias for: ft sync').action(async () => {
374
+ const args = ['node', 'ft', 'sync', ...process.argv.slice(4)];
375
+ await program.parseAsync(args);
376
+ });
377
+ return program;
378
+ }
379
+ if (import.meta.url === `file://${process.argv[1]}`) {
380
+ await buildCli().parseAsync(process.argv);
381
+ }
package/dist/config.js ADDED
@@ -0,0 +1,54 @@
1
+ import { config as loadDotenv } from 'dotenv';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import { dataDir } from './paths.js';
5
+ export function loadEnv() {
6
+ const dir = dataDir();
7
+ const candidatePaths = [
8
+ path.join(process.cwd(), '.env.local'),
9
+ path.join(process.cwd(), '.env'),
10
+ path.join(dir, '.env.local'),
11
+ path.join(dir, '.env'),
12
+ ];
13
+ for (const envPath of candidatePaths) {
14
+ loadDotenv({ path: envPath, quiet: true });
15
+ }
16
+ }
17
+ function detectChromeUserDataDir() {
18
+ const platform = os.platform();
19
+ const home = os.homedir();
20
+ if (platform === 'darwin')
21
+ return path.join(home, 'Library', 'Application Support', 'Google', 'Chrome');
22
+ if (platform === 'linux')
23
+ return path.join(home, '.config', 'google-chrome');
24
+ if (platform === 'win32')
25
+ return path.join(home, 'AppData', 'Local', 'Google', 'Chrome', 'User Data');
26
+ return undefined;
27
+ }
28
+ export function loadChromeSessionConfig() {
29
+ loadEnv();
30
+ const dir = process.env.FT_CHROME_USER_DATA_DIR ?? detectChromeUserDataDir();
31
+ if (!dir) {
32
+ throw new Error('Could not detect Chrome user-data directory.\n' +
33
+ 'Set FT_CHROME_USER_DATA_DIR in .env or pass --chrome-user-data-dir.');
34
+ }
35
+ return {
36
+ chromeUserDataDir: dir,
37
+ chromeProfileDirectory: process.env.FT_CHROME_PROFILE_DIRECTORY ?? 'Default',
38
+ };
39
+ }
40
+ export function loadXApiConfig() {
41
+ loadEnv();
42
+ const apiKey = process.env.X_API_KEY ?? process.env.X_CONSUMER_KEY;
43
+ const apiSecret = process.env.X_API_SECRET ?? process.env.X_SECRET_KEY;
44
+ const clientId = process.env.X_CLIENT_ID;
45
+ const clientSecret = process.env.X_CLIENT_SECRET;
46
+ const bearerToken = process.env.X_BEARER_TOKEN;
47
+ const callbackUrl = process.env.X_CALLBACK_URL ?? 'http://127.0.0.1:3000/callback';
48
+ if (!apiKey || !apiSecret || !clientId || !clientSecret) {
49
+ throw new Error('Missing X API credentials for API sync.\n' +
50
+ 'Set X_API_KEY, X_API_SECRET, X_CLIENT_ID, and X_CLIENT_SECRET in .env.\n' +
51
+ 'These are only needed for --api mode. Default sync uses your Chrome session.');
52
+ }
53
+ return { apiKey, apiSecret, clientId, clientSecret, bearerToken, callbackUrl };
54
+ }
package/dist/db.js ADDED
@@ -0,0 +1,33 @@
1
+ import { createRequire } from 'node:module';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ const require = createRequire(import.meta.url);
5
+ let sqlPromise;
6
+ function getSql() {
7
+ if (!sqlPromise) {
8
+ const initSqlJs = require('sql.js-fts5');
9
+ const wasmPath = require.resolve('sql.js-fts5/dist/sql-wasm.wasm');
10
+ const wasmBinary = fs.readFileSync(wasmPath);
11
+ sqlPromise = initSqlJs({ wasmBinary });
12
+ }
13
+ return sqlPromise;
14
+ }
15
+ export async function openDb(filePath) {
16
+ const SQL = await getSql();
17
+ if (fs.existsSync(filePath)) {
18
+ const buf = fs.readFileSync(filePath);
19
+ return new SQL.Database(buf);
20
+ }
21
+ return new SQL.Database();
22
+ }
23
+ export async function createDb() {
24
+ const SQL = await getSql();
25
+ return new SQL.Database();
26
+ }
27
+ export function saveDb(db, filePath) {
28
+ const dir = path.dirname(filePath);
29
+ if (!fs.existsSync(dir))
30
+ fs.mkdirSync(dir, { recursive: true });
31
+ const data = db.export();
32
+ fs.writeFileSync(filePath, Buffer.from(data));
33
+ }
package/dist/fs.js ADDED
@@ -0,0 +1,45 @@
1
+ import { access, mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
2
+ export async function ensureDir(dirPath) {
3
+ await mkdir(dirPath, { recursive: true });
4
+ }
5
+ export async function pathExists(filePath) {
6
+ try {
7
+ await access(filePath);
8
+ return true;
9
+ }
10
+ catch {
11
+ return false;
12
+ }
13
+ }
14
+ export async function listFiles(dirPath) {
15
+ try {
16
+ return await readdir(dirPath);
17
+ }
18
+ catch {
19
+ return [];
20
+ }
21
+ }
22
+ export async function writeJson(filePath, value) {
23
+ await writeFile(filePath, JSON.stringify(value, null, 2), 'utf8');
24
+ }
25
+ export async function readJson(filePath) {
26
+ const raw = await readFile(filePath, 'utf8');
27
+ return JSON.parse(raw);
28
+ }
29
+ export async function writeJsonLines(filePath, rows) {
30
+ const content = rows.map((row) => JSON.stringify(row)).join('\n') + (rows.length ? '\n' : '');
31
+ await writeFile(filePath, content, 'utf8');
32
+ }
33
+ export async function readJsonLines(filePath) {
34
+ try {
35
+ const raw = await readFile(filePath, 'utf8');
36
+ return raw
37
+ .split('\n')
38
+ .map((line) => line.trim())
39
+ .filter(Boolean)
40
+ .map((line) => JSON.parse(line));
41
+ }
42
+ catch {
43
+ return [];
44
+ }
45
+ }