[Bug]: strip_line_numbers() is not idempotent — line-number prefixes accumulate in merged memories
Issue Origin
Observed or reproduced in a real environment
Bug Description
strip_line_numbers() removes only one line-number prefix per line, while add_line_numbers() can be applied to content that already carries one. On the patch-merge path this makes prefixes accumulate in stored memory content: a card merged N times ends up as 1\t1\t1\t## Title.
every_line_has_line_numbers() uses the same single-prefix regex, so it reports True on content that still carries prefixes — the "aggressive stripping fallback" is therefore not reached.
Steps to Reproduce
from openviking.session.memory.utils.line_numbers import (
add_line_numbers, strip_line_numbers, every_line_has_line_numbers)
c = "## Title\n- fact one"
twice = add_line_numbers(add_line_numbers(c)) # '1\t1\t## Title'
s = strip_line_numbers(twice) # '1\t## Title' <-- one remains
every_line_has_line_numbers(s) # TrueExpected Behavior
strip_line_numbers() is idempotent with respect to add_line_numbers(): stripping returns content with no line-number prefix, regardless of how many were applied.
Actual Behavior
One prefix survives per call. In stored memories this accumulates with each merge until the content becomes unreadable.
Minimal Reproducible Example
Observed on real stored memories (v0.4.16, entities schema, merge_op: patch). The number of surviving prefixes tracked the card version: version 1 → 0 prefixes, version 4 → 3, version 5 → 2. Cards never merged were always clean, and the session transcripts contained no numbered lines — confirming the prefixes are introduced by the merge path, not by the conversation.
Stored card after four merges:
1 1 1 ## Point Roadmap avec Steven
2 2 2 - Point avec Steven sur la roadmapError Logs
None — the corruption is silent.
OpenViking Version
v0.4.16
Python Version
3.13.15
Additional Context
Root cause: _LINE_NUMBER_PREFIX_RE = re.compile(r"^(\d+)\t") is anchored on ^, so re.sub matches at most once per line.
A minimal fix would be a separate repeated pattern used only by strip_line_numbers (leaving extract_start_line_number's capture group untouched):
_LINE_NUMBER_PREFIXES_RE = re.compile(r"^(?:\d+\t)+")
_LINE_NUMBER_PREFIXES_WITH_LEADING_SPACE_RE = re.compile(r"^(?:\s*\d+\t)+")⚠️ One caveat we could not resolve from the outside: stripping all prefixes would also strip legitimate leading TSV columns (e.g. a stored table whose first cells are digits). Guarding add_line_numbers() against already-numbered input may be the safer place to fix this. We deliberately leave that design choice to you.
Workaround in the meantime: stripping all levels once is stable — after cleaning, two further merges and a fresh write produced zero prefixes.
Source: volcengine/OpenViking