Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,15 @@ Describe what you want to see — or never see again — in plain language.
- **Teach it.** Mark a hidden post as a wrong call, or pick "Hide posts like
this" from the ⋯ menu; your recent corrections go to the model as examples.
- **Teach X too.** Optionally, Sharp tells X you're not interested in what it
hides, so the algorithm learns alongside the model.
hides, so the algorithm learns alongside the model. This is the one feature
that acts as you on X; the [privacy policy](privacy-policy.md) says exactly
how.
- **Thread escape hatch.** One click above the reply box shows every comment in
a thread, filters off.
- **Nothing shown before it's judged.** Undecided posts wait behind a
placeholder, so nothing slips through and the timeline never jumps.
placeholder, so the timeline never jumps. If your provider keeps failing, a
post is shown after three tries rather than held back forever: a broken
provider should not blank your timeline.
- **A classifier by default.** A decision model scores every post: fast, nearly
free, and it says how sure it is. Close calls stay behind a tinted banner, and
you set where the lines fall. Reach it through OpenRouter, Vercel AI Gateway or
Expand Down
21 changes: 21 additions & 0 deletions privacy-policy.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,27 @@ do with that request. Check them for the provider you pick — for example
post so you can inspect it on the page. That record lives in the open tab's
memory only. It is never stored and never sent anywhere.

## What Sharp does on X as you

One optional feature, **Teach X too**, off by default, sends X's own "Not
interested in this post" for posts Sharp hides on your Home timeline, so X's
ranking learns from them. To do that the way X's own client does, Sharp runs a
small script in the x.com page that wraps the page's `fetch`. While the feature
is on, that script reads two things X's client already has:

- the headers X signs its own API requests with, which include your X session
credentials (`authorization`, `x-csrf-token` and related headers)
- the per-post feedback data in X's timeline responses

It passes them to the rest of the extension inside the same page, and Sharp uses
them to send that one request to X, to x.com, as you. They never leave the
x.com page for anywhere else: not to your AI provider, not to the developer, not
to storage. They are kept in the tab's memory only.

While **Teach X too** is off, the script reads nothing and passes nothing on. It
is still loaded, because a browser cannot load a page script conditionally, but
it does no more than hand each request straight to X's own `fetch`.

## Permissions, and why

- **Storage** — to save your settings, key, and decision cache locally.
Expand Down
2 changes: 1 addition & 1 deletion src/popup/GeneralPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ export function GeneralPanel({
<section class="group">
<a
class="support"
href="https://github.com/tshmielash"
href="https://github.com/tshmieldev/sharp"
target="_blank"
rel="noreferrer noopener"
>
Expand Down
10 changes: 10 additions & 0 deletions src/x/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import * as view from './view';
import { ThreadControl } from './thread-control';
import { NotInterested } from './not-interested';
import { Feedback } from './feedback';
import type { WireSwitch } from './wire-extract';

type Phase =
| { type: 'queued' }
Expand Down Expand Up @@ -227,6 +228,13 @@ export class TimelineController {
void this.refresh();
};

/** Tells the page script whether "Teach X too" is on. It reads X's requests
* only while it is, and cannot see settings itself. */
private wire(on: boolean) {
const message: WireSwitch = { aitf: 'wire', kind: 'switch', on };
window.postMessage(message, location.origin);
}

async refresh() {
const revision = ++this.refreshing;
try {
Expand All @@ -242,6 +250,7 @@ export class TimelineController {
}
this.settings = settings;
this.rules = createRules(settings);
this.wire(settings.enabled && settings.notInterested);
document.documentElement.dataset.aitfMotion = settings.motion;
if (settings.greyscaleUi) document.documentElement.dataset.aitfGreyUi = '';
else delete document.documentElement.dataset.aitfGreyUi;
Expand Down Expand Up @@ -275,6 +284,7 @@ export class TimelineController {
this.hiddenInfo.clear();
this.threadControl.dispose();
this.notInterested.stop();
this.wire(false);
this.feedback.stop();
delete document.documentElement.dataset.aitfMotion;
delete document.documentElement.dataset.aitfGreyUi;
Expand Down
34 changes: 34 additions & 0 deletions src/x/wire-extract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,40 @@ export function pickSigning(headers: Headers): Record<string, string> | null {
return picked.authorization && picked['x-csrf-token'] ? picked : null;
}

/** The content script's word on whether anything here is wanted: only "Teach X
* too" uses it. The page script cannot read settings itself. */
export type WireSwitch = { aitf: 'wire'; kind: 'switch'; on: boolean };
export const isWireSwitch = (data: unknown): data is WireSwitch =>
typeof data === 'object' &&
data !== null &&
(data as { aitf?: unknown }).aitf === 'wire' &&
(data as { kind?: unknown }).kind === 'switch' &&
typeof (data as { on?: unknown }).on === 'boolean';

/** Nothing is passed on until the content script says the feature is on. The
* page script starts before settings can be read, and X's first timeline
* response comes early, so until the word arrives a few messages are held
* back rather than sent or lost. Off drops them and stops the reading itself. */
export function createGate(post: (message: WireMessage) => void, limit = 20) {
let state: 'unknown' | 'on' | 'off' = 'unknown';
let held: WireMessage[] = [];
return {
/** Whether there is any point looking at a request at all. */
get reading() {
return state !== 'off';
},
send(message: WireMessage) {
if (state === 'on') post(message);
else if (state === 'unknown') held = [...held, message].slice(-limit);
},
set(on: boolean) {
state = on ? 'on' : 'off';
if (on) for (const message of held) post(message);
held = [];
},
};
}

export type WireMessage =
| { aitf: 'wire'; kind: 'metadata'; entries: [string, string][] }
| { aitf: 'wire'; kind: 'headers'; headers: Record<string, string> };
Expand Down
35 changes: 29 additions & 6 deletions src/x/wire.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,37 @@
// Runs in the page's world, not the extension's. It decorates window.fetch so
// Sharp can read what X's own client already has: per-post feedback metadata
// from timeline responses, and the headers X signs its API calls with. Nothing
// here touches chrome.*, the DOM, or the request itself; the original fetch is
// called with the original arguments and its response returned untouched.
import { feedbackMetadata, pickSigning, type WireMessage } from './wire-extract';
// from timeline responses, and the headers X signs its API calls with. Only
// "Teach X too" uses either, so nothing is read or passed on unless the content
// script says that setting is on. Nothing here touches chrome.*, the DOM, or
// the request itself; the original fetch is called with the original arguments
// and its response returned untouched.
import {
createGate,
feedbackMetadata,
isWireSwitch,
pickSigning,
type WireMessage,
} from './wire-extract';

const apiPath = '/i/api/';
const post = (message: WireMessage) => window.postMessage(message, location.origin);
const gate = createGate((message: WireMessage) => window.postMessage(message, location.origin));
const post = (message: WireMessage) => gate.send(message);
let lastHeaders = '';

let heard = false;
window.addEventListener('message', (event) => {
if (event.source !== window || event.origin !== location.origin) return;
if (!isWireSwitch(event.data)) return;
heard = true;
// Turned on again later: the headers have to be sent again, not deduplicated.
if (event.data.on) lastHeaders = '';
gate.set(event.data.on);
});
// No word from the content script (disabled, or gone): stop looking.
setTimeout(() => {
if (!heard) gate.set(false);
}, 10_000);

function requestOf(input: RequestInfo | URL, init?: RequestInit) {
const url = input instanceof Request ? input.url : String(input);
const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : {}));
Expand Down Expand Up @@ -42,7 +65,7 @@ window.fetch = function (input: RequestInfo | URL, init?: RequestInit) {
const promise = original.call(this, input, init);
try {
const { url, headers } = requestOf(input, init);
if (url.includes(apiPath)) {
if (gate.reading && url.includes(apiPath)) {
void promise.then((response) => observe(url, headers, response)).catch(() => {});
}
} catch {
Expand Down
57 changes: 56 additions & 1 deletion tests/wire.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { expect, it } from 'vitest';
import { feedbackMetadata, isWireMessage, pickSigning } from '../src/x/wire-extract';
import {
createGate,
feedbackMetadata,
isWireMessage,
isWireSwitch,
pickSigning,
type WireMessage,
} from '../src/x/wire-extract';

it('reads per-post feedback metadata out of a timeline response', () => {
// The shape of HomeTimeline: entries, conversation modules with items, and
Expand Down Expand Up @@ -82,3 +89,51 @@ it('recognises only its own messages', () => {
expect(isWireMessage({ kind: 'headers' })).toBe(false);
expect(isWireMessage(null)).toBe(false);
});

it('passes nothing on until "Teach X too" is on, and stops reading when it is off', () => {
const headers: WireMessage = { aitf: 'wire', kind: 'headers', headers: { authorization: 'x' } };
const metadata: WireMessage = { aitf: 'wire', kind: 'metadata', entries: [['1', 'AAA=']] };

// Before the content script has spoken, messages wait; nothing is posted.
const sent: WireMessage[] = [];
const gate = createGate((message) => sent.push(message));
gate.send(headers);
gate.send(metadata);
expect(sent).toEqual([]);
expect(gate.reading).toBe(true);
// On: what was held goes out, and the rest follows as it comes.
gate.set(true);
expect(sent).toEqual([headers, metadata]);
gate.send(metadata);
expect(sent).toHaveLength(3);

// Off: what was held is dropped, nothing is posted, and there is no reading.
const dropped: WireMessage[] = [];
const off = createGate((message) => dropped.push(message));
off.send(headers);
off.set(false);
off.send(metadata);
expect(dropped).toEqual([]);
expect(off.reading).toBe(false);
// Switched on later, it starts clean rather than replaying what it dropped.
off.set(true);
expect(dropped).toEqual([]);
expect(off.reading).toBe(true);

// Held messages are bounded while waiting.
const bounded: WireMessage[] = [];
const small = createGate((message) => bounded.push(message), 2);
for (let i = 0; i < 5; i++) small.send({ ...metadata, entries: [[String(i), 'A']] });
small.set(true);
expect(
bounded.map((message) => (message.kind === 'metadata' ? message.entries[0]![0] : '')),
).toEqual(['3', '4']);
});

it('tells a switch apart from what the page script sends', () => {
expect(isWireSwitch({ aitf: 'wire', kind: 'switch', on: true })).toBe(true);
expect(isWireSwitch({ aitf: 'wire', kind: 'switch' })).toBe(false);
expect(isWireSwitch({ aitf: 'wire', kind: 'headers', headers: {} })).toBe(false);
// The content script's listener never mistakes a switch for data.
expect(isWireMessage({ aitf: 'wire', kind: 'switch', on: true })).toBe(false);
});
Loading