Skip to content

Commit 708ef4a

Browse files
authored
Merge pull request #1702 from MemPalace/feat/migrate-wing-normalize
feat(migrate): mempalace migrate-wings — normalize legacy wing names (#1675 follow-up)
2 parents 6ac2de8 + 8ec438e commit 708ef4a

4 files changed

Lines changed: 456 additions & 0 deletions

File tree

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# Recovery: legacy wing names split after the normalization change
2+
3+
**Companion to #1675.** `normalize_wing_name` now strips leading and trailing
4+
separators, so a path-encoded project dir like `-home-user-proj` derives the
5+
wing `home_user_proj` instead of `_home_user_proj`. Palaces mined before that
6+
change filed drawers under the old, separator-padded name. New mining and diary
7+
writes land on the new name, so the two no longer meet — the history is
8+
**split**, not lost. `mempalace migrate-wings` re-unites them.
9+
10+
## Symptom
11+
12+
After upgrading, a project that used to surface its memories returns less than
13+
expected, and `mempalace status` shows two wings for one project — e.g. both
14+
`_home_user_proj` (old drawers) and `home_user_proj` (newly mined). MCP writes
15+
to the padded wing may also have been rejected, since `sanitize_name` does not
16+
accept a leading underscore.
17+
18+
## Recovery
19+
20+
Preview first — this never modifies anything:
21+
22+
```bash
23+
mempalace migrate-wings --dry-run
24+
mempalace migrate-wings --dry-run --palace /path/to/palace
25+
```
26+
27+
The plan lists each rename and flags collisions that will **merge** into an
28+
existing wing:
29+
30+
```
31+
Wing-name migration plan:
32+
'_home_user_proj' -> 'home_user_proj': 1284 drawer(s), 96 closet(s) (MERGE into existing wing)
33+
```
34+
35+
Apply it:
36+
37+
```bash
38+
mempalace migrate-wings # prompts for confirmation
39+
mempalace migrate-wings --yes # no prompt
40+
```
41+
42+
## What it does
43+
44+
- Re-keys the `wing` **metadata field** on drawers and closets to the normalized
45+
form, merging collisions into the existing wing.
46+
- Re-keys the `topics_by_wing` registry (merging topic lists on collision).
47+
48+
## What it leaves alone
49+
50+
- **Drawer/closet IDs** are untouched. The wing in an ID (`drawer_<wing>_…`) is
51+
an opaque prefix that is never decoded back into a wing, so leaving it keeps
52+
closet `→drawer_id` pointers valid and lets future mining still skip
53+
already-mined files (no duplicates). The verbatim drawer content is never
54+
read or rewritten.
55+
- **Tunnels** already normalize wing names at read time, so they resolve under
56+
the new name without a rewrite.
57+
58+
## Notes
59+
60+
- **Idempotent.** A second run reports "nothing to migrate" and changes nothing.
61+
- **Backend-agnostic.** Works on any configured storage backend.
62+
- Run it once per palace after upgrading. New palaces are born with normalized
63+
wing names and never need it.

mempalace/cli.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -812,6 +812,18 @@ def cmd_migrate(args):
812812
)
813813

814814

815+
def cmd_migrate_wings(args):
816+
"""Normalize legacy wing names (strip leading/trailing separators)."""
817+
palace_path = os.path.expanduser(args.palace) if args.palace else MempalaceConfig().palace_path
818+
from .migrate import migrate_wing_names
819+
820+
migrate_wing_names(
821+
palace_path=palace_path,
822+
dry_run=args.dry_run,
823+
confirm=getattr(args, "yes", False),
824+
)
825+
826+
815827
def cmd_status(args):
816828
from .miner import status
817829

@@ -1662,6 +1674,18 @@ def main():
16621674
"--yes", action="store_true", help="Skip confirmation for destructive changes"
16631675
)
16641676

1677+
# migrate-wings
1678+
p_migrate_wings = sub.add_parser(
1679+
"migrate-wings",
1680+
help="Normalize legacy wing names (strip leading/trailing separators) so pre-#1675 palaces stay discoverable",
1681+
)
1682+
p_migrate_wings.add_argument(
1683+
"--dry-run",
1684+
action="store_true",
1685+
help="Show what would change without modifying the palace",
1686+
)
1687+
p_migrate_wings.add_argument("--yes", action="store_true", help="Skip the confirmation prompt")
1688+
16651689
p_status = sub.add_parser("status", help="Show what's been filed")
16661690
p_status.add_argument(
16671691
"--backend",
@@ -1706,6 +1730,7 @@ def main():
17061730
"repair": cmd_repair,
17071731
"repair-status": cmd_repair_status,
17081732
"migrate": cmd_migrate,
1733+
"migrate-wings": cmd_migrate_wings,
17091734
"status": cmd_status,
17101735
}
17111736
dispatch[args.command](args)

mempalace/migrate.py

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -360,3 +360,218 @@ def migrate(palace_path: str, dry_run: bool = False, confirm: bool = False):
360360

361361
print(f"\n{'=' * 60}\n")
362362
return True
363+
364+
365+
# ---------------------------------------------------------------------------
366+
# Wing-name normalization migration (#1675 follow-up)
367+
# ---------------------------------------------------------------------------
368+
#
369+
# normalize_wing_name now strips leading/trailing separators, so a path-encoded
370+
# dirname like ``-home-user-proj`` derives ``home_user_proj`` instead of
371+
# ``_home_user_proj``. Palaces built before that rule filed drawers under the
372+
# old, leading-underscore wing, which the new derivation no longer matches —
373+
# searches and diary reads under the new name miss the old memories.
374+
#
375+
# This migration re-keys the ``wing`` metadata field on drawers and closets to
376+
# the normalized form, merging collisions. Drawer/closet IDs embed the wing as
377+
# an opaque prefix that is never decoded back into a wing (verified: nothing
378+
# splits a wing out of an ID; mining idempotency keys on ``source_file``), so
379+
# the IDs are left untouched — closet ``→drawer_id`` pointers stay valid and
380+
# future mining still skips already-mined files. Tunnels resolve via existing
381+
# read-time normalization and need no rewrite. The pass is idempotent.
382+
383+
384+
def _normalized_wing_target(wing):
385+
"""Return the normalized wing if it differs from ``wing``, else ``None``.
386+
387+
``None`` means "no migration needed" — either the value is not a non-empty
388+
string, normalization is a no-op, or it would normalize to empty.
389+
"""
390+
from .config import normalize_wing_name
391+
392+
if not isinstance(wing, str) or not wing:
393+
return None
394+
# Apply the full normalization and explicitly strip leading/trailing
395+
# separators. The strip is this migration's whole purpose (#1675); doing it
396+
# here rather than relying on normalize_wing_name keeps the migration correct
397+
# even when run against a build whose normalize_wing_name predates #1675, and
398+
# matches the post-#1675 derivation exactly.
399+
target = normalize_wing_name(wing).strip("_")
400+
if not target or target == wing:
401+
return None
402+
return target
403+
404+
405+
def plan_wing_renames(items):
406+
"""Pure planner over ``(id, metadata)`` pairs.
407+
408+
Returns ``(summary, updates)`` where ``summary`` is ``{(old, new): count}``
409+
and ``updates`` is ``[(id, new_metadata), ...]`` for only the records whose
410+
wing changes. Metadata is copied; only the ``wing`` key is rewritten.
411+
"""
412+
summary = defaultdict(int)
413+
updates = []
414+
for rec_id, meta in items:
415+
meta = dict(meta or {})
416+
target = _normalized_wing_target(meta.get("wing"))
417+
if target is None:
418+
continue
419+
summary[(meta["wing"], target)] += 1
420+
meta["wing"] = target
421+
updates.append((rec_id, meta))
422+
return summary, updates
423+
424+
425+
def _iter_collection_items(col, batch_size=1000):
426+
"""Yield ``(id, metadata)`` for every record in a backend collection."""
427+
total = col.count()
428+
offset = 0
429+
while offset < total:
430+
batch = col.get(limit=batch_size, offset=offset, include=["metadatas"])
431+
ids = batch.ids if hasattr(batch, "ids") else batch["ids"]
432+
metas = batch.metadatas if hasattr(batch, "metadatas") else batch["metadatas"]
433+
if not ids:
434+
break
435+
for rec_id, meta in zip(ids, metas):
436+
yield rec_id, meta
437+
offset += len(ids)
438+
439+
440+
def _apply_wing_updates(col, updates, batch_size=500):
441+
"""Re-label the ``wing`` metadata field in place for the planned updates."""
442+
for i in range(0, len(updates), batch_size):
443+
chunk = updates[i : i + batch_size]
444+
col.update(ids=[u[0] for u in chunk], metadatas=[u[1] for u in chunk])
445+
446+
447+
def _plan_topics_by_wing_renames():
448+
"""Return ``{old_wing: new_wing}`` for ``topics_by_wing`` keys to normalize."""
449+
try:
450+
from .miner import _load_known_entities_raw
451+
452+
reg = _load_known_entities_raw()
453+
except Exception:
454+
return {}
455+
tbw = reg.get("topics_by_wing")
456+
if not isinstance(tbw, dict):
457+
return {}
458+
renames = {}
459+
for wing in list(tbw.keys()):
460+
target = _normalized_wing_target(wing)
461+
if target is not None:
462+
renames[wing] = target
463+
return renames
464+
465+
466+
def _apply_topics_by_wing_renames(renames):
467+
"""Re-key ``topics_by_wing`` in known_entities.json, merging on collision."""
468+
if not renames:
469+
return
470+
import json
471+
472+
from .miner import _ENTITY_REGISTRY_PATH, _load_known_entities_raw
473+
474+
try:
475+
reg = _load_known_entities_raw()
476+
except Exception:
477+
return
478+
tbw = reg.get("topics_by_wing")
479+
if not isinstance(tbw, dict):
480+
return
481+
for old, new in renames.items():
482+
if old not in tbw:
483+
continue
484+
old_topics = tbw.pop(old) or []
485+
if new in tbw:
486+
merged = list(tbw[new])
487+
for topic in old_topics:
488+
if topic not in merged:
489+
merged.append(topic)
490+
tbw[new] = merged
491+
else:
492+
tbw[new] = old_topics
493+
reg["topics_by_wing"] = tbw
494+
os.makedirs(os.path.dirname(_ENTITY_REGISTRY_PATH), exist_ok=True)
495+
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(_ENTITY_REGISTRY_PATH), suffix=".tmp")
496+
try:
497+
with os.fdopen(fd, "w", encoding="utf-8") as f:
498+
json.dump(reg, f, ensure_ascii=False, indent=2)
499+
os.replace(tmp, _ENTITY_REGISTRY_PATH)
500+
except Exception:
501+
if os.path.exists(tmp):
502+
os.remove(tmp)
503+
raise
504+
505+
506+
def migrate_wing_names(palace_path: str, dry_run: bool = False, confirm: bool = False) -> bool:
507+
"""Normalize legacy wing names in ``palace_path`` (strip leading/trailing
508+
separators), so palaces built before #1675 keep their memories discoverable.
509+
510+
Re-keys the ``wing`` metadata on drawers and closets in place (IDs untouched)
511+
and the ``topics_by_wing`` registry, merging collisions. Idempotent.
512+
513+
Returns True if anything was (or, in dry-run, would be) migrated.
514+
"""
515+
from .palace import get_closets_collection, get_collection
516+
517+
try:
518+
drawers = get_collection(palace_path, create=False)
519+
except Exception as exc:
520+
print(f" No drawer collection found at {palace_path} ({exc}).")
521+
return False
522+
523+
d_items = list(_iter_collection_items(drawers))
524+
all_wings = {(m or {}).get("wing") for _, m in d_items if (m or {}).get("wing")}
525+
d_summary, d_updates = plan_wing_renames(d_items)
526+
527+
closets = None
528+
c_summary, c_updates = defaultdict(int), []
529+
try:
530+
closets = get_closets_collection(palace_path, create=False)
531+
c_summary, c_updates = plan_wing_renames(_iter_collection_items(closets))
532+
except Exception:
533+
closets = None
534+
535+
topic_renames = _plan_topics_by_wing_renames()
536+
537+
if not d_updates and not c_updates and not topic_renames:
538+
print(" All wing names are already normalized — nothing to migrate.")
539+
return False
540+
541+
print("\n Wing-name migration plan:")
542+
merged = defaultdict(lambda: [0, 0])
543+
for key, count in d_summary.items():
544+
merged[key][0] = count
545+
for key, count in c_summary.items():
546+
merged[key][1] = count
547+
for (old, new), (d_count, c_count) in sorted(merged.items()):
548+
note = " (MERGE into existing wing)" if new in all_wings else ""
549+
print(f" {old!r} -> {new!r}: {d_count} drawer(s), {c_count} closet(s){note}")
550+
if topic_renames:
551+
print(f" topics_by_wing: {len(topic_renames)} key(s) re-keyed")
552+
553+
if dry_run:
554+
print("\n DRY RUN — no changes made.\n")
555+
return True
556+
557+
if not confirm:
558+
try:
559+
resp = input(" Apply this wing-name migration? [y/N] ").strip().lower()
560+
except EOFError:
561+
resp = ""
562+
if resp not in ("y", "yes"):
563+
print(" Aborted.")
564+
return False
565+
566+
_apply_wing_updates(drawers, d_updates)
567+
if closets is not None and c_updates:
568+
_apply_wing_updates(closets, c_updates)
569+
_apply_topics_by_wing_renames(topic_renames)
570+
571+
parts = [f"{len(d_updates)} drawer(s)"]
572+
if c_updates:
573+
parts.append(f"{len(c_updates)} closet(s)")
574+
if topic_renames:
575+
parts.append(f"{len(topic_renames)} topic key(s)")
576+
print(f"\n Migrated {', '.join(parts)}.\n")
577+
return True

0 commit comments

Comments
 (0)