Skip to content

Commit 230b8ca

Browse files
authored
Merge pull request #7 from tshmieldev/tshmieldev/issue-5
Read X's requests only while "Teach X too" is on, and document it
2 parents 189a3a9 + 1327ee0 commit 230b8ca

7 files changed

Lines changed: 157 additions & 10 deletions

File tree

‎README.md‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,15 @@ Describe what you want to see — or never see again — in plain language.
3535
- **Teach it.** Mark a hidden post as a wrong call, or pick "Hide posts like
3636
this" from the ⋯ menu; your recent corrections go to the model as examples.
3737
- **Teach X too.** Optionally, Sharp tells X you're not interested in what it
38-
hides, so the algorithm learns alongside the model.
38+
hides, so the algorithm learns alongside the model. This is the one feature
39+
that acts as you on X; the [privacy policy](privacy-policy.md) says exactly
40+
how.
3941
- **Thread escape hatch.** One click above the reply box shows every comment in
4042
a thread, filters off.
4143
- **Nothing shown before it's judged.** Undecided posts wait behind a
42-
placeholder, so nothing slips through and the timeline never jumps.
44+
placeholder, so the timeline never jumps. If your provider keeps failing, a
45+
post is shown after three tries rather than held back forever: a broken
46+
provider should not blank your timeline.
4347
- **A classifier by default.** A decision model scores every post: fast, nearly
4448
free, and it says how sure it is. Close calls stay behind a tinted banner, and
4549
you set where the lines fall. Reach it through OpenRouter, Vercel AI Gateway or

‎privacy-policy.md‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,27 @@ do with that request. Check them for the provider you pick — for example
5959
post so you can inspect it on the page. That record lives in the open tab's
6060
memory only. It is never stored and never sent anywhere.
6161

62+
## What Sharp does on X as you
63+
64+
One optional feature, **Teach X too**, off by default, sends X's own "Not
65+
interested in this post" for posts Sharp hides on your Home timeline, so X's
66+
ranking learns from them. To do that the way X's own client does, Sharp runs a
67+
small script in the x.com page that wraps the page's `fetch`. While the feature
68+
is on, that script reads two things X's client already has:
69+
70+
- the headers X signs its own API requests with, which include your X session
71+
credentials (`authorization`, `x-csrf-token` and related headers)
72+
- the per-post feedback data in X's timeline responses
73+
74+
It passes them to the rest of the extension inside the same page, and Sharp uses
75+
them to send that one request to X, to x.com, as you. They never leave the
76+
x.com page for anywhere else: not to your AI provider, not to the developer, not
77+
to storage. They are kept in the tab's memory only.
78+
79+
While **Teach X too** is off, the script reads nothing and passes nothing on. It
80+
is still loaded, because a browser cannot load a page script conditionally, but
81+
it does no more than hand each request straight to X's own `fetch`.
82+
6283
## Permissions, and why
6384

6485
- **Storage** — to save your settings, key, and decision cache locally.

‎src/popup/GeneralPanel.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,7 +124,7 @@ export function GeneralPanel({
124124
<section class="group">
125125
<a
126126
class="support"
127-
href="https://github.com/tshmielash"
127+
href="https://github.com/tshmieldev/sharp"
128128
target="_blank"
129129
rel="noreferrer noopener"
130130
>

‎src/x/controller.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import * as view from './view';
1616
import { ThreadControl } from './thread-control';
1717
import { NotInterested } from './not-interested';
1818
import { Feedback } from './feedback';
19+
import type { WireSwitch } from './wire-extract';
1920

2021
type Phase =
2122
| { type: 'queued' }
@@ -227,6 +228,13 @@ export class TimelineController {
227228
void this.refresh();
228229
};
229230

231+
/** Tells the page script whether "Teach X too" is on. It reads X's requests
232+
* only while it is, and cannot see settings itself. */
233+
private wire(on: boolean) {
234+
const message: WireSwitch = { aitf: 'wire', kind: 'switch', on };
235+
window.postMessage(message, location.origin);
236+
}
237+
230238
async refresh() {
231239
const revision = ++this.refreshing;
232240
try {
@@ -242,6 +250,7 @@ export class TimelineController {
242250
}
243251
this.settings = settings;
244252
this.rules = createRules(settings);
253+
this.wire(settings.enabled && settings.notInterested);
245254
document.documentElement.dataset.aitfMotion = settings.motion;
246255
if (settings.greyscaleUi) document.documentElement.dataset.aitfGreyUi = '';
247256
else delete document.documentElement.dataset.aitfGreyUi;
@@ -275,6 +284,7 @@ export class TimelineController {
275284
this.hiddenInfo.clear();
276285
this.threadControl.dispose();
277286
this.notInterested.stop();
287+
this.wire(false);
278288
this.feedback.stop();
279289
delete document.documentElement.dataset.aitfMotion;
280290
delete document.documentElement.dataset.aitfGreyUi;

‎src/x/wire-extract.ts‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,40 @@ export function pickSigning(headers: Headers): Record<string, string> | null {
5252
return picked.authorization && picked['x-csrf-token'] ? picked : null;
5353
}
5454

55+
/** The content script's word on whether anything here is wanted: only "Teach X
56+
* too" uses it. The page script cannot read settings itself. */
57+
export type WireSwitch = { aitf: 'wire'; kind: 'switch'; on: boolean };
58+
export const isWireSwitch = (data: unknown): data is WireSwitch =>
59+
typeof data === 'object' &&
60+
data !== null &&
61+
(data as { aitf?: unknown }).aitf === 'wire' &&
62+
(data as { kind?: unknown }).kind === 'switch' &&
63+
typeof (data as { on?: unknown }).on === 'boolean';
64+
65+
/** Nothing is passed on until the content script says the feature is on. The
66+
* page script starts before settings can be read, and X's first timeline
67+
* response comes early, so until the word arrives a few messages are held
68+
* back rather than sent or lost. Off drops them and stops the reading itself. */
69+
export function createGate(post: (message: WireMessage) => void, limit = 20) {
70+
let state: 'unknown' | 'on' | 'off' = 'unknown';
71+
let held: WireMessage[] = [];
72+
return {
73+
/** Whether there is any point looking at a request at all. */
74+
get reading() {
75+
return state !== 'off';
76+
},
77+
send(message: WireMessage) {
78+
if (state === 'on') post(message);
79+
else if (state === 'unknown') held = [...held, message].slice(-limit);
80+
},
81+
set(on: boolean) {
82+
state = on ? 'on' : 'off';
83+
if (on) for (const message of held) post(message);
84+
held = [];
85+
},
86+
};
87+
}
88+
5589
export type WireMessage =
5690
| { aitf: 'wire'; kind: 'metadata'; entries: [string, string][] }
5791
| { aitf: 'wire'; kind: 'headers'; headers: Record<string, string> };

‎src/x/wire.ts‎

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,37 @@
11
// Runs in the page's world, not the extension's. It decorates window.fetch so
22
// Sharp can read what X's own client already has: per-post feedback metadata
3-
// from timeline responses, and the headers X signs its API calls with. Nothing
4-
// here touches chrome.*, the DOM, or the request itself; the original fetch is
5-
// called with the original arguments and its response returned untouched.
6-
import { feedbackMetadata, pickSigning, type WireMessage } from './wire-extract';
3+
// from timeline responses, and the headers X signs its API calls with. Only
4+
// "Teach X too" uses either, so nothing is read or passed on unless the content
5+
// script says that setting is on. Nothing here touches chrome.*, the DOM, or
6+
// the request itself; the original fetch is called with the original arguments
7+
// and its response returned untouched.
8+
import {
9+
createGate,
10+
feedbackMetadata,
11+
isWireSwitch,
12+
pickSigning,
13+
type WireMessage,
14+
} from './wire-extract';
715

816
const apiPath = '/i/api/';
9-
const post = (message: WireMessage) => window.postMessage(message, location.origin);
17+
const gate = createGate((message: WireMessage) => window.postMessage(message, location.origin));
18+
const post = (message: WireMessage) => gate.send(message);
1019
let lastHeaders = '';
1120

21+
let heard = false;
22+
window.addEventListener('message', (event) => {
23+
if (event.source !== window || event.origin !== location.origin) return;
24+
if (!isWireSwitch(event.data)) return;
25+
heard = true;
26+
// Turned on again later: the headers have to be sent again, not deduplicated.
27+
if (event.data.on) lastHeaders = '';
28+
gate.set(event.data.on);
29+
});
30+
// No word from the content script (disabled, or gone): stop looking.
31+
setTimeout(() => {
32+
if (!heard) gate.set(false);
33+
}, 10_000);
34+
1235
function requestOf(input: RequestInfo | URL, init?: RequestInit) {
1336
const url = input instanceof Request ? input.url : String(input);
1437
const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : {}));
@@ -42,7 +65,7 @@ window.fetch = function (input: RequestInfo | URL, init?: RequestInit) {
4265
const promise = original.call(this, input, init);
4366
try {
4467
const { url, headers } = requestOf(input, init);
45-
if (url.includes(apiPath)) {
68+
if (gate.reading && url.includes(apiPath)) {
4669
void promise.then((response) => observe(url, headers, response)).catch(() => {});
4770
}
4871
} catch {

‎tests/wire.test.ts‎

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
import { expect, it } from 'vitest';
2-
import { feedbackMetadata, isWireMessage, pickSigning } from '../src/x/wire-extract';
2+
import {
3+
createGate,
4+
feedbackMetadata,
5+
isWireMessage,
6+
isWireSwitch,
7+
pickSigning,
8+
type WireMessage,
9+
} from '../src/x/wire-extract';
310

411
it('reads per-post feedback metadata out of a timeline response', () => {
512
// The shape of HomeTimeline: entries, conversation modules with items, and
@@ -82,3 +89,51 @@ it('recognises only its own messages', () => {
8289
expect(isWireMessage({ kind: 'headers' })).toBe(false);
8390
expect(isWireMessage(null)).toBe(false);
8491
});
92+
93+
it('passes nothing on until "Teach X too" is on, and stops reading when it is off', () => {
94+
const headers: WireMessage = { aitf: 'wire', kind: 'headers', headers: { authorization: 'x' } };
95+
const metadata: WireMessage = { aitf: 'wire', kind: 'metadata', entries: [['1', 'AAA=']] };
96+
97+
// Before the content script has spoken, messages wait; nothing is posted.
98+
const sent: WireMessage[] = [];
99+
const gate = createGate((message) => sent.push(message));
100+
gate.send(headers);
101+
gate.send(metadata);
102+
expect(sent).toEqual([]);
103+
expect(gate.reading).toBe(true);
104+
// On: what was held goes out, and the rest follows as it comes.
105+
gate.set(true);
106+
expect(sent).toEqual([headers, metadata]);
107+
gate.send(metadata);
108+
expect(sent).toHaveLength(3);
109+
110+
// Off: what was held is dropped, nothing is posted, and there is no reading.
111+
const dropped: WireMessage[] = [];
112+
const off = createGate((message) => dropped.push(message));
113+
off.send(headers);
114+
off.set(false);
115+
off.send(metadata);
116+
expect(dropped).toEqual([]);
117+
expect(off.reading).toBe(false);
118+
// Switched on later, it starts clean rather than replaying what it dropped.
119+
off.set(true);
120+
expect(dropped).toEqual([]);
121+
expect(off.reading).toBe(true);
122+
123+
// Held messages are bounded while waiting.
124+
const bounded: WireMessage[] = [];
125+
const small = createGate((message) => bounded.push(message), 2);
126+
for (let i = 0; i < 5; i++) small.send({ ...metadata, entries: [[String(i), 'A']] });
127+
small.set(true);
128+
expect(
129+
bounded.map((message) => (message.kind === 'metadata' ? message.entries[0]![0] : '')),
130+
).toEqual(['3', '4']);
131+
});
132+
133+
it('tells a switch apart from what the page script sends', () => {
134+
expect(isWireSwitch({ aitf: 'wire', kind: 'switch', on: true })).toBe(true);
135+
expect(isWireSwitch({ aitf: 'wire', kind: 'switch' })).toBe(false);
136+
expect(isWireSwitch({ aitf: 'wire', kind: 'headers', headers: {} })).toBe(false);
137+
// The content script's listener never mistakes a switch for data.
138+
expect(isWireMessage({ aitf: 'wire', kind: 'switch', on: true })).toBe(false);
139+
});

0 commit comments

Comments
 (0)