Repository navigation
Expand file tree
/
Copy pathengine.js
More file actions
207 lines (200 loc) · 11.7 KB
/
Copy pathengine.js
File metadata and controls
207 lines (200 loc) · 11.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
import { API_URL, MODEL, MAX_BATCH, MAX_RULES, configured, initialState, snapshot, validateSettings, normalizePost, fingerprint, buildDecisionRequest, parseDecisions, recordDecision, emptyStats, trimHistoryToQuota } from './core.js';
export function createEngine({ storage, fetchImpl = fetch, onChange = () => {}, now = Date.now, uuid = () => crypto.randomUUID() }) {
let state;
let writes = Promise.resolve();
let classification = Promise.resolve();
let backoffUntil = 0;
const ready = storage.get('siftState').then(saved => { state = initialState(saved.siftState); });
function mutate(operation, notify = true) {
const result = writes.then(async () => {
await ready;
const draft = structuredClone(state);
const value = await operation(draft);
trimHistoryToQuota(draft);
await storage.set({ siftState: draft });
state = draft;
if (notify) Promise.resolve(onChange(snapshot(state))).catch(() => {});
return value;
});
writes = result.catch(() => {});
return result;
}
async function getState(trusted = true) {
await ready;
await writes;
return snapshot(state, trusted);
}
function failOpen(posts, source, reason, revision) {
return { ok: true, decisions: posts.map(post => ({ id: post.id, action: 'show', reason, ruleIds: [], source })), revision };
}
async function request(body, apiKey) {
const response = await fetchImpl(API_URL, {
method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiKey}` },
body: JSON.stringify(body), signal: AbortSignal.timeout(20000), credentials: 'omit', redirect: 'error',
});
if (!response.ok) {
// Do not echo server responses that could contain submitted text or secrets.
const messages = {
400: 'OpenAI rejected the Decisions request. Check API access and the extension version.',
401: 'OpenAI rejected the API key. Update it in Connection.',
403: 'Your OpenAI project does not have access to the Decisions API or gpt-6-luna.',
404: 'The Decisions API or gpt-6-luna is unavailable for this OpenAI project.',
429: 'OpenAI rate limit or API quota reached. Check your API billing and limits; Sift will retry after a cooldown.',
};
throw Object.assign(new Error(messages[response.status] || `OpenAI request failed (HTTP ${response.status}). Posts were left visible.`), { status: response.status });
}
return response.json();
}
async function classify(rawPosts) {
if (!Array.isArray(rawPosts) || !rawPosts.length || rawPosts.length > MAX_BATCH) throw new Error(`Send between 1 and ${MAX_BATCH} posts at a time.`);
const posts = rawPosts.map(normalizePost);
if (new Set(posts.map(post => post.id)).size !== posts.length) throw new Error('Post IDs must be unique within a batch.');
const current = await getState();
const revision = current.revision;
if (!current.settings.enabled) return failOpen(posts, 'disabled', 'Filtering is paused.', revision);
const decisions = [];
const pending = [];
const localToRecord = [];
for (const post of posts) {
if (post.kind === 'reply' && current.settings.filterReplies === false) {
decisions.push({ id: post.id, action: 'show', reason: 'Reply filtering is turned off.', ruleIds: [], source: 'disabled' });
continue;
}
const explicit = current.rules.find(rule => rule.example.id === post.id);
const cached = current.history.find(entry => entry.id === post.id && entry.revision === revision && fingerprint(entry) === fingerprint(post));
if (explicit) {
const decision = { id: post.id, action: explicit.action, reason: `Your feedback: ${explicit.text}`, ruleIds: [explicit.id], source: 'feedback', confidence: 1 };
decisions.push(decision);
if (!cached) localToRecord.push({ post, decision });
}
else if (cached) decisions.push({ id: cached.id, action: cached.action, reason: cached.reason, ruleIds: cached.ruleIds, source: cached.source, confidence: cached.confidence });
else pending.push(post);
}
if (localToRecord.length) {
let stale = false;
await mutate(draft => {
if (draft.revision !== revision) { stale = true; return; }
for (const { post, decision } of localToRecord) recordDecision(draft, post, decision, now());
});
if (stale) return { ...failOpen(posts, 'stale', 'Preferences changed during classification. Rechecking.', revision), stale: true };
}
if (!pending.length) return { ok: true, decisions, revision };
if (!configured(current)) return { ...failOpen(pending, 'unconfigured', 'Add an API key and feed preferences to start filtering.', revision), decisions: [...decisions, ...failOpen(pending, 'unconfigured', 'Add an API key and feed preferences to start filtering.', revision).decisions] };
if (now() < backoffUntil) return { ...failOpen(pending, 'error', current.lastError || 'AI filtering is cooling down; posts remain visible.', revision), error: current.lastError || 'AI filtering is cooling down.', decisions: [...decisions, ...failOpen(pending, 'error', 'AI filtering is cooling down; posts remain visible.', revision).decisions] };
const { body, outcomes } = buildDecisionRequest(pending, current);
try {
await mutate(draft => { draft.stats.apiRequests += 1; }, false);
const data = await request(body, current.settings.apiKey);
const results = parseDecisions(data, pending, outcomes);
const incomplete = results.some(result => result.source === 'error');
const incompleteMessage = 'Some Decisions answers were missing or invalid. Posts were left visible; retrying after a cooldown.';
let stale = false;
await mutate(draft => {
draft.stats.inputTokens += Math.max(0, Number(data.usage?.input_tokens) || 0);
draft.stats.outputTokens += Math.max(0, Number(data.usage?.output_tokens) || 0);
if (draft.revision !== revision) { stale = true; return; }
for (let index = 0; index < pending.length; index += 1) {
if (results[index].source !== 'error') recordDecision(draft, pending[index], results[index], now());
}
draft.lastError = incomplete ? incompleteMessage : null;
});
if (stale) return { ...failOpen(posts, 'stale', 'Preferences changed during classification. Rechecking.', revision), stale: true };
backoffUntil = incomplete ? now() + 60000 : 0;
return { ok: true, decisions: [...decisions, ...results], revision, ...(incomplete ? { error: incompleteMessage } : {}) };
} catch (error) {
const message = error.name === 'TimeoutError' || error.name === 'AbortError' ? 'OpenAI took too long to respond. Posts were left visible.' : error.status ? error.message : 'Could not complete the Decisions API request. Check your connection; posts were left visible.';
const stillCurrent = (await getState()).revision === revision;
if (stillCurrent) {
backoffUntil = now() + (error.status === 401 || error.status === 403 || error.status === 404 ? 300000 : 60000);
await mutate(draft => { if (draft.revision === revision) draft.lastError = message; });
}
return { ok: true, decisions: [...decisions, ...failOpen(pending, 'error', message, revision).decisions], revision, error: message };
}
}
async function handle(message, trusted = true) {
switch (message?.type) {
case 'GET_STATE': return { ok: true, state: await getState(trusted) };
case 'SAVE_SETTINGS': {
const settings = validateSettings(message.settings);
await mutate(draft => {
if (Object.entries(settings).some(([key, value]) => draft.settings[key] !== value)) {
Object.assign(draft.settings, settings);
draft.revision += 1;
draft.lastError = null;
backoffUntil = 0;
}
});
return { ok: true, state: await getState(trusted) };
}
case 'CLASSIFY_POSTS': {
const result = classification.then(() => classify(message.posts));
classification = result.catch(() => {});
return result;
}
case 'SAVE_FEEDBACK': {
const post = normalizePost(message.post);
if (!['show', 'hide'].includes(message.action)) throw new Error('Choose whether to keep or hide posts like this.');
if (typeof message.comment !== 'string' || !message.comment.trim()) throw new Error('Describe why you want to keep or hide posts like this.');
if (message.comment.length > 1000) throw new Error('Feedback must be 1,000 characters or fewer.');
let decision;
await mutate(draft => {
if (draft.rules.length >= MAX_RULES) throw new Error(`You have ${MAX_RULES} remembered rules. Remove an old rule before adding one.`);
const rule = { id: uuid(), text: message.comment.trim(), action: message.action, example: post, createdAt: now() };
draft.rules.unshift(rule);
draft.revision += 1;
draft.stats.feedback += 1;
draft.lastError = null;
decision = { id: post.id, action: rule.action, reason: `Your feedback: ${rule.text}`, ruleIds: [rule.id], source: 'feedback', confidence: 1 };
recordDecision(draft, post, decision, now());
backoffUntil = 0;
});
return { ok: true, state: await getState(trusted), decision };
}
case 'DELETE_RULE': {
await mutate(draft => {
if (!draft.rules.some(rule => rule.id === message.id)) throw new Error('This rule no longer exists.');
draft.rules = draft.rules.filter(rule => rule.id !== message.id);
draft.revision += 1;
draft.lastError = null;
backoffUntil = 0;
});
return { ok: true, state: await getState(trusted) };
}
case 'CLEAR_HISTORY': {
await mutate(draft => { draft.history = []; draft.revision += 1; });
return { ok: true, state: await getState(trusted) };
}
case 'CLEAR_STATS': {
await mutate(draft => { draft.stats = emptyStats(); draft.seen = []; draft.hiddenSeen = []; });
return { ok: true, state: await getState(trusted) };
}
case 'TEST_CONNECTION': {
const current = await getState();
if (!current.settings.apiKey) throw new Error('Save your OpenAI API key first.');
const body = { model: MODEL, input: 'Sift connection test.', questions: [{ name: 'connection', type: 'predicate', instructions: 'Does the input contain the word Sift?' }] };
try {
await mutate(draft => { draft.stats.apiRequests += 1; }, false);
const data = await request(body, current.settings.apiKey);
const answer = data.answers?.[0];
if (answer?.type !== 'predicate' || !Number.isFinite(answer.probability)) throw new Error('OpenAI did not return a valid Decisions result.');
let stale = false;
await mutate(draft => {
draft.stats.inputTokens += Math.max(0, Number(data.usage?.input_tokens) || 0);
draft.stats.outputTokens += Math.max(0, Number(data.usage?.output_tokens) || 0);
if (draft.revision !== current.revision) { stale = true; return; }
draft.lastError = null;
backoffUntil = 0;
});
if (stale) return { ok: false, error: 'Settings changed during the connection test. Test your current saved settings again.' };
return { ok: true, message: `Connected to OpenAI Decisions API (${MODEL}).` };
} catch (error) {
const message = error.status ? error.message : 'The Decisions connection test failed. Check your connection and API access.';
await mutate(draft => { if (draft.revision === current.revision) draft.lastError = message; });
return { ok: false, error: message };
}
}
default: throw new Error('Unknown Sift request.');
}
}
return { handle, getState };
}