The occurrence hint in _resolve_fingerprint (_local_index_hint, "the
occurrence the user clicked") came from the contributor's dedupe=False
mutate, which this salvage deliberately dropped: MemoryStore._mutate
collapses byte-identical entries and _apply goes back to the first match
with entries.index(text), so the hint could never pick an occurrence. Its
only live effect was a spurious "stale" refusal for a duplicated card
after an earlier entry was removed, although the text was still there.
Match by fingerprint -> first entry with that text, delete
_local_index_hint (and with it the second copy of the profile-index
formula), and read _memory_cards() only on the legacy index-id path, so
a fingerprinted id no longer re-reads and hashes both files for a hint.
Legacy ids still resolve by position.
The render lookup goes back to a single memory_node_id key: every
render_frames caller builds the graph fresh, and build_learning_graph
always sets the fingerprint. The desktop star-map keeps its dual key
(imported graphs). The memory_fingerprint docstring said a memory add
prepends; it appends; what shifts a card is an earlier entry removed.
_memory_cards re-split MEMORY.md with plain utf-8 while MemoryStore reads
utf-8-sig, so a Notepad BOM stayed glued to the first entry: its card
title rendered with a U+FEFF, and — now that the node id carries a
digest of the card's text — its fingerprint never matched the store's,
so every edit/delete of that card failed with "stale — refresh the
graph" and refreshing could not fix it. Iterating
MemoryStore._read_file makes the fingerprint contract true by
construction and drops the duplicated delimiter literal.
The edit request still carried only the displayed index, so the entry it
meant was rebuilt from whatever sat at that index when the mutation ran.
A writer prepending an entry between the graph being drawn and the edit
being submitted shifted the list: selecting "beta" then edited "alpha"
and left "beta" untouched.
The node id now carries a digest of the card's text
(memory:<source>:<index>:<fingerprint>). Resolution prefers the position
while it still holds that text, and otherwise finds the text, refusing
when it is gone or ambiguous. The locked re-resolution in _mutate_memory
(43d3d4e851) goes through the same _locate_memory, so it now names the
clicked card too. Ids from an older graph have no fingerprint and resolve
by position, as before.
(cherry picked from commit 4bdf50f5dadcbe84efe019c50ac5d54ef9548e6e)
Reconcile plugin declarations and validation through PM's atomic generation publication; preserve external runtimes, target markers, and conflict refusal. Keep one source-update completion owner and port upstream lifecycle changes to the PM desktop/runtime paths.
Review finding on #112218 (major): `_skill_lock_path` opened `<skills>/.locks/<name>.lock`
before the name was validated, so `skill_manage(action='create', name='a'*300)` raised
OSError (File name too long) and a NUL name raised ValueError instead of the handler's
JSON error, and every rejected name ('../../etc', '') left a residue lock file.
- tools/skill_manager_tool.py: lock filename is sha256(basename).lock (fixed width, no
filesystem limit reachable; `foo` and `category/foo` still share one lock), the redundant
`_find_skill` rglob is gone, and `skill_manage` runs `_validate_name` on the name
(create) / basename (other actions) before the lock is opened.
- '.locks' joins the skills-dir exclusion sets (EXCLUDED_SKILL_DIRS, ledger
_NON_PACKAGE_TOPS, learning-graph/skill-commands skip parts, curator backup excludes).
- tests: 2 invariants in TestSkillMutationLock (rejected names -> JSON + no .locks residue;
digest-keyed lock shared across name forms), red on the old head.
record_created now stamps created_by="learn" on foreground creates
(e.g. /learn) instead of leaving it unset, and the learning-graph filter
honors "learn" alongside "agent"/used. "learn" is a learning-signal
marker only: curator management stays keyed strictly on "agent"
(_is_curator_managed_record), so user-taught skills appear in /journey
without becoming eligible for autonomous curation.
Fixes#111317.
---
authored with AI assistance (Muse, Meta's Muse Spark) under the contributor's direction; the contributor reviewed the diff and ran the tests.
Read text files with the encoding utf-8-sig so a BOM at the start of a
file does not cause a Unicode decode error (Windows editors add BOMs).
Reconstructed from ethie/pm commits 48a32b135b + 013219e814 onto the
current upstream/main base: only the utf-8 -> utf-8-sig transforms were
carried (370 exact line pairs across 205 files); pm-rename hunks that
rode in the original commit were left to the pm-store commit, and
utf8sig hunks entangled with content changes ride their owning commit.
Rebuilt on ethie/pm-clean off ac6c8028e0 (upstream/main).
parse_frontmatter's malformed-YAML fallback stores every value as a string,
so a skill's `metadata` can be a str. `_category`/`_related` chained
`.get("metadata", {}).get("hermes", {})` and blew up with `'str' object has
no attribute 'get'`, taking down `build_learning_graph()` (and thus /journey
and `hermes journey`) whenever any installed skill had bad frontmatter.
Extract a `_hermes_meta()` helper that returns the nested dict only when it
really is one. Fixes the whole class, not just the two call sites.
Assemble a per-profile graph of memories and learned skills over time
(agent/learning_graph.py) and serve it at GET /api/learning/graph
(hermes_cli/web_server.py), with tests. The radial time axis the desktop
renders is derived from this payload; the REST path stays under /learning
for backend compatibility.