Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 40 additions & 15 deletions better_memory/cli/install_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,16 +27,21 @@

@dataclass(frozen=True)
class HookSpec:
module: str # e.g. "better_memory.hooks.session_start"
event: str # "SessionStart" | "PostToolUse" | "Stop"
matcher: str | None # None for SessionStart/Stop; "Write|Edit|Bash" for observer
is_async: bool # True for PostToolUse + Stop
module: str # e.g. "better_memory.hooks.session_start"
event: str # "SessionStart" | "PostToolUse" | "Stop"
matcher: str | None # None for SessionStart/Stop; "Write|Edit|Bash" for observer
is_async: bool # True for PostToolUse + Stop
needs_stdout: bool # True for SessionStart bootstrap — Claude Code reads
# the hook's stdout for additionalContext, so the
# interpreter MUST keep stdout attached. On Windows
# pythonw.exe silently nulls sys.stdout; the bootstrap
# would print nothing and Claude would get no context.


_OUR_HOOKS: tuple[HookSpec, ...] = (
HookSpec("better_memory.hooks.session_bootstrap", "SessionStart", None, False),
HookSpec("better_memory.hooks.observer", "PostToolUse", "Write|Edit|Bash", True),
HookSpec("better_memory.hooks.session_close", "Stop", None, True),
HookSpec("better_memory.hooks.session_bootstrap", "SessionStart", None, False, True),
HookSpec("better_memory.hooks.observer", "PostToolUse", "Write|Edit|Bash", True, False),
HookSpec("better_memory.hooks.session_close", "Stop", None, True, False),
)

# Module paths that are no longer registered but may be present in users'
Expand Down Expand Up @@ -83,18 +88,28 @@ def merge_claude_json(existing: dict, *, command: str, home: str) -> dict:
# ------------------------------------------------------- pure merge: settings


def _hook_entry(spec: HookSpec, venv_pyw: str) -> dict:
"""Build the JSON object for a single hook entry."""
def _hook_entry(spec: HookSpec, venv_py: str, venv_pyw: str) -> dict:
"""Build the JSON object for a single hook entry.

Interpreter selection: hooks marked ``needs_stdout`` use ``venv_py``
(python.exe on Windows) so Claude Code can read the hook's stdout
for ``additionalContext``. Other hooks use ``venv_pyw`` (pythonw.exe
on Windows) to avoid the brief console flash on each tool call.
On non-Windows systems setup.sh passes the same path for both.
"""
interpreter = venv_py if spec.needs_stdout else venv_pyw
entry: dict = {
"type": "command",
"command": f'"{venv_pyw}" -m {spec.module}',
"command": f'"{interpreter}" -m {spec.module}',
}
if spec.is_async:
entry["async"] = True
return entry


def merge_settings_json(existing: dict, *, venv_pyw: str) -> dict:
def merge_settings_json(
existing: dict, *, venv_pyw: str, venv_py: str | None = None,
) -> dict:
"""Smart-merge our hook entries into ~/.claude/settings.json content.

Two-pass strategy:
Expand All @@ -107,7 +122,15 @@ def merge_settings_json(existing: dict, *, venv_pyw: str) -> dict:
each get their own group.

User's other (non-better-memory) hooks and matcher-groups are untouched.

``venv_py`` defaults to ``venv_pyw`` for back-compat with callers that
only know about one interpreter. On Windows the two are different:
``venv_py`` is python.exe (foreground, keeps stdout), ``venv_pyw`` is
pythonw.exe (background, no console). See ``_hook_entry`` for the
per-spec selection rule.
"""
if venv_py is None:
venv_py = venv_pyw
config = dict(existing)
hooks = dict(config.get("hooks", {}))
our_module_paths = {spec.module for spec in _OUR_HOOKS}
Expand All @@ -132,18 +155,18 @@ def merge_settings_json(existing: dict, *, venv_pyw: str) -> dict:
session_start_specs = [s for s in _OUR_HOOKS if s.event == "SessionStart"]
if session_start_specs:
hooks.setdefault("SessionStart", []).append({
"hooks": [_hook_entry(s, venv_pyw) for s in session_start_specs],
"hooks": [_hook_entry(s, venv_py, venv_pyw) for s in session_start_specs],
})

for spec in (s for s in _OUR_HOOKS if s.event == "PostToolUse"):
group: dict = {"hooks": [_hook_entry(spec, venv_pyw)]}
group: dict = {"hooks": [_hook_entry(spec, venv_py, venv_pyw)]}
if spec.matcher is not None:
group["matcher"] = spec.matcher
hooks.setdefault("PostToolUse", []).append(group)

for spec in (s for s in _OUR_HOOKS if s.event == "Stop"):
hooks.setdefault("Stop", []).append({
"hooks": [_hook_entry(spec, venv_pyw)],
"hooks": [_hook_entry(spec, venv_py, venv_pyw)],
})

config["hooks"] = hooks
Expand Down Expand Up @@ -236,7 +259,9 @@ def main(argv: list[str] | None = None) -> None:
(
"hooks",
Path.home() / ".claude" / "settings.json",
lambda d: merge_settings_json(d, venv_pyw=args.venv_pyw),
lambda d: merge_settings_json(
d, venv_pyw=args.venv_pyw, venv_py=args.venv_py,
),
),
]

Expand Down
39 changes: 37 additions & 2 deletions better_memory/services/reflection.py
Original file line number Diff line number Diff line change
Expand Up @@ -1180,16 +1180,19 @@ class ReflectionService:
"""UI-facing writes for reflections.

Sibling of ``ReflectionSynthesisService``: this class does NOT
synthesise — it handles the three lifecycle actions the user
synthesise — it handles the four lifecycle actions the user
drives from the Reflections tab drawer:

- ``confirm``: pending_review → confirmed (idempotent on confirmed).
- ``retire``: pending_review/confirmed → retired (idempotent on retired).
- ``update_text``: edit use_cases / hints in place; blocked on
retired and superseded so we don't surprise the synthesis
pipeline by mutating retired text.
- ``promote_to_general``: project → general scope; idempotent on
already-general; blocked on retired and superseded so promoted-but-
invisible state can't slip into the cross-project pile.

All three bump ``updated_at`` only when the row actually changes
All four bump ``updated_at`` only when the row actually changes
(no-op cases leave the timestamp untouched so reinforcement /
audit trails stay honest).
"""
Expand Down Expand Up @@ -1292,3 +1295,35 @@ def update_text(
(use_cases, json.dumps(hint_list), now, reflection_id),
)
self._conn.commit()

def promote_to_general(self, *, reflection_id: str) -> None:
"""project → general; idempotent on already-general; raise on retired/superseded.

Mirrors the no-op-on-already-target semantics of ``confirm`` and
``retire``: when the reflection is already general we return
without bumping ``updated_at`` so audit trails stay honest.

Status guard matches the UI gate in the drawer template — the
button is hidden on retired/superseded, but we enforce server
side too in case of direct API calls.
"""
row = self._conn.execute(
"SELECT scope, status FROM reflections WHERE id = ?",
(reflection_id,),
).fetchone()
if row is None:
raise ValueError(f"Reflection not found: {reflection_id}")
status = row["status"]
if status not in ("pending_review", "confirmed"):
raise ValueError(
f"Cannot promote reflection in status {status!r}"
)
if row["scope"] == "general":
return
now = self._clock().isoformat()
self._conn.execute(
"UPDATE reflections SET scope = 'general', updated_at = ? "
"WHERE id = ?",
(now, reflection_id),
)
self._conn.commit()
21 changes: 21 additions & 0 deletions better_memory/ui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,27 @@ def reflection_edit_save(id: str) -> tuple[str, int, dict[str, str]]:
)
return rendered, 200, {"HX-Trigger": "reflection-changed"}

@app.post("/reflections/<id>/promote")
def reflection_promote(id: str) -> tuple[str, int, dict[str, str]]:
conn = app.extensions["db_connection"]
if queries.reflection_detail(conn, reflection_id=id) is None:
abort(404)
try:
app.extensions["reflection_service"].promote_to_general(
reflection_id=id,
)
except ValueError as exc:
return (
f'<div class="card card-error">'
f"<p>{escape(str(exc))}</p>"
"</div>"
), 409, {}
detail = queries.reflection_detail(conn, reflection_id=id)
rendered = render_template(
"fragments/reflection_drawer.html", detail=detail
)
return rendered, 200, {"HX-Trigger": "reflection-changed"}

@app.get("/semantic")
def semantic() -> str:
return render_template(
Expand Down
4 changes: 3 additions & 1 deletion better_memory/ui/queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,7 @@ class ReflectionFull:
use_cases: str
hints: str
evidence_count: int
scope: str
created_at: str
updated_at: str

Expand Down Expand Up @@ -356,7 +357,7 @@ def reflection_detail(
"""
r_row = conn.execute(
"SELECT id, title, project, tech, phase, polarity, "
"confidence, status, use_cases, hints, evidence_count, "
"confidence, status, use_cases, hints, evidence_count, scope, "
"created_at, updated_at "
"FROM reflections WHERE id = ?",
(reflection_id,),
Expand Down Expand Up @@ -413,6 +414,7 @@ def reflection_detail(
use_cases=r_row["use_cases"],
hints=r_row["hints"],
evidence_count=r_row["evidence_count"],
scope=r_row["scope"],
created_at=r_row["created_at"],
updated_at=r_row["updated_at"],
),
Expand Down
10 changes: 10 additions & 0 deletions better_memory/ui/templates/fragments/reflection_drawer.html
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ <h3>{{ detail.reflection.title }}</h3>
<dt>Confidence</dt>
<dd>{{ '%.2f' | format(detail.reflection.confidence) }}</dd>
<dt>Status</dt><dd>{{ detail.reflection.status }}</dd>
<dt>Scope</dt><dd>{{ detail.reflection.scope }}</dd>
<dt>Evidence</dt><dd>{{ detail.reflection.evidence_count }} observation{{ 's' if detail.reflection.evidence_count != 1 else '' }}</dd>
<dt>Updated</dt><dd>{{ detail.reflection.updated_at }}</dd>
</dl>
Expand Down Expand Up @@ -64,6 +65,15 @@ <h4>Hints</h4>
hx-swap="innerHTML">
Edit
</button>
{% if detail.reflection.scope == 'project' %}
<button type="button"
class="action-promote"
hx-post="{{ url_for('reflection_promote', id=detail.reflection.id) }}"
hx-target="#reflection-drawer"
hx-swap="innerHTML">
Promote to general
</button>
{% endif %}
</div>
{% endif %}

Expand Down
Loading
Loading