From aee5396da9fd75767549bec87b02e5893ea20a73 Mon Sep 17 00:00:00 2001 From: Andrei Sorokin Date: Mon, 9 Nov 2026 21:22:12 +0200 Subject: [PATCH 01/11] init with planned changes --- .gitignore | 2 + package-lock.json | 237 +++++++++++++++++++++++++------------------ package.json | 4 +- public/manifest.json | 3 + public/options.html | 9 ++ src/background.ts | 7 +- src/jev.ts | 24 ++++- src/options.ts | 6 ++ src/types.ts | 8 ++ 9 files changed, 195 insertions(+), 105 deletions(-) diff --git a/.gitignore b/.gitignore index 6de233e..b6c0aec 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ node_modules/ dist/ *.zip .DS_Store + +.env diff --git a/package-lock.json b/package-lock.json index 07c3f84..e094b15 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,16 +7,17 @@ "": { "name": "lkclean", "version": "0.1.0", + "license": "MIT", "devDependencies": { "@types/chrome": "^0.0.270", - "esbuild": "^0.23.0", + "esbuild": "^0.28.1", "typescript": "^5.5.0" } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.1.tgz", - "integrity": "sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ==", + "version": "0.28.2", + "resolved": "https://pkgs.safetycli.com/repository/public/npmjs/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", "cpu": [ "ppc64" ], @@ -31,9 +32,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.23.1.tgz", - "integrity": "sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ==", + "version": "0.28.2", + "resolved": "https://pkgs.safetycli.com/repository/public/npmjs/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", "cpu": [ "arm" ], @@ -48,9 +49,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.23.1.tgz", - "integrity": "sha512-xw50ipykXcLstLeWH7WRdQuysJqejuAGPd30vd1i5zSyKK3WE+ijzHmLKxdiCMtH1pHz78rOg0BKSYOSB/2Khw==", + "version": "0.28.2", + "resolved": "https://pkgs.safetycli.com/repository/public/npmjs/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", "cpu": [ "arm64" ], @@ -65,9 +66,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.23.1.tgz", - "integrity": "sha512-nlN9B69St9BwUoB+jkyU090bru8L0NA3yFvAd7k8dNsVH8bi9a8cUAUSEcEEgTp2z3dbEDGJGfP6VUnkQnlReg==", + "version": "0.28.2", + "resolved": "https://pkgs.safetycli.com/repository/public/npmjs/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", "cpu": [ "x64" ], @@ -82,9 +83,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.23.1.tgz", - "integrity": "sha512-YsS2e3Wtgnw7Wq53XXBLcV6JhRsEq8hkfg91ESVadIrzr9wO6jJDMZnCQbHm1Guc5t/CdDiFSSfWP58FNuvT3Q==", + "version": "0.28.2", + "resolved": "https://pkgs.safetycli.com/repository/public/npmjs/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", "cpu": [ "arm64" ], @@ -99,9 +100,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.23.1.tgz", - "integrity": "sha512-aClqdgTDVPSEGgoCS8QDG37Gu8yc9lTHNAQlsztQ6ENetKEO//b8y31MMu2ZaPbn4kVsIABzVLXYLhCGekGDqw==", + "version": "0.28.2", + "resolved": "https://pkgs.safetycli.com/repository/public/npmjs/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", "cpu": [ "x64" ], @@ -116,9 +117,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.23.1.tgz", - "integrity": "sha512-h1k6yS8/pN/NHlMl5+v4XPfikhJulk4G+tKGFIOwURBSFzE8bixw1ebjluLOjfwtLqY0kewfjLSrO6tN2MgIhA==", + "version": "0.28.2", + "resolved": "https://pkgs.safetycli.com/repository/public/npmjs/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", "cpu": [ "arm64" ], @@ -133,9 +134,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.23.1.tgz", - "integrity": "sha512-lK1eJeyk1ZX8UklqFd/3A60UuZ/6UVfGT2LuGo3Wp4/z7eRTRYY+0xOu2kpClP+vMTi9wKOfXi2vjUpO1Ro76g==", + "version": "0.28.2", + "resolved": "https://pkgs.safetycli.com/repository/public/npmjs/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", "cpu": [ "x64" ], @@ -150,9 +151,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.23.1.tgz", - "integrity": "sha512-CXXkzgn+dXAPs3WBwE+Kvnrf4WECwBdfjfeYHpMeVxWE0EceB6vhWGShs6wi0IYEqMSIzdOF1XjQ/Mkm5d7ZdQ==", + "version": "0.28.2", + "resolved": "https://pkgs.safetycli.com/repository/public/npmjs/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", "cpu": [ "arm" ], @@ -167,9 +168,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.23.1.tgz", - "integrity": "sha512-/93bf2yxencYDnItMYV/v116zff6UyTjo4EtEQjUBeGiVpMmffDNUyD9UN2zV+V3LRV3/on4xdZ26NKzn6754g==", + "version": "0.28.2", + "resolved": "https://pkgs.safetycli.com/repository/public/npmjs/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", "cpu": [ "arm64" ], @@ -184,9 +185,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.23.1.tgz", - "integrity": "sha512-VTN4EuOHwXEkXzX5nTvVY4s7E/Krz7COC8xkftbbKRYAl96vPiUssGkeMELQMOnLOJ8k3BY1+ZY52tttZnHcXQ==", + "version": "0.28.2", + "resolved": "https://pkgs.safetycli.com/repository/public/npmjs/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", "cpu": [ "ia32" ], @@ -201,9 +202,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.23.1.tgz", - "integrity": "sha512-Vx09LzEoBa5zDnieH8LSMRToj7ir/Jeq0Gu6qJ/1GcBq9GkfoEAoXvLiW1U9J1qE/Y/Oyaq33w5p2ZWrNNHNEw==", + "version": "0.28.2", + "resolved": "https://pkgs.safetycli.com/repository/public/npmjs/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", "cpu": [ "loong64" ], @@ -218,9 +219,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.23.1.tgz", - "integrity": "sha512-nrFzzMQ7W4WRLNUOU5dlWAqa6yVeI0P78WKGUo7lg2HShq/yx+UYkeNSE0SSfSure0SqgnsxPvmAUu/vu0E+3Q==", + "version": "0.28.2", + "resolved": "https://pkgs.safetycli.com/repository/public/npmjs/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", "cpu": [ "mips64el" ], @@ -235,9 +236,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.23.1.tgz", - "integrity": "sha512-dKN8fgVqd0vUIjxuJI6P/9SSSe/mB9rvA98CSH2sJnlZ/OCZWO1DJvxj8jvKTfYUdGfcq2dDxoKaC6bHuTlgcw==", + "version": "0.28.2", + "resolved": "https://pkgs.safetycli.com/repository/public/npmjs/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", "cpu": [ "ppc64" ], @@ -252,9 +253,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.23.1.tgz", - "integrity": "sha512-5AV4Pzp80fhHL83JM6LoA6pTQVWgB1HovMBsLQ9OZWLDqVY8MVobBXNSmAJi//Csh6tcY7e7Lny2Hg1tElMjIA==", + "version": "0.28.2", + "resolved": "https://pkgs.safetycli.com/repository/public/npmjs/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", "cpu": [ "riscv64" ], @@ -269,9 +270,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.23.1.tgz", - "integrity": "sha512-9ygs73tuFCe6f6m/Tb+9LtYxWR4c9yg7zjt2cYkjDbDpV/xVn+68cQxMXCjUpYwEkze2RcU/rMnfIXNRFmSoDw==", + "version": "0.28.2", + "resolved": "https://pkgs.safetycli.com/repository/public/npmjs/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", "cpu": [ "s390x" ], @@ -286,9 +287,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.23.1.tgz", - "integrity": "sha512-EV6+ovTsEXCPAp58g2dD68LxoP/wK5pRvgy0J/HxPGB009omFPv3Yet0HiaqvrIrgPTBuC6wCH1LTOY91EO5hQ==", + "version": "0.28.2", + "resolved": "https://pkgs.safetycli.com/repository/public/npmjs/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", "cpu": [ "x64" ], @@ -302,10 +303,27 @@ "node": ">=18" } }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://pkgs.safetycli.com/repository/public/npmjs/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.23.1.tgz", - "integrity": "sha512-aevEkCNu7KlPRpYLjwmdcuNz6bDFiE7Z8XC4CPqExjTvrHugh28QzUXVOZtiYghciKUacNktqxdpymplil1beA==", + "version": "0.28.2", + "resolved": "https://pkgs.safetycli.com/repository/public/npmjs/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", "cpu": [ "x64" ], @@ -320,9 +338,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.1.tgz", - "integrity": "sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q==", + "version": "0.28.2", + "resolved": "https://pkgs.safetycli.com/repository/public/npmjs/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", "cpu": [ "arm64" ], @@ -337,9 +355,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.23.1.tgz", - "integrity": "sha512-aY2gMmKmPhxfU+0EdnN+XNtGbjfQgwZj43k8G3fyrDM/UdZww6xrWxmDkuz2eCZchqVeABjV5BpildOrUbBTqA==", + "version": "0.28.2", + "resolved": "https://pkgs.safetycli.com/repository/public/npmjs/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", "cpu": [ "x64" ], @@ -353,10 +371,27 @@ "node": ">=18" } }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://pkgs.safetycli.com/repository/public/npmjs/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/sunos-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.23.1.tgz", - "integrity": "sha512-RBRT2gqEl0IKQABT4XTj78tpk9v7ehp+mazn2HbUeZl1YMdaGAQqhapjGTCe7uw7y0frDi4gS0uHzhvpFuI1sA==", + "version": "0.28.2", + "resolved": "https://pkgs.safetycli.com/repository/public/npmjs/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", "cpu": [ "x64" ], @@ -371,9 +406,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.23.1.tgz", - "integrity": "sha512-4O+gPR5rEBe2FpKOVyiJ7wNDPA8nGzDuJ6gN4okSA1gEOYZ67N8JPk58tkWtdtPeLz7lBnY6I5L3jdsr3S+A6A==", + "version": "0.28.2", + "resolved": "https://pkgs.safetycli.com/repository/public/npmjs/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", "cpu": [ "arm64" ], @@ -388,9 +423,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.23.1.tgz", - "integrity": "sha512-BcaL0Vn6QwCwre3Y717nVHZbAa4UBEigzFm6VdsVdT/MbZ38xoj1X9HPkZhbmaBGUD1W8vxAfffbDe8bA6AKnQ==", + "version": "0.28.2", + "resolved": "https://pkgs.safetycli.com/repository/public/npmjs/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", "cpu": [ "ia32" ], @@ -405,9 +440,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.23.1.tgz", - "integrity": "sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg==", + "version": "0.28.2", + "resolved": "https://pkgs.safetycli.com/repository/public/npmjs/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", "cpu": [ "x64" ], @@ -457,9 +492,9 @@ "license": "MIT" }, "node_modules/esbuild": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.23.1.tgz", - "integrity": "sha512-VVNz/9Sa0bs5SELtn3f7qhJCDPCF5oMEl5cO9/SSinpE9hbPVvxbd572HH5AKiP7WD8INO53GgfDDhRjkylHEg==", + "version": "0.28.2", + "resolved": "https://pkgs.safetycli.com/repository/public/npmjs/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -470,30 +505,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.23.1", - "@esbuild/android-arm": "0.23.1", - "@esbuild/android-arm64": "0.23.1", - "@esbuild/android-x64": "0.23.1", - "@esbuild/darwin-arm64": "0.23.1", - "@esbuild/darwin-x64": "0.23.1", - "@esbuild/freebsd-arm64": "0.23.1", - "@esbuild/freebsd-x64": "0.23.1", - "@esbuild/linux-arm": "0.23.1", - "@esbuild/linux-arm64": "0.23.1", - "@esbuild/linux-ia32": "0.23.1", - "@esbuild/linux-loong64": "0.23.1", - "@esbuild/linux-mips64el": "0.23.1", - "@esbuild/linux-ppc64": "0.23.1", - "@esbuild/linux-riscv64": "0.23.1", - "@esbuild/linux-s390x": "0.23.1", - "@esbuild/linux-x64": "0.23.1", - "@esbuild/netbsd-x64": "0.23.1", - "@esbuild/openbsd-arm64": "0.23.1", - "@esbuild/openbsd-x64": "0.23.1", - "@esbuild/sunos-x64": "0.23.1", - "@esbuild/win32-arm64": "0.23.1", - "@esbuild/win32-ia32": "0.23.1", - "@esbuild/win32-x64": "0.23.1" + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" } }, "node_modules/typescript": { diff --git a/package.json b/package.json index 57a0baa..2911b40 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ }, "devDependencies": { "@types/chrome": "^0.0.270", - "esbuild": "^0.23.0", + "esbuild": "^0.28.1", "typescript": "^5.5.0" }, "repository": { @@ -31,4 +31,4 @@ ], "author": "stefw", "license": "MIT" -} +} \ No newline at end of file diff --git a/public/manifest.json b/public/manifest.json index 57ebd2e..44b8442 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -5,6 +5,9 @@ "description": "Masque les posts LinkedIn inintéressants grâce à Jev (TypeSafe AI).", "permissions": ["storage"], "host_permissions": ["https://api.typesafe.ai/*"], + // TODO(learner) [Étape 1 / ancre 3 — REVISED: dual provider]: KEEP the + // TypeSafe entry and ADD "https://openrouter.ai/*" — the array is an + // allowlist, one entry per host the service worker may fetch. "background": { "service_worker": "background.js", "type": "module" }, "content_scripts": [ { diff --git a/public/options.html b/public/options.html index 3b3742c..a7d02c2 100644 --- a/public/options.html +++ b/public/options.html @@ -22,6 +22,15 @@

Accès à Jev

Récupérable sur console.typesafe.ai. Stockée dans le stockage synchronisé de Chrome, jamais injectée dans la page LinkedIn. + diff --git a/src/background.ts b/src/background.ts index e6a9867..9ad7a6e 100644 --- a/src/background.ts +++ b/src/background.ts @@ -195,7 +195,12 @@ async function classifyOnce(post: PostPayload): Promise { const cached = await cacheGet(post.id); if (cached) return { verdict: cached, meta: { ...NO_CALL, evaluated: true, cached: true } }; - if (!s.apiKey) throw new Error("Clé API TypeSafe manquante (voir les options de l'extension)."); + // TODO(learner) [Étape 1 / ancre 6 — REVISED again: default typesafe, TWO + // key fields]: make this provider-neutral, e.g. "aucune clé API pour ce + // fournisseur (voir les options de l'extension)", and select the key by + // provider: `const apiKey = s.provider === "openrouter" ? s.openrouterApiKey + // : s.apiKey`. Then pass BOTH apiKey and s.provider into askJev() (see + // jev.ts ancre 1b). const state = { user_interests: s.interests, diff --git a/src/jev.ts b/src/jev.ts index 82e1394..d3244b3 100644 --- a/src/jev.ts +++ b/src/jev.ts @@ -1,5 +1,6 @@ -// Client minimal pour l'endpoint System One de TypeSafe. -// Réf. : https://docs.typesafe.ai/api +// Client minimal pour l'endpoint System One — TypeSafe direct OU via OpenRouter, +// au choix de l'utilisateur (voir `Provider` ci-dessous). +// Réf. : https://docs.typesafe.ai/api et https://openrouter.ai/docs/guides/community/jev-tutorial export type NoulQuestion = { type: "noul"; @@ -47,6 +48,18 @@ export interface JevResponse> { } const ENDPOINT = "https://api.typesafe.ai/v1/systemone"; +// TODO(learner) [Étape 1 / ancre 1b — REVISED: dual provider]: replace the single +// const with a map + derived type, and give askJev() a first parameter: +// +// const ENDPOINTS = { +// typesafe: "https://api.typesafe.ai/v1/systemone", +// openrouter: "https://openrouter.ai/api/v1/systemone", +// } as const; +// export type Provider = keyof typeof ENDPOINTS; +// +// In askJev(...): add `provider: Provider` first, read ENDPOINTS[provider], and +// thread the value in from background.ts (ancre 6 there). Both endpoints serve +// the identical { model, state, questions } body — your Step 0 curl proved it. const TIMEOUT_MS = 10_000; export async function askJev>( @@ -67,6 +80,13 @@ export async function askJev>( }); if ((res.status === 429 || res.status === 529) && attempt < 3) { + // TODO(learner) [Étape 1 / ancre ... — retry]: with both providers served by + // this one function, widen to `res.status >= 500 || res.status === 429` + // (OpenRouter adds 502/503/524 retryables; TypeSafe's 529 is already >= 500). + // First answer for yourself: is there any 5xx in OpenRouter's table that is + // NOT transient? (500 "Internal Server Error" — think about what retrying it + // hides.) + // API reference: https://openrouter.ai/docs/api/api-reference/alphadecisions/submit-a-decisions-request const retryAfter = Number(res.headers.get("retry-after")); const delay = retryAfter > 0 ? Math.min(retryAfter * 1000, 10_000) : 400 * 2 ** attempt; await new Promise((r) => setTimeout(r, delay)); diff --git a/src/options.ts b/src/options.ts index 5d09237..78b543c 100644 --- a/src/options.ts +++ b/src/options.ts @@ -59,6 +59,12 @@ async function load(): Promise { render({ ...DEFAULT_SETTINGS, ...(stored as Partial) }); } +// TODO(learner) [Étape 1 / ancre 5 — REVISED again: TWO key fields]: map BOTH +// new fields through this file — add `provider` (an HTMLSelectElement, +// id="provider") and `openrouterApiKey` (a password input, id="openrouterKey") +// to the `fields` map, then mirror BOTH in render() and read() the way +// `apiKey` / `model` flow today. + async function save(): Promise { await chrome.storage.sync.set(read()); status.textContent = "Enregistré. Les nouveaux posts suivent ces réglages ; recharge LinkedIn pour réévaluer le fil."; diff --git a/src/types.ts b/src/types.ts index 8eba1c7..fec9685 100644 --- a/src/types.ts +++ b/src/types.ts @@ -29,6 +29,14 @@ export const DEFAULT_SETTINGS: Settings = { enabled: true, apiKey: "", model: "jev-latest", + // TODO(learner) [Étape 1 / ancre 2 — REVISED again: default typesafe, TWO + // key fields]: ADD to this interface: `provider: Provider` (import the type + // from "./jev") and `openrouterApiKey: string`. In DEFAULT_SETTINGS add + // `provider: "typesafe"` and `openrouterApiKey: ""`, and LEAVE the existing + // `apiKey` (it stays the TypeSafe key — an existing install's synced storage + // keeps working) and `model: "jev-latest"` alone; they pair with the + // default provider. Note accepted rough edge: switching provider does not + // auto-switch `model` — document your v1 stance in a comment here. interests: [], blocked: [], interestThreshold: 0.35, From d4e128d91a0f953a30f8730eed68e2f7a05c74f2 Mon Sep 17 00:00:00 2001 From: Andrei Sorokin Date: Mon, 9 Nov 2026 22:37:12 +0200 Subject: [PATCH 02/11] Add OpenRouter option (only Jev); switch all to English (idk French, sorry), including comments --- public/content.css | 4 +- public/manifest.json | 40 ++++++++++---- public/options.css | 2 +- public/options.html | 74 +++++++++++++------------ src/background.ts | 127 ++++++++++++++++++++++++++++++------------- src/content.ts | 125 +++++++++++++++++++++--------------------- src/jev.ts | 42 +++++--------- src/options.ts | 44 ++++++++++----- src/sticker.ts | 36 ++++++------ src/types.ts | 52 +++++++++++------- 10 files changed, 319 insertions(+), 227 deletions(-) diff --git a/public/content.css b/public/content.css index c554961..a1bda98 100644 --- a/public/content.css +++ b/public/content.css @@ -1,6 +1,6 @@ [data-lkclean-hidden] > :not(.lkclean-bar) { display: none !important; } [data-lkclean-hidden] { padding: 0 !important; min-height: 0 !important; } -/* Relance du scroll infini : pousse brièvement le marqueur de fin de fil hors de l'écran. */ +/* Infinite scroll kick: briefly pushes the end-of-feed marker off the screen. */ [data-lkclean-push] { margin-bottom: 8000px !important; } .lkclean-bar, .lkclean-note { @@ -16,7 +16,7 @@ } .lkclean-bar { padding: 8px 14px; margin: 4px 0; background: var(--lk-fill); border-radius: 8px; } .lkclean-bar--shown { margin-bottom: 8px; } -/* Post gardé : bloc vert plein, textes blancs (contraste ≥ 5:1, identique en thème clair et sombre). */ +/* Kept post: solid green block, white text (contrast ≥ 5:1, identical in light and dark themes). */ .lkclean-note, .lkclean-note.lkclean--dark { --lk-ink: #ffffff; --lk-muted: #ffffff; --lk-line: #ffffff73; padding: 7px 12px; margin: 4px 0 6px; border-radius: 8px; background: #0f7a61; diff --git a/public/manifest.json b/public/manifest.json index 44b8442..78c7a9e 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -2,21 +2,37 @@ "manifest_version": 3, "name": "lkclean", "version": "0.1.0", - "description": "Masque les posts LinkedIn inintéressants grâce à Jev (TypeSafe AI).", - "permissions": ["storage"], - "host_permissions": ["https://api.typesafe.ai/*"], - // TODO(learner) [Étape 1 / ancre 3 — REVISED: dual provider]: KEEP the - // TypeSafe entry and ADD "https://openrouter.ai/*" — the array is an - // allowlist, one entry per host the service worker may fetch. - "background": { "service_worker": "background.js", "type": "module" }, + "description": "Hides uninteresting LinkedIn posts, verified by Jev (TypeSafe direct or via OpenRouter).", + "permissions": [ + "storage" + ], + "host_permissions": [ + "https://api.typesafe.ai/*", + "https://openrouter.ai/*" + ], + "background": { + "service_worker": "background.js", + "type": "module" + }, "content_scripts": [ { - "matches": ["https://www.linkedin.com/*"], - "js": ["content.js"], - "css": ["content.css"], + "matches": [ + "https://www.linkedin.com/*" + ], + "js": [ + "content.js" + ], + "css": [ + "content.css" + ], "run_at": "document_idle" } ], - "options_ui": { "page": "options.html", "open_in_tab": true }, - "action": { "default_title": "lkclean — options" } + "options_ui": { + "page": "options.html", + "open_in_tab": true + }, + "action": { + "default_title": "lkclean — settings" + } } diff --git a/public/options.css b/public/options.css index 6616ba8..38337d8 100644 --- a/public/options.css +++ b/public/options.css @@ -19,7 +19,7 @@ section { padding: 24px 0; border-top: 1px solid var(--line); } label { display: block; font-weight: 500; margin: 14px 0 6px; } label:first-of-type { margin-top: 0; } small { display: block; color: var(--muted); font-size: 13px; margin-top: 6px; } -input[type="text"], input[type="password"], input[type="number"], textarea { +input[type="text"], input[type="password"], input[type="number"], textarea, select { width: 100%; padding: 9px 11px; font: inherit; color: var(--ink); background: var(--field); border: 1px solid var(--line); border-radius: 6px; } diff --git a/public/options.html b/public/options.html index a7d02c2..c1b482a 100644 --- a/public/options.html +++ b/public/options.html @@ -1,83 +1,87 @@ - + - lkclean — réglages + lkclean — settings

lkclean

-

Décide, post par post, ce qui mérite ta lecture. Chaque post du fil est évalué par Jev ; les verdicts sont mis en cache pour la session.

+

Decides, post by post, what is worth reading. Every feed post is evaluated by Jev; verdicts are cached for the session.

-

Accès à Jev

- +

Jev access

+ + + Jev runs identically on both; pick where your key lives. OpenRouter needs no TypeSafe account. + + - Récupérable sur console.typesafe.ai. Stockée dans le stockage synchronisé de Chrome, jamais injectée dans la page LinkedIn. - - + Get one at console.typesafe.ai. Stored in Chrome's synced storage, never injected into the LinkedIn page. + + + + Get one at openrouter.ai/settings/keys (credits at openrouter.ai/credits). Only used when the provider above is OpenRouter. + + + jev-latest (TypeSafe) or typesafe/jev-1.14 (OpenRouter). Note: switching provider does not switch the model id for you.
-

Ce qui t'intéresse

- - - Laisse vide pour ne filtrer que le bruit (engagement bait, auto-promo, sponsorisé). - - +

What you like

+ + + Leave empty to filter only the noise (engagement bait, self-promo, sponsored). + +
-

Sensibilité

+

Sensitivity

- + - Probabilité qu'un post soit de l'engagement bait ou de l'auto-promo. Plus haut = plus tolérant. + Probability a post is engagement bait or self-promo. Higher = more tolerant.
- + - Probabilité que le post touche un de tes centres d'intérêt. Plus bas = ne masque que ce dont Jev est sûr. + Probability the post touches one of your interests. Lower = only hide what Jev is confident about.
- -
caractères
+ +
characters
-

Sur la page LinkedIn

+

On the LinkedIn page

- +
diff --git a/src/background.ts b/src/background.ts index 9ad7a6e..6bae17f 100644 --- a/src/background.ts +++ b/src/background.ts @@ -14,16 +14,21 @@ interface Classified { verdict: Verdict; meta: CallMeta; } -const NO_CALL: CallMeta = { evaluated: false, cached: false, ms: 0, inputTokens: 0 }; +const NO_CALL: CallMeta = { + evaluated: false, + cached: false, + ms: 0, + inputTokens: 0, +}; -// ---------- réglages ---------- +// ---------- settings ---------- async function loadSettings(): Promise { const stored = await chrome.storage.sync.get(DEFAULT_SETTINGS); return { ...DEFAULT_SETTINGS, ...(stored as Partial) }; } -// ---------- cache des verdicts (session) ---------- +// ---------- verdict cache (session) ---------- const memCache = new Map(); @@ -42,7 +47,7 @@ async function cacheSet(id: string, v: Verdict): Promise { await chrome.storage.session.set({ [`v:${id}`]: v }); } -// Un changement de réglages invalide les verdicts. +// A settings change invalidates verdicts. chrome.storage.onChanged.addListener((_, area) => { if (area === "sync") { memCache.clear(); @@ -51,18 +56,19 @@ chrome.storage.onChanged.addListener((_, area) => { } }); -// ---------- questions posées à Jev ---------- +// ---------- questions asked of Jev ---------- function buildQuestions(s: Settings) { - // Consignes en anglais : Jev est entraîné d'abord sur l'anglais (les posts, eux, restent dans leur langue). + // Instructions in English: Jev is trained primarily on English (the posts themselves stay in their language). const q: Record = { engagement_bait: { type: "noul", instructions: "Is this LinkedIn post engagement bait: content designed primarily to trigger reactions rather than to inform?", criteria: { - true: "Rhetorical closing question (\"Agree?\", \"Thoughts?\", \"Qu'en pensez-vous ?\"), hollow emotional storytelling, clickbait hook, generic opinion with no substance, polls or 'comment X to get Y' mechanics", - false: "The post delivers concrete information, analysis, data or first-hand experience the reader can use", + true: 'Rhetorical closing question ("Agree?", "Thoughts?", "What do you think?"), hollow emotional storytelling, clickbait hook, generic opinion with no substance, polls or \'comment X to get Y\' mechanics', + false: + "The post delivers concrete information, analysis, data or first-hand experience the reader can use", }, }, self_promo: { @@ -70,8 +76,9 @@ function buildQuestions(s: Settings) { instructions: "Is this post mainly self-promotion (personal announcement, humble-brag, celebrating a job/award/certification) with nothing useful for the reader?", criteria: { - true: "\"I'm thrilled to announce\", new job, certification, thanking the team, company PR, with no takeaway for the reader", - false: "Even if it talks about the author, the post shares something the reader can act on or learn from", + true: '"I\'m thrilled to announce", new job, certification, thanking the team, company PR, with no takeaway for the reader', + false: + "Even if it talks about the author, the post shares something the reader can act on or learn from", }, }, }; @@ -88,9 +95,11 @@ function buildQuestions(s: Settings) { }; } - // « Lequel ? » : matches_interests ne donne qu'une probabilité globale, pas le sujet concerné. + // "Which one?": matches_interests only gives a global probability, not the specific topic. if (s.interests.length > 1) { - const criteria: Record = { none: "The post does not substantially cover any of them" }; + const criteria: Record = { + none: "The post does not substantially cover any of them", + }; for (const t of s.interests) criteria[t] = null; q.top_interest = { type: "choice", @@ -101,7 +110,9 @@ function buildQuestions(s: Settings) { } if (s.blocked.length > 0) { - const criteria: Record = { none: "None of the blocked topics" }; + const criteria: Record = { + none: "None of the blocked topics", + }; for (const t of s.blocked) criteria[t] = null; q.blocked_topic = { type: "choice", @@ -114,7 +125,7 @@ function buildQuestions(s: Settings) { return q; } -// ---------- décision ---------- +// ---------- decision ---------- function decide(s: Settings, answers: Record): Verdict { const noul = (k: string) => { @@ -130,13 +141,17 @@ function decide(s: Settings, answers: Record): Verdict { if (promo !== undefined) scores.self_promo = promo; if (match !== undefined) scores.matches_interests = match; - // Un seul intérêt déclaré : pas besoin de demander lequel. + // Only one interest declared: no need to ask which. const top = answers.top_interest; let interest: string | undefined; if (top?.type === "choice") { if (top.choice !== "none") interest = top.choice; scores.top_interest_confidence = top.confidence; - } else if (s.interests.length === 1 && match !== undefined && match >= s.interestThreshold) { + } else if ( + s.interests.length === 1 && + match !== undefined && + match >= s.interestThreshold + ) { interest = s.interests[0]; } @@ -144,17 +159,35 @@ function decide(s: Settings, answers: Record): Verdict { if (blocked?.type === "choice") { scores.blocked_confidence = blocked.confidence; if (blocked.choice !== "none" && blocked.confidence >= 0.6) { - return { hide: true, reason: "blocked_topic", detail: blocked.choice, interest, scores }; + return { + hide: true, + reason: "blocked_topic", + detail: blocked.choice, + interest, + scores, + }; } } if (bait !== undefined && bait >= s.noiseThreshold) { - return { hide: true, reason: "engagement_bait", detail: pct(bait), interest, scores }; + return { + hide: true, + reason: "engagement_bait", + detail: pct(bait), + interest, + scores, + }; } if (promo !== undefined && promo >= s.noiseThreshold) { - return { hide: true, reason: "self_promo", detail: pct(promo), interest, scores }; + return { + hide: true, + reason: "self_promo", + detail: pct(promo), + interest, + scores, + }; } - // On ne masque pour hors-sujet que si Jev est vraiment sûr que ça ne colle pas. + // Only hide as off-topic when Jev is truly sure the post doesn't match. if (match !== undefined && match < s.interestThreshold) { return { hide: true, reason: "off_topic", detail: pct(match), scores }; } @@ -165,7 +198,7 @@ const pct = (x: number) => `${Math.round(x * 100)} %`; // ---------- classification ---------- -// Deux demandes simultanées pour le même post ne doivent coûter qu'un appel. +// Two simultaneous requests for the same post must cost only one call. const inflight = new Map>(); function classify(post: PostPayload): Promise { @@ -173,7 +206,7 @@ function classify(post: PostPayload): Promise { if (pending) return pending; const p = classifyOnce(post) .catch((e: unknown) => { - // Signal visible sur l'icône : sans ça une clé absente/invalide passe inaperçue. + // Visible signal on the toolbar icon: without it a missing/invalid key goes unnoticed. void chrome.action.setBadgeBackgroundColor({ color: "#c0392b" }); void chrome.action.setBadgeText({ text: "!" }); throw e; @@ -190,32 +223,47 @@ async function classifyOnce(post: PostPayload): Promise { if (post.sponsored && s.hideSponsored) { return { verdict: { hide: true, reason: "sponsored" }, meta: NO_CALL }; } - if (post.text.trim().length < s.minChars) return { verdict: { hide: false }, meta: NO_CALL }; + if (post.text.trim().length < s.minChars) + return { verdict: { hide: false }, meta: NO_CALL }; const cached = await cacheGet(post.id); - if (cached) return { verdict: cached, meta: { ...NO_CALL, evaluated: true, cached: true } }; - - // TODO(learner) [Étape 1 / ancre 6 — REVISED again: default typesafe, TWO - // key fields]: make this provider-neutral, e.g. "aucune clé API pour ce - // fournisseur (voir les options de l'extension)", and select the key by - // provider: `const apiKey = s.provider === "openrouter" ? s.openrouterApiKey - // : s.apiKey`. Then pass BOTH apiKey and s.provider into askJev() (see - // jev.ts ancre 1b). + if (cached) + return { + verdict: cached, + meta: { ...NO_CALL, evaluated: true, cached: true }, + }; const state = { user_interests: s.interests, post: { author: post.author, text: post.text.slice(0, 4000) }, }; + const apiKey = s.provider === "openrouter" ? s.openrouterApiKey : s.apiKey; + if (!apiKey) { + throw new Error("No API key configured for provider: " + s.provider); + } + const t0 = performance.now(); - const res = await askJev(s.apiKey, s.model, state, buildQuestions(s)); + const res = await askJev( + apiKey, + s.provider, + s.model, + state, + buildQuestions(s), + ); const ms = Math.round(performance.now() - t0); void chrome.action.setBadgeText({ text: "" }); const verdict = decide(s, res.answers as Record); await cacheSet(post.id, verdict); return { verdict, - meta: { evaluated: true, cached: false, ms, inputTokens: res.usage?.input_tokens ?? 0, model: res.model }, + meta: { + evaluated: true, + cached: false, + ms, + inputTokens: res.usage?.input_tokens ?? 0, + model: res.model, + }, }; } @@ -224,14 +272,19 @@ async function classifyOnce(post: PostPayload): Promise { chrome.runtime.onMessage.addListener((msg: Message, _sender, sendResponse) => { if (msg.type === "classify") { classify(msg.post) - .then(({ verdict, meta }) => sendResponse({ ok: true, verdict, meta } satisfies ClassifyResponse)) + .then(({ verdict, meta }) => + sendResponse({ ok: true, verdict, meta } satisfies ClassifyResponse), + ) .catch((e: unknown) => - sendResponse({ ok: false, error: e instanceof Error ? e.message : String(e) } satisfies ClassifyResponse), + sendResponse({ + ok: false, + error: e instanceof Error ? e.message : String(e), + } satisfies ClassifyResponse), ); - return true; // réponse asynchrone + return true; // async response } if (msg.type === "getUiSettings") { - // Le content script ne reçoit que ce dont il a besoin pour l'affichage : jamais la clé API. + // The content script only gets what it needs for display: never the API key. loadSettings().then((s) => sendResponse({ enabled: s.enabled, diff --git a/src/content.ts b/src/content.ts index b76fbe3..197ced3 100644 --- a/src/content.ts +++ b/src/content.ts @@ -1,12 +1,12 @@ import { sticker } from "./sticker"; import type { ClassifyResponse, Message, PostPayload, Reason, UiSettings, Verdict } from "./types"; -// Sélecteurs LinkedIn : les classes sont obfusquées et bougent souvent, on vise -// en priorité les attributs data-* (bien plus stables) puis des fallbacks. +// LinkedIn selectors: classes are obfuscated and move often, so we target +// data-* attributes first (much more stable), then fall back to classes. const POST_SELECTORS = [ - // nouveau front React : le fil est une liste ARIA, un listitem par post + // new React front end: the feed is an ARIA list, one listitem per post '[data-testid="mainFeed"] [role="listitem"]', - // ancien front Ember + // old Ember front end 'div[data-id^="urn:li:activity"]', 'div[data-urn^="urn:li:activity"]', "div.feed-shared-update-v2", @@ -17,7 +17,7 @@ const TEXT_SELECTORS = [ ".feed-shared-update-v2__description", ".feed-shared-inline-show-more-text", ]; -// Les commentaires réutilisent les mêmes composants de texte que les posts. +// Comments reuse the same text components as posts. const COMMENTS_SELECTOR = '[data-testid*="commentList"], [componentkey^="replaceableComment"]'; const AUTHOR_SELECTORS = [ ".update-components-actor__title", @@ -25,16 +25,16 @@ const AUTHOR_SELECTORS = [ ]; const PROCESSED = "lkcleanProcessed"; -// Attribut plutôt que classe : React réécrit className à chaque rendu, pas les attributs qu'il ignore. +// Attribute rather than class: React rewrites className on every render, not the attributes it ignores. const HIDDEN_ATTR = "data-lkclean-hidden"; const MAX_CONCURRENT = 4; const REASON_LABEL: Record = { engagement_bait: "engagement bait", - self_promo: "auto-promo", - off_topic: "hors de tes centres d'intérêt", - blocked_topic: "sujet masqué", - sponsored: "sponsorisé", + self_promo: "self-promo", + off_topic: "off-topic", + blocked_topic: "blocked topic", + sponsored: "sponsored", }; // ---------- extraction ---------- @@ -44,7 +44,7 @@ function firstText(root: Element, selectors: string[]): string { for (const el of root.querySelectorAll(sel)) { if (el.closest(COMMENTS_SELECTOR)) continue; let t = el.innerText?.trim() ?? ""; - // Retire le libellé du bouton « … plus » inclus dans la boîte de texte. + // Strip the label of the "… more" button included in the text box. const more = el.querySelector('[data-testid="expandable-text-button"]')?.innerText.trim(); if (more && t.endsWith(more)) t = t.slice(0, -more.length).trim(); if (t) return t; @@ -53,11 +53,13 @@ function firstText(root: Element, selectors: string[]): string { return ""; } -// \b ne fonctionne pas après « é » : on borne avec des lookarounds Unicode. -const SPONSORED_RE = /(?("span, p, a, div")) { @@ -73,7 +75,7 @@ function hasSponsoredLabel(el: HTMLElement): boolean { function authorOf(el: HTMLElement): string { const named = firstText(el, AUTHOR_SELECTORS); if (named) return named.split("\n")[0] ?? ""; - // Nouveau front : pas d'attribut stable, on prend le premier lien de profil/page qui porte un nom. + // New front end: no stable attribute, take the first profile/page link that carries a name. for (const a of el.querySelectorAll('a[href*="/in/"], a[href*="/company/"]')) { if (a.closest(COMMENTS_SELECTOR)) continue; const name = a.innerText.trim().split("\n")[0]; @@ -83,17 +85,17 @@ function authorOf(el: HTMLElement): string { } function extract(el: HTMLElement): PostPayload | null { - // Pas de repli sur innerText : sans vrai texte (image seule), on jugerait le post sur ses boutons. + // No innerText fallback: without real text (image-only post) we'd judge the post by its buttons. const text = firstText(el, TEXT_SELECTORS); // Le nouveau front n'expose plus l'URN (les componentkey sont des UUID par rendu) : - // à défaut, on identifie le post par un hash de son texte, stable entre rechargements. + // as a fallback, identify the post by a hash of its text — stable across reloads. const id = el.getAttribute("data-id") ?? el.getAttribute("data-urn") ?? el.querySelector("[data-urn]")?.getAttribute("data-urn") ?? `h:${hash(text || el.innerText.slice(0, 300))}`; const author = authorOf(el); - // On ne regarde que l'en-tête (avant le texte du post), sinon « I got promoted » passe pour une pub. + // Only look at the header (before the post text), otherwise "I got promoted" would read as an ad. const full = el.innerText; const textStart = text ? full.indexOf(text.slice(0, 40)) : -1; const head = (textStart > 0 ? full.slice(0, textStart) : full.slice(0, 300)).toLowerCase(); @@ -108,7 +110,7 @@ function hash(s: string): string { return `${(h >>> 0).toString(36)}${s.length.toString(36)}`; } -// ---------- rendu ---------- +// ---------- rendering ---------- let ui: UiSettings = { enabled: true, @@ -118,7 +120,7 @@ let ui: UiSettings = { interestThreshold: 0.35, }; -const pct = (x: number) => `${Math.round(x * 100)} %`; +const pct = (x: number) => `${Math.round(x * 100)}%`; function node(tag: K, cls: string, text?: string): HTMLElementTagNameMap[K] { const n = document.createElement(tag); @@ -127,7 +129,7 @@ function node(tag: K, cls: string, text?: return n; } -// LinkedIn gère son thème sombre par classe, pas par prefers-color-scheme : on mesure le fond réel. +// LinkedIn handles dark mode with a class, not prefers-color-scheme: measure the actual background. function isDarkPage(): boolean { const m = getComputedStyle(document.body).backgroundColor.match(/\d+(\.\d+)?/g); if (!m || m.length < 3) return false; @@ -135,38 +137,38 @@ function isDarkPage(): boolean { return 0.2126 * r + 0.7152 * g + 0.0722 * b < 110; } -/** Résumé en quelques mots d'un verdict, partagé entre la note du post et la pastille. */ +/** A verdict in a few words, shared between the post note and the sticker. */ function verdictLabel(v: Verdict): string { - if (v.hide) return v.reason ? REASON_LABEL[v.reason] : "masqué"; + if (v.hide) return v.reason ? REASON_LABEL[v.reason] : "hidden"; const match = v.scores?.matches_interests; - if (match === undefined) return "peu de bruit"; - return v.interest ? `${v.interest} · ${pct(match)}` : `intérêt ${pct(match)}`; + if (match === undefined) return "low noise"; + return v.interest ? `${v.interest} · ${pct(match)}` : `interest ${pct(match)}`; } type ChipState = "plain" | "near" | "over"; -// Où se situe chaque score par rapport au seuil qui fait masquer : franchi, frôlé (à 15 points), ou loin. +// Where each score stands relative to the threshold that hides a post: crossed, near (within 15 points), or far. function scoreStates(v: Verdict): { label: string; value: number; state: ChipState; hint: string }[] { const sc = v.scores ?? {}; const out: ReturnType = []; const m = sc.matches_interests; if (m !== undefined) { const state = m < ui.interestThreshold ? "over" : m < ui.interestThreshold + 0.15 ? "near" : "plain"; - const which = v.interest ? `Sujet reconnu : ${v.interest}. ` : ""; - out.push({ label: "intérêt", value: m, state, hint: `${which}Masqué sous ${pct(ui.interestThreshold)}` }); + const which = v.interest ? `Recognized topic: ${v.interest}. ` : ""; + out.push({ label: "interest", value: m, state, hint: `${which}Hides below ${pct(ui.interestThreshold)}` }); } for (const [key, label] of [["engagement_bait", "bait"], ["self_promo", "promo"]] as const) { const x = sc[key]; if (x === undefined) continue; const state = x >= ui.noiseThreshold ? "over" : x > ui.noiseThreshold - 0.15 ? "near" : "plain"; - out.push({ label, value: x, state, hint: `Masqué à partir de ${pct(ui.noiseThreshold)}` }); + out.push({ label, value: x, state, hint: `Hides at ${pct(ui.noiseThreshold)}` }); } return out; } function scoreChips(v: Verdict): HTMLElement[] { return scoreStates(v).map(({ label, value, state, hint }) => { - // Sur un post masqué, seul le score qui a franchi le seuil ressort. + // On a hidden post, only the score that crossed its threshold stands out. const shown = v.hide && state === "near" ? "plain" : state; const c = node("span", `lkclean-chip lkclean-chip--${shown}`, `${label} ${pct(value)}`); c.title = hint; @@ -177,16 +179,16 @@ function scoreChips(v: Verdict): HTMLElement[] { function noteKept(el: HTMLElement, v: Verdict): void { if (!ui.showKeptReason || !v.scores || el.querySelector(":scope > .lkclean-note")) return; const tight = scoreStates(v).some((x) => x.state === "near"); - const why = v.interest ?? (v.scores.matches_interests !== undefined ? "pertinent pour toi" : "peu de bruit"); + const why = v.interest ?? (v.scores.matches_interests !== undefined ? "relevant to you" : "low noise"); const note = node("div", `lkclean-note${isDarkPage() ? " lkclean--dark" : ""}`); const lead = node("span", "lkclean-note__lead"); lead.append( node("span", `lkclean-note__dot${tight ? " lkclean-note__dot--near" : ""}`), - node("b", "", tight ? "Gardé de justesse" : "Gardé par Jev"), + node("b", "", tight ? "Barely kept" : "Kept by Jev"), " · ", v.interest ? node("span", "lkclean-note__topic", why) : why, ); - if (v.interest) lead.title = `Centre d'intérêt reconnu : ${v.interest}`; + if (v.interest) lead.title = `Recognized interest: ${v.interest}`; note.append(lead, ...scoreChips(v)); el.prepend(note); } @@ -196,15 +198,15 @@ function hidePost(el: HTMLElement, v: Verdict): void { el.querySelector(":scope > .lkclean-note")?.remove(); const bar = node("div", `lkclean-bar${isDarkPage() ? " lkclean--dark" : ""}`); const reason = node("span", "lkclean-bar__reason"); - // verdictLabel() d'un post masqué = la raison ; le sujet reconnu est donné en complément. - reason.append("Post masqué · ", node("b", "", verdictLabel(v)), v.detail ? ` · ${v.detail}` : ""); - if (v.interest) reason.append(" · sujet : ", node("span", "lkclean-note__topic", v.interest)); + // verdictLabel() of a hidden post = the reason; the recognized topic is added as a complement. + reason.append("Post hidden · ", node("b", "", verdictLabel(v)), v.detail ? ` · ${v.detail}` : ""); + if (v.interest) reason.append(" · topic: ", node("span", "lkclean-note__topic", v.interest)); reason.title = reason.textContent ?? ""; - const btn = node("button", "", "Afficher"); + const btn = node("button", "", "Show"); btn.type = "button"; btn.addEventListener("click", () => { const shown = el.toggleAttribute(HIDDEN_ATTR) === false; - btn.textContent = shown ? "Re-masquer" : "Afficher"; + btn.textContent = shown ? "Hide again" : "Show"; bar.classList.toggle("lkclean-bar--shown", shown); }); bar.append(reason, ...scoreChips(v), btn); @@ -224,12 +226,12 @@ let seen = 0; const ANY_POST = POST_SELECTORS.join(","); function enqueue(el: HTMLElement): void { - // Les sélecteurs matchent aussi des conteneurs imbriqués : on ne traite que le plus externe. + // Selectors also match nested containers: only process the outermost. const outer = el.parentElement?.closest(ANY_POST); if (outer) return enqueue(outer); if (el.dataset[PROCESSED]) return; el.dataset[PROCESSED] = "1"; - if (seen++ === 0) console.info("[lkclean] premier post détecté"); + if (seen++ === 0) console.info("[lkclean] first post detected"); lastArrival = Date.now(); kicks = 0; queue.push(el); @@ -252,8 +254,8 @@ async function process(el: HTMLElement): Promise { const post = extract(el); if (!post) return; - // React insère souvent le conteneur avant son contenu : on libère le post pour qu'une - // mutation ultérieure (arrivée du texte) le remette en file, dans la limite de MAX_ATTEMPTS. + // React often inserts the container before its content: release the post so + // a later mutation (the text arriving) queues it again, up to MAX_ATTEMPTS. if (!post.text && !post.sponsored) { const n = (attempts.get(el) ?? 0) + 1; attempts.set(el, n); @@ -267,13 +269,13 @@ async function process(el: HTMLElement): Promise { res = await chrome.runtime.sendMessage({ type: "classify", post }); } catch (e) { console.warn("[lkclean] message error", e); - sticker.fail("Extension rechargée : actualise la page."); + sticker.fail("Extension reloaded: refresh the page."); return; } if (!res.ok) { console.warn("[lkclean]", res.error); sticker.fail(res.error); - if (/Clé API|Jev 40[13]/.test(res.error)) apiBroken = true; + if (/API key|Jev 40[13]/.test(res.error)) apiBroken = true; return; } console.debug("[lkclean] verdict", post.author.slice(0, 40), res.verdict); @@ -283,12 +285,13 @@ async function process(el: HTMLElement): Promise { else noteKept(el, res.verdict); } -// ---------- relance du scroll infini ---------- +// ---------- infinite scroll kick ---------- -// LinkedIn charge la suite quand un marqueur en bas du fil *entre* dans l'écran. Si on masque -// presque tout, le fil devient si court que le marqueur ne quitte plus l'écran : plus aucun -// événement, le fil se fige. On le pousse donc brièvement hors champ (grande marge sous le -// dernier post, invisible car sous le contenu) puis on le laisse revenir. +// LinkedIn loads more when a marker at the bottom of the feed *enters* the +// viewport. If we hide almost everything, the feed becomes so short the marker +// never leaves the screen: no more events, the feed freezes. So we push it +// briefly out of view (a large margin under the last post, invisible because +// below the content) and then let it come back. const PUSH_ATTR = "data-lkclean-push"; const KICK_IDLE_MS = 1500; const MAX_KICKS = 8; @@ -302,7 +305,7 @@ function lastPost(): HTMLElement | null { function kickInfiniteScroll(): void { if (document.hidden || running > 0 || queue.length > 0) return; - if (!document.querySelector(`[${HIDDEN_ATTR}]`)) return; // on n'a rien raccourci : pas notre problème + if (!document.querySelector(`[${HIDDEN_ATTR}]`)) return; // we shortened nothing: not our problem if (Date.now() - lastArrival < KICK_IDLE_MS * (1 + kicks) || kicks >= MAX_KICKS) return; const last = lastPost(); if (!last || last.getBoundingClientRect().bottom > window.innerHeight * 1.5) return; @@ -312,13 +315,13 @@ function kickInfiniteScroll(): void { last.setAttribute(PUSH_ATTR, ""); setTimeout(() => { last.removeAttribute(PUSH_ATTR); - // Pour les chargeurs basés sur la position de scroll plutôt que sur un marqueur. + // For loaders driven by scroll position rather than by a marker. window.dispatchEvent(new Event("scroll")); document.dispatchEvent(new Event("scroll")); }, 250); } -// ---------- observation du feed ---------- +// ---------- feed observation ---------- function scan(root: ParentNode): void { for (const sel of POST_SELECTORS) { @@ -337,7 +340,7 @@ const observer = new MutationObserver((mutations) => { if (isPost(node)) enqueue(node); else { scan(node); - // Contenu ajouté à l'intérieur d'un post déjà vu mais encore vide. + // Content added inside a post already seen but still empty. const parent = node.closest(ANY_POST); if (parent) enqueue(parent); } @@ -345,8 +348,8 @@ const observer = new MutationObserver((mutations) => { } }); -// Si rien n'est détecté sur le fil, on logue la structure de la page (attributs seulement, -// aucun contenu) pour pouvoir réajuster les sélecteurs. +// If nothing was detected on the feed, log the page structure (attributes only, +// no content) so the selectors can be re-tuned. function diagnose(): void { if (seen > 0 || !location.pathname.startsWith("/feed")) return; const tally = (attr: string) => { @@ -365,7 +368,7 @@ function diagnose(): void { return out; }; const main = document.querySelector("main") ?? document.body; - // Candidat : le plus petit ancêtre commun « raisonnable » d'un gros bloc de texte du fil. + // Candidate: the smallest "reasonable" common ancestor of a large text block on the feed. const big = [...main.querySelectorAll("p, span[dir], div[dir]")].find((e) => e.innerText.length > 200); let post: Element | null = big ?? null; for (let i = 0; post && i < 12 && post.parentElement && post.parentElement !== main; i++) { @@ -377,9 +380,9 @@ function diagnose(): void { testIds: tally("data-testid"), roles: tally("role"), componentKeys: tally("componentkey").slice(0, 15), - firstPost: post ? outline(post, 0, []) : "aucun candidat", + firstPost: post ? outline(post, 0, []) : "no candidate", }; - console.warn("[lkclean] DIAGNOSTIC — aucun post détecté, copie ce bloc :\n" + JSON.stringify(report, null, 1)); + console.warn("[lkclean] DIAGNOSTIC — no post detected, copy this block:\n" + JSON.stringify(report, null, 1)); } async function loadUi(): Promise { @@ -387,7 +390,7 @@ async function loadUi(): Promise { const got = await chrome.runtime.sendMessage({ type: "getUiSettings" }); if (got) ui = got; } catch { - // service worker indisponible : on garde les valeurs par défaut + // service worker unavailable: keep the defaults } if (ui.enabled && ui.showSticker) sticker.mount(); else sticker.unmount(); @@ -400,7 +403,7 @@ async function start(): Promise { }); scan(document); observer.observe(document.body, { childList: true, subtree: true }); - console.info("[lkclean] actif"); + console.info("[lkclean] active"); setTimeout(diagnose, 6000); setInterval(kickInfiniteScroll, 1000); } diff --git a/src/jev.ts b/src/jev.ts index d3244b3..ca317e4 100644 --- a/src/jev.ts +++ b/src/jev.ts @@ -1,6 +1,6 @@ -// Client minimal pour l'endpoint System One — TypeSafe direct OU via OpenRouter, -// au choix de l'utilisateur (voir `Provider` ci-dessous). -// Réf. : https://docs.typesafe.ai/api et https://openrouter.ai/docs/guides/community/jev-tutorial +// Minimal client for the System One endpoint — TypeSafe direct or via +// OpenRouter, as chosen by the user (see `Provider` below). +// Ref.: https://docs.typesafe.ai/api + https://openrouter.ai/docs/guides/community/jev-tutorial export type NoulQuestion = { type: "noul"; @@ -47,29 +47,23 @@ export interface JevResponse> { usage: { input_tokens: number; output_tokens: number }; } -const ENDPOINT = "https://api.typesafe.ai/v1/systemone"; -// TODO(learner) [Étape 1 / ancre 1b — REVISED: dual provider]: replace the single -// const with a map + derived type, and give askJev() a first parameter: -// -// const ENDPOINTS = { -// typesafe: "https://api.typesafe.ai/v1/systemone", -// openrouter: "https://openrouter.ai/api/v1/systemone", -// } as const; -// export type Provider = keyof typeof ENDPOINTS; -// -// In askJev(...): add `provider: Provider` first, read ENDPOINTS[provider], and -// thread the value in from background.ts (ancre 6 there). Both endpoints serve -// the identical { model, state, questions } body — your Step 0 curl proved it. +const ENDPOINTS = { + typesafe: "https://api.typesafe.ai/v1/systemone", + openrouter: "https://openrouter.ai/api/v1/systemone", +} as const; +export type Provider = keyof typeof ENDPOINTS; const TIMEOUT_MS = 10_000; +const RETRYABLE_STATUSES = new Set([429, 502, 503, 524, 529]); export async function askJev>( apiKey: string, + provider: Provider, model: string, state: unknown, questions: Q, attempt = 0, ): Promise> { - const res = await fetch(ENDPOINT, { + const res = await fetch(ENDPOINTS[provider], { method: "POST", headers: { Authorization: `Bearer ${apiKey}`, @@ -79,18 +73,12 @@ export async function askJev>( signal: AbortSignal.timeout(TIMEOUT_MS), }); - if ((res.status === 429 || res.status === 529) && attempt < 3) { - // TODO(learner) [Étape 1 / ancre ... — retry]: with both providers served by - // this one function, widen to `res.status >= 500 || res.status === 429` - // (OpenRouter adds 502/503/524 retryables; TypeSafe's 529 is already >= 500). - // First answer for yourself: is there any 5xx in OpenRouter's table that is - // NOT transient? (500 "Internal Server Error" — think about what retrying it - // hides.) - // API reference: https://openrouter.ai/docs/api/api-reference/alphadecisions/submit-a-decisions-request + if (RETRYABLE_STATUSES.has(res.status) && attempt < 3) { const retryAfter = Number(res.headers.get("retry-after")); - const delay = retryAfter > 0 ? Math.min(retryAfter * 1000, 10_000) : 400 * 2 ** attempt; + const delay = + retryAfter > 0 ? Math.min(retryAfter * 1000, 10_000) : 400 * 2 ** attempt; await new Promise((r) => setTimeout(r, delay)); - return askJev(apiKey, model, state, questions, attempt + 1); + return askJev(apiKey, provider, model, state, questions, attempt + 1); } if (!res.ok) { const body = await res.text().catch(() => ""); diff --git a/src/options.ts b/src/options.ts index 78b543c..69f92a7 100644 --- a/src/options.ts +++ b/src/options.ts @@ -1,10 +1,13 @@ import { DEFAULT_SETTINGS, type Settings } from "./types"; +import { Provider } from "./jev"; -const $ = (id: string) => document.getElementById(id) as T; - +const $ = (id: string) => + document.getElementById(id) as T; const fields = { enabled: $("enabled"), apiKey: $("apiKey"), + provider: $("provider"), + openrouterKey: $("openrouterKey"), model: $("model"), interests: $("interests"), blocked: $("blocked"), @@ -19,13 +22,19 @@ const noiseOut = $("noiseOut"); const interestOut = $("interestOut"); const status = $("status"); -const lines = (s: string) => s.split("\n").map((l) => l.trim()).filter(Boolean); -const pct = (v: string) => `${Math.round(Number(v) * 100)} %`; +const lines = (s: string) => + s + .split("\n") + .map((l) => l.trim()) + .filter(Boolean); +const pct = (v: string) => `${Math.round(Number(v) * 100)}%`; function render(s: Settings): void { fields.enabled.checked = s.enabled; fields.apiKey.value = s.apiKey; fields.model.value = s.model; + fields.provider.value = s.provider; + fields.openrouterKey.value = s.openrouterApiKey; fields.interests.value = s.interests.join("\n"); fields.blocked.value = s.blocked.join("\n"); fields.noiseThreshold.value = String(s.noiseThreshold); @@ -38,10 +47,18 @@ function render(s: Settings): void { interestOut.value = pct(fields.interestThreshold.value); } +function isProvider(v: string): v is Provider { + return v === "typesafe" || v === "openrouter"; +} + function read(): Settings { return { enabled: fields.enabled.checked, apiKey: fields.apiKey.value.trim(), + provider: isProvider(fields.provider.value) + ? fields.provider.value + : DEFAULT_SETTINGS.provider, + openrouterApiKey: fields.openrouterKey.value.trim(), model: fields.model.value.trim() || DEFAULT_SETTINGS.model, interests: lines(fields.interests.value), blocked: lines(fields.blocked.value), @@ -59,19 +76,20 @@ async function load(): Promise { render({ ...DEFAULT_SETTINGS, ...(stored as Partial) }); } -// TODO(learner) [Étape 1 / ancre 5 — REVISED again: TWO key fields]: map BOTH -// new fields through this file — add `provider` (an HTMLSelectElement, -// id="provider") and `openrouterApiKey` (a password input, id="openrouterKey") -// to the `fields` map, then mirror BOTH in render() and read() the way -// `apiKey` / `model` flow today. - async function save(): Promise { await chrome.storage.sync.set(read()); - status.textContent = "Enregistré. Les nouveaux posts suivent ces réglages ; recharge LinkedIn pour réévaluer le fil."; + status.textContent = + "Saved. New posts follow these settings; reload LinkedIn to re-evaluate the feed."; setTimeout(() => (status.textContent = ""), 4000); } -fields.noiseThreshold.addEventListener("input", () => (noiseOut.value = pct(fields.noiseThreshold.value))); -fields.interestThreshold.addEventListener("input", () => (interestOut.value = pct(fields.interestThreshold.value))); +fields.noiseThreshold.addEventListener( + "input", + () => (noiseOut.value = pct(fields.noiseThreshold.value)), +); +fields.interestThreshold.addEventListener( + "input", + () => (interestOut.value = pct(fields.interestThreshold.value)), +); $("save").addEventListener("click", () => void save()); void load(); diff --git a/src/sticker.ts b/src/sticker.ts index b23b08f..c2c4038 100644 --- a/src/sticker.ts +++ b/src/sticker.ts @@ -1,9 +1,9 @@ import type { CallMeta, Verdict } from "./types"; -// Pastille flottante « activité Jev ». Vit dans un Shadow DOM accroché à : -// hors de l'arbre React de LinkedIn et à l'abri de son CSS. +// Floating "Jev activity" sticker. Lives in a Shadow DOM attached to : +// outside LinkedIn's React tree and shielded from its CSS. -/** Jev 1.13 : 0,042 $ par million de tokens en entrée, sortie gratuite. */ +/** Jev 1.14: $0.042 per million input tokens; output tokens are free. */ const USD_PER_TOKEN = 0.042 / 1_000_000; const SPARK_MAX = 32; const RECENT_MAX = 5; @@ -73,7 +73,7 @@ const CSS = ` .spark { display: flex; align-items: flex-end; gap: 2px; height: 30px; margin-bottom: 12px; } .spark i { flex: 1; max-width: 8px; min-height: 2px; border-radius: 2px 2px 0 0; background: #4b5763; } .spark i.h { background: #38c6a4; } -.spark:empty::after { content: "en attente du premier appel…"; color: #6f7b87; font-size: 11.5px; align-self: center; } +.spark:empty::after { content: "waiting for the first call…"; color: #6f7b87; font-size: 11.5px; align-self: center; } .recent { list-style: none; margin: 0 0 10px; padding: 0; display: grid; grid-template-columns: minmax(0, 1fr); gap: 4px; } .recent li { display: flex; gap: 8px; justify-content: space-between; min-width: 0; } .recent li span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } @@ -113,16 +113,16 @@ function build(): void { panel.id = "panel"; const head = el("div", "head"); - head.append(el("strong", "", "Activité Jev"), (refs.model = el("span", "muted"))); + head.append(el("strong", "", "Jev activity"), (refs.model = el("span", "muted"))); const grid = el("div", "grid"); for (const [key, label] of [ - ["analysed", "Analysés"], - ["hidden", "Masqués"], - ["inflight", "En cours"], + ["analysed", "Analysed"], + ["hidden", "Hidden"], + ["inflight", "In flight"], ["tokens", "Tokens"], - ["cost", "Coût"], - ["latency", "Latence"], + ["cost", "Cost"], + ["latency", "Latency"], ] as const) { const s = el("div", "stat"); s.append(el("span", "", label), (refs[key] = el("b", "", "0"))); @@ -130,10 +130,10 @@ function build(): void { } refs.spark = el("div", "spark"); - refs.spark.title = "Latence des derniers appels (vert = post masqué)"; + refs.spark.title = "Latency of recent calls (green = post hidden)"; refs.recent = el("ul", "recent"); refs.err = el("p", "err"); - const opts = el("button", "link", "Réglages"); + const opts = el("button", "link", "Settings"); opts.type = "button"; opts.addEventListener("click", () => void chrome.runtime.sendMessage({ type: "openOptions" })); panel.append(head, grid, refs.spark, refs.recent, refs.err, opts); @@ -161,7 +161,7 @@ function setText(node: HTMLElement | undefined, text: string, bump = false): voi node.textContent = text; if (bump) { node.classList.remove("bump"); - void node.offsetWidth; // relance l'animation + void node.offsetWidth; // restart the animation node.classList.add("bump"); } } @@ -172,17 +172,17 @@ function render(): void { refs.wrap?.classList.toggle("busy", s.inflight > 0); refs.wrap?.classList.toggle("error", s.error !== ""); - const share = s.analysed ? ` (${Math.round((s.hidden / s.analysed) * 100)} %)` : ""; - setText(refs.summary, s.error ? "en panne" : `${s.analysed} lus · ${s.hidden} masqués`, true); + const share = s.analysed ? ` (${Math.round((s.hidden / s.analysed) * 100)}%)` : ""; + setText(refs.summary, s.error ? "down" : `${s.analysed} read · ${s.hidden} hidden`, true); setText(refs.analysed, String(s.analysed)); setText(refs.hidden, `${s.hidden}${share}`); setText(refs.inflight, String(s.inflight)); setText(refs.tokens, s.tokens >= 10_000 ? `${(s.tokens / 1000).toFixed(1)}k` : String(s.tokens)); - setText(refs.cost, `${(s.tokens * USD_PER_TOKEN).toFixed(4).replace(".", ",")} $`); + setText(refs.cost, `$${(s.tokens * USD_PER_TOKEN).toFixed(4)}`); setText(refs.latency, s.calls ? `${Math.round(s.msTotal / s.calls)} ms` : "—"); setText(refs.model, s.model); setText(refs.err, s.error); - if (refs.cost) refs.cost.title = `${s.calls} appels · ${s.cacheHits} verdicts servis par le cache`; + if (refs.cost) refs.cost.title = `${s.calls} calls · ${s.cacheHits} verdicts served from the cache`; if (refs.spark) { const max = Math.max(1, ...s.spark.map((p) => p.ms)); @@ -216,7 +216,7 @@ export const sticker = { stats.inflight++; render(); }, - /** Fin d'un passage, qu'il y ait eu appel à Jev ou non. */ + /** End of a pass, whether or not a Jev call happened. */ settle(author: string, verdict: Verdict, meta: CallMeta, label: string): void { stats.inflight = Math.max(0, stats.inflight - 1); stats.error = ""; diff --git a/src/types.ts b/src/types.ts index fec9685..67ec02e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,42 +1,52 @@ +import { Provider } from "./jev"; + export interface Settings { enabled: boolean; + /** TypeSafe direct API key. */ apiKey: string; + /** + * v1 stance: the model id does NOT follow a provider switch; the user keeps + * it up to date (the sticker shows which model actually served the verdicts). + * Auto-suggesting a model per provider = stretch goal. + */ model: string; - /** Une ligne par centre d'intérêt, ex. "IA appliquée", "product management" */ + provider: Provider; + openrouterApiKey: string; + /** One interest per line, e.g. "applied AI", "product management" */ interests: string[]; - /** Sujets à masquer d'office, ex. "offres d'emploi", "crypto" */ + /** Topics to always hide, e.g. "job offers", "crypto" */ blocked: string[]; - /** Masquer si P(correspond aux intérêts) < seuil */ + /** Hide if P(matches interests) < threshold */ interestThreshold: number; - /** Masquer si P(engagement bait / auto-promo) > seuil */ + /** Hide if P(engagement bait / self-promo) > threshold */ noiseThreshold: number; - /** Ne jamais masquer un post de moins de N caractères (souvent une image seule) */ + /** Never hide a post shorter than N characters (often an image alone) */ minChars: number; hideSponsored: boolean; - /** Afficher, sur les posts gardés, pourquoi Jev les a laissés passer */ + /** Show, on kept posts, why Jev let them pass */ showKeptReason: boolean; - /** Pastille flottante « activité Jev » sur la page LinkedIn */ + /** Floating "Jev activity" sticker on the LinkedIn page */ showSticker: boolean; } -/** Sous-ensemble des réglages exposé au content script (jamais la clé API). */ +/** Subset of the settings exposed to the content script (never the API key). */ export type UiSettings = Pick< Settings, - "enabled" | "showKeptReason" | "showSticker" | "noiseThreshold" | "interestThreshold" + | "enabled" + | "showKeptReason" + | "showSticker" + | "noiseThreshold" + | "interestThreshold" >; export const DEFAULT_SETTINGS: Settings = { enabled: true, apiKey: "", + /** v1 stance: the model id does NOT follow the provider switch; verified + * after each change. Fixing grudges mechanically = stretch goal. */ model: "jev-latest", - // TODO(learner) [Étape 1 / ancre 2 — REVISED again: default typesafe, TWO - // key fields]: ADD to this interface: `provider: Provider` (import the type - // from "./jev") and `openrouterApiKey: string`. In DEFAULT_SETTINGS add - // `provider: "typesafe"` and `openrouterApiKey: ""`, and LEAVE the existing - // `apiKey` (it stays the TypeSafe key — an existing install's synced storage - // keeps working) and `model: "jev-latest"` alone; they pair with the - // default provider. Note accepted rough edge: switching provider does not - // auto-switch `model` — document your v1 stance in a comment here. + provider: "typesafe", + openrouterApiKey: "", interests: [], blocked: [], interestThreshold: 0.35, @@ -58,9 +68,9 @@ export interface Verdict { hide: boolean; reason?: Reason; detail?: string; - /** Centre d'intérêt de l'utilisateur que le post recoupe le plus, s'il y en a un */ + /** The user's interest the post matches most, if any */ interest?: string; - /** scores bruts, utiles pour le debug */ + /** raw scores, useful for debugging */ scores?: Record; } @@ -71,9 +81,9 @@ export interface PostPayload { sponsored: boolean; } -/** Ce qu'a coûté le verdict : sert à la pastille d'activité. */ +/** What the verdict cost: powers the activity sticker. */ export interface CallMeta { - /** false si aucun appel à Jev n'a été nécessaire (sponsorisé, trop court, filtrage coupé) */ + /** false when no Jev call was needed (sponsored, too short, filtering off) */ evaluated: boolean; cached: boolean; ms: number; From ccd75b22b21f888f523f3231fd41b67aabaaaad0 Mon Sep 17 00:00:00 2001 From: Andrei Sorokin Date: Mon, 9 Nov 2026 22:46:17 +0200 Subject: [PATCH 03/11] Move Provider into types.ts; restore leaf-module layering --- src/jev.ts | 11 +++++++---- src/options.ts | 3 +-- src/types.ts | 3 ++- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/jev.ts b/src/jev.ts index ca317e4..51c53d7 100644 --- a/src/jev.ts +++ b/src/jev.ts @@ -1,5 +1,7 @@ +import type { Provider } from "./types"; + // Minimal client for the System One endpoint — TypeSafe direct or via -// OpenRouter, as chosen by the user (see `Provider` below). +// OpenRouter, as chosen by the user (see `Provider` in ./types). // Ref.: https://docs.typesafe.ai/api + https://openrouter.ai/docs/guides/community/jev-tutorial export type NoulQuestion = { @@ -47,11 +49,12 @@ export interface JevResponse> { usage: { input_tokens: number; output_tokens: number }; } -const ENDPOINTS = { +// Completeness is compiler-enforced: adding a value to the Provider union +// without an endpoint here fails typecheck. +const ENDPOINTS: Record = { typesafe: "https://api.typesafe.ai/v1/systemone", openrouter: "https://openrouter.ai/api/v1/systemone", -} as const; -export type Provider = keyof typeof ENDPOINTS; +}; const TIMEOUT_MS = 10_000; const RETRYABLE_STATUSES = new Set([429, 502, 503, 524, 529]); diff --git a/src/options.ts b/src/options.ts index 69f92a7..f263fa3 100644 --- a/src/options.ts +++ b/src/options.ts @@ -1,5 +1,4 @@ -import { DEFAULT_SETTINGS, type Settings } from "./types"; -import { Provider } from "./jev"; +import { DEFAULT_SETTINGS, type Provider, type Settings } from "./types"; const $ = (id: string) => document.getElementById(id) as T; diff --git a/src/types.ts b/src/types.ts index 67ec02e..bda0bce 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,5 @@ -import { Provider } from "./jev"; +/** Where Jev is served from; drives the endpoint map in jev.ts. */ +export type Provider = "typesafe" | "openrouter"; export interface Settings { enabled: boolean; From fb799bdde0ef7ef45effd6ceb50b789f6da44840 Mon Sep 17 00:00:00 2001 From: Andrei Sorokin Date: Mon, 9 Nov 2026 23:16:29 +0200 Subject: [PATCH 04/11] Cache settings; invalidate on storage change --- src/background.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/background.ts b/src/background.ts index 6bae17f..c5a0edd 100644 --- a/src/background.ts +++ b/src/background.ts @@ -23,9 +23,13 @@ const NO_CALL: CallMeta = { // ---------- settings ---------- +let settingsCache: Settings | null = null; + async function loadSettings(): Promise { + if (settingsCache) return settingsCache; const stored = await chrome.storage.sync.get(DEFAULT_SETTINGS); - return { ...DEFAULT_SETTINGS, ...(stored as Partial) }; + settingsCache = { ...DEFAULT_SETTINGS, ...(stored as Partial) }; + return settingsCache; } // ---------- verdict cache (session) ---------- @@ -51,6 +55,7 @@ async function cacheSet(id: string, v: Verdict): Promise { chrome.storage.onChanged.addListener((_, area) => { if (area === "sync") { memCache.clear(); + settingsCache = null; void chrome.storage.session.clear(); void chrome.action.setBadgeText({ text: "" }); } From 8115910f2f4de322163ef9bb0322f82d5ea44c69 Mon Sep 17 00:00:00 2001 From: Andrei Sorokin Date: Mon, 9 Nov 2026 23:21:54 +0200 Subject: [PATCH 05/11] Restore implementation plan doc (lost before any commit); mark 1.5a-c done --- docs/plan-openrouter.md | 143 ++++++++++++++++++++++++++++++++++++++++ src/types.ts | 49 ++++++++++++++ 2 files changed, 192 insertions(+) create mode 100644 docs/plan-openrouter.md diff --git a/docs/plan-openrouter.md b/docs/plan-openrouter.md new file mode 100644 index 0000000..48e9bfc --- /dev/null +++ b/docs/plan-openrouter.md @@ -0,0 +1,143 @@ +# lkclean → OpenRouter (Jev): implementation plan + +> Goal: add **OpenRouter** as a *second, selectable* API provider for lkclean — +> **TypeSafe direct stays available**. Jev is *natively available on OpenRouter* +> (`typesafe/jev-1.14`), and OpenRouter serves the **same System One API** +> (`POST https://openrouter.ai/api/v1/systemone`) with the same request/response +> shapes that `src/jev.ts` already uses. The addition is mostly **config, not +> code**: a `provider` setting, a two-entry endpoint map, and UI. +> +> Revision note (2026-11-09): scope corrected by the learner — an earlier draft +> replaced TypeSafe entirely; rejected. The provider selector is the design. +> +> The learner writes all code; the assistant provides `TODO(learner)` anchors +> with explanations, answers questions, and reviews diffs on request. + +## Working agreement + +- One commit per finished step — every step is individually revertable. +- After each step: `npm run typecheck` turns green, then the change is verified + by loading `dist/` in Chrome (`chrome://extensions` → reload → reload the + LinkedIn tab). + +## Dev loop (the only 4 commands to know; no Makefile) + +```bash +npm install # once +npm run watch # leave running while coding (auto-rebuilds dist/) +npm run typecheck # after every edit +npm run zip # at the end +``` + +## Sources (verified 2026-11-09) + +- Jev on OpenRouter: https://openrouter.ai/typesafe/jev-1.14 +- Tutorial (request/response shapes): https://openrouter.ai/docs/guides/community/jev-tutorial +- Decisions/System One API reference: https://openrouter.ai/docs/api/api-reference/alphadecisions/submit-a-decisions-request +- TypeSafe SDK pointed at OpenRouter (proves the System One path): + https://openrouter.ai/docs/guides/community/typesafe-sdk + +Key facts: + +- Two API surfaces, one key, both billed to OpenRouter: + 1. **System One**: `POST https://openrouter.ai/api/v1/systemone` — same path + shape TypeSafe serves; `askJev()`'s body `{ model, state, questions }` + works as-is. + 2. **Decisions**: `POST https://openrouter.ai/api/alpha/decisions` — same + envelope; adds `usage.cost` (USD). +- Retryable statuses on OpenRouter: `429`, `502`, `503`, `524`, `529` + (`500` deliberately NOT retried). +- Pricing (Jev 1.14): $0.042 / M input tokens, output free. +- Model ids: `typesafe/jev-1.14` (pinned — thresholds are tuned per version), + `~typesafe/jev-latest` (tracks latest). + +## Design: the providers setting + +- `Settings.provider: "typesafe" | "openrouter"`, default `"typesafe"` + (upgrade-safe default: parity with upstream; the fork owner picks + OpenRouter in the UI on first run). +- **One key field per provider**: `apiKey` (TypeSafe, unchanged — synced + storage keeps working) + `openrouterApiKey`. +- **One model field**, free text: `jev-latest` (TypeSafe) vs + `typesafe/jev-1.14` (OR). Accepted v1 rough edge: switching provider does + NOT auto-correct the model id (badges the service-worker 4xx otherwise). +- Both hosts in `manifest.json` `host_permissions`; endpoint map in `jev.ts`. +- UI language: English-only (learner decision; French interface replaced, + including code comments and prompt strings). + +## Step 0.5 — dev-dependency bump ✅ done + +`esbuild` 0.23.1 → ^0.28.1: two advisories, both triaged as unreachable +(dev-server CORS — this build never calls `serve()`; a withdrawn, +wrong-package advisory about the Deno module). Fixed to keep audits clean. + +## Step 0 — curl smoke test ✅ done + +Response verified live: `model: typesafe/jev-1.14-20261014` (dated snapshot, +as documented), `answers.self_promo = { type: "noul", noul: 0.78 }`, +`usage.input_tokens: 356`, bonus OpenRouter fields `usage.cost`, +`cost_details`, `provider`. Shape matches `askJev()` expectations exactly. + +## Step 1 — the provider selector ✅ done + +1. `jev.ts`: `ENDPOINTS: Record` map (compiler-enforced + completeness), `askJev(apiKey, provider, model, state, questions)`, + retry `Set([429, 502, 503, 524, 529])`. +2. `types.ts`: `provider` + `openrouterApiKey`, defaults `provider: "typesafe"`, + existing `apiKey`/`model` untouched. +3. `manifest.json`: both hosts allowlisted. +4. `options.html`: `` + two per-provider key inputs; - `options.ts` maps both fields through `render()`/`read()` with an - `isProvider` type predicate (no blind cast). -5. `background.ts`: key ternary by provider; provider-neutral missing-key - error; both args threaded into `askJev()`. -6. Bonus caught during review: `content.ts`'s `apiBroken` regex still expected - a French error string — updated together with the English sweep (string - content is invisible to tsc: repo-wide grep is the test suite for this). - -Verify on both providers incl. back-and-forth switch ✅ (learner). - -## Micro-steps - -- **1.5a — settings cache ✅ done.** `loadSettings()` cached at module level, - invalidated in the existing `chrome.storage.onChanged` listener (sync - propagates cross-device changes too). Verify recipe: change a threshold - mid-scroll, next post recomputes immediately. -- **1.5b — Provider moves to types.ts ✅ done.** types.ts is a pure - declaration leaf again; `ENDPOINTS: Record` in jev.ts - makes a missing endpoint fail typecheck. -- **1.5c — Question/Answer contracts move to types.ts ✅ done.** Full Jev - contract family in types.ts ("API contract" section); `jev.ts` is - transport-only; "everything imports types.ts, nothing imports back" now - holds repo-wide. - -## Step 2 — housekeeping (next up, pending) - -- `package.json`: `description`, `keywords` — dual-provider phrasing. -- `README.md`: two-provider install section (key sources for both paths), - OpenRouter pricing paragraph; decide the fate of `README.fr.md` - (assistant recommendation: delete it in this commit — stale docs rot). -- Sweep user-visible strings for provider accuracy (manifest description - already updated). -- Final: `npm run zip` → fresh Chrome profile install → one scroll session - end-to-end on both provider settings. - -## Step 3 — stretch goals (optional TS exercises) - -1. **Real USD cost on the sticker**: `usage.cost` → optional `CallMeta.costUsd`, - threaded jev→background→sticker. Optional chaining + narrow typing. -2. **"Test connection" button in Options**: options page → service worker → - one `askJev` smoke call → inline status. Messaging round-trip practice. -3. **Decisions surface client** (`/api/alpha/decisions`) as a second transport - behind the same `Question`/`Answer` contract — the repo's other-provider - path if decision models diversify. -4. **Richer choice probabilities** (`probabilities`, not just `confidence`) - surfaced in the sticker popup. -5. Per-provider model auto-suggest (closes the Step-1 rough edge). - -## Rollback philosophy - -Each step is one commit; the provider feature reverts cleanly (pre-Step-1 code -has no notion of providers, one host in the manifest). The only persisted -state is strings/numbers in `chrome.storage.sync` — fresh installs start from -`DEFAULT_SETTINGS`. diff --git a/src/background.ts b/src/background.ts index c5a0edd..f7fd17a 100644 --- a/src/background.ts +++ b/src/background.ts @@ -1,10 +1,12 @@ -import { askJev, type Answer, type Question } from "./jev"; +import { askJev } from "./jev"; import { DEFAULT_SETTINGS, + type Answer, type CallMeta, type ClassifyResponse, type Message, type PostPayload, + type Question, type Settings, type UiSettings, type Verdict, diff --git a/src/jev.ts b/src/jev.ts index 51c53d7..5402660 100644 --- a/src/jev.ts +++ b/src/jev.ts @@ -1,54 +1,9 @@ -import type { Provider } from "./types"; +import type { JevResponse, Provider, Question } from "./types"; // Minimal client for the System One endpoint — TypeSafe direct or via // OpenRouter, as chosen by the user (see `Provider` in ./types). // Ref.: https://docs.typesafe.ai/api + https://openrouter.ai/docs/guides/community/jev-tutorial -export type NoulQuestion = { - type: "noul"; - instructions: string; - criteria?: { true?: string; false?: string }; -}; -export type ChoiceQuestion = { - type: "choice"; - instructions: string; - criteria: Record; -}; -export type ScoreQuestion = { - type: "score"; - instructions: string; - criteria: string[]; -}; -export type Question = NoulQuestion | ChoiceQuestion | ScoreQuestion; - -export type NoulAnswer = { type: "noul"; noul: number }; -export type ChoiceAnswer = { - type: "choice"; - choice: string; - probabilities: Record; - confidence: number; -}; -export type ScoreAnswer = { - type: "score"; - score: number; - legend: Record; - probabilities: Record; - confidence: number; -}; -export type Answer = NoulAnswer | ChoiceAnswer | ScoreAnswer; - -type AnswerFor = Q extends NoulQuestion - ? NoulAnswer - : Q extends ChoiceQuestion - ? ChoiceAnswer - : ScoreAnswer; - -export interface JevResponse> { - model: string; - answers: { [K in keyof Q]: AnswerFor }; - usage: { input_tokens: number; output_tokens: number }; -} - // Completeness is compiler-enforced: adding a value to the Provider union // without an endpoint here fails typecheck. const ENDPOINTS: Record = { From 332c77817ae4f7942dc3c2df24d44d32fcf51e3d Mon Sep 17 00:00:00 2001 From: Andrei Sorokin Date: Mon, 9 Nov 2026 23:29:50 +0200 Subject: [PATCH 07/11] Chores: dual-provider docs (EN+FR); ignore local docs --- .gitignore | 3 +++ LICENSE | 1 + README.fr.md | 49 +++++++++++++++++++++++++++++++++---------------- README.md | 44 ++++++++++++++++++++++++++++---------------- package.json | 4 ++-- 5 files changed, 67 insertions(+), 34 deletions(-) diff --git a/.gitignore b/.gitignore index b6c0aec..ec0ad0d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ dist/ .DS_Store .env + +# local working docs (tmp plan) +docs/ diff --git a/LICENSE b/LICENSE index ce08d28..d96c50e 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,7 @@ MIT License Copyright (c) 2026 stefw +Copyright (c) 2026 40min (fork: OpenRouter provider, English UI) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.fr.md b/README.fr.md index 3e0a437..40b0a77 100644 --- a/README.fr.md +++ b/README.fr.md @@ -9,8 +9,12 @@ du texte. L'engagement bait, l'auto-promo, le hors-sujet et le sponsorisé sont repliés en une barre d'une ligne, et chaque décision est expliquée directement sur le post. -> Projet indépendant, sans lien avec LinkedIn ni TypeSafe AI. Il te faut ta propre -> clé API TypeSafe. +> **Note fork :** ce fork ajoute [OpenRouter](https://openrouter.ai) comme voie +> d'accès alternative à Jev — même modèle, facturé sur ton compte OpenRouter, +> sans compte TypeSafe. + +> Projet indépendant, sans lien avec LinkedIn ni TypeSafe AI. Il te faut ta +> propre clé API pour le fournisseur choisi (TypeSafe direct, ou OpenRouter). ## Ce que ça fait @@ -27,8 +31,9 @@ le post. trop court pour le déclencheur « charger la suite » de LinkedIn ; lkclean le relance sans déplacer ta position de lecture. - **Rapide et pas cher** – un appel API par post, toutes les questions évaluées en - parallèle, verdicts mis en cache pour la session. Environ 0,042 $ par million de - tokens en entrée : une session de scroll coûte une fraction de centime. + parallèle, verdicts mis en cache pour la session. Environ 0,042 $ par million + de tokens en entrée (les tokens de sortie sont gratuits) : une session de + scroll coûte une fraction de centime, quel que soit le fournisseur. notok @@ -43,9 +48,11 @@ le post. LinkedIn (texte dans `[data-testid="expandable-text-box"]`), avec repli sur l'ancien balisage `data-id="urn:li:activity:…"`. Le post est identifié par un hash de son texte, LinkedIn n'exposant plus d'identifiant stable. -2. L'auteur et le texte partent au service worker, qui appelle - `POST https://api.typesafe.ai/v1/systemone` avec un *state* - `{ user_interests, post }` et plusieurs questions typées en une seule requête : +2. L'auteur et le texte partent au service worker, qui appelle l'endpoint System + One — `POST https://api.typesafe.ai/v1/systemone` (TypeSafe direct) ou + `POST https://openrouter.ai/api/v1/systemone` (via OpenRouter) — avec un + *state* `{ user_interests, post }` et plusieurs questions typées en une seule + requête : | Question | Type | Sens | | --- | --- | --- | @@ -78,10 +85,14 @@ npm run build Puis charge le dossier `dist/` de la même façon. -Clique sur l'icône de l'extension pour ouvrir les réglages : colle ta clé TypeSafe -(depuis [console.typesafe.ai](https://console.typesafe.ai)), liste tes centres -d'intérêt (un par ligne), éventuellement des sujets à bloquer, puis ajuste les -seuils. Recharge LinkedIn. +Clique sur l'icône de l'extension pour ouvrir les réglages : choisis le +fournisseur d'API (**TypeSafe direct** ou **OpenRouter**), colle la clé +correspondante (depuis [console.typesafe.ai](https://console.typesafe.ai) — +`ts-…` — ou [openrouter.ai/settings/keys](https://openrouter.ai/settings/keys) — +`sk-or-…`), liste tes centres d'intérêt (un par ligne), éventuellement des +sujets à bloquer, puis ajuste les seuils. Mets l'id du modèle en accord avec le +fournisseur : `jev-latest` (TypeSafe) ou `typesafe/jev-1.14` (OpenRouter). +Recharge LinkedIn. Après chaque rebuild, clique sur ↻ sur la carte de l'extension dans `chrome://extensions`, puis recharge l'onglet LinkedIn. @@ -90,6 +101,7 @@ Après chaque rebuild, clique sur ↻ sur la carte de l'extension dans | Réglage | Défaut | Effet | | --- | --- | --- | +| Fournisseur d'API | TypeSafe direct | D'où Jev est appelé ; le champ de clé correspondant est utilisé | | Centres d'intérêt | vide | Si vide, seul le bruit est filtré (bait, auto-promo, sponsorisé) | | Sujets bloqués | vide | Toujours masqués quand Jev est sûr à ≥ 60 % | | Seuil de bruit | 70 % | Masque si P(bait) ou P(auto-promo) le dépasse | @@ -98,13 +110,17 @@ Après chaque rebuild, clique sur ↻ sur la carte de l'extension dans | Longueur minimale | 40 caractères | Les posts plus courts ne sont jamais évalués | | Expliquer les posts gardés | oui | La ligne « Gardé par Jev » | | Pastille d'activité | oui | Le badge flottant Jev | -| Modèle | `jev-latest` | Épingle une version (ex. `jev-1.13.0`) une fois tes seuils réglés | +| Modèle | `jev-latest` | Défaut TypeSafe ; sur OpenRouter, `typesafe/jev-1.14`. Épingle une version une fois tes seuils réglés | + +Aussi, l'interface (page de réglages) est en anglais dans ce fork : les libellés +du tableau ci-dessus décrivent l'effet, pas le libellé exact. ## Confidentialité -- Le nom de l'auteur et le texte de chaque post affiché dans ton fil sont envoyés à - l'API TypeSafe pour classification. Rien d'autre ne quitte ton navigateur, et - lkclean n'a ni serveur ni analytics. +- Le nom de l'auteur et le texte de chaque post affiché dans ton fil sont envoyés + au fournisseur Jev configuré (TypeSafe direct, ou OpenRouter) pour la + classification. Rien d'autre ne quitte ton navigateur, et lkclean n'a ni + serveur ni analytics. - La clé API est stockée dans le stockage synchronisé de Chrome (`chrome.storage.sync`). - Les verdicts sont mis en cache dans `chrome.storage.session` et effacés à la @@ -122,7 +138,8 @@ npm run typecheck # tsc --noEmit | `src/content.ts` | détection des posts, extraction, masquage/annotation, relance du scroll infini | | `src/sticker.ts` | la pastille d'activité Jev (Shadow DOM) | | `src/background.ts` | questions, logique de décision, cache, appels API | -| `src/jev.ts` | client typé minimal pour l'endpoint System One | +| `src/jev.ts` | client typé minimal pour l'endpoint System One (TypeSafe direct ou via OpenRouter) | +| `src/types.ts` | déclarations pures : réglages, verdicts, messagerie, contrat de questions/réponses Jev | | `src/options.ts`, `public/options.*` | page de réglages | Pour changer ce qui est filtré, modifie `buildQuestions()` et `decide()` dans diff --git a/README.md b/README.md index 0d43005..efbd016 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,12 @@ Every post is sent to **Jev**, the typed classification model from Engagement bait, self-promotion, off-topic and sponsored posts are collapsed into a one-line bar, and every decision is explained right on the post. -> Not affiliated with LinkedIn or TypeSafe AI. You need your own TypeSafe API key. +> **Fork note:** this fork adds [OpenRouter](https://openrouter.ai) as an +> alternative way to reach Jev — same model, billed to your OpenRouter account, +> no TypeSafe account required. + +> Not affiliated with LinkedIn or TypeSafe AI. You need your own API key for the +> provider you choose (TypeSafe direct, or OpenRouter). ## What it does @@ -25,8 +30,9 @@ a one-line bar, and every decision is explained right on the post. too short for LinkedIn's "load more" trigger; lkclean nudges it without moving your reading position. - **Cheap and fast** – one API call per post, all questions evaluated in parallel, - verdicts cached per session. Roughly $0.042 per million input tokens: a scrolling - session costs a fraction of a cent. + verdicts cached per session. Roughly $0.042 per million input tokens (output + tokens free): a scrolling session costs a fraction of a cent on either + provider. notok @@ -42,9 +48,11 @@ a one-line bar, and every decision is explained right on the post. front end (text in `[data-testid="expandable-text-box"]`), with fallbacks for the legacy `data-id="urn:li:activity:…"` markup. Posts are identified by a hash of their text, since LinkedIn no longer exposes a stable id. -2. Author and text go to the service worker, which calls - `POST https://api.typesafe.ai/v1/systemone` with a *state* - `{ user_interests, post }` and several typed questions in a single request: +2. Author and text go to the service worker, which calls the System One + endpoint — `POST https://api.typesafe.ai/v1/systemone` (TypeSafe direct) or + `POST https://openrouter.ai/api/v1/systemone` (via OpenRouter) — with a + *state* `{ user_interests, post }` and several typed questions in a single + request: | Question | Type | Meaning | | --- | --- | --- | @@ -77,10 +85,13 @@ npm run build Then load the `dist/` folder the same way. -Click the extension icon to open the settings: paste your TypeSafe key -(from [console.typesafe.ai](https://console.typesafe.ai)), list your interests -(one per line), optionally list blocked topics, and adjust the thresholds. -Reload LinkedIn. +Click the extension icon to open the settings: pick your API provider +(**TypeSafe direct** or **OpenRouter**), paste the matching key (from +[console.typesafe.ai](https://console.typesafe.ai) — `ts-…` — or +[openrouter.ai/settings/keys](https://openrouter.ai/settings/keys) — `sk-or-…`), +list your interests (one per line), optionally list blocked topics, and adjust +the thresholds. Set the model id to match the provider: `jev-latest` (TypeSafe) +or `typesafe/jev-1.14` (OpenRouter). Reload LinkedIn. After each rebuild, press ↻ on the extension card in `chrome://extensions`, then reload the LinkedIn tab. @@ -89,6 +100,7 @@ reload the LinkedIn tab. | Setting | Default | Effect | | --- | --- | --- | +| API provider | TypeSafe direct | Where Jev is reached from; the matching key field is used | | Interests | empty | If empty, only noise is filtered (bait, self-promo, sponsored) | | Blocked topics | empty | Always hidden when Jev is ≥ 60 % confident | | Noise threshold | 70 % | Hide when P(bait) or P(self-promo) is above it | @@ -97,15 +109,14 @@ reload the LinkedIn tab. | Minimum length | 40 chars | Shorter posts are never evaluated | | Explain kept posts | on | The "Kept by Jev" line | | Activity sticker | on | The floating Jev badge | -| Model | `jev-latest` | Pin a version such as `jev-1.13.0` once your thresholds are tuned | - -The UI is currently in French. +| Model | `jev-latest` | TypeSafe default; on OpenRouter use `typesafe/jev-1.14`. Pin one version once your thresholds are tuned | ## Privacy - The author name and text of each post displayed in your feed are sent to the - TypeSafe API for classification. Nothing else leaves your browser, and lkclean - has no server or analytics of its own. + configured Jev provider (TypeSafe direct, or OpenRouter) for classification. + Nothing else leaves your browser, and lkclean has no server or analytics of + its own. - The API key is stored in Chrome's synced storage (`chrome.storage.sync`). - Verdicts are cached in `chrome.storage.session` and cleared when the browser closes or the settings change. @@ -122,7 +133,8 @@ npm run typecheck # tsc --noEmit | `src/content.ts` | post detection, extraction, hiding/annotating, infinite-scroll nudge | | `src/sticker.ts` | the Jev activity sticker (Shadow DOM) | | `src/background.ts` | questions, decision logic, cache, API calls | -| `src/jev.ts` | minimal typed client for the System One endpoint | +| `src/jev.ts` | minimal typed client for the System One endpoint (TypeSafe direct or via OpenRouter) | +| `src/types.ts` | pure declarations: settings, verdicts, messaging, Jev question/answer contract | | `src/options.ts`, `public/options.*` | settings page | To change what gets filtered, edit `buildQuestions()` and `decide()` in diff --git a/package.json b/package.json index 2911b40..abdbadd 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "lkclean", "version": "0.1.0", "private": true, - "description": "Chrome extension that hides low-value LinkedIn posts using Jev, TypeSafe AI's typed classification model", + "description": "Chrome extension that hides low-value LinkedIn posts using Jev, via TypeSafe direct or OpenRouter", "scripts": { "build": "node build.mjs", "watch": "node build.mjs --watch", @@ -23,7 +23,7 @@ "chrome-extension", "linkedin", "feed-filter", - "typesafe-ai", + "openrouter", "jev", "manifest-v3", "typescript", From a2dbc33305ffccb50e1a56ce9e184fa8cdf9afdf Mon Sep 17 00:00:00 2001 From: Andrei Sorokin Date: Mon, 9 Nov 2026 23:30:26 +0200 Subject: [PATCH 08/11] Release v0.2.0 (fork: OpenRouter provider) --- package-lock.json | 4 ++-- package.json | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index e094b15..9827d60 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lkclean", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lkclean", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "devDependencies": { "@types/chrome": "^0.0.270", diff --git a/package.json b/package.json index abdbadd..5cb7cac 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lkclean", - "version": "0.1.0", + "version": "0.2.0", "private": true, "description": "Chrome extension that hides low-value LinkedIn posts using Jev, via TypeSafe direct or OpenRouter", "scripts": { @@ -31,4 +31,4 @@ ], "author": "stefw", "license": "MIT" -} \ No newline at end of file +} From 0bf2b5225ed4a841cc3acdf2215bc79d329dd309 Mon Sep 17 00:00:00 2001 From: Andrei Sorokin Date: Tue, 10 Nov 2026 14:11:07 +0200 Subject: [PATCH 09/11] A2: providers single source of truth; predicate derives from it --- src/options.ts | 6 +----- src/types.ts | 10 +++++++++- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/options.ts b/src/options.ts index f263fa3..3853be1 100644 --- a/src/options.ts +++ b/src/options.ts @@ -1,4 +1,4 @@ -import { DEFAULT_SETTINGS, type Provider, type Settings } from "./types"; +import { DEFAULT_SETTINGS, type Settings, isProvider } from "./types"; const $ = (id: string) => document.getElementById(id) as T; @@ -46,10 +46,6 @@ function render(s: Settings): void { interestOut.value = pct(fields.interestThreshold.value); } -function isProvider(v: string): v is Provider { - return v === "typesafe" || v === "openrouter"; -} - function read(): Settings { return { enabled: fields.enabled.checked, diff --git a/src/types.ts b/src/types.ts index 7ff5e10..c73bb83 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,13 @@ /** Where Jev is served from; drives the endpoint map in jev.ts. */ -export type Provider = "typesafe" | "openrouter"; +// types.ts hosts pure declarations plus the provider checklist — the one +// deliberate runtime exception: types alone cannot enforce "listed ↔ usable". +export const PROVIDERS = ["typesafe", "openrouter"] as const; +export type Provider = (typeof PROVIDERS)[number]; + +// Zero casts on purpose: an equality loop needs no cajoling of the compiler. +export function isProvider(v: string): v is Provider { + return PROVIDERS.some((p) => p === v); +} // ---------- Jev (System One) API contract ---------- // What we ask and what comes back. Pure declarations: jev.ts only transports From 01d35283b86e209a4c20bc9f4530a0e9d414a7e3 Mon Sep 17 00:00:00 2001 From: Andrei Sorokin Date: Tue, 10 Nov 2026 16:00:55 +0200 Subject: [PATCH 10/11] add test connection button --- public/options.css | 5 ++++ public/options.html | 1 + src/background.ts | 63 ++++++++++++++++++++++++++++++++++++++++----- src/options.ts | 54 +++++++++++++++++++++++++++++++++++--- src/types.ts | 7 ++++- 5 files changed, 119 insertions(+), 11 deletions(-) diff --git a/public/options.css b/public/options.css index 38337d8..f533267 100644 --- a/public/options.css +++ b/public/options.css @@ -40,6 +40,11 @@ button#save { background: var(--accent); color: var(--accent-ink); cursor: pointer; } button#save:hover { filter: brightness(1.08); } +button#test { + font: inherit; font-weight: 500; padding: 10px 18px; border: 1px solid var(--line); border-radius: 6px; + background: transparent; color: var(--ink); cursor: pointer; +} +button#test:hover { background: var(--field); } #status { color: var(--muted); font-size: 14px; } @media (prefers-color-scheme: dark) { :root { --ink: #e8ecf1; --muted: #98a4b1; --line: #2c3742; --paper: #14191f; --field: #1c232b; --accent: #6fb3e0; --accent-ink: #0d1a24; } diff --git a/public/options.html b/public/options.html index c1b482a..2325296 100644 --- a/public/options.html +++ b/public/options.html @@ -82,6 +82,7 @@

On the LinkedIn page

+
diff --git a/src/background.ts b/src/background.ts index f7fd17a..50ea1b7 100644 --- a/src/background.ts +++ b/src/background.ts @@ -1,6 +1,7 @@ import { askJev } from "./jev"; import { DEFAULT_SETTINGS, + type TestConnectionResponse, type Answer, type CallMeta, type ClassifyResponse, @@ -27,8 +28,8 @@ const NO_CALL: CallMeta = { let settingsCache: Settings | null = null; -async function loadSettings(): Promise { - if (settingsCache) return settingsCache; +async function loadSettings(refresh: boolean = false): Promise { + if (!refresh && settingsCache) return settingsCache; const stored = await chrome.storage.sync.get(DEFAULT_SETTINGS); settingsCache = { ...DEFAULT_SETTINGS, ...(stored as Partial) }; return settingsCache; @@ -132,6 +133,22 @@ function buildQuestions(s: Settings) { return q; } +function buildTestQuestions() { + const q: Record = { + test_question: { + type: "noul", + // Refer to state.text explicitly: "this string" alone has nothing to + // bind to and invites a degraded answer. + instructions: "In state.text: does this string contain a magic number?", + criteria: { + true: "Yes, it contains a magic number like 13, 42, or 7", + false: "No, it does not contain a magic number", + }, + }, + }; + return q; +} + // ---------- decision ---------- function decide(s: Settings, answers: Record): Verdict { @@ -223,6 +240,14 @@ function classify(post: PostPayload): Promise { return p; } +function apiKeyForConfiguredProvider(s: Settings): string { + const apiKey = s.provider === "openrouter" ? s.openrouterApiKey : s.apiKey; + if (!apiKey) { + throw new Error("No API key configured for provider: " + s.provider); + } + return apiKey; +} + async function classifyOnce(post: PostPayload): Promise { const s = await loadSettings(); if (!s.enabled) return { verdict: { hide: false }, meta: NO_CALL }; @@ -245,10 +270,7 @@ async function classifyOnce(post: PostPayload): Promise { post: { author: post.author, text: post.text.slice(0, 4000) }, }; - const apiKey = s.provider === "openrouter" ? s.openrouterApiKey : s.apiKey; - if (!apiKey) { - throw new Error("No API key configured for provider: " + s.provider); - } + const apiKey = apiKeyForConfiguredProvider(s); const t0 = performance.now(); const res = await askJev( @@ -276,7 +298,36 @@ async function classifyOnce(post: PostPayload): Promise { // ---------- messagerie ---------- +async function runSmokeTest(s: Settings): Promise { + const apiKey = apiKeyForConfiguredProvider(s); + + const smokeState = { text: "I've got 42 bottles of porter and crack 7 of them. Sad." }; + const res = await askJev( + apiKey, + s.provider, + s.model, + smokeState, + buildTestQuestions(), + ); + return { ok: true, model: res.model }; +} + chrome.runtime.onMessage.addListener((msg: Message, _sender, sendResponse) => { + if (msg.type === "testConnection") { + // refresh=true: the options page just overwrote chrome.storage.sync + // (save-first); storage.onChanged's ordering vs this message dispatch + // is not guaranteed, so the cache must not be trusted here. + loadSettings(true) + .then((s) => runSmokeTest(s)) + .then((r) => sendResponse(r)) + .catch((e: unknown) => + sendResponse({ + ok: false, + error: e instanceof Error ? e.message : String(e), + } satisfies TestConnectionResponse), + ); + return true; + } if (msg.type === "classify") { classify(msg.post) .then(({ verdict, meta }) => diff --git a/src/options.ts b/src/options.ts index 3853be1..a6ee7aa 100644 --- a/src/options.ts +++ b/src/options.ts @@ -1,4 +1,10 @@ -import { DEFAULT_SETTINGS, type Settings, isProvider } from "./types"; +import { + DEFAULT_SETTINGS, + type Settings, + isProvider, + type TestConnectionResponse, + type Message, +} from "./types"; const $ = (id: string) => document.getElementById(id) as T; @@ -73,9 +79,47 @@ async function load(): Promise { async function save(): Promise { await chrome.storage.sync.set(read()); - status.textContent = - "Saved. New posts follow these settings; reload LinkedIn to re-evaluate the feed."; - setTimeout(() => (status.textContent = ""), 4000); + showStatus( + "Saved. New posts follow these settings; reload LinkedIn to re-evaluate the feed.", + 4000, + ); +} + +let inFlight = false; + +// One shared timer: a new message cancels the previous dwell instead of +// fighting it, and callers don't await the display (finally below resets +// inFlight right away instead of pinning the button for the full dwell). +let statusTimer: number | undefined; +function showStatus(text: string, ms: number): void { + status.textContent = text; + clearTimeout(statusTimer); + statusTimer = setTimeout(() => (status.textContent = ""), ms); +} + +async function testConnection(): Promise { + if (inFlight) return; + inFlight = true; + try { + const state = read(); + await chrome.storage.sync.set(state); + const res = await chrome.runtime.sendMessage({ type: "testConnection" }); + + if (!res) { + showStatus("No response from the background worker — try again.", 6000); + return; + } + + if (res.ok) { + showStatus(`OK — saved. Model ${res.model}`, 6000); + } else { + showStatus(`Error: ${res.error}`, 6000); + } + } catch (e) { + showStatus(`Unexpected error: ${e instanceof Error ? e.message : String(e)}`, 6000); + } finally { + inFlight = false; + } } fields.noiseThreshold.addEventListener( @@ -87,4 +131,6 @@ fields.interestThreshold.addEventListener( () => (interestOut.value = pct(fields.interestThreshold.value)), ); $("save").addEventListener("click", () => void save()); +$("test").addEventListener("click", () => void testConnection()); + void load(); diff --git a/src/types.ts b/src/types.ts index c73bb83..b7d18e1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -152,8 +152,13 @@ export interface CallMeta { export type Message = | { type: "classify"; post: PostPayload } | { type: "getUiSettings" } - | { type: "openOptions" }; + | { type: "openOptions" } + | { type: "testConnection" }; export type ClassifyResponse = | { ok: true; verdict: Verdict; meta: CallMeta } | { ok: false; error: string }; + +export type TestConnectionResponse = + | { ok: true; model: string } + | { ok: false; error: string }; From 709a9757ef6f8bd055bf80daf83d5612b00ddfc8 Mon Sep 17 00:00:00 2001 From: Andrei Sorokin Date: Wed, 11 Nov 2026 11:03:57 +0200 Subject: [PATCH 11/11] fix versions --- package-lock.json | 4 ++-- package.json | 4 ++-- public/manifest.json | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9827d60..96c6915 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "lkclean", - "version": "0.2.0", + "version": "0.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "lkclean", - "version": "0.2.0", + "version": "0.2.1", "license": "MIT", "devDependencies": { "@types/chrome": "^0.0.270", diff --git a/package.json b/package.json index 5cb7cac..e57a815 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lkclean", - "version": "0.2.0", + "version": "0.2.1", "private": true, "description": "Chrome extension that hides low-value LinkedIn posts using Jev, via TypeSafe direct or OpenRouter", "scripts": { @@ -31,4 +31,4 @@ ], "author": "stefw", "license": "MIT" -} +} \ No newline at end of file diff --git a/public/manifest.json b/public/manifest.json index 78c7a9e..93f3f15 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "lkclean", - "version": "0.1.0", + "version": "0.2.1", "description": "Hides uninteresting LinkedIn posts, verified by Jev (TypeSafe direct or via OpenRouter).", "permissions": [ "storage" @@ -35,4 +35,4 @@ "action": { "default_title": "lkclean — settings" } -} +} \ No newline at end of file