pi-twitterapi.io 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +51 -0
- package/LICENSE +22 -0
- package/README.md +278 -0
- package/docs/x-search-comparison.md +119 -0
- package/package.json +56 -0
- package/src/backend/media.ts +101 -0
- package/src/backend/model.ts +111 -0
- package/src/backend/runs.ts +687 -0
- package/src/backend/synthesis.ts +344 -0
- package/src/backend.ts +11 -0
- package/src/config.ts +78 -0
- package/src/format.ts +28 -0
- package/src/index.ts +131 -0
- package/src/settings.ts +66 -0
- package/src/synthesize.ts +731 -0
- package/src/tool.ts +491 -0
- package/src/twitterapi/core.ts +124 -0
- package/src/twitterapi/endpoints.ts +957 -0
- package/src/twitterapi/http.ts +340 -0
- package/src/twitterapi/params.ts +81 -0
- package/src/twitterapi/search.ts +233 -0
- package/src/twitterapi/tweet.ts +112 -0
- package/src/twitterapi/window.ts +184 -0
- package/src/twitterapi.ts +16 -0
- package/src/types.ts +16 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
import type { TwitterSearchDetails } from "../types.js";
|
|
2
|
+
import type { SynthesisRequest } from "../synthesize.js";
|
|
3
|
+
import {
|
|
4
|
+
assistantText,
|
|
5
|
+
availableModels,
|
|
6
|
+
resolveModel,
|
|
7
|
+
type ModelLike,
|
|
8
|
+
type RegistryLike,
|
|
9
|
+
type TwitterApiSynthesisOptions,
|
|
10
|
+
} from "./model.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Validate a completion result before its text is treated as an answer.
|
|
14
|
+
*
|
|
15
|
+
* `ModelRegistry.complete` resolves with the assistant message even when the
|
|
16
|
+
* provider reported a failure, so an unchecked result turns an error into an
|
|
17
|
+
* empty-but-successful answer. Anything other than a normal stop must fail here
|
|
18
|
+
* rather than reach the user as a blank answer with real sources attached.
|
|
19
|
+
*/
|
|
20
|
+
export function completionText(message: unknown): string {
|
|
21
|
+
const record = typeof message === "object" && message !== null ? (message as Record<string, unknown>) : {};
|
|
22
|
+
const stopReason = typeof record.stopReason === "string" ? record.stopReason : undefined;
|
|
23
|
+
const detail = typeof record.errorMessage === "string" && record.errorMessage ? `: ${record.errorMessage}` : "";
|
|
24
|
+
if (stopReason === "error") throw new Error(`twitter synthesis failed${detail}`);
|
|
25
|
+
if (stopReason === "aborted") throw new Error("twitter synthesis was cancelled before it produced an answer");
|
|
26
|
+
if (stopReason === "toolUse") throw new Error("twitter synthesis tried to call a tool instead of answering");
|
|
27
|
+
const text = assistantText(message);
|
|
28
|
+
if (!text) {
|
|
29
|
+
throw new Error(
|
|
30
|
+
stopReason === "length"
|
|
31
|
+
? "twitter synthesis hit the model's output limit before producing any text"
|
|
32
|
+
: "twitter synthesis returned an empty answer",
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
return text;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Build the synthesis completion for a resolved model. Shared by every
|
|
41
|
+
* twitterapi.io path so failure handling cannot differ between them.
|
|
42
|
+
*/
|
|
43
|
+
function createCompletion(
|
|
44
|
+
registry: RegistryLike,
|
|
45
|
+
run: NonNullable<RegistryLike["complete"]>,
|
|
46
|
+
model: ModelLike,
|
|
47
|
+
): (request: SynthesisRequest) => Promise<string> {
|
|
48
|
+
return async (request) => {
|
|
49
|
+
const promptText =
|
|
50
|
+
request.mediaManifest && request.images.length > 0
|
|
51
|
+
? `${request.prompt}\n\nAttached images, in order:\n${request.mediaManifest}`
|
|
52
|
+
: request.prompt;
|
|
53
|
+
const message = await run.call(
|
|
54
|
+
registry,
|
|
55
|
+
model as never,
|
|
56
|
+
{
|
|
57
|
+
systemPrompt: request.system,
|
|
58
|
+
messages: [
|
|
59
|
+
{
|
|
60
|
+
role: "user",
|
|
61
|
+
content:
|
|
62
|
+
request.images.length > 0
|
|
63
|
+
? [
|
|
64
|
+
{ type: "text", text: promptText },
|
|
65
|
+
...request.images.map((image) => ({
|
|
66
|
+
type: "image",
|
|
67
|
+
data: image.data,
|
|
68
|
+
mimeType: image.mimeType,
|
|
69
|
+
})),
|
|
70
|
+
]
|
|
71
|
+
: request.prompt,
|
|
72
|
+
timestamp: Date.now(),
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
} as never,
|
|
76
|
+
{ signal: request.signal } as never,
|
|
77
|
+
);
|
|
78
|
+
return completionText(message);
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface SynthesisBackend {
|
|
83
|
+
fetcher: typeof fetch;
|
|
84
|
+
apiKey: string;
|
|
85
|
+
model: ModelLike;
|
|
86
|
+
/** Ready-to-use synthesis completion, with fallback and failure handling applied. */
|
|
87
|
+
complete: (request: SynthesisRequest) => Promise<string>;
|
|
88
|
+
/** Model that served the most recent completion after the primary failed. */
|
|
89
|
+
fallback?: ModelLike;
|
|
90
|
+
/** True when the most recent completion was served by the fallback model. */
|
|
91
|
+
isFallbackUsed: () => boolean;
|
|
92
|
+
/** True when images were dropped because the answering model cannot take them. */
|
|
93
|
+
imagesDropped: () => boolean;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** True for an aborted/cancelled completion, which must not trigger a fallback. */
|
|
97
|
+
export type SynthesisFailureKind =
|
|
98
|
+
| "cancelled"
|
|
99
|
+
| "quota"
|
|
100
|
+
| "auth"
|
|
101
|
+
| "unknown-model"
|
|
102
|
+
| "invalid-request"
|
|
103
|
+
| "rate-limit"
|
|
104
|
+
| "server"
|
|
105
|
+
| "transport"
|
|
106
|
+
| "empty"
|
|
107
|
+
| "unknown";
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Failure classes worth one retry on the last model. Deterministic failures
|
|
111
|
+
* (quota, auth, unknown model, invalid request, and anything unclassified) are
|
|
112
|
+
* never retried: a second attempt cannot succeed or is not worth a second billed
|
|
113
|
+
* call.
|
|
114
|
+
*/
|
|
115
|
+
const RETRYABLE_ON_LAST: ReadonlySet<SynthesisFailureKind> = new Set([
|
|
116
|
+
"rate-limit",
|
|
117
|
+
"server",
|
|
118
|
+
"transport",
|
|
119
|
+
"empty",
|
|
120
|
+
]);
|
|
121
|
+
|
|
122
|
+
const LAST_MODEL_RETRY_BASE_MS = 500;
|
|
123
|
+
const LAST_MODEL_RETRY_CAP_MS = 4_000;
|
|
124
|
+
|
|
125
|
+
/** Bounded exponential backoff: 500 ms, doubling to a 4 s cap. */
|
|
126
|
+
export function synthesisRetryDelayMs(attempt: number): number {
|
|
127
|
+
return Math.min(LAST_MODEL_RETRY_BASE_MS * 2 ** attempt, LAST_MODEL_RETRY_CAP_MS);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Classify a synthesis failure so the chain reacts per kind instead of treating
|
|
132
|
+
* every error alike.
|
|
133
|
+
*
|
|
134
|
+
* Cancellation is authoritative from the signal or error type; the text match is
|
|
135
|
+
* only a fallback for pi's own "was cancelled" wording, so a provider message
|
|
136
|
+
* like "connection aborted" is a transport failure rather than a caller cancel.
|
|
137
|
+
* Explicit status codes are checked before text heuristics, so a 429 carrying
|
|
138
|
+
* quota wording is still a rate limit and an `api key` mention cannot turn a rate
|
|
139
|
+
* limit into an auth failure.
|
|
140
|
+
*/
|
|
141
|
+
export function classifySynthesisError(error: unknown, signal?: AbortSignal): SynthesisFailureKind {
|
|
142
|
+
const name = error instanceof Error ? error.name : "";
|
|
143
|
+
const message = (error instanceof Error ? error.message : String(error)).toLowerCase();
|
|
144
|
+
if (signal?.aborted || name === "AbortError" || /\bwas cancelled\b|generation was cancelled/.test(message)) {
|
|
145
|
+
return "cancelled";
|
|
146
|
+
}
|
|
147
|
+
if (/\b429\b/.test(message)) {
|
|
148
|
+
return /insufficient_quota|billing|payment required/.test(message) ? "quota" : "rate-limit";
|
|
149
|
+
}
|
|
150
|
+
if (/\b402\b/.test(message)) return "quota";
|
|
151
|
+
if (/\b401\b|\b403\b/.test(message)) return "auth";
|
|
152
|
+
if (/\b404\b/.test(message)) return "unknown-model";
|
|
153
|
+
if (/\b400\b|\b422\b/.test(message)) return "invalid-request";
|
|
154
|
+
if (/\b5\d\d\b/.test(message)) return "server";
|
|
155
|
+
if (/insufficient_quota|quota|billing|payment required|subscription|\bcredits?\b/.test(message)) return "quota";
|
|
156
|
+
if (/unauthor|invalid api key|forbidden|authentication|\bapi key\b/.test(message)) return "auth";
|
|
157
|
+
if (/does not exist|unknown model|no such model|not found/.test(message)) return "unknown-model";
|
|
158
|
+
if (/malformed|invalid request|bad request|validation|tried to call a tool|context length|too long|token limit/.test(message)) {
|
|
159
|
+
return "invalid-request";
|
|
160
|
+
}
|
|
161
|
+
if (/too many requests|rate limit|overloaded/.test(message)) return "rate-limit";
|
|
162
|
+
if (/bad gateway|unavailable|internal server error|gateway timeout/.test(message)) return "server";
|
|
163
|
+
if (
|
|
164
|
+
/timeout|timed out|econnreset|econnrefused|enotfound|socket hang up|network|fetch failed|premature|stream ended|\babort(ed)?\b|connection (closed|drop|lost|reset)/.test(
|
|
165
|
+
message,
|
|
166
|
+
)
|
|
167
|
+
) {
|
|
168
|
+
return "transport";
|
|
169
|
+
}
|
|
170
|
+
if (/empty answer|no usable text|output limit|empty response/.test(message)) return "empty";
|
|
171
|
+
// Unclassified failures are deterministic here: an unrecognised error is not
|
|
172
|
+
// worth a second billed call.
|
|
173
|
+
return "unknown";
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Resolve after `ms`, or reject when the caller aborts. */
|
|
177
|
+
function synthesisDelay(ms: number, signal?: AbortSignal): Promise<void> {
|
|
178
|
+
if (signal?.aborted) return Promise.reject(new Error("twitter synthesis was cancelled"));
|
|
179
|
+
return new Promise((resolve, reject) => {
|
|
180
|
+
const timer = setTimeout(() => {
|
|
181
|
+
cleanup();
|
|
182
|
+
resolve();
|
|
183
|
+
}, ms);
|
|
184
|
+
const cleanup = () => {
|
|
185
|
+
clearTimeout(timer);
|
|
186
|
+
signal?.removeEventListener("abort", onAbort);
|
|
187
|
+
};
|
|
188
|
+
const onAbort = () => {
|
|
189
|
+
cleanup();
|
|
190
|
+
reject(new Error("twitter synthesis was cancelled"));
|
|
191
|
+
};
|
|
192
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Disclose the fallback model and any dropped images on the result. */
|
|
197
|
+
export function applyFallbackNote(backend: SynthesisBackend, details: TwitterSearchDetails): void {
|
|
198
|
+
const fallback = backend.fallback;
|
|
199
|
+
if (backend.isFallbackUsed() && fallback) {
|
|
200
|
+
details.model = `${fallback.provider}/${fallback.id}`;
|
|
201
|
+
details.notes = [
|
|
202
|
+
...(details.notes ?? []),
|
|
203
|
+
`The configured synthesis model failed; the answer was produced by ${fallback.provider}/${fallback.id}.`,
|
|
204
|
+
];
|
|
205
|
+
}
|
|
206
|
+
if (backend.imagesDropped()) {
|
|
207
|
+
details.notes = [
|
|
208
|
+
...(details.notes ?? []),
|
|
209
|
+
"The model that answered does not accept image input, so attached images were omitted.",
|
|
210
|
+
];
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Shared preflight: credentials, the synthesis model, and its completion. Runs
|
|
216
|
+
* before any network work so a misconfiguration fails immediately and says why.
|
|
217
|
+
*/
|
|
218
|
+
export function resolveSynthesisBackend(options: TwitterApiSynthesisOptions): SynthesisBackend {
|
|
219
|
+
const fetcher = options.fetcher ?? fetch;
|
|
220
|
+
const apiKey = options.env?.TWITTERAPI_IO_API_KEY;
|
|
221
|
+
if (!apiKey) {
|
|
222
|
+
throw new Error("TWITTERAPI_IO_API_KEY must be configured to use the twitterapi.io backend.");
|
|
223
|
+
}
|
|
224
|
+
const registry = options.registry;
|
|
225
|
+
if (!registry) {
|
|
226
|
+
throw new Error("twitter could not reach pi's model registry, which the twitterapi.io backend needs for synthesis.");
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// `complete` is not present on every supported pi version, so it is detected
|
|
230
|
+
// here instead of being assumed (verified absent on pi 0.80.6, present on 0.99.2).
|
|
231
|
+
const run = registry.complete;
|
|
232
|
+
if (typeof run !== "function") {
|
|
233
|
+
throw new Error(
|
|
234
|
+
"the twitterapi.io backend needs pi's ModelRegistry.complete, which this pi version does not provide " +
|
|
235
|
+
"(verified absent on pi 0.80.6, present on 0.99.2). Upgrade pi to a version that provides it.",
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const synthesisModelId = options.config.synthesisModel;
|
|
240
|
+
if (!synthesisModelId) {
|
|
241
|
+
throw new Error(
|
|
242
|
+
"twitter needs a synthesis model: set twitter.synthesisModel to a model id from pi's catalogue, " +
|
|
243
|
+
"or call it from a session whose active model is resolvable.",
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
const model = resolveModel(registry, synthesisModelId);
|
|
247
|
+
if (!model) {
|
|
248
|
+
const slash = synthesisModelId.indexOf("/");
|
|
249
|
+
const provider = slash > 0 ? synthesisModelId.slice(0, slash) : undefined;
|
|
250
|
+
const available = availableModels(registry.getAll(), provider);
|
|
251
|
+
throw new Error(
|
|
252
|
+
`twitter synthesis model "${synthesisModelId}" was not found in pi's model catalogue. ` +
|
|
253
|
+
(available ? `Known models${provider ? ` for "${provider}"` : ""}: ${available}. ` : "") +
|
|
254
|
+
"Set twitter.synthesisModel to a model id from pi's catalogue.",
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
// Build the model chain: the configured model first, then any caller-supplied
|
|
258
|
+
// fallbacks (typically the session model), deduplicated.
|
|
259
|
+
const chain: ModelLike[] = [model];
|
|
260
|
+
for (const id of options.fallbackModelIds ?? []) {
|
|
261
|
+
if (!id) continue;
|
|
262
|
+
const resolved = resolveModel(registry, id);
|
|
263
|
+
if (!resolved) continue;
|
|
264
|
+
if (chain.some((entry) => entry.provider === resolved.provider && entry.id === resolved.id)) continue;
|
|
265
|
+
chain.push(resolved);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const completions = new Map<ModelLike, (request: SynthesisRequest) => Promise<string>>();
|
|
269
|
+
let imagesDropped = false;
|
|
270
|
+
const completionFor = (target: ModelLike): ((request: SynthesisRequest) => Promise<string>) => {
|
|
271
|
+
let completion = completions.get(target);
|
|
272
|
+
if (!completion) {
|
|
273
|
+
const completeModel = createCompletion(registry, run, target);
|
|
274
|
+
const supportsImage = (target.input ?? []).includes("image");
|
|
275
|
+
completion = supportsImage
|
|
276
|
+
? completeModel
|
|
277
|
+
: async (request) => {
|
|
278
|
+
// A fallback that cannot take images must not receive them: a vision
|
|
279
|
+
// primary can fail and hand images to a text-only session model.
|
|
280
|
+
if (request.images.length > 0 || request.mediaManifest) {
|
|
281
|
+
imagesDropped = true;
|
|
282
|
+
return completeModel({ ...request, images: [], mediaManifest: undefined });
|
|
283
|
+
}
|
|
284
|
+
return completeModel(request);
|
|
285
|
+
};
|
|
286
|
+
completions.set(target, completion);
|
|
287
|
+
}
|
|
288
|
+
return completion;
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
let fallback: ModelLike | undefined;
|
|
292
|
+
const sleepForRetry = options.synthesisSleep ?? synthesisDelay;
|
|
293
|
+
|
|
294
|
+
const complete = async (request: SynthesisRequest): Promise<string> => {
|
|
295
|
+
// Reset per call so a run that completes more than once never mislabels the
|
|
296
|
+
// answering model from an earlier call.
|
|
297
|
+
fallback = undefined;
|
|
298
|
+
imagesDropped = false;
|
|
299
|
+
const failures: string[] = [];
|
|
300
|
+
for (let index = 0; index < chain.length; index += 1) {
|
|
301
|
+
const candidate = chain[index];
|
|
302
|
+
// An untried model is the better bet than retrying a model that already
|
|
303
|
+
// failed, so the retry budget is only spent on the last model in the chain.
|
|
304
|
+
const isLast = index === chain.length - 1;
|
|
305
|
+
const maxAttempts = isLast ? 2 : 1;
|
|
306
|
+
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
307
|
+
if (attempt > 0) {
|
|
308
|
+
// attempt 1 only happens on the last model after a retryable class.
|
|
309
|
+
await sleepForRetry(synthesisRetryDelayMs(attempt - 1), request.signal);
|
|
310
|
+
}
|
|
311
|
+
try {
|
|
312
|
+
const text = await completionFor(candidate)(request);
|
|
313
|
+
if (index > 0) fallback = candidate;
|
|
314
|
+
return text;
|
|
315
|
+
} catch (error) {
|
|
316
|
+
const kind = classifySynthesisError(error, request.signal);
|
|
317
|
+
// A cancellation stops the chain: it is the caller's intent, not a
|
|
318
|
+
// model failure to route around.
|
|
319
|
+
if (kind === "cancelled") throw error;
|
|
320
|
+
failures.push(`${candidate.provider}/${candidate.id} (${kind}): ${error instanceof Error ? error.message : String(error)}`);
|
|
321
|
+
if (!isLast || !RETRYABLE_ON_LAST.has(kind)) break;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
// Every model's cause is reported, not just the last one.
|
|
326
|
+
throw new Error(`twitter synthesis failed: ${failures.join("; ")}`);
|
|
327
|
+
};
|
|
328
|
+
|
|
329
|
+
// `fallback` is resolved lazily: the completion runs after this object is
|
|
330
|
+
// built, so a plain property would freeze the pre-run value (undefined).
|
|
331
|
+
const backend: SynthesisBackend = {
|
|
332
|
+
fetcher,
|
|
333
|
+
apiKey,
|
|
334
|
+
model,
|
|
335
|
+
complete,
|
|
336
|
+
get fallback(): ModelLike | undefined {
|
|
337
|
+
return fallback;
|
|
338
|
+
},
|
|
339
|
+
isFallbackUsed: () => fallback !== undefined,
|
|
340
|
+
imagesDropped: () => imagesDropped,
|
|
341
|
+
};
|
|
342
|
+
return backend;
|
|
343
|
+
}
|
|
344
|
+
|
package/src/backend.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Backend barrel: internal wiring for the split modules (model, media,
|
|
3
|
+
* synthesis chain, runs). The *published* surface is `src/index.ts`, which
|
|
4
|
+
* re-exports an explicit subset; `export *` here also exposes sibling-module
|
|
5
|
+
* helpers (e.g. `applyFallbackNote`, `resolveSynthesisBackend`) to the rest of
|
|
6
|
+
* the source tree by design.
|
|
7
|
+
*/
|
|
8
|
+
export * from "./backend/model.js";
|
|
9
|
+
export * from "./backend/media.js";
|
|
10
|
+
export * from "./backend/synthesis.js";
|
|
11
|
+
export * from "./backend/runs.js";
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { PiSettings } from "./settings.js";
|
|
2
|
+
|
|
3
|
+
import { DEFAULT_MAX_PAGES_CEILING } from "./twitterapi.js";
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_MAX_MEDIA_PER_SEARCH = 4;
|
|
6
|
+
export const DEFAULT_MAX_PAGES = 5;
|
|
7
|
+
/**
|
|
8
|
+
* Unpaid twitterapi.io accounts allow 0.2 QPS (one request every 5 seconds), so
|
|
9
|
+
* this is the safest default both for pacing and for retry backoff. Paid tiers
|
|
10
|
+
* can raise it.
|
|
11
|
+
*/
|
|
12
|
+
export const DEFAULT_MIN_REQUEST_INTERVAL_MS = 5_000;
|
|
13
|
+
export const DEFAULT_RETRY_BASE_DELAY_MS = 5_000;
|
|
14
|
+
const MAX_INTERVAL_MS = 600_000;
|
|
15
|
+
|
|
16
|
+
export interface TwitterConfig {
|
|
17
|
+
/**
|
|
18
|
+
* pi model id ("provider/model") that synthesizes the answer from posts
|
|
19
|
+
* retrieved via twitterapi.io. Resolved through pi's model catalogue. There is
|
|
20
|
+
* no default: an unset value is a configuration error, reported before any
|
|
21
|
+
* network work.
|
|
22
|
+
*/
|
|
23
|
+
synthesisModel?: string;
|
|
24
|
+
/** Attach image media to the synthesis request. */
|
|
25
|
+
enableImageUnderstanding: boolean;
|
|
26
|
+
/** Attach video poster frames to the synthesis request. */
|
|
27
|
+
enableVideoUnderstanding: boolean;
|
|
28
|
+
/** Upper bound on media attachments per search. */
|
|
29
|
+
maxMediaPerSearch: number;
|
|
30
|
+
/** Base page budget per search. */
|
|
31
|
+
maxPages: number;
|
|
32
|
+
/**
|
|
33
|
+
* Hard ceiling on pages fetched in one search. `maxPages` is clamped to it, so
|
|
34
|
+
* an explicit ceiling can never be exceeded.
|
|
35
|
+
*/
|
|
36
|
+
maxPagesCeiling: number;
|
|
37
|
+
/** Minimum spacing between upstream requests. */
|
|
38
|
+
minRequestIntervalMs: number;
|
|
39
|
+
/** Base delay for retry backoff. */
|
|
40
|
+
retryBaseDelayMs: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
44
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Non-negative finite milliseconds, clamped, or the fallback when absent/invalid. */
|
|
48
|
+
function intervalMs(value: unknown, fallback: number): number {
|
|
49
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.min(value, MAX_INTERVAL_MS) : fallback;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Positive integer page count, clamped, or the fallback when absent/invalid. */
|
|
53
|
+
function pageCount(value: unknown, fallback: number): number {
|
|
54
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 1 ? Math.min(value, 100) : fallback;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function loadTwitterConfig(settings: PiSettings): TwitterConfig {
|
|
58
|
+
const config = isObject(settings.twitter) ? settings.twitter : {};
|
|
59
|
+
const maxMedia = config.maxMediaPerSearch;
|
|
60
|
+
const ceiling = pageCount(config.maxPagesCeiling, DEFAULT_MAX_PAGES_CEILING);
|
|
61
|
+
const synthesisModel =
|
|
62
|
+
typeof config.synthesisModel === "string" && config.synthesisModel.trim()
|
|
63
|
+
? config.synthesisModel.trim()
|
|
64
|
+
: undefined;
|
|
65
|
+
return {
|
|
66
|
+
synthesisModel,
|
|
67
|
+
enableImageUnderstanding: config.enableImageUnderstanding === true,
|
|
68
|
+
enableVideoUnderstanding: config.enableVideoUnderstanding === true,
|
|
69
|
+
maxMediaPerSearch:
|
|
70
|
+
typeof maxMedia === "number" && Number.isInteger(maxMedia) && maxMedia >= 0
|
|
71
|
+
? Math.min(maxMedia, 20)
|
|
72
|
+
: DEFAULT_MAX_MEDIA_PER_SEARCH,
|
|
73
|
+
minRequestIntervalMs: intervalMs(config.minRequestIntervalMs, DEFAULT_MIN_REQUEST_INTERVAL_MS),
|
|
74
|
+
retryBaseDelayMs: intervalMs(config.retryBaseDelayMs, DEFAULT_RETRY_BASE_DELAY_MS),
|
|
75
|
+
maxPages: Math.min(pageCount(config.maxPages, DEFAULT_MAX_PAGES), ceiling),
|
|
76
|
+
maxPagesCeiling: ceiling,
|
|
77
|
+
};
|
|
78
|
+
}
|
package/src/format.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { TwitterSearchDetails } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export function formatTwitterResults(details: TwitterSearchDetails): string {
|
|
4
|
+
const lines = [
|
|
5
|
+
`Query: ${details.query}`,
|
|
6
|
+
`Model: ${details.model}`,
|
|
7
|
+
`Synthesis Calls: ${details.synthesisCalls ?? 0}`,
|
|
8
|
+
`Citations: ${details.citations.length}`,
|
|
9
|
+
"",
|
|
10
|
+
"## Answer",
|
|
11
|
+
"",
|
|
12
|
+
details.text || "No answer text returned.",
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
if (details.citations.length > 0) {
|
|
16
|
+
lines.push("", "## Sources", "");
|
|
17
|
+
details.citations.forEach((citation, index) => {
|
|
18
|
+
lines.push(`${index + 1}. ${citation}`);
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (details.notes?.length) {
|
|
23
|
+
lines.push("", "## Notes", "");
|
|
24
|
+
details.notes.forEach((note) => lines.push(`- ${note}`));
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return lines.join("\n");
|
|
28
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { registerTwitterTool } from "./tool.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* pi-twitterapi.io — a twitterapi.io-backed X/Twitter search extension for the
|
|
6
|
+
* pi coding agent.
|
|
7
|
+
*
|
|
8
|
+
* Registers the `twitter` tool, which reads X/Twitter through twitterapi.io and
|
|
9
|
+
* synthesizes an answer with citation URLs using a pi model. Requires
|
|
10
|
+
* `TWITTERAPI_IO_API_KEY`; synthesis uses `twitter.synthesisModel`, falling back
|
|
11
|
+
* to the model running the session.
|
|
12
|
+
*/
|
|
13
|
+
export default function (pi: ExtensionAPI) {
|
|
14
|
+
registerTwitterTool(pi);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export { registerTwitterTool, type TwitterToolOptions } from "./tool.js";
|
|
18
|
+
export {
|
|
19
|
+
runTwitterApiAbout,
|
|
20
|
+
runTwitterApiCommunity,
|
|
21
|
+
runTwitterApiFollowers,
|
|
22
|
+
runTwitterApiFollowings,
|
|
23
|
+
runTwitterApiList,
|
|
24
|
+
runTwitterApiMentions,
|
|
25
|
+
runTwitterApiProfile,
|
|
26
|
+
runTwitterApiQuotes,
|
|
27
|
+
runTwitterApiReplies,
|
|
28
|
+
runTwitterApiRetweeters,
|
|
29
|
+
runTwitterApiSearch,
|
|
30
|
+
runTwitterApiSpace,
|
|
31
|
+
runTwitterApiThread,
|
|
32
|
+
runTwitterApiTrends,
|
|
33
|
+
runTwitterApiTweetsByIds,
|
|
34
|
+
runTwitterApiUserSearch,
|
|
35
|
+
runTwitterApiUserTimeline,
|
|
36
|
+
resolveModel,
|
|
37
|
+
assistantText,
|
|
38
|
+
classifySynthesisError,
|
|
39
|
+
completionText,
|
|
40
|
+
createFetchMedia,
|
|
41
|
+
isAllowedMediaUrl,
|
|
42
|
+
synthesisRetryDelayMs,
|
|
43
|
+
toSynthesisModel,
|
|
44
|
+
} from "./backend.js";
|
|
45
|
+
export type {
|
|
46
|
+
BackendOptions,
|
|
47
|
+
ModelLike,
|
|
48
|
+
RegistryLike,
|
|
49
|
+
SynthesisFailureKind,
|
|
50
|
+
TwitterApiAboutOptions,
|
|
51
|
+
TwitterApiCommunityOptions,
|
|
52
|
+
TwitterApiFollowOptions,
|
|
53
|
+
TwitterApiListOptions,
|
|
54
|
+
TwitterApiMentionsOptions,
|
|
55
|
+
TwitterApiProfileOptions,
|
|
56
|
+
TwitterApiQuotesOptions,
|
|
57
|
+
TwitterApiRepliesOptions,
|
|
58
|
+
TwitterApiRetweetersOptions,
|
|
59
|
+
TwitterApiRunOptions,
|
|
60
|
+
TwitterApiSpaceOptions,
|
|
61
|
+
TwitterApiSynthesisOptions,
|
|
62
|
+
TwitterApiThreadOptions,
|
|
63
|
+
TwitterApiTrendsOptions,
|
|
64
|
+
TwitterApiTweetsByIdsOptions,
|
|
65
|
+
TwitterApiUserSearchOptions,
|
|
66
|
+
TwitterApiUserTimelineOptions,
|
|
67
|
+
} from "./backend.js";
|
|
68
|
+
export { DEFAULT_MAX_MEDIA_PER_SEARCH, loadTwitterConfig } from "./config.js";
|
|
69
|
+
export type { TwitterConfig } from "./config.js";
|
|
70
|
+
export {
|
|
71
|
+
buildCandidatePrompt,
|
|
72
|
+
buildTrendCandidatePrompt,
|
|
73
|
+
DOCUMENT_SYNTHESIS_SYSTEM_PROMPT,
|
|
74
|
+
collectMedia,
|
|
75
|
+
deriveCitations,
|
|
76
|
+
extractUrls,
|
|
77
|
+
statusId,
|
|
78
|
+
SYNTHESIS_SYSTEM_PROMPT,
|
|
79
|
+
synthesizeAnswer,
|
|
80
|
+
synthesizeDocument,
|
|
81
|
+
synthesizeTrends,
|
|
82
|
+
synthesizeUserAnswer,
|
|
83
|
+
toBase64,
|
|
84
|
+
TREND_SYNTHESIS_SYSTEM_PROMPT,
|
|
85
|
+
USER_SYNTHESIS_SYSTEM_PROMPT,
|
|
86
|
+
} from "./synthesize.js";
|
|
87
|
+
export type { ImageAttachment, SynthesisModel } from "./synthesize.js";
|
|
88
|
+
export {
|
|
89
|
+
buildExpression,
|
|
90
|
+
fetchCommunityTweets,
|
|
91
|
+
fetchFollowers,
|
|
92
|
+
fetchFollowings,
|
|
93
|
+
fetchListTweets,
|
|
94
|
+
fetchSpaceDetail,
|
|
95
|
+
fetchThread,
|
|
96
|
+
fetchTrends,
|
|
97
|
+
fetchTweetQuotes,
|
|
98
|
+
fetchTweetReplies,
|
|
99
|
+
fetchTweetsByIds,
|
|
100
|
+
fetchTweetRetweeters,
|
|
101
|
+
fetchUserAbout,
|
|
102
|
+
fetchUserMentions,
|
|
103
|
+
fetchUserProfile,
|
|
104
|
+
fetchUserTweets,
|
|
105
|
+
normalizeParams,
|
|
106
|
+
searchTweets,
|
|
107
|
+
searchUsers,
|
|
108
|
+
statusIdFromUrl,
|
|
109
|
+
tweetIdFromInput,
|
|
110
|
+
} from "./twitterapi.js";
|
|
111
|
+
export type {
|
|
112
|
+
FollowersDetails,
|
|
113
|
+
ReplySort,
|
|
114
|
+
RetweetersDetails,
|
|
115
|
+
SearchDetails,
|
|
116
|
+
SpaceDetails,
|
|
117
|
+
Trend,
|
|
118
|
+
TrendsDetails,
|
|
119
|
+
Tweet,
|
|
120
|
+
TweetCollection,
|
|
121
|
+
TweetQuotesDetails,
|
|
122
|
+
TweetRepliesDetails,
|
|
123
|
+
TwitterApiSearchParams,
|
|
124
|
+
UserAbout,
|
|
125
|
+
UserCollection,
|
|
126
|
+
UserMentionsDetails,
|
|
127
|
+
UserTweetsDetails,
|
|
128
|
+
} from "./twitterapi.js";
|
|
129
|
+
export type { TwitterSearchDetails } from "./types.js";
|
|
130
|
+
export { readMergedPiSettings, readPiProjectSettings, readPiUserSettings } from "./settings.js";
|
|
131
|
+
export type { PiSettings } from "./settings.js";
|
package/src/settings.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
|
|
5
|
+
export type PiSettings = Record<string, unknown>;
|
|
6
|
+
|
|
7
|
+
function readJsonFile(filePath: string): PiSettings {
|
|
8
|
+
try {
|
|
9
|
+
return JSON.parse(readFileSync(filePath, "utf8")) as PiSettings;
|
|
10
|
+
} catch (error) {
|
|
11
|
+
const err = error as NodeJS.ErrnoException;
|
|
12
|
+
if (err.code === "ENOENT") {
|
|
13
|
+
return {};
|
|
14
|
+
}
|
|
15
|
+
throw error;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function readPiProjectSettings(cwd = process.cwd()): PiSettings {
|
|
20
|
+
return readJsonFile(join(cwd, ".pi", "settings.json"));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Read the global settings file. The directory comes from pi's `getAgentDir()`,
|
|
25
|
+
* so `PI_CODING_AGENT_DIR` (and any other supported override) is respected
|
|
26
|
+
* instead of assuming `~/.pi/agent`.
|
|
27
|
+
*/
|
|
28
|
+
export function readPiUserSettings(agentDir = getAgentDir()): PiSettings {
|
|
29
|
+
return readJsonFile(join(agentDir, "settings.json"));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ReadMergedPiSettingsOptions {
|
|
33
|
+
cwd?: string;
|
|
34
|
+
/** Override the global agent config directory (defaults to pi's `getAgentDir()`). */
|
|
35
|
+
agentDir?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function mergePiSettings(userSettings: PiSettings = {}, projectSettings: PiSettings = {}): PiSettings {
|
|
39
|
+
return deepMerge(userSettings, projectSettings);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function readMergedPiSettings(options: ReadMergedPiSettingsOptions = {}): PiSettings {
|
|
43
|
+
return mergePiSettings(
|
|
44
|
+
readPiUserSettings(options.agentDir),
|
|
45
|
+
readPiProjectSettings(options.cwd),
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function deepMerge(base: PiSettings, override: PiSettings): PiSettings {
|
|
50
|
+
const result: PiSettings = { ...base };
|
|
51
|
+
for (const [key, value] of Object.entries(override)) {
|
|
52
|
+
if (value === undefined) continue;
|
|
53
|
+
|
|
54
|
+
const existing = result[key];
|
|
55
|
+
if (isPlainObject(existing) && isPlainObject(value)) {
|
|
56
|
+
result[key] = deepMerge(existing, value);
|
|
57
|
+
} else {
|
|
58
|
+
result[key] = value;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isPlainObject(value: unknown): value is PiSettings {
|
|
65
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
66
|
+
}
|