Skip to content

Commit c3a7a4a

Browse files
committed
feat(layers): select L1 wake-up drawers by salience
Issue #1629 asked why wake-up did not read like the essential story it promises. Ordering was half the answer and is fixed. The other half is what gets in: L1 has room for about 15 lines, and it filled them with whatever sorted highest, including harness injection blocks, raw tool output, markdown tables, bare timestamp lines, three near-identical chunks of one transcript, and snippets that opened mid-word. On a 149k-drawer palace the result read as fragment soup. Selection now happens between sorting and rendering. _l1_salience(text) scores a candidate. Negative means junk and drops it. Exclusion is deliberately narrow and structural, never lexical: - a harness wrapper *opening* the drawer (<system-reminder>, <command-message>, <local-command-caveat>, <task-notification>, [SYSTEM NOTIFICATION), matched with startswith after normalization - a timestamp-only or rule-only drawer - text below a 20-character floor - pipe/dash density above 0.18, which is a table, a diff or an ASCII rule - a prose ratio below 0.78, the fraction of characters that are letters or spaces The prose ratio is the load-bearing rule and the general form of a marker list: JSON, ls output, env dumps, URL lists, log lines and bare code fences all fail it without any marker being named, while an engineering sentence quoting a path, a version pin or a fence passes. Measured over the 21-item corpus in test_l1_salience_drops_soup_without_markers, soup lands in 0.40 to 0.82 and prose in 0.84 to 0.99. The floor sits at 0.78, deliberately below the gap: a false drop loses a real memory forever, a false keep costs one wake-up line. The residue that clears it, word-heavy tracebacks and diffs, is demoted rather than excluded. Nothing is excluded for merely mentioning tooling. tool_use, tool_result, fences, "Exit code:" and "cwd was reset" cost a ranking point and nothing more, because engineering writing quotes those constantly. Positive scoring is outcome wording (2 points), a clean sentence or heading start (1), and freedom from tool noise (1). Outcome is worth strictly more than both presentation signals, otherwise a drawer that is both an outcome and a little noisy ties with background chatter that merely reads cleanly: outcome + clean 4 chatter + clean 2 outcome + noisy 3 chatter + noisy 1 Anchoring rather than substring matching is the whole correctness argument here. An earlier revision also dropped any body that *repeated* a wrapper, reasoning that a repeat means concatenated injections. That rule was unanchored and re-created the same defect: an engineer writing about why a reminder fired twice names the tag twice. It is gone. Genuine concatenated soup is already caught by density and the prose floor, neither of which needs a marker named. _l1_select applies the caps. At most L1_MAX_PER_SOURCE (2) lines from one source file, so a single long transcript cannot own the story. Drawers with no source_file are uncapped, because the cap exists to stop one known file dominating and lumping unattributed drawers under one empty key would do the opposite. Near-duplicates collapse on whole-body word overlap at 0.8, not on a leading slice: mined session summaries routinely share a templated opening, and matching the first 80 characters collapsed three summaries reporting a migration, a revert and an RTL fix into one and lost two outcomes silently. Whole-body overlap scores that case at 0.27 and keeps all three, while genuine restatements stay above 0.84. The comparison list never exceeds max_drawers, so selection stays linear: measured on this machine at 8.7 ms for 500 candidates, 16.9 ms for 1,000 and 34.2 ms for 2,000, which is the MAX_SCAN ceiling, against the repo's 100 ms startup-injection budget. Rendering groups rooms in the order the ranked list arrives, not alphabetically. Grouping by room and then sorting room names before the MAX_CHARS truncation undid the ranking at the last step: a salience-4 outcome in a room named zzz_outcomes was cut while salience-2 chatter in aaa_trivia rendered. _l1_snippet composes the line: start after the next sentence boundary when a chunk opens mid-sentence, cut on a word boundary. The result is always a contiguous substring of the drawer, never a paraphrase, so the verbatim promise holds. Filtered drawers are untouched in the palace and still returned by L2 and L3. When every candidate scores as junk, L1 renders exactly what it rendered before the filter. An empty wake-up is the worse failure. No LLM call, no embedding, no I/O: a few passes over text already in memory, because L1 runs inside the wake-up hook's latency budget. Two details for non-English palaces. The clean-start point tests "not lowercase" rather than "is uppercase", so Arabic, Hebrew and CJK are not permanently denied a point that every English drawer collects. And L1_OUTCOME_KEYWORDS is English, which its docstring says along with the real two-step for extending it: the regex is compiled once at import, so appending to the tuple alone does nothing, and _l1_compile_outcome_re is exposed for that reason. The junk, cap, duplicate and boundary rules are language-neutral. Known gap, left for a follow-up to keep this in scope: the outcome keyword list has no failure vocabulary. "died", "crashed", "OOM", "timed out" and "regressed" are absent, so a drawer reporting a failure ranks as chatter unless the sentence happens to also say something passed. Orthogonal to PR #1950 (per-drawer salience from the dynamics model plus retrieval potentiation). That work scores drawers at retrieval time in dynamics.py/searcher.py/service.py/mcp_server.py; this is snippet-level composition inside layers.py and touches none of those files. If #1950 lands, its per-drawer signal can feed the importance key L1 already sorts on, and this filter keeps doing the part it does. Tests: salience edges (anchored wrapper, prose naming a wrapper twice, date-only, table soup, short text, outcome boost, fragment vs whole thought, the ISO-designator false positive, non-Latin prose scoring like Latin), a marker-free soup corpus, noise demotion ordering, snippet boundaries (sentence start, no-boundary passthrough, word-boundary cut, newline collapse), selection (per-source cap, unattributed drawers uncapped, templated-lead summaries kept, near-duplicate suppression, tier order, all-junk empty), render order under the character cap, and end to end through Layer1.generate including the fallback and a verbatim-substring assertion.
1 parent e73e75b commit c3a7a4a

4 files changed

Lines changed: 803 additions & 10 deletions

File tree

CHANGELOG.md

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

99
## [Unreleased]
1010

11+
### Features
12+
13+
- **Layer 1 wake-up filters for salience.** Drawers that are structurally not prose are skipped: a harness wrapper opening the text, a bare timestamp, table soup, or too few letters to be writing at all. Outcome-shaped prose ranks first; prose carrying tool noise (fences, exit codes, tool-call names) is ranked lower but never dropped, since engineering writing quotes those constantly. No source file contributes more than two lines; near-duplicates collapse; snippets start and end on sentence/word boundaries. Deterministic and lexical, no LLM call in the hook path. Skipped drawers stay verbatim in the palace and in L2/L3 results. (#1629)
14+
1115
---
1216

1317
## [3.7.0] — 2026-08-02

mempalace/layers.py

Lines changed: 319 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"""
1818

1919
import os
20+
import re
2021
import sys
2122
from pathlib import Path
2223
from collections import defaultdict
@@ -77,6 +78,303 @@ def token_estimate(self) -> int:
7778
# Layer 1 — Essential Story (auto-generated from palace)
7879
# ---------------------------------------------------------------------------
7980

81+
#: Words that mark a drawer as reporting an *outcome* — something settled,
82+
#: shipped, broken, or decided — rather than narrating work in progress. A
83+
#: match is a ranking boost, never a requirement: drawers without one still
84+
#: appear, just below the ones with one.
85+
#:
86+
#: This list is English, and so is the boost it drives. A non-English palace
87+
#: still gets every other part of Layer 1 (nothing else here is
88+
#: language-specific) but its drawers never earn this point, so ranking among
89+
#: them falls back to importance and recency. To change that, replace the
90+
#: keywords *and* recompile the regex, which is derived once at import::
91+
#:
92+
#: layers.L1_OUTCOME_KEYWORDS = (...)
93+
#: layers._L1_OUTCOME_RE = layers._l1_compile_outcome_re(layers.L1_OUTCOME_KEYWORDS)
94+
#:
95+
#: Reassigning the tuple alone does nothing.
96+
L1_OUTCOME_KEYWORDS = (
97+
"shipped",
98+
"shipping",
99+
"fixed",
100+
"broke",
101+
"broken",
102+
"done",
103+
"verified",
104+
"decided",
105+
"decision",
106+
"deployed",
107+
"released",
108+
"launched",
109+
"merged",
110+
"reverted",
111+
"resolved",
112+
"root cause",
113+
"blocked",
114+
"failed",
115+
"passed",
116+
"migrated",
117+
"renamed",
118+
"removed",
119+
"replaced",
120+
"agreed",
121+
"chose",
122+
"switched",
123+
)
124+
125+
126+
def _l1_compile_outcome_re(words) -> "re.Pattern":
127+
"""Build the outcome-keyword matcher. See :data:`L1_OUTCOME_KEYWORDS`."""
128+
return re.compile(
129+
r"\b(?:%s)\b" % "|".join(re.escape(word) for word in words),
130+
re.IGNORECASE,
131+
)
132+
133+
134+
_L1_OUTCOME_RE = _l1_compile_outcome_re(L1_OUTCOME_KEYWORDS)
135+
136+
#: Structural harness wrappers: literal tags the tooling injects around text
137+
#: the user never typed. These are dropped from L1, but only when one *opens*
138+
#: the drawer, because that is the difference between a drawer that **is**
139+
#: harness output and a drawer that merely *mentions* one (a bug report about
140+
#: ``<system-reminder>`` is story; the reminder itself is not). Matching these
141+
#: anywhere in the body silently deleted real outcomes — see the regression
142+
#: test ``test_l1_salience_keeps_outcomes_that_mention_scaffolding``.
143+
_L1_HARNESS_WRAPPERS = (
144+
"<system-reminder>",
145+
"<command-message>",
146+
"<local-command-caveat>",
147+
"<task-notification>",
148+
"[SYSTEM NOTIFICATION",
149+
)
150+
151+
#: Words that read as tool noise rather than story. Unlike the wrappers above
152+
#: these are *never* grounds for exclusion — they appear constantly inside
153+
#: legitimate engineering prose ("the build died with Exit code: 137", "the
154+
#: agent looped on tool_use"). They only forfeit the clean-prose point, so a
155+
#: drawer carrying them ranks below an equally outcome-shaped one that does
156+
#: not. Actual log soup is caught by density and prose ratio, not by these.
157+
_L1_NOISE_MARKERS = (
158+
"tool_use",
159+
"tool_result",
160+
"```",
161+
"Exit code:",
162+
"cwd was reset",
163+
# Word-heavy machine output that clears the prose floor on letter count
164+
# alone. Demoted rather than excluded, because a human writing "the fix
165+
# for that Traceback (most recent call last) was a missing guard" is
166+
# still telling the story.
167+
"Traceback (most recent call last)",
168+
"@@ -",
169+
)
170+
171+
#: A drawer that is nothing but a timestamp, date, or separator rule. The
172+
#: letters allowed are the ISO-8601 designators only (``T``, ``Z``).
173+
_L1_TIMESTAMP_ONLY_RE = re.compile(r"^[\s\d:.,/+TZ_-]+$")
174+
175+
#: Sentence boundary followed by the start of a new sentence.
176+
_L1_SENTENCE_START_RE = re.compile(r"[.!?]\s+(?=[A-Z#*\-])")
177+
178+
#: Below this many characters a drawer cannot carry an outcome; above the pipe
179+
#: density it is a table, a diff, or an ASCII rule rather than prose.
180+
_L1_MIN_CHARS = 20
181+
_L1_TABLE_CHARS = "|-=+_"
182+
_L1_MAX_TABLE_DENSITY = 0.18
183+
184+
#: Fraction of a drawer's characters that must be letters or spaces for it to
185+
#: count as prose at all. This is the general form of the old "does it contain
186+
#: a backtick fence" test: JSON, ``ls`` output, env dumps, URL lists, log lines
187+
#: and bare code fences all fail it without any marker being named, while an
188+
#: engineering sentence quoting a path, a version pin or a fence passes.
189+
#:
190+
#: Measured over the corpus in ``test_l1_salience_drops_soup_without_markers``
191+
#: the two populations sit at soup ``0.40-0.82`` against prose ``0.84-0.99``.
192+
#: The floor is set below that gap on purpose: a false drop loses a real
193+
#: memory forever, while a false keep only costs one wake-up line, so the
194+
#: threshold buys margin for prose and leaves the residue to be *demoted* by
195+
#: :data:`_L1_NOISE_MARKERS` rather than excluded.
196+
#:
197+
#: ``str.isalpha`` is Unicode-aware, so Arabic, Hebrew and CJK prose all score
198+
#: as letters. Counting characters rather than tokens is what makes that true:
199+
#: Chinese has no spaces, so a token-based ratio saw one giant "non-word".
200+
_L1_MIN_PROSE_RATIO = 0.78
201+
202+
#: Ranking weights. Reporting an outcome is the point of Layer 1, so it is
203+
#: worth strictly more than the presentation signals — otherwise a drawer that
204+
#: is both an outcome and a little noisy ("the build died with Exit code: 137")
205+
#: would tie with background chatter that merely reads cleanly.
206+
_L1_SCORE_OUTCOME = 2
207+
_L1_SCORE_CLEAN_START = 1
208+
_L1_SCORE_NO_NOISE = 1
209+
210+
#: How many snippets one source file may contribute to a single wake-up. One
211+
#: long transcript should not own the whole story.
212+
L1_MAX_PER_SOURCE = 2
213+
214+
#: How alike two drawers must be, as a word-overlap fraction, before the later
215+
#: one is suppressed as a near-duplicate.
216+
#:
217+
#: This compares whole bodies rather than a leading slice. Matching on the
218+
#: first 80 characters silently collapsed *distinct* drawers that happened to
219+
#: share a templated opening, which mined session summaries routinely do: three
220+
#: summaries opening "Session summary for the mempalace project on ..." and then
221+
#: reporting three different outcomes scored as one drawer and two of the
222+
#: outcomes were lost. Whole-body overlap puts that case near ``0.27`` while
223+
#: genuine restatements of the same drawer stay above ``0.84``.
224+
_L1_DUPLICATE_SIMILARITY = 0.8
225+
226+
227+
def _l1_normalize(text: str) -> str:
228+
"""Collapse whitespace so drawers compare and render on one line."""
229+
return " ".join(text.split())
230+
231+
232+
def _l1_prose_ratio(body: str) -> float:
233+
"""Fraction of ``body`` that is letters or spaces.
234+
235+
The measure of whether something was written or dumped. Near ``1.0`` for
236+
prose in any script; low for JSON, command output and log lines, which are
237+
mostly punctuation, digits, paths and identifiers.
238+
"""
239+
if not body:
240+
return 0.0
241+
return sum(1 for char in body if char.isalpha() or char.isspace()) / len(body)
242+
243+
244+
def _l1_is_harness_output(body: str) -> bool:
245+
"""True when the drawer *is* a harness injection, not prose mentioning one.
246+
247+
Anchored at the start, because that is where an injected wrapper opens. A
248+
drawer that quotes or discusses a wrapper part-way through is a human
249+
writing about the tooling, and dropping it was the bug this guards.
250+
251+
Deliberately the only positional rule. Counting repeats anywhere in the
252+
body was tried and removed: a drawer that names the same wrapper twice
253+
while explaining it ("``<system-reminder>`` is injected per turn, so a
254+
second ``<system-reminder>`` means the turn restarted") is prose, and a
255+
genuine run of concatenated injections is already caught downstream by
256+
table density and the prose-ratio floor, neither of which needs a marker
257+
named.
258+
"""
259+
return body.startswith(_L1_HARNESS_WRAPPERS)
260+
261+
262+
def _l1_salience(text: str) -> int:
263+
"""Score a drawer as an L1 candidate. Negative means "not story, drop it".
264+
265+
Deterministic and purely lexical — no LLM call, no embedding, no I/O.
266+
Layer 1 runs inside the wake-up hook's latency budget, so this has to stay
267+
a few passes over text already in memory.
268+
269+
Exclusion is deliberately narrow: only things that are *structurally* not
270+
prose (a harness wrapper opening the drawer, a bare timestamp, table or
271+
log soup, too few characters to say anything). Merely mentioning tooling
272+
is not disqualifying — that test dropped real outcomes such as a build
273+
that died on an exit code or an agent stuck emitting tool calls.
274+
275+
Returns:
276+
``-1`` junk, otherwise ``0``-``4``: points for reading like an outcome
277+
(weighted highest), starting cleanly at a sentence, and being free of
278+
tool noise.
279+
"""
280+
body = _l1_normalize(text)
281+
if len(body) < _L1_MIN_CHARS:
282+
return -1
283+
if _L1_TIMESTAMP_ONLY_RE.match(body):
284+
return -1
285+
if _l1_is_harness_output(body):
286+
return -1
287+
density = sum(body.count(char) for char in _L1_TABLE_CHARS) / len(body)
288+
if density > _L1_MAX_TABLE_DENSITY:
289+
return -1
290+
if _l1_prose_ratio(body) < _L1_MIN_PROSE_RATIO:
291+
return -1
292+
293+
score = 0
294+
if _L1_OUTCOME_RE.search(body):
295+
score += _L1_SCORE_OUTCOME
296+
# A chunk that starts at a sentence or a heading reads as a whole thought;
297+
# one that starts mid-sentence is a fragment of someone else's. Phrased as
298+
# "not lowercase" rather than "is uppercase" so that uncased scripts
299+
# (Arabic, Hebrew, CJK) are not permanently denied the point.
300+
if not body[0].islower() or body[0] in "#*":
301+
score += _L1_SCORE_CLEAN_START
302+
# Clean prose outranks prose carrying tool noise, without excluding it.
303+
if not any(marker in body for marker in _L1_NOISE_MARKERS):
304+
score += _L1_SCORE_NO_NOISE
305+
return score
306+
307+
308+
def _l1_snippet(text: str, max_chars: int = 200) -> str:
309+
"""Compose the snippet shown for one drawer.
310+
311+
Verbatim: the result is always a contiguous substring of the drawer, never
312+
a paraphrase. What this chooses is where to start and stop.
313+
314+
* Chunked drawers frequently open mid-sentence. When the text starts
315+
lowercase and a sentence boundary is close by, start after that boundary
316+
instead, so the line does not begin in the middle of someone's thought.
317+
* Truncation cuts on a word boundary, so the tail is never a half word.
318+
"""
319+
body = _l1_normalize(text)
320+
if body[:1].islower():
321+
match = _L1_SENTENCE_START_RE.search(body[:300])
322+
if match and len(body) - match.end() >= _L1_MIN_CHARS:
323+
body = body[match.end() :]
324+
if len(body) <= max_chars:
325+
return body
326+
keep = max_chars - 3
327+
cut = body.rfind(" ", max_chars // 2, keep)
328+
return body[: cut if cut > 0 else keep] + "..."
329+
330+
331+
def _l1_word_overlap(left: set, right: set) -> float:
332+
"""Jaccard overlap of two word sets: 1.0 identical, 0.0 nothing in common."""
333+
union = len(left | right)
334+
return len(left & right) / union if union else 0.0
335+
336+
337+
def _l1_select(scored: list, max_drawers: int, max_per_source: int = L1_MAX_PER_SOURCE) -> list:
338+
"""Choose the drawers that make up the essential story.
339+
340+
``scored`` is the importance/recency-ordered candidate list, each entry
341+
``(importance, metadata, document)``. Selection keeps that order inside a
342+
salience tier and applies three caps: junk is dropped, no source file may
343+
contribute more than ``max_per_source`` snippets, and near-duplicate
344+
drawers (mostly the same words) collapse to one.
345+
346+
Returns an empty list when every candidate scored as junk. The caller
347+
decides what to do about that; L1 never silently renders nothing.
348+
"""
349+
ranked = []
350+
for entry in scored:
351+
score = _l1_salience(entry[2])
352+
if score >= 0:
353+
ranked.append((score, entry))
354+
# Stable: candidates keep their importance/recency order inside a tier.
355+
ranked.sort(key=lambda item: item[0], reverse=True)
356+
357+
selected = []
358+
per_source: dict = defaultdict(int)
359+
seen_words: list = []
360+
for _score, entry in ranked:
361+
if len(selected) >= max_drawers:
362+
break
363+
_imp, meta, doc = entry
364+
source = (meta or {}).get("source_file") or ""
365+
# Drawers with no source_file cannot be attributed, so they are not
366+
# capped: the cap exists to stop one known file dominating.
367+
if source and per_source[source] >= max_per_source:
368+
continue
369+
words = set(_l1_normalize(doc).lower().split())
370+
if any(_l1_word_overlap(words, prev) >= _L1_DUPLICATE_SIMILARITY for prev in seen_words):
371+
continue
372+
# Only ever as long as `max_drawers`, so the scan stays linear.
373+
seen_words.append(words)
374+
per_source[source] += 1
375+
selected.append(entry)
376+
return selected
377+
80378

81379
class Layer1:
82380
"""
@@ -156,11 +454,26 @@ def generate(self) -> str:
156454
recency = str(meta.get("filed_at") or "")
157455
scored.append((importance, recency, meta, doc))
158456

159-
# Sort by importance desc, then recency (filed_at) desc; take top N.
457+
# Sort by importance desc, then recency (filed_at) desc.
160458
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]]
162-
163-
# Group by room for readability
459+
candidates = [(imp, meta, doc) for imp, _recency, meta, doc in scored]
460+
461+
# Then pick the story out of the candidates: drop scaffolding and
462+
# table soup, prefer outcome-shaped prose, cap how much any one source
463+
# file contributes, and collapse near-duplicates.
464+
top = _l1_select(candidates, self.MAX_DRAWERS)
465+
if not top:
466+
# Everything scored as junk (a palace of pure tool logs, or drawers
467+
# too short for the floor). An empty wake-up would be worse than an
468+
# unfiltered one, so fall back to exactly the pre-filter behavior.
469+
top = candidates[: self.MAX_DRAWERS]
470+
471+
# Group by room for readability. Insertion order, not alphabetical:
472+
# `top` arrives in salience order, so a dict preserves "best room
473+
# first". Sorting by name instead let MAX_CHARS truncate the story
474+
# because of where a room sits in the alphabet, which threw away the
475+
# highest-ranked drawer while keeping chatter from a room called
476+
# "aaa_*". Selection ranking is worthless if rendering reshuffles it.
164477
by_room = defaultdict(list)
165478
for imp, meta, doc in top:
166479
room = meta.get("room", "general")
@@ -170,18 +483,15 @@ def generate(self) -> str:
170483
lines = ["## L1 — ESSENTIAL STORY"]
171484

172485
total_len = 0
173-
for room, entries in sorted(by_room.items()):
486+
for room, entries in by_room.items():
174487
room_line = f"\n[{room}]"
175488
lines.append(room_line)
176489
total_len += len(room_line)
177490

178491
for _imp, meta, doc in entries:
179492
source = Path(meta.get("source_file", "")).name if meta.get("source_file") else ""
180493

181-
# Truncate doc to keep L1 compact
182-
snippet = doc.strip().replace("\n", " ")
183-
if len(snippet) > 200:
184-
snippet = snippet[:197] + "..."
494+
snippet = _l1_snippet(doc)
185495

186496
entry_line = f" - {snippet}"
187497
if source:

0 commit comments

Comments
 (0)