Merge master into the OpenCode Go branch

Codex now asks itself for its model list, doctor grew ready flags and a
per-provider line, and the Groq key joined the masked ones; the OpenCode
additions are folded into each. The no-CLI cleanup test follows the
fake_run to fake_cli rename.
This commit is contained in:
2026-08-27 15:51:49 +03:00
39 changed files with 3445 additions and 469 deletions
+124 -33
View File
@@ -5,6 +5,8 @@ import hashlib
import json
import os
import sys
import threading
import time
from . import api
from . import ggml
@@ -532,6 +534,30 @@ TRANSCRIBERS = {
"openrouter_base_url", "openrouter_transcribe_model"),
}
# One lock for the history file and the meeting index both, rather than one
# each: the files are a few kilobytes, the writes happen a handful of times an
# hour, and a second lock would only add a way to take them in the wrong order.
_FILES_LOCK = threading.Lock()
def _replace_with_retry(tmp, target):
"""The atomic swap, tried again briefly when the target is held.
On Windows an antivirus or sync tool opens a freshly written file to look
at it, and a rename over the file fails for as long as it is held. The
hold lasts milliseconds, so three tries with a short sleep cover it; a
file held longer than that is a real error and is raised as one.
"""
for attempt in range(3):
try:
tmp.replace(target)
return
except OSError:
if attempt == 2:
raise
time.sleep(0.05)
# Corners used to be stored with Turkish names.
_CORNER_MIGRATION = {
"sol-alt": "bottom-left", "sağ-alt": "bottom-right",
@@ -552,7 +578,19 @@ class Config:
self.data.update({k: v for k, v in stored.items() if k in DEFAULTS})
except FileNotFoundError:
pass
except (json.JSONDecodeError, OSError) as exc:
except json.JSONDecodeError as exc:
# Set aside rather than left in place: the next save would write
# the defaults over it, and whatever broke the file deserves to
# still be there to look at. Best effort; a rename that fails
# changes nothing about falling back to the defaults.
broken = CONFIG_FILE.with_suffix(".json.broken")
try:
CONFIG_FILE.replace(broken)
except OSError:
pass
print(f"dikte: could not read settings ({exc}), using defaults; "
f"the unreadable file was kept as {broken}")
except OSError as exc:
print(f"dikte: could not read settings ({exc}), using defaults")
self.data["overlay_corner"] = _CORNER_MIGRATION.get(
self.data["overlay_corner"], self.data["overlay_corner"]
@@ -567,8 +605,13 @@ class Config:
tmp = CONFIG_FILE.with_suffix(".json.tmp")
with open(tmp, "w", encoding="utf-8") as fh:
json.dump(self.data, fh, ensure_ascii=False, indent=2)
# Pushed to the disk before the rename: swapping in a file that
# still lives in the page cache turns a power cut into a settings
# wipe, which the atomic replace exists to prevent.
fh.flush()
os.fsync(fh.fileno())
os.chmod(tmp, 0o600)
tmp.replace(CONFIG_FILE)
_replace_with_retry(tmp, CONFIG_FILE)
i18n.set_language(self.data["ui_language"])
def __getitem__(self, key):
@@ -737,12 +780,22 @@ def default_assistant_prompt():
def append_history(entry):
DATA_DIR.mkdir(parents=True, exist_ok=True)
with open(HISTORY_FILE, "a", encoding="utf-8") as fh:
fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
with _FILES_LOCK:
with open(HISTORY_FILE, "a", encoding="utf-8") as fh:
fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
def read_history(limit=None):
"""Newest last. A limit of None (or 0) reads the whole file."""
# Locked even though the rewrites are atomic: it costs nothing, and a read
# that waits out a rewrite in flight hands back the settled file rather
# than whichever side of the swap it happened to land on.
with _FILES_LOCK:
return _read_history(limit)
def _read_history(limit=None):
"""The body of read_history, for callers already holding the lock."""
try:
with open(HISTORY_FILE, encoding="utf-8") as fh:
lines = fh.readlines()
@@ -765,40 +818,66 @@ def _write_history(lines):
tmp = HISTORY_FILE.with_suffix(".jsonl.tmp")
with open(tmp, "w", encoding="utf-8") as fh:
fh.writelines(lines)
tmp.replace(HISTORY_FILE)
fh.flush()
os.fsync(fh.fileno())
_replace_with_retry(tmp, HISTORY_FILE)
def trim_history(limit):
"""Drop the oldest entries once the file passes `limit` rows. 0 means keep all."""
if not limit or limit < 0:
return
try:
with open(HISTORY_FILE, encoding="utf-8") as fh:
lines = fh.readlines()
except OSError:
return
if len(lines) <= limit:
return
_write_history(lines[-limit:])
# Read and rewrite under one lock, so a dictation appended in between the
# two is not erased by a rewrite that never saw it.
with _FILES_LOCK:
try:
with open(HISTORY_FILE, encoding="utf-8") as fh:
lines = fh.readlines()
except OSError:
return
if len(lines) <= limit:
return
_write_history(lines[-limit:])
def _row_key(row):
return json.dumps(row, ensure_ascii=False, sort_keys=True)
def amend_history(entry, **changes):
"""Patch one entry in place, matched on its whole content like delete_history.
For the caller that learns something after its row is already written: the
row goes in before the paste is attempted, and a paste that then fails
still has to end up in the record. None when the row is gone, which a trim
in between can legitimately make true."""
wanted = _row_key(entry)
with _FILES_LOCK:
rows = _read_history()
for row in rows:
if _row_key(row) == wanted:
row.update(changes)
_write_history([json.dumps(r, ensure_ascii=False) + "\n"
for r in rows])
return row
return None
def delete_history(rows):
"""Remove the given entries, matched on their whole content rather than on a
line number: the worker may have appended a new one since the list was read."""
doomed = {_row_key(row) for row in rows}
if not doomed:
return
kept = [json.dumps(row, ensure_ascii=False) + "\n"
for row in read_history() if _row_key(row) not in doomed]
_write_history(kept)
with _FILES_LOCK:
kept = [json.dumps(row, ensure_ascii=False) + "\n"
for row in _read_history() if _row_key(row) not in doomed]
_write_history(kept)
def clear_history():
HISTORY_FILE.unlink(missing_ok=True)
with _FILES_LOCK:
HISTORY_FILE.unlink(missing_ok=True)
# --- meetings -------------------------------------------------------------
@@ -814,6 +893,12 @@ def meeting_paths(base):
def read_meetings():
"""Newest last."""
with _FILES_LOCK:
return _read_meetings()
def _read_meetings():
"""The body of read_meetings, for callers already holding the lock."""
try:
with open(MEETINGS_FILE, encoding="utf-8") as fh:
lines = fh.readlines()
@@ -836,29 +921,33 @@ def _write_meetings(rows):
with open(tmp, "w", encoding="utf-8") as fh:
for row in rows:
fh.write(json.dumps(row, ensure_ascii=False) + "\n")
tmp.replace(MEETINGS_FILE)
fh.flush()
os.fsync(fh.fileno())
_replace_with_retry(tmp, MEETINGS_FILE)
def save_meeting(entry):
"""Insert the row, or replace the one with the same base."""
rows = read_meetings()
for index, row in enumerate(rows):
if row["base"] == entry["base"]:
rows[index] = entry
break
else:
rows.append(entry)
_write_meetings(rows)
with _FILES_LOCK:
rows = _read_meetings()
for index, row in enumerate(rows):
if row["base"] == entry["base"]:
rows[index] = entry
break
else:
rows.append(entry)
_write_meetings(rows)
def update_meeting(base, **changes):
"""Patch one row and hand it back, or None when it is gone."""
rows = read_meetings()
for row in rows:
if row["base"] == base:
row.update(changes)
_write_meetings(rows)
return row
with _FILES_LOCK:
rows = _read_meetings()
for row in rows:
if row["base"] == base:
row.update(changes)
_write_meetings(rows)
return row
return None
@@ -867,7 +956,9 @@ def delete_meetings(bases):
doomed = set(bases)
if not doomed:
return
_write_meetings([row for row in read_meetings() if row["base"] not in doomed])
with _FILES_LOCK:
_write_meetings([row for row in _read_meetings()
if row["base"] not in doomed])
for base in doomed:
for path in meeting_paths(base):
try: