Skip to content

Commit ec8788c

Browse files
authored
Merge pull request #2022 from MemPalace/chore/sync-main-before-3.6.0
chore: sync main hotfixes into develop before 3.6.0
2 parents 6340d61 + f62ce26 commit ec8788c

5 files changed

Lines changed: 67 additions & 9 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5656

5757
- **Mining and configuration correctness.** Oversized files produce a visible stderr warning, `~` in configured palace paths expands consistently, wing slugs handle special characters, and Windows background daemon / synchronous hook mines use `CREATE_NO_WINDOW`. (#923, #1852, #1857, #1863, #1865)
5858

59+
- **`mempalace init` handles non-ASCII `.gitignore` files on Windows.** The project-file ignore guard now reads and appends UTF-8 explicitly instead of relying on locale defaults such as GBK. (#1648)
60+
61+
- **L1 wake-up surfaces the latest moments.** Drawers with equal importance are now ordered by `filed_at` recency rather than insertion order, so startup context prefers recent memories instead of the oldest ones. (#1630)
62+
5963
- **Backend detection requires a SQLite magic header** before classifying a target as Chroma or `sqlite_exact`, preventing unrelated files from being mistaken for a palace. (#1893, #1896)
6064

6165
### Documentation

mempalace/cli.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -259,14 +259,16 @@ def _ensure_mempalace_files_gitignored(project_dir) -> bool:
259259
if not (project_path / ".git").exists():
260260
return False
261261
gitignore = project_path / ".gitignore"
262-
existing = gitignore.read_text() if gitignore.exists() else ""
262+
# Force UTF-8: Windows defaults to GBK and chokes on non-ASCII .gitignore
263+
# comments, killing auto-init even though the file is valid UTF-8.
264+
existing = gitignore.read_text(encoding="utf-8", errors="replace") if gitignore.exists() else ""
263265
existing_lines = {line.strip() for line in existing.splitlines()}
264266
missing = [p for p in _MEMPALACE_PROJECT_FILES if p not in existing_lines]
265267
if not missing:
266268
return False
267269
prefix = "" if not existing or existing.endswith("\n") else "\n"
268270
block = prefix + "\n# MemPalace per-project files (issue #185)\n" + "\n".join(missing) + "\n"
269-
with open(gitignore, "a") as f:
271+
with open(gitignore, "a", encoding="utf-8") as f:
270272
f.write(block)
271273
print(f" Added {', '.join(missing)} to {gitignore.name}")
272274
return True

mempalace/layers.py

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -126,12 +126,21 @@ def generate(self) -> str:
126126
if not docs:
127127
return "## L1 — No memories yet."
128128

129-
# Score each drawer: prefer high importance, recent filing
129+
# Score each drawer: prefer high importance, then most-recent filing.
130+
# NOTE: the ingest pipeline (miner, convo_miner, diary, add_drawer)
131+
# records provenance metadata — wing/room/source/chunk/filed_at — but
132+
# never an evaluative importance/weight field. So `importance` is
133+
# absent on virtually every drawer and ties at the default, which used
134+
# to collapse the sort to insertion order (oldest first). `filed_at`
135+
# is present on every drawer, so it is the *effective* ordering signal:
136+
# newest first. This keeps importance as the primary key for the day a
137+
# scoring pass populates it, while making the "recent filing" half of
138+
# the promise true today with data we already have.
130139
scored = []
131140
for doc, meta in zip(docs, metas):
132141
meta = meta or {}
133142
doc = doc or ""
134-
importance = 3
143+
importance = 3.0
135144
# Try multiple metadata keys that might carry weight info
136145
for key in ("importance", "emotional_weight", "weight"):
137146
val = meta.get(key)
@@ -141,11 +150,15 @@ def generate(self) -> str:
141150
except (ValueError, TypeError):
142151
pass
143152
break
144-
scored.append((importance, meta, doc))
145-
146-
# Sort by importance descending, take top N
147-
scored.sort(key=lambda x: x[0], reverse=True)
148-
top = scored[: self.MAX_DRAWERS]
153+
# filed_at is an ISO-8601 string; ISO strings sort lexicographically
154+
# in chronological order. Coerce to str so a missing/odd value sorts
155+
# oldest rather than raising during the comparison.
156+
recency = str(meta.get("filed_at") or "")
157+
scored.append((importance, recency, meta, doc))
158+
159+
# Sort by importance desc, then recency (filed_at) desc; take top N.
160+
scored.sort(key=lambda x: (x[0], x[1]), reverse=True)
161+
top = [(imp, meta, doc) for imp, _recency, meta, doc in scored[: self.MAX_DRAWERS]]
149162

150163
# Group by room for readability
151164
by_room = defaultdict(list)

tests/test_init_gitignore_protection.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"""
88

99
from pathlib import Path
10+
from unittest.mock import mock_open, patch
1011

1112
from mempalace.cli import _ensure_mempalace_files_gitignored
1213

@@ -60,3 +61,20 @@ def test_handles_gitignore_without_trailing_newline(tmp_path):
6061
assert "dist\n" in contents
6162
assert "mempalace.yaml" in contents
6263
assert "entities.json" in contents
64+
65+
66+
def test_gitignore_io_pins_utf8_and_defensive_decode(tmp_path):
67+
"""Regression for #1648: never fall back to the Windows locale codec."""
68+
_git_init(tmp_path)
69+
gitignore = tmp_path / ".gitignore"
70+
gitignore.write_text("# café\n", encoding="utf-8")
71+
append_handle = mock_open()
72+
73+
with (
74+
patch.object(Path, "read_text", return_value="# café\n") as read_text,
75+
patch("builtins.open", append_handle),
76+
):
77+
assert _ensure_mempalace_files_gitignored(tmp_path) is True
78+
79+
read_text.assert_called_once_with(encoding="utf-8", errors="replace")
80+
append_handle.assert_called_once_with(gitignore, "a", encoding="utf-8")

tests/test_layers.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,27 @@ def test_layer1_importance_from_various_keys():
201201
assert "ESSENTIAL STORY" in result
202202

203203

204+
def test_layer1_breaks_importance_ties_by_filed_at_recency():
205+
"""Equal-importance drawers surface newest-first instead of insertion order."""
206+
docs = ["oldest memory", "newest memory", "middle memory"]
207+
metas = [
208+
{"room": "moments", "importance": 3, "filed_at": "2026-01-01T00:00:00Z"},
209+
{"room": "moments", "importance": 3, "filed_at": "2026-03-01T00:00:00Z"},
210+
{"room": "moments", "importance": 3, "filed_at": "2026-02-01T00:00:00Z"},
211+
]
212+
mock_col = _mock_chromadb_for_layer(docs, metas)
213+
214+
with (
215+
patch("mempalace.layers.MempalaceConfig") as mock_cfg,
216+
patch("mempalace.layers._get_collection", return_value=mock_col),
217+
):
218+
mock_cfg.return_value.palace_path = "/fake"
219+
result = Layer1(palace_path="/fake").generate()
220+
221+
assert result.index("newest memory") < result.index("middle memory")
222+
assert result.index("middle memory") < result.index("oldest memory")
223+
224+
204225
def test_layer1_batch_exception_breaks():
205226
"""If col.get raises on a batch, loop breaks gracefully."""
206227
mock_col = MagicMock()

0 commit comments

Comments
 (0)