@remnic/connector-x 9.69.64

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.
@@ -0,0 +1,1482 @@
1
+ // openclaw-engram: Local-first memory plugin
2
+
3
+ // src/config.ts
4
+ var X_SOURCE_KINDS = ["mcp", "corpusDir", "cli"];
5
+ var X_MEMORY_MODES = ["suggest", "store"];
6
+ var X_SYNC_SCHEDULES = ["hourly", "4x-daily", "3x-daily", "2x-daily", "daily", "weekly"];
7
+ var X_DEFAULT_MCP_URL = "https://api.x.com/mcp";
8
+ var X_DEFAULT_TOKEN_FILE = "~/.openclaw/secrets/x-tokens.json";
9
+ var X_DEFAULT_STATE_DIR = "~/.remnic/x-connector";
10
+ var X_DEFAULT_COST_PER_READ_USD = 0.01;
11
+ var XConfigError = class extends Error {
12
+ constructor(message) {
13
+ super(message);
14
+ this.name = "XConfigError";
15
+ }
16
+ };
17
+ function coerceXBool(value, field) {
18
+ if (typeof value === "boolean") return value;
19
+ if (typeof value === "string") {
20
+ const normalized = value.trim().toLowerCase();
21
+ if (["true", "1", "yes", "on"].includes(normalized)) return true;
22
+ if (["false", "0", "no", "off"].includes(normalized)) return false;
23
+ }
24
+ throw new XConfigError(`${field} must be a boolean (got ${JSON.stringify(value)})`);
25
+ }
26
+ function requiredString(value, field) {
27
+ if (typeof value !== "string" || value.trim().length === 0) {
28
+ throw new XConfigError(`${field} must be a non-empty string`);
29
+ }
30
+ return value.trim();
31
+ }
32
+ function optionalString(value, field) {
33
+ if (value === void 0 || value === null) return void 0;
34
+ return requiredString(value, field);
35
+ }
36
+ function positiveInt(value, field, fallback) {
37
+ if (value === void 0 || value === null) return fallback;
38
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
39
+ throw new XConfigError(`${field} must be an integer >= 1 (got ${JSON.stringify(value)})`);
40
+ }
41
+ return value;
42
+ }
43
+ function nonNegativeNumber(value, field, fallback) {
44
+ if (value === void 0 || value === null) return fallback;
45
+ if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
46
+ throw new XConfigError(`${field} must be a finite number >= 0 (got ${JSON.stringify(value)})`);
47
+ }
48
+ return value;
49
+ }
50
+ function parseBudget(raw, sourceId) {
51
+ const input = raw === void 0 || raw === null ? {} : objectOrThrow(raw, `sources[${sourceId}].budget`);
52
+ return {
53
+ maxPagesPerSync: positiveInt(input.maxPagesPerSync, `sources[${sourceId}].budget.maxPagesPerSync`, 2),
54
+ maxCostUsdPerMonth: nonNegativeNumber(
55
+ input.maxCostUsdPerMonth,
56
+ `sources[${sourceId}].budget.maxCostUsdPerMonth`,
57
+ 1
58
+ ),
59
+ costPerReadUsd: nonNegativeNumber(
60
+ input.costPerReadUsd,
61
+ `sources[${sourceId}].budget.costPerReadUsd`,
62
+ X_DEFAULT_COST_PER_READ_USD
63
+ )
64
+ };
65
+ }
66
+ function objectOrThrow(value, field) {
67
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
68
+ throw new XConfigError(`${field} must be an object`);
69
+ }
70
+ return value;
71
+ }
72
+ function parseMcpSource(raw) {
73
+ const id = requiredString(raw.id, "source.id");
74
+ const auth = objectOrThrow(raw.auth ?? {}, `sources[${id}].auth`);
75
+ return {
76
+ id,
77
+ kind: "mcp",
78
+ url: optionalString(raw.url, `sources[${id}].url`) ?? X_DEFAULT_MCP_URL,
79
+ tokenFile: optionalString(auth.tokenFile ?? raw.tokenFile, `sources[${id}].auth.tokenFile`) ?? X_DEFAULT_TOKEN_FILE,
80
+ bookmarksTool: optionalString(raw.bookmarksTool, `sources[${id}].bookmarksTool`) ?? "get_users_bookmarks",
81
+ timelineTool: optionalString(raw.timelineTool, `sources[${id}].timelineTool`) ?? "get_users_tweets",
82
+ maxResults: positiveInt(raw.maxResults, `sources[${id}].maxResults`, 20),
83
+ budget: parseBudget(raw.budget, id)
84
+ };
85
+ }
86
+ function stringArray(value, field) {
87
+ if (value === void 0 || value === null) return [];
88
+ if (!Array.isArray(value)) {
89
+ throw new XConfigError(`${field} must be an array of strings`);
90
+ }
91
+ return value.map((entry, index) => requiredString(entry, `${field}[${index}]`));
92
+ }
93
+ function parseCorpusSource(raw) {
94
+ const id = requiredString(raw.id, "source.id");
95
+ return {
96
+ id,
97
+ kind: "corpusDir",
98
+ path: requiredString(raw.path, `sources[${id}].path`)
99
+ };
100
+ }
101
+ function parseCliSource(raw) {
102
+ const id = requiredString(raw.id, "source.id");
103
+ const bookmarksArgs = stringArray(raw.bookmarksArgs, `sources[${id}].bookmarksArgs`);
104
+ return {
105
+ id,
106
+ kind: "cli",
107
+ bin: optionalString(raw.bin, `sources[${id}].bin`) ?? "bird",
108
+ bookmarksArgs: bookmarksArgs.length > 0 ? bookmarksArgs : ["bookmarks", "--json"],
109
+ postsArgs: (() => {
110
+ const postsArgs = stringArray(raw.postsArgs, `sources[${id}].postsArgs`);
111
+ return postsArgs.length > 0 ? postsArgs : void 0;
112
+ })()
113
+ };
114
+ }
115
+ function parseXConnectorConfig(raw) {
116
+ const input = objectOrThrow(raw, "xConnector");
117
+ const enabled = coerceXBool(input.enabled ?? true, "xConnector.enabled");
118
+ const userId = optionalString(input.userId, "xConnector.userId");
119
+ if (userId !== void 0 && !/^\d+$/.test(userId)) {
120
+ throw new XConfigError("xConnector.userId must be the numeric X user id");
121
+ }
122
+ const sourcesRaw = input.sources;
123
+ if (!Array.isArray(sourcesRaw) || sourcesRaw.length === 0) {
124
+ throw new XConfigError("xConnector.sources must be a non-empty array");
125
+ }
126
+ const sources = [];
127
+ const seenIds = /* @__PURE__ */ new Set();
128
+ for (let index = 0; index < sourcesRaw.length; index++) {
129
+ const entry = objectOrThrow(sourcesRaw[index], `sources[${index}]`);
130
+ const kind = requiredString(entry.kind, `sources[${index}].kind`);
131
+ if (!X_SOURCE_KINDS.includes(kind)) {
132
+ throw new XConfigError(
133
+ `sources[${index}].kind must be one of ${X_SOURCE_KINDS.join(", ")} (got ${JSON.stringify(kind)})`
134
+ );
135
+ }
136
+ const source = kind === "mcp" ? parseMcpSource(entry) : kind === "corpusDir" ? parseCorpusSource(entry) : parseCliSource(entry);
137
+ if (seenIds.has(source.id)) {
138
+ throw new XConfigError(`duplicate source id ${JSON.stringify(source.id)} in xConnector.sources`);
139
+ }
140
+ seenIds.add(source.id);
141
+ sources.push(source);
142
+ }
143
+ const sourcePriority = stringArray(input.sourcePriority, "xConnector.sourcePriority");
144
+ for (const id of sourcePriority) {
145
+ if (!seenIds.has(id)) {
146
+ throw new XConfigError(`xConnector.sourcePriority references unknown source id ${JSON.stringify(id)}`);
147
+ }
148
+ }
149
+ const orderedPriority = sourcePriority.length > 0 ? sourcePriority : sources.map((source) => source.id);
150
+ const memoryModeRaw = optionalString(input.memoryMode, "xConnector.memoryMode") ?? "suggest";
151
+ if (!X_MEMORY_MODES.includes(memoryModeRaw)) {
152
+ throw new XConfigError(
153
+ `xConnector.memoryMode must be one of ${X_MEMORY_MODES.join(", ")} (got ${JSON.stringify(memoryModeRaw)})`
154
+ );
155
+ }
156
+ const syncSchedule = optionalString(input.syncSchedule, "xConnector.syncSchedule") ?? "3x-daily";
157
+ if (!X_SYNC_SCHEDULES.includes(syncSchedule)) {
158
+ throw new XConfigError(
159
+ `xConnector.syncSchedule must be one of ${X_SYNC_SCHEDULES.join(", ")} (got ${JSON.stringify(syncSchedule)})`
160
+ );
161
+ }
162
+ return {
163
+ enabled,
164
+ userId,
165
+ sources,
166
+ sourcePriority: orderedPriority,
167
+ syncSchedule,
168
+ memoryMode: memoryModeRaw,
169
+ stateDir: optionalString(input.stateDir, "xConnector.stateDir") ?? X_DEFAULT_STATE_DIR
170
+ };
171
+ }
172
+ function resolveMcpClientCredentials(source, env = process.env) {
173
+ const clientId = env.REMNIC_X_CLIENT_ID ?? env.X_CLIENT_ID;
174
+ const clientSecret = env.REMNIC_X_CLIENT_SECRET ?? env.X_CLIENT_SECRET;
175
+ return {
176
+ clientId: typeof clientId === "string" && clientId.trim().length > 0 ? clientId.trim() : void 0,
177
+ clientSecret: typeof clientSecret === "string" && clientSecret.trim().length > 0 ? clientSecret.trim() : void 0,
178
+ tokenFile: source.tokenFile
179
+ };
180
+ }
181
+ function monthlyCostCapUsd(config) {
182
+ let cap = 0;
183
+ for (const source of config.sources) {
184
+ if (source.kind === "mcp") cap = Math.max(cap, source.budget.maxCostUsdPerMonth);
185
+ }
186
+ return cap;
187
+ }
188
+
189
+ // src/file-sink.ts
190
+ import { mkdir, rename, writeFile } from "fs/promises";
191
+ import path from "path";
192
+ import { expandTildePath } from "@remnic/core";
193
+ function createFileSink(options) {
194
+ const root = path.join(expandTildePath(options.stateDir), options.mode === "store" ? "records" : "suggestions");
195
+ const write = async (suggestion) => {
196
+ await mkdir(root, { recursive: true });
197
+ const safeName = suggestion.record.postId.replace(/[^0-9A-Za-z._-]/g, "_");
198
+ const target = path.join(root, `${safeName}.json`);
199
+ const tmp = `${target}.tmp`;
200
+ await writeFile(tmp, `${JSON.stringify(suggestion, null, 2)}
201
+ `, { mode: 384 });
202
+ await rename(tmp, target);
203
+ };
204
+ return {
205
+ submitSuggestion: write,
206
+ storeMemory: write
207
+ };
208
+ }
209
+
210
+ // src/guards.ts
211
+ function isXObject(value) {
212
+ return typeof value === "object" && value !== null && !Array.isArray(value);
213
+ }
214
+
215
+ // src/normalize.ts
216
+ import { createHash } from "crypto";
217
+ function stableStringify(value) {
218
+ return JSON.stringify(value, (_key, entry) => {
219
+ if (isXObject(entry)) {
220
+ return Object.fromEntries(
221
+ Object.keys(entry).sort().map((key) => [key, entry[key]])
222
+ );
223
+ }
224
+ return entry;
225
+ });
226
+ }
227
+ function recordFingerprint(record) {
228
+ return createHash("sha256").update(
229
+ stableStringify({
230
+ postId: record.postId,
231
+ kind: record.kind,
232
+ text: record.text,
233
+ urls: [...record.urls].sort(),
234
+ authorUsername: record.author?.username ?? null
235
+ })
236
+ ).digest("hex");
237
+ }
238
+ function firstString(...values) {
239
+ for (const value of values) {
240
+ if (typeof value === "string" && value.trim().length > 0) return value.trim();
241
+ }
242
+ return void 0;
243
+ }
244
+ function asStringArray(value) {
245
+ if (typeof value === "string") return [value];
246
+ if (!Array.isArray(value)) return [];
247
+ const urls = [];
248
+ for (const entry of value) {
249
+ if (typeof entry === "string" && entry.trim().length > 0) urls.push(entry.trim());
250
+ else if (isXObject(entry)) {
251
+ const expanded = firstString(entry.expanded_url, entry.url, entry.href);
252
+ if (expanded !== void 0) urls.push(expanded);
253
+ }
254
+ }
255
+ return urls;
256
+ }
257
+ function kindFrom(value, fallback) {
258
+ const raw = typeof value === "string" ? value.trim().toLowerCase() : "";
259
+ if (raw === "bookmark" || raw === "bookmarks") return "bookmark";
260
+ if (raw === "own_post" || raw === "post" || raw === "tweet" || raw === "own-post") {
261
+ return "own_post";
262
+ }
263
+ return fallback;
264
+ }
265
+ function normalizeMcpPayload(payload, kind, ownUsername) {
266
+ const container = isXObject(payload) ? payload : {};
267
+ const includesUsers = isXObject(container.includes) ? Array.isArray(container.includes.users) ? container.includes.users : [] : [];
268
+ const rows = Array.isArray(container.data) ? container.data : Array.isArray(payload) ? payload : isXObject(container.bookmarks) && Array.isArray(container.bookmarks.data) ? container.bookmarks.data : Array.isArray(container.bookmarks) ? container.bookmarks : [];
269
+ const records = [];
270
+ for (const row of rows) {
271
+ const record = normalizeEntry(row, kind, includesUsers, ownUsername);
272
+ if (record !== null) records.push(record);
273
+ }
274
+ return records;
275
+ }
276
+ function normalizeCorpusEntry(entry, fallbackKind, ownUsername) {
277
+ return normalizeEntry(entry, fallbackKind, [], ownUsername);
278
+ }
279
+ function normalizeEntry(entry, fallbackKind, includesUsers, ownUsername) {
280
+ if (!isXObject(entry)) return null;
281
+ const postId = firstString(entry.post_id, entry.id, entry.tweet_id, entry.postId);
282
+ const text = firstString(entry.text, entry.full_text, entry.content, entry.note) ?? "";
283
+ if (postId === void 0 || text.length === 0 && !hasUrls(entry)) return null;
284
+ const kind = kindFrom(entry.kind ?? entry.type, fallbackKind);
285
+ const authorRaw = isXObject(entry.author) ? entry.author : entry;
286
+ const authorId = firstString(entry.author_id, entry.authorId, authorRaw.id);
287
+ const authorUsername = firstString(authorRaw.username, authorRaw.handle, authorRaw.screen_name) ?? lookupIncludedUsername(includesUsers, authorId) ?? ownUsername;
288
+ const authorName = firstString(authorRaw.name, authorRaw.display_name);
289
+ const author = authorUsername !== void 0 || authorId !== void 0 || authorName !== void 0 ? {
290
+ ...authorId !== void 0 ? { id: authorId } : {},
291
+ ...authorUsername !== void 0 ? { username: authorUsername } : {},
292
+ ...authorName !== void 0 ? { name: authorName } : {}
293
+ } : void 0;
294
+ const urls = collectUrls(entry);
295
+ const createdAt = firstString(entry.created_at, entry.createdAt, entry.created_at_iso);
296
+ const bookmarkedAt = firstString(entry.bookmarked_at, entry.bookmarkedAt, entry.saved_at);
297
+ const mediaCount = countMedia(entry);
298
+ const enrichment = isXObject(entry.enrichment) ? entry.enrichment : void 0;
299
+ return {
300
+ postId,
301
+ kind,
302
+ ...author !== void 0 ? { author } : {},
303
+ ...createdAt !== void 0 ? { createdAt } : {},
304
+ ...bookmarkedAt !== void 0 ? { bookmarkedAt } : {},
305
+ text: trimTcoSuffix(text, entry),
306
+ urls,
307
+ mediaCount,
308
+ ...enrichment !== void 0 ? { enrichment } : {}
309
+ };
310
+ }
311
+ function hasUrls(entry) {
312
+ return collectUrls(entry).length > 0;
313
+ }
314
+ function collectUrls(entry) {
315
+ const urls = [...asStringArray(entry.urls), ...asStringArray(entry.url), ...asStringArray(entry.links)];
316
+ if (isXObject(entry.entities) && Array.isArray(entry.entities.urls)) {
317
+ urls.push(...asStringArray(entry.entities.urls));
318
+ }
319
+ return [...new Set(urls)];
320
+ }
321
+ function countMedia(entry) {
322
+ if (isXObject(entry.attachments) && Array.isArray(entry.attachments.media_keys)) {
323
+ return entry.attachments.media_keys.length;
324
+ }
325
+ if (Array.isArray(entry.media)) return entry.media.length;
326
+ if (isXObject(entry.media) && Array.isArray(entry.media.media_keys)) {
327
+ return entry.media.media_keys.length;
328
+ }
329
+ return 0;
330
+ }
331
+ function lookupIncludedUsername(includesUsers, authorId) {
332
+ if (authorId === void 0) return void 0;
333
+ for (const user of includesUsers) {
334
+ if (isXObject(user) && user.id === authorId) {
335
+ return firstString(user.username, user.screen_name);
336
+ }
337
+ }
338
+ return void 0;
339
+ }
340
+ function trimTcoSuffix(text, entry) {
341
+ const entities = isXObject(entry.entities) && Array.isArray(entry.entities.urls) ? entry.entities.urls : [];
342
+ for (const raw of entities) {
343
+ if (!isXObject(raw) || typeof raw.url !== "string") continue;
344
+ if (raw.url.includes("://t.co/") && text.endsWith(raw.url)) {
345
+ return text.slice(0, text.length - raw.url.length).trimEnd();
346
+ }
347
+ }
348
+ return text;
349
+ }
350
+ function postUrl(record) {
351
+ const username = record.author?.username;
352
+ return username !== void 0 ? `https://x.com/${username}/status/${record.postId}` : `https://x.com/i/status/${record.postId}`;
353
+ }
354
+ var QUOTE = '"';
355
+ function suggestionForRecord(record) {
356
+ const quoted = `${QUOTE}${record.text.slice(0, 280)}${record.text.length > 280 ? "\u2026" : ""}${QUOTE}`;
357
+ const from = record.author?.username !== void 0 ? ` from @${record.author.username}` : "";
358
+ const firstUrl = record.urls[0];
359
+ const title = enrichmentTitle(record);
360
+ const isOwnPost = record.kind === "own_post";
361
+ const content = isOwnPost ? `Posted on X: ${quoted}${firstUrl !== void 0 ? ` ${firstUrl}` : ""}${title !== void 0 ? ` (${title})` : ""}` : `Bookmarked on X${from}: ${quoted}${firstUrl !== void 0 ? ` ${firstUrl}` : ""}${title !== void 0 ? ` (${title})` : ""}`;
362
+ return {
363
+ record,
364
+ tags: [isOwnPost ? "x/post" : "x/bookmark"],
365
+ category: isOwnPost ? "expression" : firstUrl !== void 0 ? "reference" : "interest",
366
+ ...record.author?.username !== void 0 ? { entityRef: `person-${record.author.username.toLowerCase()}` } : {},
367
+ confidence: isOwnPost ? 0.9 : 0.7,
368
+ postUrl: postUrl(record),
369
+ content
370
+ };
371
+ }
372
+ function enrichmentTitle(record) {
373
+ if (record.enrichment === void 0) return void 0;
374
+ const title = isXObject(record.enrichment) ? record.enrichment.title : void 0;
375
+ return typeof title === "string" && title.trim().length > 0 ? title.trim() : void 0;
376
+ }
377
+
378
+ // src/mcp-client.ts
379
+ import { setTimeout as sleepMs } from "timers/promises";
380
+ import {
381
+ ConnectorApiError,
382
+ describeNetworkError,
383
+ retryingFetch,
384
+ stripTrailingSlashes
385
+ } from "@remnic/core/http-retry";
386
+ var DEFAULT_TIMEOUT_MS = 3e4;
387
+ var MAX_RETRIES = 2;
388
+ var MAX_RETRY_DELAY_MS = 8e3;
389
+ var X_MCP_PROTOCOL_VERSION = "2025-06-18";
390
+ var X_MCP_DEFAULT_URL = "https://api.x.com/mcp";
391
+ var XMcpError = class extends ConnectorApiError {
392
+ constructor(message, status) {
393
+ super(message, status);
394
+ this.name = "XMcpError";
395
+ }
396
+ };
397
+ var XCreditsDepletedError = class extends Error {
398
+ constructor() {
399
+ super("X API credits depleted \u2014 skipping this sync cycle");
400
+ this.name = "XCreditsDepletedError";
401
+ }
402
+ };
403
+ function parseSseData(body) {
404
+ const values = [];
405
+ for (const line of body.split(/\r?\n/)) {
406
+ if (!line.startsWith("data:")) continue;
407
+ const payload = line.slice("data:".length).trim();
408
+ if (payload.length === 0) continue;
409
+ try {
410
+ values.push(JSON.parse(payload));
411
+ } catch {
412
+ }
413
+ }
414
+ return values;
415
+ }
416
+ function looksLikeCreditsDepleted(text) {
417
+ return text.includes("credits depleted") || text.includes('"status":402');
418
+ }
419
+ function toolResultTexts(payload) {
420
+ const content = Array.isArray(payload.content) ? payload.content : [];
421
+ const texts = [];
422
+ for (const block of content) {
423
+ if (isXObject(block) && block.type === "text" && typeof block.text === "string") {
424
+ texts.push(block.text);
425
+ }
426
+ }
427
+ return texts;
428
+ }
429
+ var XMcpClient = class {
430
+ url;
431
+ tokenProvider;
432
+ fetchImpl;
433
+ timeoutMs;
434
+ sleep;
435
+ protocolVersion;
436
+ clientName;
437
+ clientVersion;
438
+ sessionId = null;
439
+ nextMessageId = 1;
440
+ constructor(options) {
441
+ if (typeof options.tokenProvider !== "function") {
442
+ throw new XMcpError("XMcpClient requires a tokenProvider function");
443
+ }
444
+ this.url = stripTrailingSlashes(options.url ?? X_MCP_DEFAULT_URL);
445
+ this.tokenProvider = options.tokenProvider;
446
+ this.fetchImpl = options.fetchImpl ?? fetch;
447
+ this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
448
+ this.sleep = options.sleep ?? sleepMs;
449
+ this.protocolVersion = options.protocolVersion ?? X_MCP_PROTOCOL_VERSION;
450
+ this.clientName = options.clientName ?? "remnic-connector-x";
451
+ this.clientVersion = options.clientVersion ?? "1.0.0";
452
+ }
453
+ /**
454
+ * Calls an MCP tool. Re-initializes once when the server rejects the
455
+ * session id (e.g. expired session), then retries the call.
456
+ */
457
+ async callTool(name, args, signal) {
458
+ return this.withSessionRetry(async () => {
459
+ const { result } = await this.rpcMessage(
460
+ {
461
+ jsonrpc: "2.0",
462
+ id: this.allocateId(),
463
+ method: "tools/call",
464
+ params: { name, arguments: args }
465
+ },
466
+ signal,
467
+ true
468
+ );
469
+ if (!isXObject(result)) {
470
+ throw new XMcpError(`tool ${name} returned a non-object result`);
471
+ }
472
+ const texts = toolResultTexts(result);
473
+ const isError = result.isError === true;
474
+ if (isError && looksLikeCreditsDepleted(texts.join("\n"))) {
475
+ throw new XCreditsDepletedError();
476
+ }
477
+ return { isError, texts, raw: result };
478
+ });
479
+ }
480
+ /** Best-effort session shutdown (MCP `DELETE`). */
481
+ async close() {
482
+ if (this.sessionId === null) return;
483
+ const sessionId = this.sessionId;
484
+ this.sessionId = null;
485
+ try {
486
+ await this.fetchImpl(this.url, {
487
+ method: "DELETE",
488
+ headers: {
489
+ Authorization: `Bearer ${await this.tokenProvider()}`,
490
+ "Mcp-Session-Id": sessionId
491
+ }
492
+ });
493
+ } catch {
494
+ }
495
+ }
496
+ async withSessionRetry(operation) {
497
+ await this.ensureSession();
498
+ try {
499
+ return await operation();
500
+ } catch (err) {
501
+ if (err instanceof XMcpError && err.status === 404) {
502
+ this.sessionId = null;
503
+ await this.ensureSession();
504
+ return operation();
505
+ }
506
+ throw err;
507
+ }
508
+ }
509
+ async ensureSession(signal) {
510
+ if (this.sessionId !== null) return;
511
+ const initialized = await this.rpcMessage(
512
+ {
513
+ jsonrpc: "2.0",
514
+ id: this.allocateId(),
515
+ method: "initialize",
516
+ params: {
517
+ protocolVersion: this.protocolVersion,
518
+ capabilities: {},
519
+ clientInfo: { name: this.clientName, version: this.clientVersion }
520
+ }
521
+ },
522
+ signal,
523
+ true
524
+ );
525
+ const sessionId = initialized.headers?.get("mcp-session-id");
526
+ if (typeof sessionId === "string" && sessionId.length > 0) {
527
+ this.sessionId = sessionId;
528
+ }
529
+ await this.rpcMessage({ jsonrpc: "2.0", method: "notifications/initialized" }, signal, false);
530
+ }
531
+ allocateId() {
532
+ const id = this.nextMessageId;
533
+ this.nextMessageId += 1;
534
+ return id;
535
+ }
536
+ async rpcMessage(message, signal, expectBody) {
537
+ const response = await retryingFetch(this.url, {
538
+ buildInit: async () => {
539
+ const headers = {
540
+ "Content-Type": "application/json",
541
+ Accept: "application/json, text/event-stream",
542
+ Authorization: `Bearer ${await this.tokenProvider()}`
543
+ };
544
+ if (this.sessionId !== null) headers["Mcp-Session-Id"] = this.sessionId;
545
+ return {
546
+ method: "POST",
547
+ headers,
548
+ body: JSON.stringify(message)
549
+ };
550
+ },
551
+ fetchImpl: this.fetchImpl,
552
+ sleep: this.sleep,
553
+ signal,
554
+ timeoutMs: this.timeoutMs,
555
+ maxRetries: MAX_RETRIES,
556
+ maxRetryDelayMs: MAX_RETRY_DELAY_MS,
557
+ backoffBaseMs: 500,
558
+ networkError: (err, attempts) => new XMcpError(`X MCP request failed after ${attempts} attempts: ${describeNetworkError(err)}`),
559
+ retryableError: (retryable) => new XMcpError(`X MCP responded ${retryable.status}`, retryable.status)
560
+ });
561
+ if (response.status === 402) throw new XCreditsDepletedError();
562
+ if (response.status === 401) {
563
+ throw new XMcpError(
564
+ "X MCP rejected the bearer token (401) \u2014 the OAuth2 token or refresh chain is broken; re-authorize",
565
+ 401
566
+ );
567
+ }
568
+ if (response.status === 404 && this.sessionId !== null) {
569
+ throw new XMcpError("X MCP session expired", 404);
570
+ }
571
+ if (!response.ok) {
572
+ throw new XMcpError(`X MCP responded ${response.status}`, response.status);
573
+ }
574
+ if (!expectBody || response.status === 202) {
575
+ return { result: null, headers: response.headers };
576
+ }
577
+ const body = await response.text();
578
+ return {
579
+ result: this.decodeBody(body, response.headers, message.id),
580
+ headers: response.headers
581
+ };
582
+ }
583
+ decodeBody(body, headers, messageId) {
584
+ const contentType = headers.get("content-type") ?? "";
585
+ let messages;
586
+ if (contentType.includes("text/event-stream")) {
587
+ messages = parseSseData(body);
588
+ } else {
589
+ try {
590
+ messages = [JSON.parse(body)];
591
+ } catch {
592
+ throw new XMcpError("X MCP returned a non-JSON body");
593
+ }
594
+ }
595
+ const match = messages.find((entry) => isXObject(entry) && entry.id === messageId && entry.error === void 0);
596
+ if (match === void 0) {
597
+ const errorEntry = messages.find(
598
+ (entry) => isXObject(entry) && entry.id === messageId && entry.error !== void 0
599
+ );
600
+ if (errorEntry !== void 0 && isXObject(errorEntry)) {
601
+ const rpcError = errorEntry.error;
602
+ const detail = isXObject(rpcError) && typeof rpcError.message === "string" ? `${String(rpcError.code)}: ${rpcError.message}` : "unknown JSON-RPC error";
603
+ if (looksLikeCreditsDepleted(detail)) throw new XCreditsDepletedError();
604
+ throw new XMcpError(`X MCP tool call failed: ${detail}`);
605
+ }
606
+ throw new XMcpError("X MCP response carried no message for this request id");
607
+ }
608
+ if (isXObject(match) && "result" in match) return match.result;
609
+ return match;
610
+ }
611
+ };
612
+
613
+ // src/token-store.ts
614
+ import { open, readFile, rename as rename2, stat, unlink, writeFile as writeFile2 } from "fs/promises";
615
+ import path2 from "path";
616
+ import { setTimeout as sleepMs2 } from "timers/promises";
617
+ var X_TOKEN_REFRESH_URL = "https://api.x.com/2/oauth2/token";
618
+ var EXPIRY_MARGIN_MS = 6e4;
619
+ var DEFAULT_LOCK_STALE_MS = 6e4;
620
+ var DEFAULT_LOCK_WAIT_MS = 15e3;
621
+ var LOCK_POLL_MS = 200;
622
+ var XTokenError = class extends Error {
623
+ constructor(message) {
624
+ super(message);
625
+ this.name = "XTokenError";
626
+ }
627
+ };
628
+ var XRefreshChainBrokenError = class extends XTokenError {
629
+ constructor(detail) {
630
+ super(
631
+ `X OAuth2 refresh failed (${detail}). The refresh chain was rotated by another refresher or the grant was revoked. Ensure only one refresher owns the chain, then re-authorize to write a fresh token file.`
632
+ );
633
+ this.name = "XRefreshChainBrokenError";
634
+ }
635
+ };
636
+ var LockUnavailableError = class extends Error {
637
+ };
638
+ var XTokenStore = class {
639
+ tokenFile;
640
+ clientId;
641
+ clientSecret;
642
+ refreshUrl;
643
+ fetchImpl;
644
+ now;
645
+ sleep;
646
+ lockStaleMs;
647
+ lockWaitMs;
648
+ cached = null;
649
+ constructor(options) {
650
+ if (typeof options.tokenFile !== "string" || options.tokenFile.length === 0) {
651
+ throw new XTokenError("XTokenStore requires tokenFile");
652
+ }
653
+ for (const [field, value] of [
654
+ ["clientId", options.clientId],
655
+ ["clientSecret", options.clientSecret]
656
+ ]) {
657
+ if (typeof value !== "string" || value.trim().length === 0) {
658
+ throw new XTokenError(
659
+ `XTokenStore requires ${field} (pre-registered confidential client; set xConnector source auth or the REMNIC_X_CLIENT_ID/REMNIC_X_CLIENT_SECRET env vars)`
660
+ );
661
+ }
662
+ }
663
+ this.tokenFile = options.tokenFile;
664
+ this.clientId = options.clientId.trim();
665
+ this.clientSecret = options.clientSecret.trim();
666
+ this.refreshUrl = options.refreshUrl ?? X_TOKEN_REFRESH_URL;
667
+ this.fetchImpl = options.fetchImpl ?? fetch;
668
+ this.now = options.now ?? (() => Date.now());
669
+ this.lockStaleMs = options.lockStaleMs ?? DEFAULT_LOCK_STALE_MS;
670
+ this.lockWaitMs = options.lockWaitMs ?? DEFAULT_LOCK_WAIT_MS;
671
+ this.sleep = options.sleep ?? sleepMs2;
672
+ }
673
+ /** Returns a valid access token, refreshing under the lock when expired. */
674
+ async getAccessToken() {
675
+ return (await this.getValidPair()).accessToken;
676
+ }
677
+ async getValidPair() {
678
+ const current = this.cached ?? await this.readTokenFile();
679
+ if (current !== null && current.expiresAt - EXPIRY_MARGIN_MS > this.now()) {
680
+ this.cached = current;
681
+ return current;
682
+ }
683
+ const refreshed = await this.refreshWithLock();
684
+ this.cached = refreshed;
685
+ return refreshed;
686
+ }
687
+ /**
688
+ * Refreshes the token pair under the file lock. When another owner
689
+ * holds the lock, waits for it, then adopts the pair it wrote.
690
+ */
691
+ async refresh() {
692
+ const pair = await this.refreshWithLock();
693
+ this.cached = pair;
694
+ return pair;
695
+ }
696
+ async refreshWithLock() {
697
+ let lock = null;
698
+ try {
699
+ lock = await this.acquireLock();
700
+ } catch (err) {
701
+ if (err instanceof LockUnavailableError) {
702
+ return this.waitForOtherOwner();
703
+ }
704
+ throw err;
705
+ }
706
+ try {
707
+ const underLock = await this.readTokenFile();
708
+ if (underLock !== null && underLock.expiresAt - EXPIRY_MARGIN_MS > this.now()) {
709
+ return underLock;
710
+ }
711
+ const refreshToken = underLock?.refreshToken ?? (() => {
712
+ throw new XTokenError(
713
+ `X token file ${this.tokenFile} is missing or carries no refresh_token \u2014 run the OAuth2 user-code flow once to seed it`
714
+ );
715
+ })();
716
+ const rotated = await this.requestRefresh(refreshToken);
717
+ await this.writeTokenFile(rotated);
718
+ return rotated;
719
+ } finally {
720
+ await lock.release();
721
+ }
722
+ }
723
+ async waitForOtherOwner() {
724
+ const deadline = this.now() + this.lockWaitMs;
725
+ while (this.now() < deadline) {
726
+ await this.sleep(LOCK_POLL_MS);
727
+ const pair = await this.readTokenFile();
728
+ if (pair !== null && pair.expiresAt - EXPIRY_MARGIN_MS > this.now()) {
729
+ return pair;
730
+ }
731
+ }
732
+ throw new XTokenError(
733
+ `another refresher has held ${this.tokenFile}.lock for over ${Math.round(this.lockWaitMs / 1e3)}s \u2014 investigate the competing owner`
734
+ );
735
+ }
736
+ async acquireLock() {
737
+ const lockPath = `${this.tokenFile}.lock`;
738
+ const deadline = this.now() + this.lockWaitMs;
739
+ for (; ; ) {
740
+ try {
741
+ const handle = await open(lockPath, "wx");
742
+ await handle.write(`${process.pid}
743
+ `);
744
+ await handle.close();
745
+ return {
746
+ release: async () => {
747
+ try {
748
+ await unlink(lockPath);
749
+ } catch {
750
+ }
751
+ }
752
+ };
753
+ } catch (err) {
754
+ const code = err.code;
755
+ if (code !== "EEXIST") throw err;
756
+ if (await this.stealStaleLock(lockPath)) continue;
757
+ if (this.now() >= deadline) throw new LockUnavailableError("lock wait timeout");
758
+ await this.sleep(LOCK_POLL_MS);
759
+ }
760
+ }
761
+ }
762
+ /** Steals the lock when its mtime is older than lockStaleMs. */
763
+ async stealStaleLock(lockPath) {
764
+ let mtimeMs;
765
+ try {
766
+ mtimeMs = (await stat(lockPath)).mtimeMs;
767
+ } catch {
768
+ return true;
769
+ }
770
+ if (this.now() - mtimeMs < this.lockStaleMs) return false;
771
+ try {
772
+ await unlink(lockPath);
773
+ } catch {
774
+ }
775
+ return true;
776
+ }
777
+ async requestRefresh(refreshToken) {
778
+ const body = new URLSearchParams({
779
+ grant_type: "refresh_token",
780
+ refresh_token: refreshToken,
781
+ client_id: this.clientId
782
+ }).toString();
783
+ let response;
784
+ try {
785
+ response = await this.fetchImpl(this.refreshUrl, {
786
+ method: "POST",
787
+ headers: {
788
+ "Content-Type": "application/x-www-form-urlencoded",
789
+ Authorization: `Basic ${Buffer.from(`${this.clientId}:${this.clientSecret}`).toString("base64")}`
790
+ },
791
+ body
792
+ });
793
+ } catch (err) {
794
+ throw new XTokenError(`X OAuth2 refresh request failed: ${err instanceof Error ? err.name : "network error"}`);
795
+ }
796
+ if (response.status === 400 || response.status === 401 || response.status === 403) {
797
+ throw new XRefreshChainBrokenError(`HTTP ${response.status}`);
798
+ }
799
+ if (!response.ok) {
800
+ throw new XTokenError(`X OAuth2 refresh responded HTTP ${response.status}`);
801
+ }
802
+ let payload;
803
+ try {
804
+ payload = await response.json();
805
+ } catch {
806
+ throw new XTokenError("X OAuth2 refresh returned a non-JSON body");
807
+ }
808
+ if (!isTokenResponse(payload)) {
809
+ throw new XTokenError("X OAuth2 refresh returned an unexpected payload shape");
810
+ }
811
+ return {
812
+ accessToken: payload.access_token,
813
+ // X rotates refresh tokens; keep the old one when the response omits it.
814
+ refreshToken: typeof payload.refresh_token === "string" ? payload.refresh_token : refreshToken,
815
+ expiresAt: this.now() + payload.expires_in * 1e3
816
+ };
817
+ }
818
+ async readTokenFile() {
819
+ let raw;
820
+ try {
821
+ raw = await readFile(this.tokenFile, "utf8");
822
+ } catch {
823
+ return null;
824
+ }
825
+ let parsed;
826
+ try {
827
+ parsed = JSON.parse(raw);
828
+ } catch {
829
+ throw new XTokenError(`X token file ${this.tokenFile} is not valid JSON`);
830
+ }
831
+ if (!isXObject(parsed)) return null;
832
+ const accessToken = firstString2(parsed.access_token, parsed.accessToken);
833
+ if (accessToken === void 0) {
834
+ throw new XTokenError(`X token file ${this.tokenFile} carries no access token`);
835
+ }
836
+ const refreshToken = firstString2(parsed.refresh_token, parsed.refreshToken) ?? "";
837
+ const expiresAtRaw = parsed.expires_at ?? parsed.expiresAt;
838
+ const expiresAt = typeof expiresAtRaw === "number" && Number.isFinite(expiresAtRaw) ? expiresAtRaw : (
839
+ // Files without expiry force one refresh on first use.
840
+ 0
841
+ );
842
+ return { accessToken, refreshToken, expiresAt };
843
+ }
844
+ /** Atomic 0600 write; preserves unknown top-level fields from the prior file. */
845
+ async writeTokenFile(pair) {
846
+ let previous = {};
847
+ try {
848
+ const priorRaw = JSON.parse(await readFile(this.tokenFile, "utf8"));
849
+ if (isXObject(priorRaw)) previous = priorRaw;
850
+ } catch {
851
+ previous = {};
852
+ }
853
+ const next = {
854
+ ...previous,
855
+ access_token: pair.accessToken,
856
+ refresh_token: pair.refreshToken,
857
+ expires_at: pair.expiresAt
858
+ };
859
+ const dir = path2.dirname(this.tokenFile);
860
+ const tmpPath = path2.join(dir, `.${path2.basename(this.tokenFile)}.${process.pid}.${Date.now()}.tmp`);
861
+ await writeFile2(tmpPath, `${JSON.stringify(next, null, 2)}
862
+ `, { mode: 384 });
863
+ try {
864
+ await rename2(tmpPath, this.tokenFile);
865
+ } catch (err) {
866
+ try {
867
+ await unlink(tmpPath);
868
+ } catch {
869
+ }
870
+ throw new XTokenError(
871
+ `failed to persist rotated X tokens to ${this.tokenFile}: ${err instanceof Error ? err.name : "write error"}`
872
+ );
873
+ }
874
+ }
875
+ };
876
+ function firstString2(...values) {
877
+ for (const value of values) {
878
+ if (typeof value === "string" && value.trim().length > 0) return value.trim();
879
+ }
880
+ return void 0;
881
+ }
882
+ function isTokenResponse(value) {
883
+ return isXObject(value) && typeof value.access_token === "string";
884
+ }
885
+
886
+ // src/sources.ts
887
+ import { execFile } from "child_process";
888
+ import { lstat, readFile as readFile2, readdir, realpath, stat as stat2 } from "fs/promises";
889
+ import path3 from "path";
890
+ import { promisify } from "util";
891
+ import { expandTildePath as expandTildePath2 } from "@remnic/core";
892
+ var execFileAsync = promisify(execFile);
893
+ var XBudgetTracker = class {
894
+ constructor(budget, monthSpendUsd) {
895
+ this.budget = budget;
896
+ this.monthSpendUsd = monthSpendUsd;
897
+ this.maxPages = budget.maxPagesPerSync;
898
+ }
899
+ budget;
900
+ monthSpendUsd;
901
+ pagesUsed = 0;
902
+ reads = 0;
903
+ maxPages;
904
+ canRead() {
905
+ if (this.pagesUsed >= this.budget.maxPagesPerSync) {
906
+ return { ok: false, reason: "page-cap", detail: `${this.budget.maxPagesPerSync} pages/sync` };
907
+ }
908
+ const projected = this.monthSpendUsd + (this.reads + 1) * this.budget.costPerReadUsd;
909
+ if (projected > this.budget.maxCostUsdPerMonth + 1e-9) {
910
+ return {
911
+ ok: false,
912
+ reason: "monthly-cost-cap",
913
+ detail: `$${projected.toFixed(2)} projected vs $${this.budget.maxCostUsdPerMonth.toFixed(2)} cap`
914
+ };
915
+ }
916
+ return { ok: true };
917
+ }
918
+ noteRead() {
919
+ this.reads += 1;
920
+ }
921
+ };
922
+ var unlimitedBudget = {
923
+ pagesUsed: 0,
924
+ maxPages: Number.POSITIVE_INFINITY,
925
+ canRead: () => ({ ok: true }),
926
+ noteRead: () => {
927
+ }
928
+ };
929
+ function createXSource(config, deps = {}) {
930
+ if (config.kind === "mcp") return createMcpSource(config, deps);
931
+ if (config.kind === "corpusDir") return createCorpusSource(config);
932
+ return createCliSource(config, deps);
933
+ }
934
+ function createMcpSource(config, deps) {
935
+ const credentials = resolveMcpClientCredentials(config, deps.env ?? process.env);
936
+ let client = null;
937
+ const getClient = () => {
938
+ if (client === null) {
939
+ if (credentials.clientId === void 0 || credentials.clientSecret === void 0) {
940
+ throw new XMcpError(
941
+ `source ${config.id}: OAuth2 client credentials missing \u2014 set auth on the source or REMNIC_X_CLIENT_ID/REMNIC_X_CLIENT_SECRET`
942
+ );
943
+ }
944
+ const store = new XTokenStore({
945
+ tokenFile: expandTildePath2(credentials.tokenFile),
946
+ clientId: credentials.clientId,
947
+ clientSecret: credentials.clientSecret,
948
+ ...deps.fetchImpl !== void 0 ? { fetchImpl: deps.fetchImpl } : {},
949
+ ...deps.sleep !== void 0 ? { sleep: deps.sleep } : {},
950
+ ...deps.now !== void 0 ? { now: deps.now } : {}
951
+ });
952
+ client = new XMcpClient({
953
+ url: config.url,
954
+ tokenProvider: () => store.getAccessToken(),
955
+ ...deps.fetchImpl !== void 0 ? { fetchImpl: deps.fetchImpl } : {},
956
+ ...deps.sleep !== void 0 ? { sleep: deps.sleep } : {}
957
+ });
958
+ }
959
+ return client;
960
+ };
961
+ return {
962
+ id: config.id,
963
+ kind: "mcp",
964
+ async fetch(ctx) {
965
+ const records = [];
966
+ let reads = 0;
967
+ let pages = 0;
968
+ let skipped;
969
+ try {
970
+ getClient();
971
+ } catch (err) {
972
+ return {
973
+ records,
974
+ reads,
975
+ pages,
976
+ skipped: {
977
+ reason: "auth-not-configured",
978
+ ...err instanceof Error ? { detail: err.message } : {}
979
+ }
980
+ };
981
+ }
982
+ const runKind = async (kind, toolName, args) => {
983
+ let nextToken;
984
+ for (; ; ) {
985
+ const gate = ctx.budget.canRead();
986
+ if (!gate.ok) {
987
+ skipped ??= {
988
+ reason: gate.reason,
989
+ ...gate.detail !== void 0 ? { detail: gate.detail } : {}
990
+ };
991
+ return;
992
+ }
993
+ const page = await getClient().callTool(toolName, {
994
+ ...args,
995
+ ...nextToken !== void 0 ? { pagination_token: nextToken } : {}
996
+ });
997
+ ctx.budget.noteRead();
998
+ reads += 1;
999
+ pages += 1;
1000
+ ctx.budget.pagesUsed = pages;
1001
+ const payload = parseToolJson(page.texts);
1002
+ if (payload === null) {
1003
+ skipped ??= { reason: "unexpected-payload", detail: `tool ${toolName}` };
1004
+ return;
1005
+ }
1006
+ const pageRecords = normalizeMcpPayload(payload, kind);
1007
+ records.push(...pageRecords);
1008
+ nextToken = nextPageToken(payload);
1009
+ if (nextToken === void 0 || pageRecords.length === 0) return;
1010
+ if (pageRecords.every((record) => ctx.knownIds.has(record.postId))) return;
1011
+ }
1012
+ };
1013
+ try {
1014
+ await runKind("bookmark", config.bookmarksTool, { max_results: config.maxResults });
1015
+ if (deps.userId !== void 0) {
1016
+ await runKind("own_post", config.timelineTool, {
1017
+ id: deps.userId,
1018
+ max_results: config.maxResults
1019
+ });
1020
+ }
1021
+ } catch (err) {
1022
+ if (err instanceof XCreditsDepletedError) {
1023
+ return { records, reads, pages, skipped: { reason: "credits-depleted" } };
1024
+ }
1025
+ throw err;
1026
+ }
1027
+ return { records, reads, pages, ...skipped !== void 0 ? { skipped } : {} };
1028
+ }
1029
+ };
1030
+ }
1031
+ function parseToolJson(texts) {
1032
+ const candidates = [texts.join("\n"), ...texts];
1033
+ for (const candidate of candidates) {
1034
+ try {
1035
+ return JSON.parse(candidate);
1036
+ } catch {
1037
+ }
1038
+ }
1039
+ return null;
1040
+ }
1041
+ function nextPageToken(payload) {
1042
+ if (!isXObject(payload)) return void 0;
1043
+ if (isXObject(payload.meta)) {
1044
+ for (const key of ["next_token", "next_cursor", "nextToken"]) {
1045
+ const value = payload.meta[key];
1046
+ if (typeof value === "string" && value.length > 0) return value;
1047
+ }
1048
+ }
1049
+ for (const key of ["next_token", "next_cursor", "nextToken"]) {
1050
+ const value = payload[key];
1051
+ if (typeof value === "string" && value.length > 0) return value;
1052
+ }
1053
+ return void 0;
1054
+ }
1055
+ function createCorpusSource(config) {
1056
+ return {
1057
+ id: config.id,
1058
+ kind: "corpusDir",
1059
+ async fetch() {
1060
+ const root = expandTildePath2(config.path);
1061
+ let entries;
1062
+ try {
1063
+ const rootStat = await stat2(root);
1064
+ if (!rootStat.isDirectory()) {
1065
+ return { records: [], reads: 0, pages: 0, skipped: { reason: "corpus-dir-missing" } };
1066
+ }
1067
+ entries = (await readdir(root)).sort();
1068
+ } catch {
1069
+ return { records: [], reads: 0, pages: 0, skipped: { reason: "corpus-dir-missing" } };
1070
+ }
1071
+ const records = [];
1072
+ let parseFailures = 0;
1073
+ let skippedFiles = 0;
1074
+ const rootReal = await realpath(root);
1075
+ for (const name of entries) {
1076
+ if (!name.endsWith(".json")) continue;
1077
+ const filePath = path3.join(root, name);
1078
+ try {
1079
+ const info = await lstat(filePath);
1080
+ if (info.isSymbolicLink()) {
1081
+ const target = await realpath(filePath);
1082
+ if (!(target === rootReal || target.startsWith(`${rootReal}${path3.sep}`))) {
1083
+ skippedFiles += 1;
1084
+ continue;
1085
+ }
1086
+ } else if (!info.isFile()) {
1087
+ continue;
1088
+ }
1089
+ const parsed = JSON.parse(await readFile2(filePath, "utf8"));
1090
+ for (const entry of asEntryList(parsed)) {
1091
+ const record = normalizeCorpusEntry(entry, "bookmark");
1092
+ if (record !== null) records.push(record);
1093
+ }
1094
+ } catch {
1095
+ parseFailures += 1;
1096
+ }
1097
+ }
1098
+ const degraded = records.length === 0 && (parseFailures > 0 || skippedFiles > 0) ? {
1099
+ skipped: {
1100
+ reason: "corpus-empty",
1101
+ detail: `${parseFailures} unparseable, ${skippedFiles} out-of-root files skipped`
1102
+ }
1103
+ } : {};
1104
+ return { records, reads: 0, pages: 1, ...degraded };
1105
+ }
1106
+ };
1107
+ }
1108
+ function asEntryList(parsed) {
1109
+ if (Array.isArray(parsed)) return parsed;
1110
+ if (isXObject(parsed) && Array.isArray(parsed.data)) return parsed.data;
1111
+ return [parsed];
1112
+ }
1113
+ function createCliSource(config, deps) {
1114
+ const exec = deps.execImpl ?? defaultExec;
1115
+ return {
1116
+ id: config.id,
1117
+ kind: "cli",
1118
+ async fetch() {
1119
+ const records = [];
1120
+ const commands = [
1121
+ { args: config.bookmarksArgs, kind: "bookmark" },
1122
+ ...config.postsArgs !== void 0 ? [{ args: config.postsArgs, kind: "own_post" }] : []
1123
+ ];
1124
+ let skipped;
1125
+ for (const command of commands) {
1126
+ let stdout;
1127
+ try {
1128
+ ({ stdout } = await exec(config.bin, command.args));
1129
+ } catch (err) {
1130
+ const code = err.code;
1131
+ skipped = {
1132
+ reason: code === "ENOENT" ? "cli-not-installed" : "cli-failed",
1133
+ detail: code === "ENOENT" ? config.bin : exitDetail(err)
1134
+ };
1135
+ continue;
1136
+ }
1137
+ const parsed = parseStdout(stdout);
1138
+ if (parsed === null) {
1139
+ skipped = {
1140
+ reason: "cli-output-unparseable",
1141
+ detail: `${config.bin} ${command.args.join(" ")}`
1142
+ };
1143
+ continue;
1144
+ }
1145
+ for (const entry of asEntryList(parsed)) {
1146
+ const record = normalizeCorpusEntry(entry, command.kind);
1147
+ if (record !== null) records.push(record);
1148
+ }
1149
+ }
1150
+ return { records, reads: 0, pages: commands.length, ...skipped !== void 0 ? { skipped } : {} };
1151
+ }
1152
+ };
1153
+ }
1154
+ function exitDetail(err) {
1155
+ if (isXObject(err) && typeof err.code === "number") return `exit ${err.code}`;
1156
+ return "non-zero exit";
1157
+ }
1158
+ function parseStdout(stdout) {
1159
+ try {
1160
+ return JSON.parse(stdout);
1161
+ } catch {
1162
+ return null;
1163
+ }
1164
+ }
1165
+ async function defaultExec(bin, args) {
1166
+ const { stdout, stderr } = await execFileAsync(bin, args, {
1167
+ timeout: 6e4,
1168
+ maxBuffer: 10 * 1024 * 1024
1169
+ });
1170
+ return { stdout, stderr };
1171
+ }
1172
+
1173
+ // src/sync.ts
1174
+ import { randomUUID } from "crypto";
1175
+ import { mkdir as mkdir2, readFile as readFile3, rename as rename3, stat as stat3, writeFile as writeFile3 } from "fs/promises";
1176
+ import path4 from "path";
1177
+ import { expandTildePath as expandTildePath3 } from "@remnic/core";
1178
+ var SEEN_CAP = 2e4;
1179
+ function freshState() {
1180
+ return { version: 1, seen: {}, lastSyncAt: {}, lastNewCount: {}, costLedger: {} };
1181
+ }
1182
+ function monthKeyOf(nowMs) {
1183
+ return new Date(nowMs).toISOString().slice(0, 7);
1184
+ }
1185
+ function resolveStateDir(config) {
1186
+ return expandTildePath3(config.stateDir);
1187
+ }
1188
+ function stringRecord(raw) {
1189
+ const out = {};
1190
+ for (const [key, value] of Object.entries(raw)) {
1191
+ if (typeof value === "string") out[key] = value;
1192
+ }
1193
+ return out;
1194
+ }
1195
+ async function loadState(stateDir) {
1196
+ const statePath = path4.join(stateDir, "state.json");
1197
+ let raw;
1198
+ try {
1199
+ raw = await readFile3(statePath, "utf8");
1200
+ } catch {
1201
+ return { state: freshState() };
1202
+ }
1203
+ try {
1204
+ const parsed = JSON.parse(raw);
1205
+ if (!isXObject(parsed) || !isXObject(parsed.seen)) throw new Error("bad shape");
1206
+ const costLedger = {};
1207
+ if (isXObject(parsed.costLedger)) {
1208
+ for (const [key, value] of Object.entries(parsed.costLedger)) {
1209
+ if (typeof value === "number" && Number.isFinite(value)) costLedger[key] = value;
1210
+ }
1211
+ }
1212
+ const seen = {};
1213
+ for (const [postId, entry] of Object.entries(parsed.seen)) {
1214
+ if (isXObject(entry) && typeof entry.fingerprint === "string") {
1215
+ seen[postId] = {
1216
+ fingerprint: entry.fingerprint,
1217
+ firstSeenAt: typeof entry.firstSeenAt === "string" ? entry.firstSeenAt : "",
1218
+ lastSeenAt: typeof entry.lastSeenAt === "string" ? entry.lastSeenAt : "",
1219
+ kind: typeof entry.kind === "string" ? entry.kind : ""
1220
+ };
1221
+ }
1222
+ }
1223
+ const lastNewCountRaw = isXObject(parsed.lastNewCount) ? parsed.lastNewCount : {};
1224
+ const lastNewCount = {};
1225
+ for (const [key, value] of Object.entries(lastNewCountRaw)) {
1226
+ if (typeof value === "number" && Number.isFinite(value)) lastNewCount[key] = value;
1227
+ }
1228
+ return {
1229
+ state: {
1230
+ version: 1,
1231
+ seen,
1232
+ lastSyncAt: stringRecord(isXObject(parsed.lastSyncAt) ? parsed.lastSyncAt : {}),
1233
+ lastNewCount,
1234
+ costLedger
1235
+ }
1236
+ };
1237
+ } catch {
1238
+ const quarantine = `${statePath}.corrupt`;
1239
+ try {
1240
+ await rename3(statePath, quarantine);
1241
+ } catch {
1242
+ }
1243
+ return {
1244
+ state: freshState(),
1245
+ warning: `state.json was unreadable and has been quarantined at ${quarantine}`
1246
+ };
1247
+ }
1248
+ }
1249
+ async function saveState(stateDir, state) {
1250
+ const entries = Object.entries(state.seen);
1251
+ if (entries.length > SEEN_CAP) {
1252
+ entries.sort((a, b) => compareIso(a[1].firstSeenAt, b[1].firstSeenAt, a[0], b[0]));
1253
+ for (const [postId] of entries.slice(0, entries.length - SEEN_CAP)) {
1254
+ delete state.seen[postId];
1255
+ }
1256
+ }
1257
+ const statePath = path4.join(stateDir, "state.json");
1258
+ const tmpPath = `${statePath}.${process.pid}.tmp`;
1259
+ await writeFile3(tmpPath, `${JSON.stringify(state, null, 2)}
1260
+ `, { mode: 384 });
1261
+ await rename3(tmpPath, statePath);
1262
+ }
1263
+ function compareIso(a, b, idA, idB) {
1264
+ if (a < b) return -1;
1265
+ if (a > b) return 1;
1266
+ return idA < idB ? -1 : idA > idB ? 1 : 0;
1267
+ }
1268
+ function orderedSources(config) {
1269
+ const byId = new Map(config.sources.map((source) => [source.id, source]));
1270
+ const ordered = [];
1271
+ for (const id of config.sourcePriority) {
1272
+ const source = byId.get(id);
1273
+ if (source !== void 0) ordered.push(source);
1274
+ }
1275
+ for (const source of config.sources) {
1276
+ if (!config.sourcePriority.includes(source.id)) ordered.push(source);
1277
+ }
1278
+ return ordered;
1279
+ }
1280
+ async function writeRecordFile(stateDir, record) {
1281
+ const recordsDir = path4.join(stateDir, "records");
1282
+ await mkdir2(recordsDir, { recursive: true });
1283
+ const safeName = record.postId.replace(/[^0-9A-Za-z._-]/g, "_");
1284
+ const recordPath = path4.join(recordsDir, `${safeName}.json`);
1285
+ const tmpPath = `${recordPath}.tmp`;
1286
+ await writeFile3(tmpPath, `${JSON.stringify(record, null, 2)}
1287
+ `, { mode: 384 });
1288
+ await rename3(tmpPath, recordPath);
1289
+ }
1290
+ async function runXSync(config, deps) {
1291
+ const stateDir = resolveStateDir(config);
1292
+ await mkdir2(path4.join(stateDir, "records"), { recursive: true });
1293
+ const { state, warning } = await loadState(stateDir);
1294
+ if (warning !== void 0) {
1295
+ process.stderr.write(`[remnic-x] warning: ${warning}
1296
+ `);
1297
+ }
1298
+ const now = deps.now ?? (() => Date.now());
1299
+ const startedMs = now();
1300
+ const runId = randomUUID();
1301
+ const monthKey = monthKeyOf(startedMs);
1302
+ const knownIds = new Set(Object.keys(state.seen));
1303
+ const summaries = [];
1304
+ let suggestionsSubmitted = 0;
1305
+ let memoriesStored = 0;
1306
+ let sinkFailures = 0;
1307
+ for (const sourceConfig of orderedSources(config)) {
1308
+ const summary = {
1309
+ sourceId: sourceConfig.id,
1310
+ kind: sourceConfig.kind,
1311
+ recordsNew: 0,
1312
+ recordsKnown: 0,
1313
+ reads: 0,
1314
+ pages: 0
1315
+ };
1316
+ const source = createXSource(sourceConfig, { ...deps, userId: config.userId });
1317
+ const budget = sourceConfig.kind === "mcp" ? new XBudgetTracker(sourceConfig.budget, state.costLedger[monthKey] ?? 0) : unlimitedBudget;
1318
+ let outcome;
1319
+ try {
1320
+ outcome = await source.fetch({ knownIds, budget });
1321
+ } catch (err) {
1322
+ summary.error = err instanceof Error ? err.message : String(err);
1323
+ summaries.push(summary);
1324
+ continue;
1325
+ }
1326
+ summary.reads = outcome.reads;
1327
+ summary.pages = outcome.pages;
1328
+ summary.skipped = outcome.skipped;
1329
+ for (const raw of outcome.records) {
1330
+ const record = {
1331
+ ...raw,
1332
+ provenance: {
1333
+ sourceId: sourceConfig.id,
1334
+ sourceKind: sourceConfig.kind,
1335
+ syncRunId: runId,
1336
+ fetchedAt: new Date(startedMs).toISOString()
1337
+ }
1338
+ };
1339
+ const fingerprint = recordFingerprint(record);
1340
+ const seenEntry = state.seen[record.postId];
1341
+ if (seenEntry !== void 0 && seenEntry.fingerprint === fingerprint) {
1342
+ seenEntry.lastSeenAt = new Date(startedMs).toISOString();
1343
+ summary.recordsKnown += 1;
1344
+ continue;
1345
+ }
1346
+ knownIds.add(record.postId);
1347
+ state.seen[record.postId] = {
1348
+ fingerprint,
1349
+ firstSeenAt: seenEntry !== void 0 && seenEntry.firstSeenAt.length > 0 ? seenEntry.firstSeenAt : new Date(startedMs).toISOString(),
1350
+ lastSeenAt: new Date(startedMs).toISOString(),
1351
+ kind: record.kind
1352
+ };
1353
+ summary.recordsNew += 1;
1354
+ try {
1355
+ await writeRecordFile(stateDir, record);
1356
+ } catch (err) {
1357
+ sinkFailures += 1;
1358
+ summary.error = `record write failed: ${err instanceof Error ? err.name : "write error"}`;
1359
+ continue;
1360
+ }
1361
+ try {
1362
+ const suggestion = suggestionForRecord(record);
1363
+ if (config.memoryMode === "store") {
1364
+ await deps.sink.storeMemory(suggestion);
1365
+ memoriesStored += 1;
1366
+ } else {
1367
+ await deps.sink.submitSuggestion(suggestion);
1368
+ suggestionsSubmitted += 1;
1369
+ }
1370
+ } catch {
1371
+ sinkFailures += 1;
1372
+ }
1373
+ }
1374
+ if (sourceConfig.kind === "mcp" && outcome.reads > 0) {
1375
+ state.costLedger[monthKey] = (state.costLedger[monthKey] ?? 0) + outcome.reads * sourceConfig.budget.costPerReadUsd;
1376
+ }
1377
+ state.lastSyncAt[sourceConfig.id] = new Date(startedMs).toISOString();
1378
+ state.lastNewCount[sourceConfig.id] = summary.recordsNew;
1379
+ summaries.push(summary);
1380
+ }
1381
+ const finishedMs = now();
1382
+ await saveState(stateDir, state);
1383
+ return {
1384
+ runId,
1385
+ startedAt: new Date(startedMs).toISOString(),
1386
+ finishedAt: new Date(finishedMs).toISOString(),
1387
+ memoryMode: config.memoryMode,
1388
+ sources: summaries,
1389
+ suggestionsSubmitted,
1390
+ memoriesStored,
1391
+ sinkFailures,
1392
+ monthKey,
1393
+ monthSpendUsd: state.costLedger[monthKey] ?? 0
1394
+ };
1395
+ }
1396
+ async function getXStatus(config, deps = {}) {
1397
+ const stateDir = resolveStateDir(config);
1398
+ const { state } = await loadState(stateDir);
1399
+ const monthKey = monthKeyOf(Date.now());
1400
+ const sources = [];
1401
+ for (let index = 0; index < config.sourcePriority.length; index++) {
1402
+ const id = config.sourcePriority[index];
1403
+ const sourceConfig = config.sources.find((entry) => entry.id === id);
1404
+ if (sourceConfig === void 0) continue;
1405
+ sources.push({
1406
+ sourceId: id,
1407
+ kind: sourceConfig.kind,
1408
+ priority: index,
1409
+ lastSyncAt: state.lastSyncAt[id] ?? null,
1410
+ lastRecordsNew: state.lastNewCount[id] ?? 0,
1411
+ ...await probeAvailability(sourceConfig, deps)
1412
+ });
1413
+ }
1414
+ const lastSyncValues = Object.values(state.lastSyncAt).sort();
1415
+ return {
1416
+ enabled: config.enabled,
1417
+ memoryMode: config.memoryMode,
1418
+ syncSchedule: config.syncSchedule,
1419
+ sources,
1420
+ seenCount: Object.keys(state.seen).length,
1421
+ monthKey,
1422
+ monthSpendUsd: state.costLedger[monthKey] ?? 0,
1423
+ monthlyCostCapUsd: monthlyCostCapUsd(config),
1424
+ lastSyncAt: lastSyncValues.length > 0 ? lastSyncValues[lastSyncValues.length - 1] : null
1425
+ };
1426
+ }
1427
+ async function probeAvailability(sourceConfig, deps) {
1428
+ if (sourceConfig.kind === "corpusDir") {
1429
+ const dir = expandTildePath3(sourceConfig.path);
1430
+ try {
1431
+ const info = await stat3(dir);
1432
+ return info.isDirectory() ? { available: true } : { available: false, availabilityDetail: `${sourceConfig.path} is not a directory` };
1433
+ } catch {
1434
+ return { available: false, availabilityDetail: `${sourceConfig.path} not found` };
1435
+ }
1436
+ }
1437
+ if (sourceConfig.kind === "cli") {
1438
+ return { available: true, availabilityDetail: `assumed present (${sourceConfig.bin})` };
1439
+ }
1440
+ const credentials = resolveMcpClientCredentials(sourceConfig, deps.env ?? process.env);
1441
+ return credentials.clientId !== void 0 && credentials.clientSecret !== void 0 ? { available: true } : { available: false, availabilityDetail: "OAuth2 client credentials missing" };
1442
+ }
1443
+
1444
+ export {
1445
+ X_SOURCE_KINDS,
1446
+ X_MEMORY_MODES,
1447
+ X_SYNC_SCHEDULES,
1448
+ X_DEFAULT_MCP_URL,
1449
+ X_DEFAULT_TOKEN_FILE,
1450
+ X_DEFAULT_STATE_DIR,
1451
+ X_DEFAULT_COST_PER_READ_USD,
1452
+ XConfigError,
1453
+ coerceXBool,
1454
+ parseXConnectorConfig,
1455
+ resolveMcpClientCredentials,
1456
+ monthlyCostCapUsd,
1457
+ createFileSink,
1458
+ isXObject,
1459
+ stableStringify,
1460
+ recordFingerprint,
1461
+ normalizeMcpPayload,
1462
+ normalizeCorpusEntry,
1463
+ suggestionForRecord,
1464
+ X_MCP_PROTOCOL_VERSION,
1465
+ X_MCP_DEFAULT_URL,
1466
+ XMcpError,
1467
+ XCreditsDepletedError,
1468
+ parseSseData,
1469
+ looksLikeCreditsDepleted,
1470
+ toolResultTexts,
1471
+ XMcpClient,
1472
+ X_TOKEN_REFRESH_URL,
1473
+ XTokenError,
1474
+ XRefreshChainBrokenError,
1475
+ XTokenStore,
1476
+ XBudgetTracker,
1477
+ unlimitedBudget,
1478
+ createXSource,
1479
+ runXSync,
1480
+ getXStatus
1481
+ };
1482
+ //# sourceMappingURL=chunk-JR2ZNAYD.js.map