Commit Graph

21 Commits

Author SHA1 Message Date
kshitijk4poor
88dee3e866 refactor(memory): derive batch receipts from the matched list
`_batch` kept three accumulators holding the same selected entry; keep only
the per-op `matched` list and build `replaced_entries`/`removed_entries` from it
on commit. The background delete gate now asks `destructive_ops` instead of
re-spelling the predicate, `destructive_ops` tolerates a None op like the rest
of the batch path, and the pinned lookup no longer threads a dead `ambiguous`.
2026-09-24 15:38:22 +05:30
kshitijk4poor
c013f1b1cb refactor(memory): dry-run the staged batch through apply_batch's own walk
resolve_batch_entries copy-pasted apply_batch's op loop and had already
drifted from it: no `op or {}` normalisation, no pre-disk content scan, no
empty-store or budget check. A background-review batch carrying an
injection payload was therefore staged (and echoed in /memory pending) and
only rejected at approve time. One private _batch(commit=...) now backs
both: apply_batch persists, resolve_batch_entries returns the per-op
matched entries from the same validated walk, so pin time fails exactly
where the direct batch would.

Also fold the twice-written "pinned entry -> index or stale" lookup into
_pinned_index, and state in _mutate's docstring that any dict a closure
returns is passed through unpersisted (the read-only resolve closures
rely on that, not only error dicts).
2026-09-24 15:38:22 +05:30
John Paul Soliva
b96e88b245 fix(memory): pin a staged replace/remove to the entry the approver reviewed
A staged replace/remove recorded only its old_text search string, and
/memory approve re-ran that search against the file as it was at
approval time. The background review stages every replace/remove it
wants (#105921), so when the live agent updated the targeted entry in
place and the new text still contained old_text, approving deleted or
overwrote the newer entry the approver never saw, and the output said
only "Approved 1 memory write(s)."

- Both staging gates resolve each replace/remove, under the store lock,
  to the full entry it matches and record it as matched_entry. No match
  or an ambiguous match is refused at staging, as the direct write is.
- Approval matches that entry exactly. If it changed since staging, the
  write is refused and the pending record is kept, as #96059 does for
  skills.
- /memory approve lists removed entries next to overwritten ones, so a
  record staged before this change (no matched_entry, still replayed by
  old_text) never removes an entry silently.
- /memory pending shows the full entry each staged replace/remove
  targets, not just its search string.

(cherry picked from commit 28700fa750c9321ded48be77a76531b5809f5fea)
2026-09-24 15:38:22 +05:30
brooklyn!
43d3d4e851 fix(journey): route memory edit/delete through MemoryStore._mutate
A Journey memory edit or delete (Desktop panel, `hermes journey`, TUI
`learning.*`) rewrote the whole MEMORY.md / USER.md from an unlocked
snapshot via `_read_file` + `_write_file`: a memory the agent stored in
between was dropped, and hand-edited content that does not round-trip
through the § parser was reformatted with no .bak.

Both mutations now go through `MemoryStore._mutate` — the memory tool's
cross-process lock, re-read under lock and drift guard. The node id is
resolved to its entry text inside the lock and matched by exact text
against the re-read entries; a vanished target, drift or an unreadable
file is refused with the store's own message instead of written over.
Router/CLI/TUI response shapes are unchanged.

Fixes #119668
2026-09-24 05:02:30 -05:00
ehz0ah
d57c2a3254 fix(memory): forward committed entry identity to providers 2026-09-23 01:06:32 -07:00
kshitijk4poor
c6ed01e774 fix(memory): key replaced_entries by 1-based op position; tidy review nits
Quality fold on the #117952 salvage stack: replaced_entries keys now match
the 1-based "Operation N" numbering the model sees for failed ops in the
same batch response (0-based dict keys JSON-serialialize to "0" and would
misalign with "Operation 1"); _apply_batch_op docstring states the actual
None/None-success semantics; the missing-old_text replace hint is hoisted
out of the f-string concat.

90/90 across the memory, schema, import-fallback, write-approval,
background-review-scope and honho-write suites.
2026-09-21 14:26:37 +05:30
kshitijk4poor
7d90c44f17 refactor(memory): surface replaced-entry fields via the existing **extra convention
Reuse fold on the #117952 salvage stack: _mutate's optional third value was
plumbed through a (field-name, lambda-extractor) tuple + extra kwarg + two
module constants. _error already had the convention this needed — caller
supplies a dict, the response merges it. _apply closures now return the
payload dict directly ("replaced_entry"/"replaced_entries") and
_success_response takes **extra, dropping the constants, the extractor
lambdas and ~8 lines of machinery. Add/remove-only batches no longer carry
a noise field (the closure omits the key when nothing was replaced).

79/79 across memory + schema + write-approval suites; E2E re-run on the
final shape (single/batch/replay visibility present, no noise on
remove-only batches); mutation check re-proven (dropping the merge turns
test_replace_whole_entry_contract red).
2026-09-21 14:26:37 +05:30
kshitijk4poor
f918a6e32c test(memory): isolate the 3-surface replace test; drop empty replaced_entries noise
Review fold on the #117952 salvage stack:

- test_replace_same_across_single_batch_and_approval_replay built three
  stores over ONE shared store dir, so the batch/replay stages appended onto
  the single-stage result and read its leftover entry back — the cross-surface
  equality passed without exercising the batch or replay surfaces. Each
  surface now gets its own dir; the batch visibility assertion moved into
  test_replace_whole_entry_contract where it binds the batch surface directly.
- _BATCH_REPLACED_ENTRIES now maps an empty dict to None so add/remove-only
  batches don't carry a "replaced_entries": {} noise field in every result.

Mutation re-check: reverting the batch visibility field turns
test_replace_whole_entry_contract red; 61/61 suite green after the fold.
2026-09-21 14:26:37 +05:30
kshitijk4poor
67208caac3 fix(memory): replace states and surfaces the whole-entry contract instead of truncating silently (#117952, salvage #109177)
A partial-entry replace (old_text = a span inside an entry, content = its
replacement) silently overwrote the WHOLE entry: every clause outside the
match was destroyed with success=true, most damagingly through the
write-approval replay path (/memory approve -> apply_memory_pending ->
apply_batch) that exists precisely so background agents can stage writes for
human review (#117952, dup of #59184).

The cluster's repeated fix attempt (#66321, #61357, four closed predecessors)
flipped replace to naive span-splicing (entry.replace(old_text, new, 1)).
That corrupts the canonical identifier-style call (replace(old_text="3.11",
content="Python 3.12 project") -> "Python Python 3.12 project project"),
breaks the background-review refine consumer, and split-brains external
memory mirrors, which receive the raw op content, not a merged entry.

The defect is the CONTRACT, not the mechanism: the schema invited patch-style
calls ("mirrors old_text (patch-tool shape)") while the store commits
whole entries. This pins whole-entry semantics end to end and makes the
overwrite visible instead of silent:

- schema + recovery error text + memory_tool docstring now say content is the
  COMPLETE new entry and old_text only locates it (website docs updated in the
  same PR)
- a successful replace surfaces replaced_entry / replaced_entries (full text
  that was overwritten) on single, batch, and approval-replay paths, so the
  caller can re-add lost clauses instead of discovering them gone later
- _mutate gains an optional extra payload field; _apply_batch_op returns the
  replaced entry text alongside the error

Live repro on origin/main dec236b21 before: approve-apply of a staged
partial-entry replace truncated a 3-clause entry to the replacement span
alone. After: same final entry (whole-entry contract, unchanged semantics)
plus replaced_entries in the response. Grafted naive span-splice on main:
2 existing tests red (test_replace_entry,
test_attended_review_keeps_full_operation_set) — the regression this PR
avoids. Mutation checks: reverting the visibility fields or the exact-match
priority turns the new invariant tests red.
2026-09-21 14:26:37 +05:30
lll
c2bc93588f fix(memory): exact whole-entry match beats substring match in replace/remove/batch
A short entry whose full text is contained inside a longer sibling entry
was unaddressable: remove('test') against entries ['test', '...tests
pass...'] hit _find_unique_match's substring scan, reported 'Multiple
entries matched', and refused — the entry could never be targeted.
apply_batch hit the same matcher and aborted the whole operation.

_find_unique_match() now prefers whole-entry EXACT matches
(old_text == entry) and only falls back to substring matches when no
entry equals old_text. Both call sites (_edit for single replace/remove,
_apply_batch_op for batch) share the matcher, so the whole bug class is
fixed at one seam. Substring partial matching is unchanged.

Reproduced red on current main (2 invariant tests), 53/53 green after
the fix; 8 sibling memory suites green via scripts/run_tests.sh.
2026-09-21 14:26:37 +05:30
686f6c61
840c41e9a2 fix(memory): omit current_entries on batch abort
A failed all-or-nothing consolidation must not echo the whole
store back into the turn. Single replace/remove still return
the entry list so the model can retry with exact text.
2026-09-20 16:13:31 +05:30
Konstantin Khlopkov
7f7d229f83 fix(utils): atomic writers refuse to resurrect a deleted named profile home
Background writers that still carry a tombstoned profile as their Hermes
home (reasoning-caps warm thread, models cache, models.dev ETag, gateway
lifecycle ledger, MCP OAuth tokens, memory store) re-created
profiles/<name>/ with a bare mkdir right before an atomic write. Route the
parent-dir creation through mkdir_under_hermes_home so a deleted named
profile raises FileNotFoundError and stays gone, matching the tombstone
contract already enforced for logging and state.
2026-09-16 00:32:15 -07:00
Hermes fleet-fix
c445987559 fix(memory): secure built-in memory lock files 2026-09-12 20:43:42 -07:00
easyvibecoding
4e1b3daa86 fix(memory): warn when MEMORY.md / USER.md exceed their char limit on load
The cap only fires on add/replace, so an externally written over-budget
file rode silently in the system prompt while every later add was refused
with no visible cause. Warn at load; entries stay loaded (never truncate
a user's memories).

Salvage of #10886 (original hunk targeted memory_tool.py before the
store split); authored by @easyvibecoding.

Refs #10877
2026-09-12 08:30:52 -07:00
kshitijk4poor
117dc02870 refactor(memory): derive the store label from _path_for, trim the guard comment
Review follow-up on the salvaged #103421 guard: the USER.md/MEMORY.md ternary
was a third copy (also _path_for and memory_tool._memory_target_error); use the
path's name. "profile" → "store" in the error since MEMORY.md is not a profile.
2026-09-06 14:53:13 +05:30
Halldrix
7602b33d79 fix(memory): point empty-batch refusal at single remove() calls (#103419)
Review feedback: the sanctioned deliberate wipe is repeated single-op
remove() calls, not a manual file edit — direct the model there.
2026-09-06 14:53:13 +05:30
Halldrix
c3718d5500 fix(memory): refuse batch that would empty a non-empty store (#103419)
apply_batch() committed an empty USER.md/MEMORY.md as a normal
successful write when a consolidation batch removed the last entry.
Refuse all-or-nothing with live entries so background consolidation
keeps at least one entry; a deliberate wipe stays a manual file edit.
2026-09-06 14:53:13 +05:30
Teknium
e83816a4d1 review-fix(comments): restore lost #NNNN rationale comments across non-test source (mechanical sweep, condensed, code unchanged)
For each issue anchor present in BASE 63279301bc non-test .py and absent on HEAD, the BASE comment/docstring block was re-attached at the HEAD location of the code it explained (matched by the distinctive code line / enclosing def). Sentences already covered by an existing HEAD comment were deduped; the issue number always survives. Insert-only: no code lines changed.
2026-09-03 09:44:26 -07:00
Teknium
17f95967f4 refactor(tools): wave-2 compaction of memory/session_search/graph/notification modules
memory_tool_store: _error() helper for every failure dict, reload folded into
_mutate, load_from_disk loops over targets with an inline snapshot sanitizer,
_locate inlined into _edit, batch-op splice, drift check compacted; on-disk
format and every result string unchanged (golden corpus).
memory_tool: single _apply_write_gate handles op + batch, _batch_op_line helper,
gate/validation returns folded, on-disk store built directly.
session_search_tool: _get_session_meta unifies 5 get_session lookups, rebuild
note folded into _discover_payload via _ok, lineage dedupe inlined into
_discover, scroll rebind inlined, title-match shaping via one closure, browse
comprehension, unreachable scroll guard dropped, single hermes_state import.
microsoft_graph_client/auth: signatures hugged, bodiless-response handling
inlined, header dict built in one expression, dead from_env removed.
process_registry_notifications: _preamble merges header/task-source/role lines,
_notice_lines shared, table-driven completion status, comprehension headers.
Schemas byte-identical (SCHEMA-OK); golden corpus old-vs-new identical.
2026-09-03 01:43:42 -07:00
Teknium
d4b4e8b0de refactor(tools): simplify memory_tool(+store), session_search_tool, microsoft_graph_{client,auth} (group I, -17% LOC)
- session_search_tool_common/_discover folded back into session_search_tool (single module, no re-export shim)
- MemoryStore: one locked _mutate pipeline for add/replace/remove/batch; _reload_target/_read_entries_checked/_commit/_batch_error/_previews inlined
- memory_tool: _STORE_ACTIONS table now carries store call + gate text; _target_label/_missing_old_text_error inlined
- session_search: _discovery_entry (title + FTS hits), _same_lineage, _loud (error-level DB failure -> tool_error), _session_end_reason/_normalize_title_query inlined
- graph client: iterate_pages folded into collect_paginated (its only caller), _decode_json_or, try/else retry loop
- docstrings compacted by hand keeping every WHY/invariant; tool schemas byte-identical; SQL untouched
2026-09-02 23:43:14 -07:00
Teknium
49d15faae6 refactor(tools/skills_sync,memory): split skills_sync client wire/org and bundled/optional ops; extract memory_tool_store and session_search common/discover 2026-09-02 14:45:15 -07:00