From 54b280cfd8482dba31163be038f89bfed8186af4 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Wed, 7 Oct 2026 20:27:21 -0400 Subject: [PATCH 1/3] fix(extension): fixing a word's language no longer deletes it when the answer is the same word Launch check F1 on rc.4: "Another language" queues a new add that replaces the old one, and once it's saved the old add is undone. When the model answered with the same word in the same language ("Already in your list"), the new add landed on the old add's word, and undoing the old add deleted it: the word left the list for Recently deleted while the popup said it was already there. Retiring the old add now keeps any word the new one landed on. A regression test re-adds a word as itself: before, the list was empty; now the word stays. The existing test (a real change of language removes the old word) still passes. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Ayla Croft --- CHANGELOG.md | 4 ++++ extension/background.js | 9 ++++++--- test/bg/local-mode.test.mjs | 16 ++++++++++++++++ 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fbfc7a..e55b813 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ Conventional Commits by release-please. ## Unreleased +- Fixing a word's language no longer makes the word disappear when the model answers with the + same word in the same language ("Already in your list"): the word stays in your list. Before, + it went to Recently deleted. + - Privacy policy version 6: "What websites can see" now names every way a page can hide text that Kotiko still swaps (a transparent text colour, text turned away), and says a disguised word's card can open on a click as well as a resting pointer. diff --git a/extension/background.js b/extension/background.js index f6fd3b8..60ed3db 100644 --- a/extension/background.js +++ b/extension/background.js @@ -1995,7 +1995,10 @@ async function relang(m) { async function retireReplaced(job) { const { id, key } = job.replaces; - const res = await undoWord(id, key); + // The new add can land on the very word the old one made ("Already in your list", when the + // model answers the same word in the same language): that word stays. + const keep = new Set((job.results ?? []).map((r) => r.wordId).filter(Boolean)); + const res = await undoWord(id, key, { keep }); if (res.ok) await queue.patch(id, { replacedBy: job.id }); } @@ -2065,8 +2068,8 @@ const jobWords = async (id, key) => { }; const writeOps = async (ops) => (await wordRoutes["words.write"].run({ ops })).results; -async function undoWord(id, key) { - const records = (await jobWords(id, key)).filter((r) => r.result !== "unchanged" && r.undo !== "done"); +async function undoWord(id, key, { keep = new Set() } = {}) { + const records = (await jobWords(id, key)).filter((r) => r.result !== "unchanged" && r.undo !== "done" && !keep.has(r.wordId)); if (!records.length) return { ok: true }; await queue.setUndo(id, key, { undo: "pending", undoError: null }); const ops = records.map((r) => diff --git a/test/bg/local-mode.test.mjs b/test/bg/local-mode.test.mjs index ae9f4e6..2f43933 100644 --- a/test/bg/local-mode.test.mjs +++ b/test/bg/local-mode.test.mjs @@ -128,6 +128,22 @@ describe("a fresh install keeps words in this browser (slice 11)", () => { assert.equal(bg.store.addJobs.find((j) => j.id === first.job.id).replacedBy, null); }); + // Launch check F1: the model can answer the same word in the same language ("Already in + // your list"); retiring the old add must not delete the word the new one landed on. + test("another language that comes back as the same word keeps the word", async () => { + const bg = loadBackground({ local: { baseLangs: ["en"] } }); + await bg.k.ready(); + const first = await bg.send({ type: "add", text: "gato = cat", hintLang: "es" }, POPUP); + await bg.until(() => bg.store.addJobs?.find((j) => j.id === first.job.id && j.state === "done")); + const relang = await bg.send({ type: "jobs.relang", id: first.job.id, key: "es\u001fgato", lang: "es" }, POPUP); + assert.equal(relang.ok, true); + const next = await bg.until(() => bg.store.addJobs?.find((j) => j.id === relang.newId && j.state === "done")); + assert.equal(next.results[0].result, "unchanged", "the new add found the same word"); + await bg.until(() => bg.store.addJobs?.find((j) => j.id === first.job.id && j.replacedBy === relang.newId)); + const active = (await (await bg.k.getStore()).list()).map((w) => `${w.lang}:${w.native}`); + assert.deepEqual(active, ["es:gato"], "the word is still in the list"); + }); + test("Focus on one language decides a 'native = meaning' word's language", async () => { const bg = loadBackground({ local: { baseLangs: ["en"] } }); await bg.k.ready(); From c9567b9787e31286ac35e9bf28ffcc377942b961 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Wed, 7 Oct 2026 20:27:22 -0400 Subject: [PATCH 2/3] docs: typed words do reach Wiktionary, for their pronunciation Launch check F5: the docs site and the README said nothing leaves your browser when you type 'word = meaning', but for a language with word stress Kotiko looks up the word on English Wiktionary for its pronunciation. The privacy policy already said so; now these pages do too. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Ayla Croft --- README.md | 2 +- site/src/content/docs/index.mdx | 5 +++-- site/src/content/docs/start.md | 4 +++- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 83c5821..ce99eba 100644 --- a/README.md +++ b/README.md @@ -85,7 +85,7 @@ data folders and the service file. ## Privacy The text of the pages you read never leaves your browser. A word you add goes only to the AI -service you chose, or to your own server. Kotiko has no servers of its own and collects +service you chose, or to your own server, and, for its pronunciation, to English Wiktionary. Kotiko has no servers of its own and collects nothing. The [privacy policy](https://kotiko.org/privacy/) (its source is [docs/privacy/en.md](docs/privacy/en.md)) says exactly what is kept and sent, and [docs/privacy/inventory.md](docs/privacy/inventory.md) lists every request with the code diff --git a/site/src/content/docs/index.mdx b/site/src/content/docs/index.mdx index e2d2974..76c1ce7 100644 --- a/site/src/content/docs/index.mdx +++ b/site/src/content/docs/index.mdx @@ -48,7 +48,7 @@ Kotiko never assumes you read English. - Write `perro = dog` and it's yours. No account, no key, nothing leaves your browser. + Write `perro = dog` and it's yours. No account, no key, no AI service. With a free OpenRouter key, ask in your own words: "how do you say cat in Japanese". @@ -71,7 +71,8 @@ Until the store listings are live, you can [install Kotiko from its source](/ins ## Private by design - The text of the pages you read never leaves your browser. -- A word you add goes only to the AI service you chose, or to your own server. +- A word you add goes only to the AI service you chose, or to your own server, and, for its + pronunciation, to English Wiktionary. - Kotiko has no servers of its own, no accounts, no ads and no tracking. Neither does this site. Read the [privacy policy](/privacy/): it takes about two minutes. diff --git a/site/src/content/docs/start.md b/site/src/content/docs/start.md index 02fbc8d..0b24ef8 100644 --- a/site/src/content/docs/start.md +++ b/site/src/content/docs/start.md @@ -23,7 +23,9 @@ Pick one of three ways. You can change it at any time in **Settings**, **Word lo ### No key: type words with their meaning Select **Skip: I'll type meanings myself**. You add words as `word = meaning`, like -`perro = dog` or `спасибо (spasibo) = thanks`. Nothing leaves your browser. This always works, +`perro = dog` or `спасибо (spasibo) = thanks`. No AI service is asked. For a language with +word stress, such as Russian or Spanish, Kotiko looks up only the word on English +Wiktionary to get its pronunciation right ([privacy policy](/privacy/)). This always works, even with a key set up. ### A free OpenRouter key (recommended) From 1090aa6a975213f409c2799f206d63594cc61093 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Wed, 7 Oct 2026 20:27:39 -0400 Subject: [PATCH 3/3] docs(security): the review names the one code change after rc.4 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Ayla Croft --- docs/security/review-v1.0.0.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/security/review-v1.0.0.md b/docs/security/review-v1.0.0.md index 53cebf7..56b88a7 100644 --- a/docs/security/review-v1.0.0.md +++ b/docs/security/review-v1.0.0.md @@ -134,8 +134,11 @@ rc.4 zips rebuild byte for byte from the tag. with issues: SCR-836 (treat a transparent text colour and text turned away as hidden) and SCR-837 (read the report the way a CommonMark renderer shows it). The privacy policy is now version 6, which names every way a page can hide text that Kotiko still swaps. -v1.0.0 differs from rc.4 only in documentation (this report, the privacy policy's wording -and other docs) and the release notes. +v1.0.0 differs from rc.4 in documentation (this report, the privacy policy's wording and +other docs), the release notes, and one fix from the launch checks that touches no security +control: fixing a word's language no longer deletes the word when the new add lands on it +(`retireReplaced` in `extension/background.js` keeps that word; launch check F1, with a +regression test). One slip: a misquoted test setting sent two of F's test lookups ("shukran", "gato") to OpenRouter's public endpoint, with no key, token or identity. OpenRouter refused them