diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 554f92c..9bdc8f3 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ { "name": "mnemo", "description": "7 memory skills for Claude Code and Codex via Obsidian — vault audit, knowledge search, bounded cross-runtime recall, connection discovery, session handoff, memory routing, review orchestration, and setup", - "version": "1.2.12", + "version": "1.2.13", "source": "./plugins/mnemo", "category": "productivity", "keywords": [ diff --git a/CHANGELOG.md b/CHANGELOG.md index 13bd3e3..f58f505 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,33 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +## [1.2.13] - 2026-07-27 + +### Changed + +- **The handoff index rotates by calendar, not by line count.** `handoff.keepDays` (default **31**) is now the rule; `handoff.maxLines` is gone. At the measured pace of a live vault — 7.5 sessions/day, 234 in 31 days — the old 180-line ceiling silently delivered ~24 days under a config that read `keepDays: 31`. `handoff.maxKB` (**56**, up from 40) is a backstop *underneath* the window, sized from measurement: 234 lines × ~195 B ≈ 46 KiB, so a normal month fits with ~20% headroom and the note still opens in a single read. `handoff.hardCapBytes` folded into `maxKB`, so the config carries **three** knobs where it carried five. +- **When the window does not fit, the file says so.** A month busier than `maxKB` allows drops its oldest pointers and states it in one `> _overflow:` line. A window that silently holds less than it promises is the failure this whole reform exists to remove; it must not reappear as a silent truncation. +- **Every index line is re-clipped, including ones written by hand.** An inline open-item list (`· open 4 (a · b · c) ·`) is now recognised and trimmed — measured at 754 B and 881 B on the live vault against a 200 B ceiling. The `[[Session — …]]` link is still never truncated (median 106 B, max 177 B on that vault): only the project label gives way. + +### Added + +- **`relink-orphan-pointers.py` — a pointer must have a live target.** A migrated block with no `[[Session — …]]` produced a pointer reading `(без session-заметки)`. That was cosmetic while the index kept everything; under a calendar window it is data loss, because eviction removes the block's last inbound link. Now such pointers are repointed at the exact archive part holding the block (10 of 186 blocks on the live vault, carrying 170 open items). The migration no longer creates them in the first place. +- **`backfill-tails-from-archive.py` — recently-archived tails move into their own session note.** Pre-reform sessions wrote unfinished work into the shared handoff, so after migration those tails sat in cold parts the digest deliberately does not read. This is a deliberately narrow revisit of "don't rewrite the user's notes": it appends **only genuinely missing** items — measured, 105 of 136 recent tails already existed in their note under different wording, so 31 were appended, each marked with its block's date. Coverage is fuzzy on purpose (a literal comparison reports 0% where the truth is 77%), which also makes a second run a no-op. +- **The handoff maintenance scripts now ship with the plugin.** `migrate-handoff-to-index.py`, `split-handoff-archive.py`, `restore-handoff-from-bak.py` and the two above moved from the repo into `plugins/mnemo/scripts/`, and `health` Step 7.6 offers them in dependency order when it finds a handoff still in block format. An installed user previously had no migration path at all: their handoff was in block format while the new `session` wrote pointers. +- **`setup`'s config template carries the `handoff`, `hot` and `hooks.hotDigest` keys**, so a fresh install learns about the window, the digest and their bounds instead of silently inheriting defaults. + +- **`docs/design-decisions.md` — раздел про handoff**, которого не было: почему индекс, что отвергнуто (архивировать сильнее, триаж ради размера, сжатие блоков, переписывание чужих заметок) и почему миграция остаётся отдельной ручной операцией. Non-goal `hot.md` переписан: отвергнут файл-кэш в волте, отгружен эфемерный вычисляемый дайджест. + +### Fixed + +- **Откат миграции был no-op.** `restore-handoff-from-bak.py` выбирал бэкап по mtime, а повторный прогон миграции пишет бэкап УЖЕ мигрированного файла — «отмена» восстанавливала индекс поверх индекса. Теперь кандидат обязан выглядеть до-миграционным (содержит `## YYYY-MM-DD` блоки либо крупнее текущего файла), иначе скрипт требует явный `--stamp`. +- **Повторный `split-handoff-archive.py --apply` затирал уже разложенные части** — `.bak` делался только хабу, блоки прошлого прогона исчезали. Теперь при существующих частях скрипт отказывается работать без `--force`, а с ним бэкапит каждую часть. Плюс размер части проверяется ДО добавления блока (одна часть выходила на 204 819 B при потолке 204 800). +- **`handoff-index-upsert` молча терял указатель**, если handoff ещё в блочном формате и больше потолка: цикл усечения выбрасывал и только что вставленную строку, возвращая `ok`. Теперь строка, ради которой сделан вызов, неприкосновенна, а неподъёмный легаси-файл получает явную ошибку «migrate first». +- **`migrate --keep-blocks N` при повторном прогоне дублировал указатели** — гейт «блоков не осталось» не срабатывал, пока часть блоков намеренно оставлена. +- **Существующие строки индекса не переклипывались** — правило длины применялось только к новой строке, поэтому запись от старой версии или руками жила вечно (на живом vault — 754 B при потолке 200). +- **Шаблон отчёта `health` украл хвост content-lint**: блок `📮 Handoff` был вставлен между заголовком и деталями, и правило о вердиктах читалось как часть handoff. Плюс Step 7.6 честно помечен legacy-only — на мигрированном handoff он видит 0 блоков. +- **Сиблинги контракта досвипаны**: `session-template.md`, `session/SKILL.md` («unfinished threads become handoff items») и хардкод `max_kb: 40 / keep_days: 14` вместо конфиг-плейсхолдеров. + ## [1.2.12] - 2026-07-25 ### Changed @@ -965,7 +992,8 @@ Frontmatter now includes `session_id: {CLAUDE_SESSION_ID}` — disambiguates sam - `config.example.json` - MIT License -[Unreleased]: https://github.com/jojoprison/mnemo/compare/v1.2.12...HEAD +[Unreleased]: https://github.com/jojoprison/mnemo/compare/v1.2.13...HEAD +[1.2.13]: https://github.com/jojoprison/mnemo/compare/v1.2.12...v1.2.13 [1.2.12]: https://github.com/jojoprison/mnemo/compare/v1.2.10...v1.2.12 [1.2.10]: https://github.com/jojoprison/mnemo/compare/v1.2.9...v1.2.10 [1.2.9]: https://github.com/jojoprison/mnemo/compare/v1.2.8...v1.2.9 diff --git a/README.md b/README.md index 3de2c10..2cf0208 100644 --- a/README.md +++ b/README.md @@ -266,18 +266,21 @@ mnemo doesn't force a note structure. Change `taxonomy` to match yours: ## Cross-Session Continuity -The killer feature. When a session ends, `/mn:session` writes a handoff note: +The killer feature. When a session ends, `/mn:session` writes the open tails into that session's own note: ```markdown -## Pending +## Next steps / pending - [ ] Check orphans after mass note creation - [ ] Update MOC — AI Research with 3 new notes +``` + +…and adds **one pointer line** to `Meta — Session Handoff`, which is an index of the last 31 days, not a store: -## Context -- Researched WeChat agent ecosystem, all saved in Session — 2026-03-23 +```markdown +- 2026-03-23 · research · open 2 · [[Session — 2026-03-23 WeChat agent ecosystem]] ``` -The next session reads this and picks up where you left off. +The next session opens with the **digest**: a SessionStart hook collects still-open tails from recent session notes and injects a byte-capped, project-scoped summary — so you pick up where you left off without anyone having to open a file. (The handoff note itself is never read at startup: past ~256 KB nothing can read it, which is exactly how the old "write the tails into the handoff" contract failed.) ## Requirements @@ -319,6 +322,13 @@ mnemo/ │ │ ├── session-scan.py # JSONL parser (Claude + Codex) with incremental cache │ │ ├── skills-discover.py # Auto-discovery across Claude/Codex skill paths │ │ ├── review-candidates.py # type-aware staleness scan for /mn:health +│ │ ├── hot-scan.py # open-tails digest injected at SessionStart +│ │ ├── handoff-resolver.py # report-only handoff triage (legacy block format) +│ │ ├── migrate-handoff-to-index.py # one-shot: blocks → pointer index (dry-run default) +│ │ ├── split-handoff-archive.py # cold archive → readable per-month parts +│ │ ├── relink-orphan-pointers.py # pointers with no target → their archive part +│ │ ├── backfill-tails-from-archive.py # archived tails → their own session note +│ │ ├── restore-handoff-from-bak.py # undo for the migration, content-picked backup │ │ └── check-cm-version.sh # claude-mem cache inspector │ └── hooks/ # Harness hooks │ ├── hooks.json # Codex-safe shared SessionStart + Stop baseline @@ -515,7 +525,7 @@ cp config.example.json ~/.mnemo/config.json ## Непрерывность между сессиями -Киллер-фича. `/mn:session` записывает handoff-заметку — следующая сессия подхватывает с того места. Больше никакого «а что я вчера делал?» +Киллер-фича. `/mn:session` пишет открытые хвосты в **саму session-заметку**, а в `Meta — Session Handoff` кладёт одну строку-указатель (индекс за последний 31 день, не хранилище). Следующая сессия открывается с **дайджеста**: SessionStart-хук собирает незакрытые хвосты из свежих заметок и инжектит краткую сводку по твоему проекту. Больше никакого «а что я вчера делал?» ## Требования diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index ae417a8..c2e1362 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -22,6 +22,8 @@ Priorities: 🔴 P1 must-do · 🟠 P2 valuable · 🟡 P3 nice · ⚪ P4 someda Nothing is rewritten; the output is a worklist. Natural home is **`mn:health`** as one more check (it already owns report-only detects and the research-gap candidates), which also keeps detects in one house instead of scattering them across skills. +**What already exists — do not rebuild it (added 2026-07-27).** `handoff-resolver.py` (v1.2.11) already ships the *anchor* half of this idea: it extracts Linear keys and PR numbers from open items, separates an item's **own** anchors from ones inherited from its header (inherited counting inflated resolvability 27.6% → 60.6%), and reports a worklist without ever writing. `health` Step 7.6 is its report-only host. So this card's remaining, genuinely new part is narrower than it reads: **the git side** — mapping "paths touched since the note's `date`" to the notes that mention them. Reuse the resolver's anchor parsing and its report shape; do not write a second anchor extractor, and do not widen this card back into "a general staleness detector" — type-aware staleness already ships in `health` Steps 7/7.5. + **On-philosophy.** It is the *report-only half* — the exact pattern already shipped for web-search imputation (declined as a writer, shipped as `health` Step 8.5 research-gap candidates): point at the gap, never fill it. Non-destructive holds (no note is authored or overwritten), human-authored holds (the user judges and edits), in-agent holds (reads the repo mnemo is already running in — no external service, no crawl). Contrast with the ambitious version below, which is where the cost sits. **Open questions.** diff --git a/docs/design-decisions.md b/docs/design-decisions.md index 781af2e..7d0c5fa 100644 --- a/docs/design-decisions.md +++ b/docs/design-decisions.md @@ -69,6 +69,33 @@ Descriptions get an agent to *consider* mnemo, but Opus 4.8 / Fable 5 under-trig - **Runtime-safe hook composition** (v1.2.3; Claude loader fix v1.2.4) — `hooks/hooks.json` is the auto-discovered Codex-safe baseline (`SessionStart` + `Stop`). Claude's manifest lists only the additive `hooks/claude-hooks.json`, which contains `UserPromptExpansion`; explicitly listing the standard file as well makes current Claude Code reject the plugin as a duplicate. Each event still has one definition: Claude keeps all three behaviors, while Codex never has to ignore an undocumented event. The Codex manifest deliberately relies on default discovery because the bundled validator rejects an explicit `hooks` field even though the current manual documents it. - **Rejected:** `PreToolUse(Read)` auto-recall (needs an index/daemon — mnemo has neither), `UserPromptSubmit` nudges (a cost on every prompt), and a **default-on** blocking Stop (loop risk for others). The nudge measures its own worth: ship it only if a trigger-eval shows lift over the bare description — otherwise it's paying an always-on price for nothing. +## The handoff is an index of pointers, not a store (v1.2.12) + +**What changed.** An unfinished thread now lives in its own session note's pending section; the handoff holds one line per session — `- 2026-07-25 · project · open 3 · [[Session — …]]`. + +**Why.** The previous contract sent open threads *into* the shared handoff. Measured on a real vault: only **9%** of fresh open items also existed in their own session note (34% for older ones), so the handoff had become the sole home of forward state. It reached **805 KiB** — and at that size nothing can read it (a file read refuses past 256 KB / 25,000 tokens), so the "read the handoff at session start" instruction was unexecutable and continuity happened in **6%** of sessions. A pointer line is bounded by the *number of sessions*; a copied thread is bounded by nothing. + +**What was rejected, and why it matters:** + +- **Archiving harder.** The archiver may never move a block holding an open `- [ ]` — an unkept promise must not slip into cold storage silently. On the live file that invariant meant it could free **zero** bytes while the file sat 20× over its ceiling. The guard is correct and insufficient: size is decided by the *format* of what gets written, not by rotation. +- **Triage as a size strategy.** Resolving *every* open item would have freed **2.47%**. Triage buys correctness, not size — which is why `handoff-resolver.py` is permanently report-only. +- **Compressing blocks into lines.** Live state is not always a `- [ ]`: 90 flat bullets and ~200 prose lines carried it. Extraction would have dropped them, so migration moves blocks **verbatim and whole**. +- **Rewriting the user's session notes** to redistribute 863 existing threads. Those files are human-authored and the vault has no version control; the threads went to the archive with a searchable inventory instead. (v1.2.13 revisits this **narrowly**, not generally — see the rotation rule below.) + +**Why migration is a separate manual step.** `migrate-handoff-to-index.py` is not run by any skill: it rewrites the vault irreversibly, so it defaults to `--dry-run`, writes `.bak` first, verifies every block is byte-present in the archive afterwards, and ships with a rehearsed `restore-handoff-from-bak.py`. A skill that silently reshapes your vault mid-session would violate the one principle above. + +## The index rotates by calendar, and says so when it can't (v1.2.13) + +**What changed.** The index keeps the last `handoff.keepDays` (default **31**) days of pointers. A line count (`handoff.maxLines`) no longer decides anything and the key is gone; `handoff.maxKB` (56) is a backstop underneath the window, and `handoff.maxLineBytes` (200) bounds a line. Five knobs became three. + +**Why.** "The last month of sessions" is what a reader asks for; a line count answers it only by accident. At this vault's measured pace — **7.5 sessions/day, 234 in 31 days** — the old 180-line ceiling silently delivered ~24 days under a config that said `keepDays: 31`. The sizing follows from measurement rather than taste: 234 lines × ~195 B ≈ 46 KiB, so 56 KiB carries a normal month with ~20% headroom and still opens in one read. + +**Evicting a pointer loses nothing — but only because two gaps were closed first.** A pointer is derived from a session note that keeps its own dated file, so dropping it drops a duplicate. That argument fails for a pointer whose target does not exist, which is why v1.2.13 ships `relink-orphan-pointers.py` (a pointer with no session note now points at the archive part holding its block — 10 such on the live vault, 170 open items) and `backfill-tails-from-archive.py` (tails that exist *only* in recently-archived blocks are appended to their own session note). The backfill is the narrow revisit of "don't rewrite the user's notes": it appends **only genuinely missing** items — measured, 105 of 136 recent tails were already in their note under different wording, so 31 were appended, each marked with the block's date — instead of redistributing 863. + +**When the window doesn't fit, the file says so.** If a month is busier than `maxKB` allows, the oldest pointers give way and one `> _overflow:` line states it. A window that silently holds less than it promises is the exact failure this reform exists to remove, so it must never fail silently again. + +**The remaining gap.** Tails older than the digest's `hot.windowDays` that live only in archived blocks are still outside its reach — deliberately: the digest is for current work, and teaching it to scan cold archive parts on every session start would pay a permanent cost for a legacy pile. They stay searchable, and `health` Step 7.6 triages them on demand. + ## Non-goals (deliberately rejected) These surfaced during the Karpathy "LLM Wiki" audit (v0.14.0 — see [CHANGELOG](../CHANGELOG.md)). Each is a reasonable idea for a *different* tool; each conflicts with the principle above. None are "forgotten" — they were evaluated and declined. If you want one, the "If you want it" note shows the on-philosophy way: always opt-in, default off, never masquerading as hand-curated content. @@ -95,7 +122,9 @@ These surfaced during the Karpathy "LLM Wiki" audit (v0.14.0 — see [CHANGELOG] **What it is:** a tiny (~500-char) cache of the most-recent context that downstream agents read first, to avoid crawling the whole vault on every query. -**Why not (near-N/A):** mnemo runs in-agent; the harness already injects `memory/MEMORY.md` + the live conversation as hot context, so the cold-crawl cost `hot.md` solves doesn't exist here. It would only help a *separate, external* agent querying the vault out-of-band — not mnemo's loop. +**Why not (as a *file*):** a cache note in the vault is another artifact to keep true, and a stale one is worse than none. + +**What shipped instead (v1.2.11):** the same need — "what was I in the middle of" — is served by an **ephemeral, computed** digest: `hot-scan.py` reads the pending sections of recent session notes at SessionStart and injects a byte-capped summary (`hot.scope` / `hot.windowDays` / `hot.maxKB`). Nothing is written to the vault, so it cannot rot; it is recomputed every session from the notes themselves. Note the naming: the `hot.*` config namespace belongs to that digest, not to a `hot.md` file. **If you want it:** only worthwhile if you build an external service that queries the vault headlessly; then a bounded `hot.md` maintained by `/mn:save` could accelerate it. diff --git a/docs/session.md b/docs/session.md index 2fae076..0b68914 100644 --- a/docs/session.md +++ b/docs/session.md @@ -18,7 +18,7 @@ No arguments. Summarizes the current conversation automatically. 2. Checks for duplicate session notes (same day) 3. Creates a note with the prefix/tag reached through `taxonomy_roles.session` 4. Verifies the note is linked in the hub reached through `taxonomy_roles.moc` -5. Updates `Meta — Session Handoff` with pending items +5. Writes the session's open tails into that note's own `## Next steps / pending`, and upserts **one pointer line** into `Meta — Session Handoff` 6. Checks for orphans after creation 7. Self-checks its own note (duplicate / MOC link / orphan / atom-delegation) before confirming @@ -29,18 +29,20 @@ No arguments. Summarizes the current conversation automatically. Note: "Session — 2026-03-24 Tech Research + mnemo plugin" MOC: [[MOC — Claude Code Tools]] — link added ✅ -Handoff: Updated with 2 pending items +Handoff: pointer line upserted (2 open tails stay in the note) Orphans: 0 new -Handoff contents: -## Pending +In the session note: +## Next steps / pending - [ ] Test /mn:review in real session - [ ] Update docs for new skill names -## Context -- Refactored mnemo plugin, 8 skills, CE-pattern naming +In Meta — Session Handoff (one line): +- 2026-03-24 · mnemo · open 2 · [[Session — 2026-03-24 Tech Research + mnemo plugin]] ``` +**Why the tail stays in its own note.** When unfinished work was written into the shared handoff instead, only 9% of a live vault's fresh open items existed in their own session note — the handoff became the single home of forward state, reached 805 KiB, and could then be neither read nor shrunk. A pointer line is bounded by the number of sessions; a copied tail is bounded by nothing. The handoff keeps the last `handoff.keepDays` (default 31) days of pointers; older ones drop, because the note behind each pointer keeps the detail. + ## Cross-Session Continuity The next session starts with the **open-tails digest**: `hooks/mnemo-context.sh` runs `hot-scan.py` over the pending sections of recent session notes and injects a byte-capped summary (`hot.windowDays`, `hot.maxKB`) next to the memory nudge, scoped to the repo you are in (`hot.scope`). diff --git a/plugins/mnemo/.claude-plugin/plugin.json b/plugins/mnemo/.claude-plugin/plugin.json index 83cc7c2..380d667 100644 --- a/plugins/mnemo/.claude-plugin/plugin.json +++ b/plugins/mnemo/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "mn", "displayName": "mnemo", - "version": "1.2.12", + "version": "1.2.13", "description": "Persistent memory layer for Claude Code and Codex via Obsidian — 7 skills for vault health, knowledge search, verified cross-runtime project recall, connection discovery, session continuity, memory routing, skill-aware session review, and interactive setup. Bundled JSON-stdin atomic vault writer, argv-safe indexed reads, dual-runtime helpers, hybrid model routing, parallel CLI calls, SessionStart prewarm, incremental JSONL parsing, shared references, and semantic taxonomy roles.", "author": { "name": "jojoprison", diff --git a/plugins/mnemo/.codex-plugin/plugin.json b/plugins/mnemo/.codex-plugin/plugin.json index 52881fe..cbd9713 100644 --- a/plugins/mnemo/.codex-plugin/plugin.json +++ b/plugins/mnemo/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mnemo", - "version": "1.2.12", + "version": "1.2.13", "description": "Persistent memory layer for Codex and Claude Code via Obsidian: vault health, knowledge search, verified cross-runtime project recall, link discovery, session notes, memory routing, review orchestration, and setup.", "author": { "name": "jojoprison", diff --git a/plugins/mnemo/assets/session-template.md b/plugins/mnemo/assets/session-template.md index 9d7a4a0..a936618 100644 --- a/plugins/mnemo/assets/session-template.md +++ b/plugins/mnemo/assets/session-template.md @@ -29,7 +29,7 @@ session_id: {session_id} ## Next steps / pending -- [ ] {Follow-up item carried to handoff} +- [ ] {Unfinished thread — it LIVES here; the handoff only gets a pointer line to this note} {links_section} diff --git a/plugins/mnemo/references/config-schema.md b/plugins/mnemo/references/config-schema.md index 9d9639e..5215049 100644 --- a/plugins/mnemo/references/config-schema.md +++ b/plugins/mnemo/references/config-schema.md @@ -64,8 +64,9 @@ Path: `~/.mnemo/config.json`. Created by `setup` skill on first install. All oth }, "handoff": { - "maxKB": 40, - "keepDays": 14 + "maxKB": 56, + "keepDays": 31, + "maxLineBytes": 200 }, "review": { @@ -132,11 +133,9 @@ The `recall` section is optional and ships off. `recall.codeGraph` (default `nul | `hooks.stopNudge` | At session end, if the session looks worth-saving but the save and/or session skill never ran, block once with the current runtime's native commands. This is **opt-in, default false**; an anti-loop governor prevents repeated blocking | hooks/mnemo-stop-nudge.sh | | `hooks.invocationEcho` | Claude Code only: on a `/mn:*` slash command, emit a `systemMessage` line (`🧠 mnemo: /mn:save → skill body loaded`) via the Claude-only `UserPromptExpansion` hook — a **deterministic** invocation confirmation that, unlike the in-body marker, does not depend on model compliance. Codex does not load this unsupported event. Default **true**; set false to silence | hooks/mnemo-skill-echo.sh | | `memory.indexWarnKB` | Early loaded-content byte warning for Claude `MEMORY.md`. Current loader limits are 200 lines or 25,000 bytes after stripping leading YAML frontmatter and block-level HTML comments; health always reports either hard-limit breach independently of this threshold. Default **22** | health | -| `handoff.maxKB` | Size ceiling (KB) before `vault-write.py archive-handoff` rotates CLOSED old blocks into ` Archive` (cold). The handoff is a live index, not a store. What an un-rotated one costs is **unreadability, not tokens** — a host runtime truncates a large read to a preview, and a file read refuses outright past 256 KB / 25,000 tokens, so past a point nothing can consult it and live items drown under months of closed history. Default **40** | session | -| `handoff.keepDays` | Blocks newer than this stay hot regardless of status; older **and** closed (no open `- [ ]`) move to the archive. Default **14** | session | -| `handoff.maxLines` | Pointer lines kept in the handoff index before the oldest rotate out. Default **180** — at the maintainer's measured pace (5.9 sessions/day lifetime, 12.4 over the last week) that is a 14-31 day window that never needs retuning as the pace changes | session | -| `handoff.maxLineBytes` | Per-line ceiling for an index line, in **bytes**. Default **200** (measured: the longest real session-note link is 177 B, so the pointer always fits). Trimming sacrifices the project label — never the `[[Session — …]]` link, because a cut wikilink is a dead link | session | -| `handoff.hardCapBytes` | Whole-file ceiling for the index, in **bytes**; oldest pointers drop until it fits. Default **38912** (38 KiB, under the 40 KB `maxKB` guard) | session | +| `handoff.keepDays` | **The rotation rule.** The index keeps pointers from the last N days; older ones are dropped, because a pointer is derived from a session note that keeps its own dated file. Default **31** — one calendar month, the unit a reader actually asks for. (In a handoff still in legacy block format the same knob keeps blocks newer than N days hot regardless of status.) | session | +| `handoff.maxKB` | Whole-note ceiling, the backstop **under** `keepDays`: if a month is busier than the window fits, the oldest pointers give way and the note says so in one `> _overflow:` line — a window that silently holds less than it promises is the failure this reform removed. Default **56** — measured pace 7.5 sessions/day × 31 days × ~195 B ≈ 46 KiB, so a normal month fits with ~20% headroom and the note still opens in a single read (a file read refuses past 256 KB / 25,000 tokens). The same value bounds the legacy `archive-handoff` rotation. | session | +| `handoff.maxLineBytes` | Per-line ceiling for an index line, in **bytes**, enforced on **every** line and not only the newly written one (measured: hand-written lines reached 754 B under a 200 B ceiling). Default **200** — the `[[Session — …]]` link alone is a median 106 B and up to 177 B on this vault, and it is never truncated: a cut wikilink is a dead link. Only the project label gives way. | session | | `hooks.hotDigest` | Inject the open-tails digest alongside the SessionStart nudge — the forward-state half of memory ("where did I stop"), collected from recent session notes' pending sections. This is the automatic reader continuity never had: a full handoff cannot be read at all (a file read caps at 256 KB / 25,000 tokens), so before this the digest existed only if someone opened the note by hand. Degrades to nudge-only on any failure. Default **true**; set false to silence | hooks/mnemo-context.sh | | `hot.scope` | `"project"` limits the digest to the repo you are in (project label = the basename of the git **common** dir, so a worktree reports its project, not the worktree name); `"all"` gives the cross-project view. Default **project** | hooks/mnemo-context.sh | | `hot.windowDays` | How far back the digest looks for open tails. Default **7** | hot-scan | diff --git a/plugins/mnemo/scripts/backfill-tails-from-archive.py b/plugins/mnemo/scripts/backfill-tails-from-archive.py new file mode 100644 index 0000000..9cab014 --- /dev/null +++ b/plugins/mnemo/scripts/backfill-tails-from-archive.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +"""Move still-open tails from cold archive blocks into their session notes. + +Pre-reform sessions wrote their unfinished work into the shared handoff, not +into their own note. After migration those tails sit in cold archive parts, +where the digest deliberately does not look — so a tail from three days ago can +be invisible while the reform claims tails are visible by default. + +Measured on the live vault (2026-07-27, 7-day window): 136 open items in +archived blocks, **105 already present** in their session note under different +wording, **31 missing**. So this is a small, surgical backfill — not a mass +rewrite of human notes. Only genuinely missing items are appended, each marked +with the date of the block it came from. + +Coverage is fuzzy on purpose: the note usually phrases the same tail +differently, and a literal comparison reports 0% when the truth is 77%. That +same check makes a second run a no-op — once appended, an item is covered. + + python3 scripts/backfill-tails-from-archive.py "" + python3 scripts/backfill-tails-from-archive.py "" --since-days 7 --apply +""" +import argparse +import datetime as dt +import difflib +import os +import re +import sys + +ARCHIVE_PREFIX = "Meta — Session Handoff Archive" +BLOCK_RE = re.compile(r"(?m)^## (\d{4}-\d{2}-\d{2})") +OPEN_RE = re.compile(r"(?m)^\s*[-*]\s+\[ \]\s*(.+)$") +LINK_RE = re.compile(r"\[\[((?:Session|Сессия) — [^\]]+)\]\]") +HEADING_RE = re.compile(r"^#{1,6}\s") +PENDING_HEADING_RE = re.compile( + r"^#{1,6}\s*.*(?:pending|next\s*steps?|to\s?-?do|follow[\s-]?up|" + r"open\s+(?:items?|threads?)|остал|жд[её]м|хвост|незакрыт|не\s+закры)", + re.IGNORECASE, +) +DEFAULT_THRESHOLD = 0.55 + + +def clean(text: str) -> str: + text = re.sub(r"\[\[([^\]|]+)(?:\|[^\]]+)?\]\]", r"\1", text) + return re.sub(r"[*`_#]", "", text).strip().lower() + + +def blocks_in_window(vault: str, cutoff: str) -> list[tuple[str, str]]: + """(date, block text) for archived blocks newer than cutoff.""" + found: list[tuple[str, str]] = [] + for name in sorted(os.listdir(vault)): + if not (name.startswith(ARCHIVE_PREFIX) and name.endswith(".md")): + continue + text = open(os.path.join(vault, name), encoding="utf-8").read() + marks = list(BLOCK_RE.finditer(text)) + for i, mark in enumerate(marks): + if mark.group(1) < cutoff: + continue + end = marks[i + 1].start() if i + 1 < len(marks) else len(text) + found.append((mark.group(1), text[mark.start():end])) + return found + + +def missing_items(block: str, note_body: str, threshold: float) -> list[str]: + note_items = [clean(x) for x in OPEN_RE.findall(note_body)] + missing = [] + for raw in OPEN_RE.findall(block): + candidate = clean(raw) + best = max( + (difflib.SequenceMatcher(None, candidate, other).ratio() + for other in note_items), + default=0.0, + ) + if best < threshold: + missing.append(raw.strip()) + return missing + + +def append_to_pending(body: str, items: list[str], block_date: str) -> str: + """Append items at the END of the pending section, as plain `- [ ]` lines. + + Never as a sub-heading: any heading closes the pending section, so a + `### backfilled` block would push the very items it carries back out of the + digest's view. + """ + additions = [f"- [ ] {item} — _из архива handoff ({block_date})_" + for item in items] + lines = body.split("\n") + start = next((i for i, line in enumerate(lines) + if PENDING_HEADING_RE.match(line)), None) + if start is None: + anchor = next((i for i, line in enumerate(lines) + if line.startswith("## Связи")), len(lines)) + block = ["## Next steps / pending", ""] + additions + [""] + return "\n".join(lines[:anchor] + block + lines[anchor:]) + end = next((i for i in range(start + 1, len(lines)) + if HEADING_RE.match(lines[i])), len(lines)) + while end > start + 1 and not lines[end - 1].strip(): + end -= 1 + return "\n".join(lines[:end] + additions + lines[end:]) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("vault") + parser.add_argument("--since-days", type=int, default=7) + parser.add_argument("--threshold", type=float, default=DEFAULT_THRESHOLD) + parser.add_argument("--today") + parser.add_argument("--apply", action="store_true") + args = parser.parse_args() + + vault = os.path.expanduser(args.vault) + if not os.path.isdir(vault): + print(f"error: not a directory: {vault}", file=sys.stderr) + return 1 + today = (dt.date.fromisoformat(args.today) if args.today else dt.date.today()) + cutoff = (today - dt.timedelta(days=args.since_days)).isoformat() + + planned: dict[str, tuple[str, list[str]]] = {} + orphan_blocks = 0 + for date, block in blocks_in_window(vault, cutoff): + if not OPEN_RE.search(block): + continue + link = LINK_RE.search(block) + path = os.path.join(vault, f"{link.group(1)}.md") if link else None + if path is None or not os.path.isfile(path): + orphan_blocks += 1 + continue + body = open(path, encoding="utf-8").read() + gaps = missing_items(block, body, args.threshold) + if not gaps: + continue + note, existing = planned.get(path, (date, [])) + planned[path] = (max(note, date), existing + gaps) + + total = sum(len(items) for _, items in planned.values()) + print(f"окно: с {cutoff} · заметок к правке: {len(planned)} · " + f"пунктов к переносу: {total} · блоков без заметки: {orphan_blocks}") + for path, (date, items) in sorted(planned.items()): + print(f" {os.path.basename(path)[:70]} ← {len(items)} ({date})") + if not planned: + print("нечего переносить — хвосты уже в своих заметках") + return 0 + if not args.apply: + print("DRY RUN — запусти с --apply, чтобы записать") + return 0 + + for path, (date, items) in sorted(planned.items()): + body = open(path, encoding="utf-8").read() + with open(path + ".bak-backfill", "w", encoding="utf-8") as handle: + handle.write(body) + with open(path, "w", encoding="utf-8") as handle: + handle.write(append_to_pending(body, items, date)) + print(f"записано в {len(planned)} заметок (рядом с каждой — .bak-backfill)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/mnemo/scripts/hot-scan.py b/plugins/mnemo/scripts/hot-scan.py index 49a66e4..a791cb7 100644 --- a/plugins/mnemo/scripts/hot-scan.py +++ b/plugins/mnemo/scripts/hot-scan.py @@ -261,6 +261,11 @@ def compose(items: list[dict]) -> tuple[str, list[dict]]: for group in surviving: lines.append(f"{group['project']} · {group['count']}:") lines.extend(f" · {item['text']}" for item in group["items"]) + # Say plainly when the cap hid most of the list: a digest showing 43 of + # 234 without saying so reads like the whole picture. + if len(items) < total: + lines.append(f"(показано {len(items)} из {total} — остальное " + "в pending-секциях session-заметок)") lines.append(tail) return "\n".join(lines), surviving diff --git a/scripts/migrate-handoff-to-index.py b/plugins/mnemo/scripts/migrate-handoff-to-index.py similarity index 89% rename from scripts/migrate-handoff-to-index.py rename to plugins/mnemo/scripts/migrate-handoff-to-index.py index d5faf70..a0dba5c 100644 --- a/scripts/migrate-handoff-to-index.py +++ b/plugins/mnemo/scripts/migrate-handoff-to-index.py @@ -60,12 +60,15 @@ def clip_to_bytes(value: str, budget: int) -> str: return trimmed -def index_line(block: str) -> str: +def index_line(block: str, archive_note: str = "") -> str: """A pointer for one block: date · label · open N · [[Session — …]]. The session link is preferred as the pointer because it leads to the - narrative; when a block has none, the header text is kept as the label so - the line still says what it was about. + narrative; when a block has none, the pointer falls back to the archive + note that now holds the block. That fallback is load-bearing: a pointer + reading `(без session-заметки)` is a dead end, and once the calendar window + evicts it the block has no inbound link at all — on the live vault 10 of + 186 blocks had no session note, carrying 170 open items between them. """ date_match = BLOCK_DATE_RE.match(block) date = date_match.group(1) if date_match else "0000-00-00" @@ -75,6 +78,8 @@ def index_line(block: str) -> str: link_match = SESSION_LINK_RE.search(block) if link_match: suffix = f" · open {open_count} · [[{link_match.group(1)}]]" + elif archive_note: + suffix = f" · open {open_count} · [[{archive_note}]]" else: suffix = f" · open {open_count} · (без session-заметки)" overhead = len(f"- {date}".encode()) + len(" · ".encode()) + len(suffix.encode()) @@ -97,7 +102,8 @@ def block_date(block: str) -> str: return match.group(1) if match else "0000-00-00" -def plan(handoff_body: str, archive_body: str, *, today: str, keep_blocks: int): +def plan(handoff_body: str, archive_body: str, *, today: str, keep_blocks: int, + archive_note: str = ""): header, blocks = split_blocks(handoff_body) # Order by date, never by file position: a real handoff is not sorted (the # live file had a 2026-07-19 block sitting among March ones), so "the newest @@ -107,7 +113,7 @@ def plan(handoff_body: str, archive_body: str, *, today: str, keep_blocks: int): kept = set(map(id, keep)) move = [b for b in by_date if id(b) not in kept] - lines = [index_line(b) for b in by_date] + lines = [index_line(b, archive_note) for b in by_date] new_handoff = header.rstrip("\n") + "\n\n" + "\n".join(lines) + "\n" if keep: new_handoff += "\n" + "".join( @@ -156,7 +162,14 @@ def main() -> int: if os.path.isfile(archive_path) else "") _, existing_blocks = split_blocks(handoff_body) - if not existing_blocks: + already_indexed = any( + line.startswith("- 20") and " · " in line + for line in handoff_body.splitlines() + ) + # With --keep-blocks the kept blocks stay behind, so "no blocks left" never + # becomes true and a re-run kept appending duplicate pointers. The real + # no-op condition is: an index already exists and nothing is left to move. + if already_indexed and len(existing_blocks) <= args.keep_blocks: # Already migrated (or never block-format). Re-running must be a true # no-op: a second pass used to append a "moved 0 blocks" note to the # archive and stray blank lines to the handoff — harmless to the data, @@ -165,7 +178,8 @@ def main() -> int: return 0 new_handoff, new_archive, blocks, moved = plan( - handoff_body, archive_body, today=today, keep_blocks=args.keep_blocks) + handoff_body, archive_body, today=today, keep_blocks=args.keep_blocks, + archive_note=os.path.basename(archive_path)[:-3]) before, after = len(handoff_body.encode()), len(new_handoff.encode()) print(f"handoff: {before} B → {after} B " diff --git a/plugins/mnemo/scripts/relink-orphan-pointers.py b/plugins/mnemo/scripts/relink-orphan-pointers.py new file mode 100644 index 0000000..11e7d93 --- /dev/null +++ b/plugins/mnemo/scripts/relink-orphan-pointers.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Give every handoff pointer a live target. + +A migrated handoff can hold pointers that link nowhere — blocks whose text +never mentioned a `[[Session — …]]` note. While the index kept everything they +were merely ugly. Once rotation is calendar-based they become a data problem: +the window evicts the pointer, and the block it described keeps no inbound link +at all. Measured on the live vault: 10 of 186 blocks, 170 open items between +them. + +This finds those pointers and repoints them at the archive note that actually +holds the block — the exact monthly part when the archive has been split, the +hub otherwise. Read-only by default. + + python3 scripts/relink-orphan-pointers.py "/Meta — Session Handoff.md" + python3 scripts/relink-orphan-pointers.py "…" --apply +""" +import argparse +import os +import re +import sys + +POINTER_RE = re.compile(r"^- (\d{4}-\d{2}-\d{2}) · ") +LINK_RE = re.compile(r"\[\[[^\]]+\]\]") +BLOCK_DATE_RE = re.compile(r"(?m)^## (\d{4}-\d{2}-\d{2})") +MAX_LINE_BYTES = 200 + + +def clip_to_bytes(value: str, budget: int) -> str: + """Longest prefix fitting `budget` UTF-8 bytes — bytes, never characters.""" + if budget <= 0: + return "" + if len(value.encode()) <= budget: + return value + room = budget - len("…".encode()) + trimmed = value + while trimmed and len(trimmed.encode()) > room: + trimmed = trimmed[:-1] + trimmed = trimmed.rstrip() + return trimmed + "…" if len(trimmed) >= 12 else "" + + +def archive_index(vault: str, hub_name: str) -> dict[str, str]: + """date -> note holding that date's block. Parts win over the hub. + + Sorted so a later part cannot silently shadow an earlier one for a date + they both contain: the first note found for a date keeps it. + """ + found: dict[str, str] = {} + for name in sorted(os.listdir(vault)): + if not name.endswith(".md") or not name.startswith(hub_name): + continue + note = name[:-3] + try: + text = open(os.path.join(vault, name), encoding="utf-8").read() + except OSError: + continue + for match in BLOCK_DATE_RE.finditer(text): + found.setdefault(match.group(1), note) + return found + + +def relink(line: str, target: str) -> str: + """Replace the dead tail of a pointer with a link, keeping it in budget.""" + head, _, _ = line.partition(" · open ") + open_match = re.search(r" · open (\d+)", line) + count = open_match.group(1) if open_match else "0" + suffix = f" · open {count} · [[{target}]]" + budget = MAX_LINE_BYTES - len(suffix.encode()) + return clip_to_bytes(head, budget) + suffix + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("handoff") + parser.add_argument("--apply", action="store_true") + args = parser.parse_args() + + path = os.path.expanduser(args.handoff) + if not os.path.isfile(path): + print(f"error: not a file: {path}", file=sys.stderr) + return 1 + vault = os.path.dirname(path) + hub_name = os.path.basename(path)[:-3] + " Archive" + + body = open(path, encoding="utf-8").read() + by_date = archive_index(vault, hub_name) + + out, fixed, unresolved = [], 0, [] + for line in body.split("\n"): + match = POINTER_RE.match(line) + if match is None or LINK_RE.search(line): + out.append(line) + continue + target = by_date.get(match.group(1)) + if target is None: + unresolved.append(line[:90]) + out.append(line) + continue + out.append(relink(line, target)) + fixed += 1 + + print(f"указателей без ссылки: {fixed + len(unresolved)} · " + f"перепривязано: {fixed} · без блока в архиве: {len(unresolved)}") + for line in unresolved: + print(f" ⚠️ {line}") + if not fixed: + print("нечего менять") + return 0 + if not args.apply: + print("DRY RUN — запусти с --apply, чтобы записать") + return 0 + + new_body = "\n".join(out) + backup = path + ".bak-relink" + with open(backup, "w", encoding="utf-8") as handle: + handle.write(body) + with open(path, "w", encoding="utf-8") as handle: + handle.write(new_body) + print(f"записано · бэкап: {os.path.basename(backup)}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/restore-handoff-from-bak.py b/plugins/mnemo/scripts/restore-handoff-from-bak.py similarity index 72% rename from scripts/restore-handoff-from-bak.py rename to plugins/mnemo/scripts/restore-handoff-from-bak.py index af1f332..f86e1a7 100644 --- a/scripts/restore-handoff-from-bak.py +++ b/plugins/mnemo/scripts/restore-handoff-from-bak.py @@ -21,22 +21,53 @@ import datetime as dt import glob import os +import re import shutil import sys +BLOCK_RE = re.compile(r"(?m)^## \d{4}-\d{2}-\d{2}") + + def backups_for(path: str) -> list[str]: return sorted(glob.glob(f"{path}.bak*"), key=os.path.getmtime, reverse=True) +def looks_pre_migration(candidate: str, current: str) -> bool: + """Is this backup actually worth restoring, or a post-migration snapshot? + + "Newest" is the wrong criterion: running the migration twice writes a fresh + backup of the ALREADY migrated file, and picking by mtime then silently + restores the index over the index — an undo that undoes nothing. A useful + backup either still holds dated `## YYYY-MM-DD` blocks (the pre-migration + shape) or is simply bigger than what is on disk now. + """ + try: + with open(candidate, encoding="utf-8", errors="ignore") as handle: + head = handle.read(200_000) + except OSError: + return False + if BLOCK_RE.search(head): + return True + current_size = os.path.getsize(current) if os.path.isfile(current) else 0 + return os.path.getsize(candidate) > current_size + + def pick(path: str, stamp: str | None) -> str | None: candidates = backups_for(path) if not candidates: return None if stamp: + # An explicit stamp is the operator overriding the heuristic on purpose. matching = [c for c in candidates if stamp in c] return matching[0] if matching else None - return candidates[0] + useful = [c for c in candidates if looks_pre_migration(c, path)] + if useful: + return useful[0] + print(f"⚠️ {os.path.basename(path)}: все бэкапы выглядят пост-миграционными " + f"(нет блоков и не крупнее текущего файла). Укажи --stamp явно, " + f"если уверен.", file=sys.stderr) + return None def describe(path: str, backup: str | None) -> None: diff --git a/scripts/split-handoff-archive.py b/plugins/mnemo/scripts/split-handoff-archive.py similarity index 82% rename from scripts/split-handoff-archive.py rename to plugins/mnemo/scripts/split-handoff-archive.py index ef1a759..88374a1 100644 --- a/scripts/split-handoff-archive.py +++ b/plugins/mnemo/scripts/split-handoff-archive.py @@ -49,12 +49,17 @@ def month_of(block: str) -> str: def part_body(hub_name: str, month: str, blocks: list[str]) -> str: opens = sum(len(OPEN_TODO_RE.findall(b)) for b in blocks) + # `month` may carry a part suffix ("2026-06 ч2"); the frontmatter date must + # stay a real date, or property-based views silently skip the file. + iso_month = month[:7] + hot_name = hub_name.replace(" Archive", "") return ( - f"---\ntype: meta\ntags: [meta, handoff, archive, cold]\ndate: {month}-01\n---\n\n" + f"---\ntype: meta\ntags: [meta, handoff, archive, cold]\ndate: {iso_month}-01\n---\n\n" f"# {hub_name} {month}\n\n" f"> ❄️ Холодный архив за {month}: {len(blocks)} блоков, {opens} незакрытых пунктов. " f"Текст перенесён дословно. Навигация — [[{hub_name}]].\n\n" + "".join(b if b.endswith("\n") else b + "\n" for b in blocks) + + f"\n## Связи\n\n- [[{hub_name}]] — хаб архива\n- [[{hot_name}]] — горячий индекс\n" ) @@ -82,6 +87,8 @@ def main() -> int: parser.add_argument("archive") parser.add_argument("--apply", action="store_true") parser.add_argument("--today") + parser.add_argument("--force", action="store_true", + help="overwrite existing parts (each is backed up first)") parser.add_argument("--max-part-bytes", type=int, default=200 * 1024, help="split a month once it exceeds this (default 200 KB, " "under the 256 KB read limit)") @@ -117,6 +124,8 @@ def main() -> int: part, used, index = [], 0, 1 for block in month_blocks: size = len(block.encode()) + # Check BEFORE adding: closing the part after the overflow let one + # part reach 204819 B against a 204800 B ceiling. if part and used + size > args.max_part_bytes: months[f"{month} ч{index}"] = part part, used, index = [], 0, index + 1 @@ -140,6 +149,21 @@ def main() -> int: print("\n🔍 DRY RUN — ничего не записано. Для применения: --apply") return 0 + # A part that already exists holds blocks from an earlier split. Overwriting + # it destroys them silently — and a later migration appends new blocks to the + # hub, so a second split is a realistic accident, not a hypothetical one. + collisions = [ + f"{hub_name} {month}.md" for month in months + if os.path.isfile(os.path.join(directory, f"{hub_name} {month}.md")) + ] + if collisions and not args.force: + print(f"\n🛑 {len(collisions)} частей уже существуют — перезапись стёрла бы " + f"их блоки. Проверь и запусти с --force (каждая часть будет " + f"забэкаплена):", file=sys.stderr) + for name in collisions[:5]: + print(f" {name}", file=sys.stderr) + return 3 + backup = f"{path}.bak-split-{today}" index = 1 while os.path.exists(backup): @@ -152,6 +176,8 @@ def main() -> int: written = [] for month, month_blocks in months.items(): part_path = os.path.join(directory, f"{hub_name} {month}.md") + if os.path.isfile(part_path): + shutil.copy2(part_path, f"{part_path}.bak-split-{today}") with open(part_path, "w", encoding="utf-8") as handle: handle.write(part_body(hub_name, month, month_blocks)) written.append((part_path, month_blocks)) diff --git a/plugins/mnemo/scripts/vault-write.py b/plugins/mnemo/scripts/vault-write.py index f04324f..f6fdd59 100755 --- a/plugins/mnemo/scripts/vault-write.py +++ b/plugins/mnemo/scripts/vault-write.py @@ -698,26 +698,47 @@ def transform_append(current: CurrentFile, payload: dict[str, Any]) -> str: INDEX_LINE_RE = re.compile(r"^- (\d{4}-\d{2}-\d{2}) · ") INDEX_DATE_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$") -DEFAULT_INDEX_MAX_LINES = 180 +# The window is calendar-based, not count-based: "the last month of sessions" is +# what a reader asks for, while a line count answers it only by accident. At the +# measured pace of this vault (7.5 sessions/day, 234 in 31 days) the previous +# 180-line ceiling silently shortened a 31-day promise to ~24 days. +DEFAULT_INDEX_KEEP_DAYS = 31 DEFAULT_INDEX_MAX_LINE_BYTES = 200 -DEFAULT_INDEX_HARD_CAP_BYTES = 38912 +# 31 days x 7.5 sessions x ~195 B ≈ 46 KiB; the ceiling leaves ~20% headroom and +# still opens in one read. It is a backstop, not the rotation rule. +DEFAULT_INDEX_HARD_CAP_BYTES = 57344 +INDEX_OVERFLOW_PREFIX = "> _overflow:" + + +MIN_LABEL_CHARS = 12 def clip_to_bytes(value: str, budget: int) -> str: - """Longest prefix of `value` fitting `budget` UTF-8 bytes. + """Longest prefix of `value` fitting `budget` UTF-8 bytes, marked when cut. Byte-wise, never character-wise: a Cyrillic label is ~2 B/char, so a character budget admits roughly twice what it promises. + + A cut is marked with `…` so the reader can tell "BTS: BRIDGE-" is a + truncation and not the whole summary; and a stub shorter than + MIN_LABEL_CHARS is dropped entirely — a fragment like "BTS: ревизия" reads + as information while carrying none. """ if budget <= 0: return "" - encoded = value.encode() - if len(encoded) <= budget: + if len(value.encode()) <= budget: return value + marker = "…" + room = budget - len(marker.encode()) + if room <= 0: + return "" trimmed = value - while trimmed and len(trimmed.encode()) > budget: + while trimmed and len(trimmed.encode()) > room: trimmed = trimmed[:-1] - return trimmed + trimmed = trimmed.rstrip() + if len(trimmed) < MIN_LABEL_CHARS: + return "" + return trimmed + marker def build_index_line( @@ -748,6 +769,41 @@ def build_index_line( return f"{fixed}{suffix}" +INDEX_PARSE_RE = re.compile( + r"^- (\d{4}-\d{2}-\d{2})(?: · (?P