#4413·OpenViking

[Bug]: strip_line_numbers() is not idempotent — line-number prefixes accumulate in merged memories

Author: nono-proW6Created Aug 27, 2026Updated Sep 16, 2026

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

python
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)                    # True

Expected 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 roadmap

Error 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):

python
_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.