Releases: esagduyu/tiro
Release list
0.9.0 sync-beta — BYO multi-device sync (Phase 7a)
Multi-device sync where the user owns the storage: state-diff capture against
a shadow manifest, LWW + both-versions-kept conflicts (no CRDT), age
encryption over an Argon2id-derived identity, filesystem/S3/WebDAV backends.
Phase 2b (Obsidian bidirectional reconciliation) shipped absorbed as S1.
The v1.0.0 go/no-go gate — S2's hypothesis property suite + S5's
multi-device integration suite — is GREEN at this tip. The physical
2-laptops-+-phone owner acceptance matrix is still pending — machine-verified
analogues exist for every criterion, but the tag is the owner's call after
that run.
Sync S6 — hardening & acceptance
- Failure drills (
tests/test_sync_drills.py): corrupted-snapshot bootstrap
recovery (newest→oldest fallback, clean refusal namingtiro sync repair,
never a half-materialized library), journal-truncation reachability
refusal (a device doc ahead of present segments can no longer produce a
silently-stale "ok" — bootstrap AND steady-state), stale-lock steal/skip
pins, mass-delete-guard e2e extras (trip persistence across cycles, the
real-argv CLI acceptance path, the annotations-guard equivalent with
rows+sidecars surviving the trip, one-shot acceptance across two guarded
segments), library re-pair chain against naturally compacted backends,
CRLF newline-posture pin, lock-renewal and compaction-corruption drills. - Doctor: new report-only
syncsection (backend reachability, stale lock,
needs-attention reason, conflict-file census, cycle warnings, clock skew)
— exit-code neutral, offline-safe, no device-identity mint from a scan;
the ONE--fixaction is clearing a provably stale backend lock. - Clock-skew warnings (>24h, spec §10): pull-time both-directions detection
(behind-check bounded by the previous successful cycle so an
offline-weekend reunion never false-fires) + a live ahead-check in
load_sync_status["warnings"]; surfaced intiro syncstatus, the
settings card, and doctor. - library_id pinning: the backend's
format.jsonidentity is pinned in
sync_shadowand enforced every cycle — pointingsync_pathat another
library's backend refuses (needs_attention) instead of cross-merging;
the setup ceremony re-pins deliberately (plaintext join included);
plaintext auto-init refuses when a pin exists (typo protection). - Same-device two-process race closed: a per-library
flock(kernel-released
on process death) makes a concurrent server cycle +tiro sync --now
skip instead of double-writing a journal seq lockless. - Long-upload protection: mid-push/compaction advisory-lock renewal (never
clobbers a stolen lock; compaction additionally skips after an observed
mid-cycle theft). - Merge core hardened by the property gate itself — fresh hypothesis
exploration during S6's own gate runs falsified order-independence three
times (counterexamples #5–#7, all latent since S2); root-fixed
culminating in an atom-based canonical note form: freshly minted conflict
blocks now use a fixed dateless> [conflict]header (block dates and
intermediate-winner filtering were provably fold-order-dependent), legacy
dated blocks re-mint canonically at first touch. Note texts are never
lost (verbatim per atom); assertions never weakened; the generator was
extended to reach the legacy/header regime. - Sync UI pass (Playwright): warning lines now render in an actual warning
color in both themes (was a muted grey variable), interval-0 reads
"manual only"; needs_attention flips the sidebar + card dots (verified
in-browser). - Docs: README "Sync across devices (beta)" (setup, recovery-code warning,
backends table, honest limitations incl. device-local reading stats,
per-device vectors/audio, LF rewriting, the re-pair/repair story),
SECURITY.md sync-encryption posture (what bucket access reveals, what
the passphrase does not protect). - Accepted risks carried for owner ratification (decisions log D-S6-*):
memory-only emitted-alias window, line/link ops not alias-remapped,
repair drops in-flight tombstones, repair state reaches other
established devices only via re-bootstrap (cross-library re-pair edge).
Sync S2 — pure merge core
tiro/sync/{journal,manifest,merge}.py: HLC + the eight journal op kinds
(spec §5, wire format frozen by tests/fixtures/sync-journal-golden.jsonl,
sync_format 1), whole-library manifest build + sync_shadow store
(migration 016), state-diff op derivation, and full op application per the
spec §4 merge-rules table — LWW files with conflict-file preservation,
per-uid JSONL merge with a canonical note algebra that never drops a note
body (losers append verbatim under sorted[conflict {date}]blocks),
per-field meta LWW with per-field clocks (sync_shadowkind='metats') and
max()-merged opened_count, link add-wins-over-concurrent-remove, article
tombstones throughdelete_articlewith edit-wins resurrection, URL
dedupe keeping the ULID-older uid + alias repointing, tombstone TTL 90d,
unreadable-file protection (a transiently unreadable file is UNKNOWN,
never deleted), and a pull-side mass-delete guard (max(10, 20%),
whole-batch refusal).- THE 1.0 HARD GATE: hypothesis property suite (commutativity, idempotence,
no-note-loss, diff∘apply round-trip, HLC monotonicity) + test-enforced
zero-network check on the pure modules. Gate command:
for i in 1 2 3; do uv run pytest tests/test_sync_properties.py -q --hypothesis-seed=random || exit 1; done - New test-only dependency: hypothesis (MPL-2.0, license verified at add).
- No product surface yet: no routes, no scheduler, no UI, no transport —
S3 (crypto/blobs) and S5 (engine loop) build on these interfaces. - Known owner-review items (decisions log D26): highlight deletes preserve
non-empty notes as conflict files unconditionally (plan-property-vs-spec
resolution); conflict blockquote headers carry the date only (device
labels are not byte-convergent at line level).
Sync S1 — local reconcile engine (absorbed Phase 2b)
- External edits to the library (Obsidian et al.) now reconcile into SQLite/
ChromaDB/anchors: changed bodies re-index + re-embed and re-check highlight
anchors; new.mdfiles inarticles/ingest asingestion_method='external'
(file never rewritten — frontmatter stays user-owned); deleted files complete
deletion throughdelete_article, guarded against directory mishaps
(all-missing or > max(10, 20%) refusals). - Two-poll hash-settle defeats editor temp+rename/partial writes; unsettled
files retry next pass. - Notes prefer the external version when ambiguous; the losing DB version is
preserved asnotes/{stem}.conflict-local-{yyyymmdd}.md— never silently
dropped.tiro doctorgains a report-onlyconflict_filescensus and no
longer treats conflict files as orphans. - New: migration 015 (
articles.body_hashbackfilled,articles.meta_updated_at,
sources.uid+ stamped at creation), configreconcile_interval_s: 30
(0 = off), scheduler taskreconcile, CLItiro reconcile [--dry-run|--json]. PATCH .../rate|read|snooze(including the M3.2 unmark paths) now bump
articles.meta_updated_at(UTC) — the LWW clock for the coming sync merge.
Sync S3 — encryption + backend blob format
- Sync engine S3 (internal, no user-facing surface yet): age encryption layer
(passphrase → Argon2id → X25519 identity via pyrage; recovery code = Bech32
age secret key), frozen cross-port crypto parity fixture, sync backend blob
format (format.json with version refusal, journal segments, content-addressed
objects, snapshot manifests, device registry docs), compaction/GC planning
(500-op/7-day cadence, 90-day dead-device rule), and corrupted-blob
quarantine semantics. New runtime deps: pyrage (MIT), argon2-cffi (MIT).
Sync engine (S4 — storage adapters)
- Storage adapter contract (
tiro/sync/adapters/): asyncput/get/list/delete/lock/unlock
byte stores with TTL-honoring advisory locks and a shared jittered retry policy
(transient faults only, 3 attempts). - Three adapters: filesystem (atomic temp+rename writes, O_EXCL lock, encryption
default off), S3-compatible (boto3, paginated listing, conditional-PUT lock,
encryption default on), WebDAV (hand-rolled on httpx: Depth-1 PROPFIND walk,
MKCOL parent creation, percent-encoded keys, encryption default on). - One shared conformance suite runs against all three: tempdir filesystem and
an in-memory WebDAV fake always; real MinIO/Nextcloud via
deploy/docker/docker-compose.sync-test.yml, auto-skipped when unavailable. Failure injection (5xx retry, list pagination, partial upload,
lock contention/steal) runs fully offline via Stubber/MockTransport. - New dependency: boto3 (Apache-2.0, license re-verified). No migration, no routes,
no UI — the engine loop and settings surface land in S5.
Sync engine (S5)
- The full pull-merge-push cycle (
tiro/sync/engine.py): S1 reconcile first,
pull per-device journal segments on watermarks and apply through the S2
merge core, push local changes in the FROZEN crash-safety order (objects →
journal segment → device doc → local last_seq THEN shadow LAST — a crash
anywhere leaves a duplicate, never a loss), then lock-gated compaction/GC. - Safety semantics: per-cycle
format.jsonencryption pinning — the local
pin is the authority, a disagreeing backend doc is refused before any
codec is built (downgrade refusal); corrupted/undecryptable/malformed
remote data quarantines the cycle asneeds_attentionwith the watermark
held — never a half-apply; a journal gap (missing segment) refuses the
pull the same way; the S2 pull-side mass-delete guard gains a ONE-SHOT
acceptance (tiro sync --now --accept-mass-delete/{accept_mass_delete}
on the API), consumed by a single guard trip. - Empty-library auto-bootstrap: a zero-article, never-synced device pointed
at a populated backend materializes the latest snapshot before its first
...
0.8.0 agents-beta — Agent runtime (Phase 6)
Every AI feature now executes as a recorded, replayable agent run; personas
and the ContradictionDetector build on the frozen kernel contract.
Agent runtime (Phase 6 K1–K2)
- New
tiro/agents/kernel:TiroAgent/AgentContextcontract,run_agent()with per-run JSONL traces ({library}/agents/traces/{run_uid}.jsonl),agent_runsindex table (migration 014), one-agent-at-a-time run lock, typedAgentRunError. - All four AI features migrated behavior-identically (golden transcript tests pin prompt bytes): MetadataExtractor (Haiku extraction — every web/email/imap/rss/import ingest is now a recorded run), PreferenceClassifier, DigestWriter (scheduler untouched), IngenuityAnalyst.
extract_metadata/classify_articles/generate_digest/analyze_articleremain as compat wrappers — zero call-site churn. /agentspage: agent cards, filterable paginated run history, collapsible trace viewer, replay with cost-note confirm. Routes:GET /api/agents,GET /api/agents/runs,GET /api/agents/runs/{run_uid}(?trace=1streams JSONL),POST /api/agents/runs/{run_uid}/replay,POST /api/agents/{name}/run.- Trace retention (
agent_trace_retention_days/agent_trace_max_mb, files pruned, rows kept); doctor gains orphan-trace vacuum + stuck-run sweep. - Evals harness:
tiro/evals/fixtures +tiro evals run [agent] [--real](structural mode is free/CI-gated via pytest);tiro agent list|run. - STATIC_VERSION 69.
Agent runtime — personas (Phase 6 K3)
- Persona files (
{library}/personas/*.md): community-shareable prompt templates over a closed placeholder set, running through the agent runtime on scope-derived read-only contexts. Untrusted by construction: suggest-only writes, no network tool, fenced interpolation with a fixed preamble, forced output kind, adversarially tested. suggestionstable (migration 017) + accept/dismiss flow running the standard validated writes; suggestion chips in inbox/reader and a queue + persona management on/agents.- Three forkable default personas: devils-advocate, daily-themes, research-brief.
personas_disabledconfig (API-toggled). Manual runs only in K3; on-ingest/cron dispatch arrives with K4's hook infrastructure. - Routes:
GET /api/personas,POST /api/personas/{slug}/enable,POST /api/personas/{slug}/disable,GET /api/suggestions?status=&article_id=,POST /api/suggestions/{uid}/accept,POST /api/suggestions/{uid}/dismiss. - Backup snapshots now include
personas/(an export/backup posture fix landed alongside K3 closeout —agents/traces/remains deliberately excluded, per the K1–K2 D15 owner-ratification item). - STATIC_VERSION 70.
Agent runtime — ContradictionDetector & on-ingest hooks (Phase 6 K4)
- New
contradiction-detectorcode agent runs on ingest (post-save hook, never
inside the rollback window; hook failures can never fail a save): finds up to
8 similar articles, keeps the trusted set (rating > 0 or must-read), gets one
light-tier claims-level verdict per candidate, and files confidence-gated
contradictionsuggestions ("challenges something you trusted"). Accepting
appends the contradiction to the article's note. Empty trusted set = zero
LLM calls. contradiction_detector_enabledkill-switch (default on, config/env only —
gates the on-ingest dispatch, never manual runs);tiro agent run contradiction-detector --backfill [--limit]for existing libraries
(resumable, newest-first; bulk imports are hook-exempt by design).- On-ingest personas (article scope) now dispatch through the same hook —
K3'sschedule: on-ingestvalue is live;crondispatch still deferred. - Six-pair eval fixture set added to the harness (
fake_similars+
ai_tierseed support). No new migration, no frontend change.
v0.7.0 — desktop-beta (Phase 5: desktop packaging)
[0.7.0] — desktop-beta (Phase 5: Installable personal app)
Tiro becomes something you install, not just something you run from a terminal:
a double-clickable macOS app that bundles the whole server, a first-run
onboarding wizard, platform-standard library locations with a safe migration
tool, a background service for run-at-login, and an official multi-arch Docker
image on GitHub Container Registry. STATIC_VERSION 67 → 68.
Added — Desktop app (macOS beta)
- PyInstaller server binary (
desktop/pyinstaller/) — the full FastAPI
server frozen as a onedir bundle (tiro-server), embedding-model included so
the first launch reaches a working, offline vector store with no unexplained
Hugging Face download. Theall-MiniLM-L6-v2snapshot is staged into the
bundle and copied into the standard HF hub cache on first launch iff
absent (tiro/model_cache.py); overridingdefault_embedding_modelstill
downloads normally. The.specstages the snapshot under its true file
names (not blob hashes), so it loads fully offline — enforced by
desktop/pyinstaller/smoke.sh, which boots the binary with
HF_HUB_OFFLINE=1and an emptyHF_HOMEand round-trips an ingest +
semantic search. - Tauri desktop shell (
desktop/tauri/) — a native window wrapping the
bundled server as a sidecar. It bootstraps a platform config, prefers port
8000 (falls back to a free port if occupied — the one case the Chrome
extension's hardcodedlocalhost:8000can't reach, documented), polls
/healthzbefore opening the window, and kills the sidecar's whole process
group on quit. Built unsigned for 0.7.0 (Gatekeeper right-click-Open);
Developer ID signing + notarization ship as an owner runbook. - First-run onboarding wizard (
GET /welcome,js/welcome.js,
welcome.html) — a web-served, skippable wizard that extends the existing
/loginsetup spine: Welcome → Library location → Password (required) → AI
provider + key → Email → Remote-access pointer → Chrome-extension card →
Sample articles → Finish. New setup routes undertiro/api/routes_setup.py
(POST /api/setup/library-path,/api/setup/ai,/api/setup/samples) share
an unconfigured-OR-authenticated gate. Unconfigured page-auth redirects now
land at/welcomerather than a/loginthat would only bounce.
Added — Library location & migration
- Platform-default library paths (
tiro/paths.py) —tiro initand the
desktop app write a platform-standardlibrary_pathinto newly-created
config files (macOS~/Library/Application Support/Tiro, Linux
$XDG_DATA_HOME/tiro, Windows%APPDATA%\Tiro). The dataclass default stays
./tiro-library, so no existing install is ever silently re-pointed. tiro migrate-library [dest](tiro/library_move.py) — copy-then-
confirm, never-remove migration:auto_backupfirst (a failed backup
aborts), copy every store, verify path-set + per-file size equality, then the
singlepersist_configwrite re-pointinglibrary_path. Interrupted runs
leave a marker and restart cleanly from scratch; the source is never touched.
A dismissible inbox banner points legacy-default installs at the command.
Added — Migrate-on-start & update check
- Migrate-on-start hardening — before applying schema migrations, a start
that crosses a schema version on a library with real data takes an
auto_backup("pre-migrate")snapshot and logs thevN → vMtransition.
Non-interactive by design (a double-clicked app has no TTY);tiro migrate
gained the same snapshot for symmetry. - Notify-only update check (
tiro/update_check.py) — a scheduler-registered
dailyPeriodicTaskqueries the GitHub Releases API (ETag-cached, 10s
timeout, one audit line per check), compares againsttiro.__version__, and
surfaces a dismissible "update available" banner (per-version dismissal),
plus the authenticated/healthzdetail andtiro status. Notify-only — it
never downloads. Kill switch:update_check_enabled: false(default True);
documented as the app's only phone-home.
Added — Service & distribution
tiro service install|uninstall|status|logs— run Tiro at login as a
background service: a launchd user agent (macOS), a systemd user unit
(Linux), and a printed nssm recipe (Windows, documented-not-built). Targets
the resolved absolute executable + absolute--config, so it's
working-directory-independent.- Official Docker image on ghcr (
.github/workflows/docker.yml) — a
tag-triggered (push: tags: ["v*"], plusworkflow_dispatchfor the owner's
supervised first push) multi-arch (amd64 + arm64) build publishing
ghcr.io/esagduyu/tirowithX.Y.Z/X.Y/latesttags.ci.yml
untouched; the compose template now references the registry image (build-it-
yourself still works). Container auth without a TTY: set
TIRO_AUTH_PASSWORD_HASH(a bcrypt hash) via theTIRO_*env overlay.
Changed
- Version
0.6.0→0.7.0;STATIC_VERSION67 → 68 (the phase's one bump). - README gained "Install Tiro", "Where your library lives", "Run at login", and
"Updates" sections; CLAUDE.md gained a Desktop packaging (Phase 5) conventions
bullet, the new endpoint/CLI rows, and a 0.7.0 status line.
Fixed
- Long-standing
test_backupflake resolved (tests/conftest.py) — the
intermittent mid-suite ChromaDB failure was rooted in chromadb's
process-wideSharedSystemClientcache holding System objects past a test's
lifetime and hitting a file-descriptor ceiling. The fixture now clears the
cache after each test (SharedSystemClient.clear_system_cache(), guarded),
freeing the held clients — a real test-durability win, not just a re-roll.
v0.6.0 — feeds-beta (Phase 4: RSS & imports)
[0.6.0] — feeds-beta (Phase 4: RSS & imports)
Recurring ingestion arrives: subscribe to RSS/Atom feeds, bulk-import an
existing reading library, and save straight from the browser with a selection
captured as a highlight. STATIC_VERSION 66 → 67.
Added — RSS & feeds
- Feed subscriptions (migration 013:
feeds+feed_entries). Subscribe
by feed URL or page URL —POST /api/feedsautodiscovers a
<link rel="alternate" type="application/rss+xml|atom+xml">when the URL is
an HTML page (30s timeout, 5-redirect cap, 10 MB body cap; 409
already_subscribedon a duplicate feed URL). Feeds carry a per-feed
fetch_interval_minutes, conditional-GET validators (etag/last-modified),
and anerror_countbackoff. - Recurring poll loop on the new
PeriodicTaskscheduler registry
(tiro/scheduler.py, extended not replaced — the imap/digest/vector loops
were refactored onto it). Each poll cycle writes one audit line. New articles
land withingestion_method="rss"; thefeed_entriesdedup ledger keeps a
deleted article from being resurrected by the next poll (itsarticle_idis
nulled, the ledger row survives). - Feed management (
GET/POST /api/feeds,PATCH/DELETE /api/feeds/{id},
POST /api/feeds/{id}/check,POST /api/feeds/check-all) and a/feeds
management page (grouped by folder, status pills, check-now / pause / rename /
delete-with-or-without-articles).DELETE ?delete_articles=truetakes an
auto_backupfirst, then loops thedelete_articlelifecycle coordinator
per article — never a raw cascade. Sidebar Library entry + Shift+F
keyboard shortcut (inbox & reader;nwas already taken). - OPML round-trip:
GET /api/feeds/export(standalone OPML 2.0, nested one
level by folder) andPOST /api/feeds/import(multipart upload, flattens
nested outlines into afolderpath, dedupes by url, returns
{added, skipped, errors}; rejects >5 MB or unparseable).
Added — library importers
- Three importers — Readwise JSON, Instapaper CSV, Omnivore zip — via
POST /api/import/{kind}(single-slot background job; 409import_running
when one is active;GET /api/import/statuspolls progress) and CLI verbs
tiro import-readwise|import-instapaper|import-omnivore(always skip
existing). Content is re-fetched where possible; a paywalled/failed re-fetch
falls back to a stub article taggedimport-stub. Original timestamps are
preserved. Imported articles useingestion_method="import". - Anchored highlight import: Readwise highlights are anchored against the
re-fetched markdown body with the same D7.4 machinery the reader uses;
unlocatable highlights are skipped and counted, never hand-placed. A
Settings "Import library" card drives it with a live progress bar.
Added — Chrome extension advanced save
- Background service worker registers three context-menu items (Save /
Save as VIP / Save with selection as highlight) and the popup gains a
save-all-open-tabs action. Selecting text and saving anchors it as a
highlight server-side viahighlight_textonPOST /api/ingest/url
(soft-fails to no-highlight, still 200, if the selection can't be located).
Added — owner UX wave
- Reading progress bar in the reader (fixed, accent fill, both themes; a
ResizeObserverre-measures when late-loading images reflow the body). - Unread-first inbox with a Library view toggle (
akey /?view=library)
that reveals read + archived rows — read/unread and active/decayed are
treated as orthogonal axes.
Added — export & backup
metadata.jsongains an additivefeedskey (durable subscription columns
only; transient fetch state and thefeed_entriesledger excluded).
sources.opmlmarks feed-backed sources withtype="rss"+xmlUrl.
tiro importmerges bundle feeds by url;tiro backup/restoreround-trips
feed rows wholesale. See EXPORT_SCHEMA.md.
Fixed
load_config()now honorsTIRO_CONFIG(ON-8 root-cause hardening).
A bareload_config()(fromtiro/app.py,scripts/, or any script run
from the repo root) previously ignored theTIRO_CONFIGenv var and
defaulted to CWD-relative./config.yaml, so a load→persist round-trip
could silently corrupt the owner's real config. Path precedence is now
explicit-arg >TIRO_CONFIG>./config.yaml, matchingrun.py/cli.py/
the MCP server. No signature change (additive default →None).
Added — iOS device pairing
- Device pairing for the iOS client (
/setup/qr?mode=device,
POST /api/auth/pair). The/setup/qrpage becomes two labeled panels —
browser sign-in (unchangedlogin/qrQR) and app pairing — the latter
encoding atiro://pair?url=…&code=…QR the native app scans in-app to
exchange a one-time code for a long-livedios:<device_name>API token.
Mirrors the QR-login token machinery exactly (sha256-only storage,
15-minute TTL, atomic single-use consume, generic-400 failures, no-store);
newdevice_pair_codestable (migration 012); doctor purges expired/used
codes in the same housekeeping bucket as login tokens.
Design pass — full frontend redesign (design/codex-pass, STATIC_VERSION 66)
Whole-app visual and interaction redesign, landed as a sequence of
self-contained tasks (icons/tokens/chrome first, then a page-by-page pass,
then motion/dark-theme/glyph closeout). No backend or API surface changed.
- Icons: a single canonical SVG icon set (
tiro/frontend/static/js/icons.js
for JS call sites,_icons.html'sicon()Jinja macro for templates, kept
in sync by a dedicated test) replaces every emoji/dingbat/HTML-entity glyph
across the app — nav, toolbars, modals, cards, empty states, close buttons. - Chrome: CSS custom-property token layer (color/spacing/radius/type
scale, light + dark) and shared component primitives (modal, overlay,
banner surfaces); rebuilt 240px sidebar with an icon rail; phone chrome
replaced with a bottom tab bar + Library/More sheets (hamburger menu
removed). - Per-page pass: inbox (icon cards, toolbar, serif pagination, empty
states), reader (chrome, audio player, callouts, phone action bar), digest,
stats, graph, sources, wiki, highlights, settings, login, QR/remote-access
wizards, and the offline fallback page all restyled on the new token/
component layer. - Motion: a consistent transition/easing pass across interactive surfaces
(cards, modals, sheets, toasts) withprefers-reduced-motion: reduce
support throughout, plus a dark-theme (Roman Night) contrast audit. - Closeout sweep: final glyph audit — remaining literal
×close
buttons (graph.html's node panel, base.html's LAN-over-HTTP banner
dismiss, reader.html's analysis/highlights panel close buttons) converted
to the canonicalcloseicon; orphaned.shortcuts-closeand
.graph-node-panel-closeCSS rules removed (both close buttons already
used.modal-close); LAN-banner phone padding constant aligned to the
real phone-header height.STATIC_VERSION65 → 66.