diff --git a/ms_agent/agent_hub/_commands.py b/ms_agent/agent_hub/_commands.py index 1ab9e3761..4a10eda52 100644 --- a/ms_agent/agent_hub/_commands.py +++ b/ms_agent/agent_hub/_commands.py @@ -1020,10 +1020,20 @@ def convert_workspace( # as-is; without this filter they would leak into the target framework. # Mirrors the dst-spec guard in cmd_download's download path. dropped: list[str] = [] + dropped_memory_payloads: list[str] = [] if source_fw != target_fw: dst_patterns = dst_spec.resolved_patterns() dropped = sorted( k for k in converted if not dst_spec.matches(k, dst_patterns)) + # Non-Markdown memory payloads get their own explicit report: they + # are user memory the target's Markdown-only memory system cannot + # host, and deserve a clearer note than the generic drop line. + dropped_memory_payloads = [ + k for k in dropped + if k.startswith(('memory/', 'memories/')) + and not k.endswith('.md') + ] + dropped = [k for k in dropped if k not in dropped_memory_payloads] converted = { k: v for k, v in converted.items() if dst_spec.matches(k, dst_patterns) @@ -1054,8 +1064,9 @@ def convert_workspace( ('written', len(effective), display.COLOR_WRITTEN)] if merge_pairs: counts.append(('merged', len(merge_pairs), display.COLOR_MERGED)) - if dropped: - counts.append(('dropped', len(dropped), display.COLOR_DROPPED)) + if dropped or dropped_memory_payloads: + counts.append(('dropped', len(dropped) + len(dropped_memory_payloads), + display.COLOR_DROPPED)) display.summary(counts) display.file_list('Written', effective, color=display.COLOR_WRITTEN) @@ -1073,6 +1084,15 @@ def convert_workspace( marker='[drop]', note=f'not part of the {target_fw} workspace spec', ) + display.file_list( + 'Memory payloads not supported', + dropped_memory_payloads, + color=display.COLOR_DROPPED, + marker='[drop]', + note=(f'non-Markdown memory files; {target_fw} memory is ' + 'Markdown-only, so they cannot travel cross-framework ' + '(kept on same-framework sync)'), + ) display.file_list( 'Skipped', skipped_skills, diff --git a/ms_agent/agent_hub/_merge.py b/ms_agent/agent_hub/_merge.py index 8133ad8b6..226806cff 100644 --- a/ms_agent/agent_hub/_merge.py +++ b/ms_agent/agent_hub/_merge.py @@ -526,11 +526,12 @@ def _is_private_file(product: str, path: str) -> bool: 'ms-agent': 'SOUL.md' }, { + # No qwenpaw / qoder entries: neither framework has a USER.md slot, + # so profile content for them folds into the target's catch-all file + # with a visible hint instead of landing in a file never read. 'nanobot': 'USER.md', 'openclaw': 'USER.md', 'hermes': 'memories/USER.md', - 'qwenpaw': 'memory/USER.md', - 'qoder': 'memory/USER.md', 'ms-agent': 'PROFILE.md' }, { @@ -548,6 +549,11 @@ def _is_private_file(product: str, path: str) -> bool: { 'qwenpaw': 'PROFILE.md', }, + { + # openhuman's long-term goals list: no counterpart elsewhere, so + # cross-framework it folds into the target's catch-all file. + 'openhuman': 'MEMORY_GOALS.md', + }, { 'nanobot': 'AGENTS.md', 'openclaw': 'AGENTS.md', @@ -665,45 +671,171 @@ def _resolve_target_path(source_product: str, source_path: str, return source_path -# Where UNMAPPED loose memory files (the ``memory/*`` / ``memories/*`` detail -# files that travel beside the canonical ``MEMORY.md`` index) land on each -# target framework. ``None`` means the target has a single-file memory slot: -# the detail is inlined INTO that file instead of written beside it, because a -# file the runtime never reads is a false promise of migration. -# -# * hermes keeps memory in ``memories/`` (plural) and reads the directory; -# * openclaw / qwenpaw / qoder keep ``memory/*.md`` beside their index; -# * nanobot's runtime reads ONLY ``memory/MEMORY.md`` (its MemoryStore has a -# fixed file list and never scans the directory) -> inline merge; -# * openhuman injects ``MEMORY.md`` every session and keeps the bulk memory in -# the Obsidian-style ``wiki/`` vault (its Memory Tree mirror) -> detail -# routes into ``wiki/memory/``; -# * ms-agent has no home-level memory slot at all (runtime memory is -# project-level) -> unmapped, the target-spec filter drops it like any -# other out-of-scope file. +# Where UNMAPPED loose memory files (the detail files that travel beside the +# canonical ``MEMORY.md`` index) land on each target. ``None`` = the target +# reads a single memory file (:data:`_SINGLE_FILE_MEMORY_SLOTS`) and the +# detail is inlined into it -- a file the runtime never reads is not a +# migration. openclaw routes imports to its own ``memory/imports//`` +# location; qwenpaw / qoder keep detail beside the index; ms-agent has no +# home-level memory slot (the target-spec filter drops the payload). _MEMORY_LOOSE_HOME = { - 'hermes': 'memories/', + 'hermes': None, 'openclaw': 'memory/', 'qwenpaw': 'memory/', 'qoder': 'memory/', - 'openhuman': 'wiki/memory/', + 'openhuman': None, 'nanobot': None, } -# The single memory file a ``None`` entry in :data:`_MEMORY_LOOSE_HOME` -# stands for (nanobot): loose detail is inlined into it. -_SINGLE_FILE_MEMORY_SLOT = 'memory/MEMORY.md' +# The single memory file each ``None`` target above actually reads. +_SINGLE_FILE_MEMORY_SLOTS = { + 'nanobot': 'memory/MEMORY.md', + 'hermes': 'memories/MEMORY.md', + 'openhuman': 'MEMORY.md', +} + +# openhuman injects MEMORY.md into the system prompt under a char cap; +# detail beyond it would never be read, so it is skipped and reported. +_OPENHUMAN_MEMORY_INJECT_CAP = 2000 + +# hermes memory files are ``§``-delimited entry stores under a per-file char +# budget; an over-budget entry makes hermes refuse further memory writes, so +# overflow is skipped, never truncated. +_HERMES_ENTRY_DELIM = '\n§\n' +_HERMES_CHAR_LIMITS = { + 'memories/MEMORY.md': 2200, + 'memories/USER.md': 1375, +} +# Headings that are really file names add no entry context. +_HERMES_HEADING_DROP_RE = re.compile( + r'\b(MEMORY|USER|SOUL|AGENTS|TOOLS|IDENTITY|CLAUDE)\.md\b', re.I) + + +def _normalize_entry_text(text: str) -> str: + """Whitespace-collapsed lowercase form used for entry dedup.""" + return re.sub(r'\s+', ' ', (text or '').strip()).lower() + +def _strip_yaml_frontmatter(text: str) -> str: + """Drop a leading YAML frontmatter block: metadata, not memory content.""" + lines = text.splitlines() + if lines and lines[0].strip() == '---': + for idx in range(1, len(lines)): + if lines[idx].strip() in ('---', '...'): + return '\n'.join(lines[idx + 1:]) + return text + + +def _markdown_to_hermes_entries(text: str) -> list[str]: + """Split a Markdown memory document into hermes entries. + + Headings become context prefixes, bullets become one entry each, + consecutive prose lines merge into one entry, code blocks and table rows + are skipped, and duplicates are dropped. + """ + entries: list[str] = [] + headings: list[str] = [] + paragraph: list[str] = [] + + def add_entry(content: str) -> None: + prefix = ' > '.join( + h for h in headings if h and not _HERMES_HEADING_DROP_RE.search(h)) + entries.append(f'{prefix}: {content}' if prefix else content) + + def flush() -> None: + block = ' '.join(line.strip() for line in paragraph).strip() + paragraph.clear() + if block: + add_entry(block) + + in_code = False + lines = _strip_yaml_frontmatter(text or '').splitlines() + for raw_line in lines: + line = raw_line.rstrip() + stripped = line.strip() + if stripped.startswith('```'): + in_code = not in_code + flush() + continue + if in_code: + continue + heading = re.match(r'^(#{1,6})\s+(.*\S)\s*$', stripped) + if heading: + flush() + headings[len(heading.group(1)) - 1:] = [heading.group(2).strip()] + continue + bullet = re.match(r'^\s*(?:[-*]|\d+\.)\s+(.*\S)\s*$', line) + if bullet: + flush() + add_entry(bullet.group(1).strip()) + continue + if not stripped or (stripped.startswith('|') + and stripped.endswith('|')): + flush() + continue + paragraph.append(stripped) + flush() + + deduped: list[str] = [] + seen: set[str] = set() + for entry in entries: + normalized = _normalize_entry_text(entry) + if normalized and normalized not in seen: + seen.add(normalized) + deduped.append(entry.strip()) + return deduped + + +def _merge_hermes_entries(existing: list[str], incoming: list[str], + limit: int) -> tuple[list[str], dict]: + """Dedupe *incoming* against *existing* under a cumulative char *limit*. + + Entries that would bust *limit* are skipped (never truncated) and + counted, keeping the result within hermes' write-acceptance budget. + """ + merged = list(existing) + seen = {_normalize_entry_text(e) for e in existing if e.strip()} + stats = {'added': 0, 'duplicates': 0, 'overflowed': 0} + current = len(_HERMES_ENTRY_DELIM.join(merged)) + for entry in incoming: + normalized = _normalize_entry_text(entry) + if not normalized: + continue + if normalized in seen: + stats['duplicates'] += 1 + continue + candidate = (len(entry) if not merged else + current + len(_HERMES_ENTRY_DELIM) + len(entry)) + if candidate > limit: + stats['overflowed'] += 1 + continue + merged.append(entry) + seen.add(normalized) + current = candidate + stats['added'] += 1 + return merged, stats + + +def _hermes_entries_to_markdown(text: str) -> str: + """Render a ``§``-delimited hermes store as plain Markdown paragraphs. + + Used when hermes is the SOURCE: other frameworks' memory slots are + Markdown documents. Content without the delimiter is returned unchanged. + """ + if _HERMES_ENTRY_DELIM not in text: + return text + entries = [e.strip() for e in text.split(_HERMES_ENTRY_DELIM) if e.strip()] + return ('\n\n'.join(entries) + '\n') if entries else text -def _rehome_loose_memory(path: str, target_product: str) -> str | None: + +def _rehome_loose_memory(path: str, source_product: str, + target_product: str) -> str | None: """Relocate one loose memory file onto the target's memory layout. - Returns the new relative path, or ``None`` when the target only reads a + Returns the new relative path, or ``None`` when the target reads a single memory file and the content must be inlined into it instead. - Non-``.md`` payloads (openclaw ``memory/*.json``, nanobot - ``memory/history.jsonl``) and targets without a table entry keep the - original path, so the downstream target-spec filter decides their fate - exactly as before. + Non-``.md`` payloads and targets without a table entry keep the original + path, so the downstream target-spec filter decides their fate. """ if not path.endswith('.md'): return path @@ -712,9 +844,80 @@ def _rehome_loose_memory(path: str, target_product: str) -> str | None: home = _MEMORY_LOOSE_HOME[target_product] if home is None: return None + if target_product == 'openclaw': + home = f'memory/imports/{source_product}/' return home + path.split('/', 1)[1] +def _memory_index_paths() -> dict: + """Per-product canonical memory index path, derived from the MEMORY group.""" + for group in SEMANTIC_GROUPS: + if group.get('nanobot') == 'memory/MEMORY.md': + return dict(group) + return {} + + +_MEMORY_INDEX_PATHS = _memory_index_paths() + +_MD_LINK_RE = re.compile(r'\[([^\]]*)\]\(([^)\s]+)\)') + + +def _rewrite_memory_index(content: str, src_index_dir: str, + moves: dict, tgt_index_dir: str, + target_product: str) -> str: + """Keep index links resolvable after loose files moved or were inlined. + + Links to moved files are rewritten to the new relative location; links + to inlined files are de-linked to plain text. For qoder -- whose runtime + discovers detail files only through index references -- moved files not + yet mentioned get a reference line appended, and a minimal index is + created when the source had none. + """ + import posixpath + + # Old link forms (relative to the source index dir, plus unambiguous + # basenames) -> new relative link, or None to de-link. + forms: dict[str, str | None] = {} + basenames: dict[str, list[str]] = {} + for src_path, new_path in moves.items(): + rel = posixpath.relpath(src_path, src_index_dir or '.') + new_rel = (None if new_path is None else + posixpath.relpath(new_path, tgt_index_dir or '.')) + forms[rel] = new_rel + base = posixpath.basename(src_path) + basenames.setdefault(base, []).append(rel) + for base, rels in basenames.items(): + if len(rels) == 1 and base not in forms: + forms[base] = forms[rels[0]] + + def _sub(m): + text, link = m.group(1), m.group(2) + key = link[2:] if link.startswith('./') else link + if key not in forms: + return m.group(0) + new_rel = forms[key] + return f'[{text}]({new_rel})' if new_rel is not None else text + + content = _MD_LINK_RE.sub(_sub, content) + + if target_product == 'qoder': + mentioned = {m.group(2) for m in _MD_LINK_RE.finditer(content)} + missing = [] + for src_path, new_path in sorted(moves.items()): + if new_path is None: + continue + rel = posixpath.relpath(new_path, tgt_index_dir or '.') + if rel not in mentioned: + stem = posixpath.basename(new_path)[:-3] + missing.append(f'- [{stem}]({rel})') + if missing: + body = content.rstrip() + if not body: + body = '# Memory Index' + content = body + '\n' + '\n'.join(missing) + '\n' + return content + + def _extract_user_diff_text(user_content: str, source_default: str) -> str: """Extract user customizations as a text block. @@ -752,8 +955,10 @@ def _catch_all_file(product: str) -> str: return 'AGENTS.md' if 'SOUL.md' in known: return 'SOUL.md' - # Products without AGENTS.md/SOUL.md (e.g. ms-agent) fall back to their - # persona file so overflow lands in a file the harness actually loads. + # Products without AGENTS.md/SOUL.md fall back to their persona file + # (ms-agent's PROFILE.md; the legacy lowercase spelling is accepted too). + if 'PROFILE.md' in known: + return 'PROFILE.md' if 'profile.md' in known: return 'profile.md' return 'SOUL.md' @@ -801,6 +1006,15 @@ def merge_resources( existing_skill_set = set(existing_skills or []) result = FullMergeResult() + # hermes as SOURCE: its memory files are ``§``-delimited entry stores; + # cross-framework they are rendered as Markdown paragraphs first. + if is_cross_product and source_product == 'hermes': + incoming = { + p: (_hermes_entries_to_markdown(c) + if p.startswith('memories/') and p.endswith('.md') else c) + for p, c in incoming.items() + } + src_cls = PRODUCT_FILE_CLASSES.get(source_product, _DEFAULT_FILE_CLASS) tgt_cls = PRODUCT_FILE_CLASSES.get(target_product, _DEFAULT_FILE_CLASS) portable_files = src_cls['portable'] | tgt_cls['portable'] @@ -809,11 +1023,12 @@ def merge_resources( handled_target_paths = set() overflow_blocks: list[tuple[str, str]] = [] - # Loose memory detail files deferred for inlining into the target's - # single-file memory slot (nanobot); applied after the loop so the - # canonical index -- possibly processed AFTER the detail files -- forms - # the base they append to. + # Loose memory detail deferred for inlining into a single-file target + # slot; applied after the loop so the canonical index forms the base. loose_inline: list[tuple[str, str]] = [] + # Source path -> moved target path (None = inlined); drives the index + # link rewrite so links keep resolving after the move. + loose_moves: dict[str, str | None] = {} for path, content in incoming.items(): # Skills: direct import, skip if exists. Hermes' official @@ -912,25 +1127,26 @@ def merge_resources( )) continue if path.startswith('memory/') or path.startswith('memories/'): - # Unmapped memory file (e.g. a framework the USER group does - # not cover): re-home it onto the target's memory layout so - # the detail lands where the target's runtime actually reads - # it -- or inline it into the single-file slot -- instead of - # passing through verbatim only to die on the target-spec - # filter with the index left dangling. - new_path = _rehome_loose_memory(path, target_product) + # Unmapped memory file: re-home it onto the target's memory + # layout (or inline it) instead of passing it through verbatim + # only to die on the target-spec filter. + new_path = _rehome_loose_memory(path, source_product, + target_product) + slot = _SINGLE_FILE_MEMORY_SLOTS.get(target_product, '') if new_path is None: loose_inline.append((path, content)) + loose_moves[path] = None result.actions.append( MergeAction( - path=_SINGLE_FILE_MEMORY_SLOT, + path=slot, action='merged', detail=(f'Loose memory detail {path} inlined into ' - f'{_SINGLE_FILE_MEMORY_SLOT}'), + f'{slot}'), src_path=path, - dst_path=_SINGLE_FILE_MEMORY_SLOT, + dst_path=slot, )) continue + loose_moves[path] = new_path result.merged_files[new_path] = content result.actions.append( MergeAction( @@ -1004,6 +1220,21 @@ def merge_resources( continue # ---- Cross-product logic ---- + # hermes memory slots are entry stores, not template-driven Markdown: + # never rebase them onto a default template (boilerplate would become + # junk entries). The entry conversion runs after the loop. + if (target_product == 'hermes' + and target_path.startswith('memories/') + and target_path.endswith('.md')): + result.merged_files[target_path] = content + result.actions.append( + MergeAction( + path=target_path, + action='import', + detail=f'Memory file imported directly (from {path})', + )) + continue + if path in src_cls['portable']: src_default = source_defaults.get(path, '') tgt_default = target_defaults.get(target_path, '') @@ -1075,12 +1306,10 @@ def merge_resources( continue if path.startswith('memory/') or path.startswith('memories/'): - # Canonical memory files with an explicit semantic mapping (the - # MEMORY.md / USER.md groups) travel verbatim to their mapped - # slot. Unmapped LOOSE files fell back to their source path, - # which no target is guaranteed to accept: re-home them onto the - # target's memory layout (or inline into its single-file slot) - # so they never die silently on the target-spec filter. + # Explicitly mapped canonical files (MEMORY.md / USER.md groups) + # travel verbatim; unmapped loose files re-home onto the target's + # memory layout (or inline) so they never die silently on the + # target-spec filter. if PATH_MAP.get((source_product, path), {}).get( target_product) is not None: result.merged_files[target_path] = content @@ -1091,19 +1320,23 @@ def merge_resources( detail='Memory file imported directly', )) continue - new_path = _rehome_loose_memory(path, target_product) + new_path = _rehome_loose_memory(path, source_product, + target_product) + slot = _SINGLE_FILE_MEMORY_SLOTS.get(target_product, '') if new_path is None: loose_inline.append((path, content)) + loose_moves[path] = None result.actions.append( MergeAction( - path=_SINGLE_FILE_MEMORY_SLOT, + path=slot, action='merged', detail=(f'Loose memory detail {path} inlined into ' - f'{_SINGLE_FILE_MEMORY_SLOT}'), + f'{slot}'), src_path=path, - dst_path=_SINGLE_FILE_MEMORY_SLOT, + dst_path=slot, )) continue + loose_moves[path] = new_path result.merged_files[new_path] = content result.actions.append( MergeAction( @@ -1145,17 +1378,87 @@ def merge_resources( result.merged_files[catch_all] = ( base.rstrip() + '\n\n' + block if base.strip() else block) - # Inline loose memory detail into the target's single-file memory slot - # (nanobot reads ONLY ``memory/MEMORY.md``): the canonical index -- if - # any -- forms the base, detail files append as sourced sections in a - # deterministic order. + # Rewrite the canonical index's links to follow the loose-file moves; + # runs before the inline append and the hermes entry conversion. + if is_cross_product and loose_moves: + import posixpath + tgt_idx = _MEMORY_INDEX_PATHS.get(target_product) + if tgt_idx: + src_idx = _MEMORY_INDEX_PATHS.get(source_product, '') + tgt_dir = posixpath.dirname(tgt_idx) + src_dir = posixpath.dirname(src_idx) if src_idx else '' + index_content = result.merged_files.get(tgt_idx) + if index_content is not None: + result.merged_files[tgt_idx] = _rewrite_memory_index( + index_content, src_dir, loose_moves, tgt_dir, + target_product) + elif target_product == 'qoder': + # No source index: qoder discovers detail only through index + # references, so build a minimal one for the moved files. + created = _rewrite_memory_index('', src_dir, loose_moves, + tgt_dir, target_product) + if created.strip(): + result.merged_files[tgt_idx] = created + + # Inline loose detail into a single-file target slot: the canonical index + # forms the base, detail files append as sourced sections in a + # deterministic order. openhuman sections beyond its prompt-injection cap + # are skipped and reported instead of landing where they are never read. if loose_inline: - base = result.merged_files.get(_SINGLE_FILE_MEMORY_SLOT, '').rstrip() + slot = _SINGLE_FILE_MEMORY_SLOTS.get(target_product, '') + cap = (_OPENHUMAN_MEMORY_INJECT_CAP + if target_product == 'openhuman' else None) + base = result.merged_files.get(slot, '').rstrip() + overflowed: list[str] = [] for src_path, detail in sorted(loose_inline): + body = detail.strip() + if target_product == 'hermes': + # The extractor only strips frontmatter at the document head; + # inlined files sit mid-document, so strip per file here or + # their metadata becomes junk entries. + body = _strip_yaml_frontmatter(body).strip() block = (f'## Imported from {source_product} {src_path}\n\n' - f'{detail.strip()}') - base = f'{base}\n\n{block}' if base else block - result.merged_files[_SINGLE_FILE_MEMORY_SLOT] = base + '\n' + f'{body}') + candidate = f'{base}\n\n{block}' if base else block + if cap is not None and len(candidate) > cap: + overflowed.append(src_path) + continue + base = candidate + for src_path in overflowed: + result.actions.append( + MergeAction( + path=slot, + action='skip', + detail=(f'Loose memory detail {src_path} exceeds the ' + f'{target_product} injection cap ({cap} chars), ' + f'left out of {slot}'), + src_path=src_path, + dst_path=slot, + )) + if base: + result.merged_files[slot] = base + '\n' + + # Convert hermes memory files from Markdown into ``§`` entry stores: + # extract entries, dedupe, and skip entries over the per-file char budget + # so the result stays acceptable to hermes' memory tools. + if is_cross_product and target_product == 'hermes': + for slot, limit in _HERMES_CHAR_LIMITS.items(): + markdown = result.merged_files.get(slot) + if markdown is None: + continue + entries = _markdown_to_hermes_entries(markdown) + merged, stats = _merge_hermes_entries([], entries, limit) + result.merged_files[slot] = ( + _HERMES_ENTRY_DELIM.join(merged) + '\n') if merged else '' + detail = (f'{slot} converted to hermes entry store: ' + f"{stats['added']} entries") + if stats['duplicates']: + detail += f", {stats['duplicates']} duplicates dropped" + if stats['overflowed']: + detail += (f", {stats['overflowed']} skipped over the " + f'{limit}-char budget') + result.actions.append( + MergeAction(path=slot, action='merged', detail=detail)) return result diff --git a/ms_agent/agent_hub/frameworks/openhuman.py b/ms_agent/agent_hub/frameworks/openhuman.py index 5750dee22..6ec86298f 100644 --- a/ms_agent/agent_hub/frameworks/openhuman.py +++ b/ms_agent/agent_hub/frameworks/openhuman.py @@ -8,9 +8,10 @@ from pathlib import Path from ms_agent.utils.logger import get_logger -from .._workspace import (ARGS_LIST_KEYS, DEFAULT_AGENT_NAME, SECRET_BAG_KEYS, - WorkspaceSpec, is_secret_key, register_framework, - scrub_scalar_url_token, scrub_toml_array_args) +from .._workspace import (ARGS_LIST_KEYS, DEFAULT_AGENT_NAME, MAX_FILE_SIZE, + SECRET_BAG_KEYS, WorkspaceSpec, is_secret_key, + register_framework, scrub_scalar_url_token, + scrub_toml_array_args) logger = get_logger() @@ -19,38 +20,33 @@ class OpenhumanWorkspace(WorkspaceSpec): """Workspace spec for the OpenHuman agent framework (root-per-agent). OpenHuman is a Rust/Tauri desktop app whose brain is a local Memory Tree - (SQLite at ``memory_tree/chunks.db``) mirrored as an Obsidian-style - ``wiki/`` Markdown vault. Per its "move to a new PC" guide the portable, - human-authored state is: the ``wiki/`` vault, the persona files - ``SOUL.md`` / ``IDENTITY.md`` / ``HEARTBEAT.md`` and the ``config.toml`` - settings (models / providers / routing / autonomy). - - ``MEMORY.md`` is the *curated* long-term memory: unlike the Memory Tree - (queried on demand via recall tools) it is injected into the system prompt - every session and maintained by the archivist sub-agent, which makes it the - direct counterpart of the other products' ``MEMORY.md``. The public - migration guide predates the feature and only lists the Memory Tree plus - the wiki mirror, so it is collected on the strength of the on-disk layout - rather than that document. - - On-disk layout: the app does NOT keep those files directly under - ``~/.openhuman`` -- they live in a per-device user workspace - ``~/.openhuman/users//workspace/``, where ```` (e.g. - ``local-u-mwj2l941-2317-local``) differs on every machine. The data root - is therefore probed at runtime rather than hardcoded (BUG-033); a fixed - ``~/.openhuman`` root collected zero files on real installs. - - Sub-agents are Profile personas: ``personalities//SOUL.md`` is a - self-contained persona per Profile, so each Profile directory maps 1:1 to - an agent (root-per-agent). The workspace-level ``SOUL.md`` is the global - default persona and is collected as the ``default`` agent -- matching the - app's own lookup order (Profile persona > ``soul_md_path`` override > - inline persona > global default). - - Deliberately *not* collected: the SQLite stores (``memory_tree/chunks.db``, - ``approval/approval.db``, ``mcp_clients/mcp_clients.db``) and the session - history (``sessions/`` / ``session_raw/``) -- binary / run-time state that - does not migrate across frameworks (the wiki is the readable mirror). + (SQLite at ``memory_tree/chunks.db``). The portable, human-authored + state is: the persona files ``SOUL.md`` / ``IDENTITY.md`` / + ``HEARTBEAT.md``, the curated ``MEMORY.md`` (+ ``MEMORY_GOALS.md``), the + ``config.toml`` settings (models / providers / routing / autonomy) and + the skill tree. + + ``MEMORY.md`` is injected into the system prompt every session (under a + char cap) and is the only memory file the agent itself may write -- the + direct counterpart of the other products' ``MEMORY.md``. + + On-disk layout: files live in a per-device user workspace + ``~/.openhuman/users//workspace/``, where ```` differs + on every machine, so the data root is probed at runtime rather than + hardcoded (BUG-033). ``config.toml`` lives ONE LEVEL ABOVE the workspace + (``users//``) and is collected/written through a special case. + + Sub-agents are Profile personas: ``personalities//`` maps 1:1 to + an agent (root-per-agent); the workspace-level persona is the ``default`` + agent. Every level of the app's lookup treats an EMPTY file as absent and + falls back to the workspace copy (mirrored by + :meth:`_with_workspace_fallbacks`). + + Deliberately *not* collected: the SQLite stores, the KV memory docs + (machine-extracted chat derivatives), the Memory Tree chunk mirrors, the + derived wiki vault (``memory_tree/content/wiki/`` -- regenerable summary + output), and the session history: binary / run-time / derived state that + does not migrate across frameworks. """ # Per-device user workspace: ``users//workspace``. The id segment @@ -167,22 +163,22 @@ def workspace_root(self) -> Path: @property def patterns(self) -> list[str]: - # fnmatch ``*`` spans ``/`` so ``wiki/*`` / ``skills/*`` recurse the - # whole vault / skill tree. + # Entries are relative to :attr:`workspace_root`, which resolves to + # ``personalities//`` for a named agent, so one list covers + # both scopes with no per-profile duplicates (fnmatch ``*`` spans + # ``/``, so ``skills/*`` recurses the whole tree). # - # Every entry is relative to :attr:`workspace_root`, which already - # resolves to ``personalities//`` for a named agent. So this - # one list covers both scopes with no per-profile duplicates: - # ``MEMORY.md`` collects the workspace-level curated memory for - # ``default`` and ``personalities//MEMORY.md`` for a Profile, and - # ``skills/*`` likewise picks up a Profile's own skill tree. + # ``config.toml`` really lives one level ABOVE the workspace + # (``users//``); the pattern stays as the resources key while the + # read/write is special-cased in :meth:`collect` / :meth:`apply`. The + # wiki vault (derived, regenerable summary output) is not collected. return [ 'SOUL.md', 'IDENTITY.md', 'HEARTBEAT.md', 'MEMORY.md', + 'MEMORY_GOALS.md', 'config.toml', - 'wiki/*', 'skills/*', ] @@ -257,35 +253,96 @@ def _active_profile_id(self) -> str | None: # ------------------------------------------------------------------ def collect(self) -> dict[str, str]: - return self._with_workspace_fallbacks(super().collect(), text=True) + resources = self._with_workspace_fallbacks(super().collect(), text=True) + self._add_parent_config(resources, text=True) + return resources def collect_bytes(self) -> dict[str, bytes]: - return self._with_workspace_fallbacks( + resources = self._with_workspace_fallbacks( super().collect_bytes(), text=False) + self._add_parent_config(resources, text=False) + return resources + + def _parent_config_path(self) -> Path | None: + """``users//config.toml`` -- one level above the workspace. + + Only resolved for the real install layout (the workspace directory is + literally named ``workspace/``), so an explicit ``local_dir`` never + reaches outside the pointed-at folder. ``None`` in all-mode, where + per-Profile copies would duplicate the shared config. + """ + if self._is_all() or self.root.name != self._WORKSPACE_DIRNAME: + return None + candidate = self.root.parent / 'config.toml' + return candidate if candidate.is_file() else None + + def _add_parent_config(self, resources: dict, *, text: bool) -> None: + """Collect the user-level ``config.toml``, keyed workspace-relative. + + The patterns are workspace-relative but the file lives one level up; + without this special case it is never collected and its TOML scrubber + never runs. Symmetric with :meth:`apply`. + """ + path = self._parent_config_path() + if path is None or 'config.toml' in resources: + return + try: + if path.stat().st_size > MAX_FILE_SIZE: + logger.warning('Skip large file %s (exceeds limit %d)', path, + MAX_FILE_SIZE) + return + resources['config.toml'] = ( + path.read_text(encoding='utf-8') if text else + path.read_bytes()) + except (OSError, UnicodeDecodeError) as e: + logger.warning('Skip %s: %s', path, e) + + def apply(self, resources: dict) -> list[str]: + """Write resources back; ``config.toml`` returns to the user dir + (symmetric with collection) instead of spawning a copy inside the + workspace that the app never reads.""" + if 'config.toml' not in resources or self._is_all() \ + or self.root.name != self._WORKSPACE_DIRNAME: + return super().apply(resources) + rest = {k: v for k, v in resources.items() if k != 'config.toml'} + written = super().apply(rest) + config_target = self.root.parent / 'config.toml' + raw = resources['config.toml'] + raw = raw if isinstance(raw, bytes) else raw.encode('utf-8') + sanitized = self.sanitize_inbound_file('config.toml', raw) + config_target.parent.mkdir(parents=True, exist_ok=True) + config_target.write_bytes(sanitized) + written.append(str(config_target)) + return written def _with_workspace_fallbacks(self, resources: dict, *, text: bool) -> dict: - """Fill missing Profile files from the workspace-level copies. - - A Profile that lacks e.g. ``MEMORY.md`` runs with the workspace-level - one at runtime (app lookup order), so a converted agent must get it - too: missing files in :data:`_WORKSPACE_FALLBACK_FILES` are taken - from the workspace root when present there. Files the Profile already - has always win; all-mode is exempt (each Profile mirrors to its own - repo and workspace files would duplicate across every Profile). + """Fill missing OR BLANK Profile files from the workspace-level copies. + + The app treats an empty persona/memory file as absent and falls back + to the workspace copy, so a 0-byte Profile ``MEMORY.md`` must not + shadow the real workspace memory. A Profile copy with substance + always wins; all-mode is exempt (each Profile mirrors to its own repo + and workspace files would duplicate across every Profile). """ if self._is_all() or self.workspace_root == self.root: return resources workspace_spec = copy.copy(self) workspace_spec.agent_name = DEFAULT_AGENT_NAME for rel, f in workspace_spec._walk_matched(): - if rel not in self._WORKSPACE_FALLBACK_FILES or rel in resources: + if rel not in self._WORKSPACE_FALLBACK_FILES: + continue + existing = resources.get(rel) + if existing is not None and existing.strip(): continue try: - resources[rel] = ( + content = ( f.read_text(encoding='utf-8') if text else f.read_bytes()) except (OSError, UnicodeDecodeError) as e: logger.warning('Skip workspace fallback %s: %s', f, e) + continue + if content.strip(): + resources[rel] = content return resources # ------------------------------------------------------------------ diff --git a/tests/agent_hub/test_agent_frameworks.py b/tests/agent_hub/test_agent_frameworks.py index 60ec3a637..afd6832f4 100644 --- a/tests/agent_hub/test_agent_frameworks.py +++ b/tests/agent_hub/test_agent_frameworks.py @@ -118,9 +118,8 @@ def _to_bytes(files: dict) -> dict: 'SOUL.md': '# Soul\n\n## Identity\nI am OpenHuman, a digital companion.\n', 'IDENTITY.md': '# Identity\nOpenHuman v1.0 - empathetic assistant.\n', 'HEARTBEAT.md': '# Heartbeat\n\n## Active Tasks\n- [ ] Remember birthday\n', + 'MEMORY_GOALS.md': '# Goals\n[g1] Maintain a well-configured environment.\n', 'config.toml': '[model]\nprovider = "openai"\napi_key = "sk-should-be-scrubbed"\n', - 'wiki/interests.md': '# Interests\nHiking trails in the Pacific Northwest.\n', - 'wiki/summaries/week1.md': '# Week 1 Summary\nGot to know the user.\n', 'skills/journal/SKILL.md': '# Journal\nHelp the user maintain a daily journal.\n', } @@ -453,7 +452,7 @@ def test_23_framework_structure(self): "openclaw": ["IDENTITY.md", "BOOTSTRAP.md", "memory/project-notes.md"], "qwenpaw": ["PROFILE.md", "BOOTSTRAP.md", "memory/story-notes.md"], "hermes": ["memories/USER.md"], - "openhuman": ["SOUL.md", "IDENTITY.md", "HEARTBEAT.md", "wiki/interests.md"], + "openhuman": ["SOUL.md", "IDENTITY.md", "HEARTBEAT.md", "MEMORY_GOALS.md"], "qoder": ["agents/code-reviewer.md", "commands/review.md", "rules/style-guide.md", "memory/MEMORY.md"], } diff --git a/tests/agent_hub/test_convert_targetname.py b/tests/agent_hub/test_convert_targetname.py index a1b7d3142..17340d54f 100644 --- a/tests/agent_hub/test_convert_targetname.py +++ b/tests/agent_hub/test_convert_targetname.py @@ -480,9 +480,11 @@ def _convert(self, src_files, source_fw, target_fw): self.assertEqual(rc, 0, f"{source_fw}->{target_fw} convert failed") return _read_all(build_spec(target_fw, "bot-a", str(out)).workspace_root) - def test_ms_agent_to_qwenpaw_persona_maps_to_user(self): - """ms-agent (single-agent) -> qwenpaw (root-per-agent): PROFILE.md - identity lands in qwenpaw memory/USER.md (USER semantic group).""" + def test_ms_agent_to_qwenpaw_persona_folds_into_agents(self): + """ms-agent (single-agent) -> qwenpaw (root-per-agent): qwenpaw has + no USER.md slot (its PROFILE.md is a composite identity+profile + file), so PROFILE.md content folds into the catch-all AGENTS.md with + a merged hint instead of writing a dead memory/USER.md.""" files = self._convert( { "PROFILE.md": "---\nversion: 1\n---\n\n# About Me\n- Call me: MS_PERSONA_MARKER\n", @@ -490,8 +492,9 @@ def test_ms_agent_to_qwenpaw_persona_maps_to_user(self): }, "ms-agent", "qwenpaw", ) - self.assertIn("memory/USER.md", files) - self.assertIn("MS_PERSONA_MARKER", files["memory/USER.md"]) + self.assertNotIn("memory/USER.md", files) + self.assertIn("AGENTS.md", files) + self.assertIn("MS_PERSONA_MARKER", files["AGENTS.md"]) # skill carried over. self.assertIn("skills/write/SKILL.md", files) diff --git a/tests/agent_hub/test_merge.py b/tests/agent_hub/test_merge.py index dcce31893..d0299bd96 100644 --- a/tests/agent_hub/test_merge.py +++ b/tests/agent_hub/test_merge.py @@ -282,28 +282,33 @@ def test_cross_product_memory_md(self): _resolve_target_path("qoder", "memory/MEMORY.md", "ms-agent")) def test_cross_product_ms_agent_profile(self): - # ms-agent PROFILE.md -> qwenpaw maps to memory/USER.md (USER group). + # qwenpaw has no USER.md slot (its profile lives in the composite + # PROFILE.md), so the USER group does not declare it: ms-agent + # PROFILE.md -> qwenpaw resolves to None and folds into the catch-all + # with a visible "merged" hint instead of writing a dead file. # qwenpaw PROFILE.md -> ms-agent has no counterpart (narrow group), - # so it resolves to None (overflow into catch-all). - self.assertEqual(_resolve_target_path("ms-agent", "PROFILE.md", "qwenpaw"), "memory/USER.md") + # so it resolves to None too (overflow into catch-all). + self.assertIsNone(_resolve_target_path("ms-agent", "PROFILE.md", "qwenpaw")) self.assertIsNone(_resolve_target_path("qwenpaw", "PROFILE.md", "ms-agent")) - def test_cross_product_qoder_user_md(self): - # qoder keeps its user profile inside the memory dir; it joins the - # USER group so it lands on each target's profile slot. + def test_cross_product_qoder_user_md_is_loose_memory(self): + # qoder has no memory/USER.md convention (its memory topic files are + # free-form), so it is NOT in the USER group: the file falls back to + # its source path and is handled as loose memory (re-homed / inlined + # per target), never written as a dead profile file. self.assertEqual( _resolve_target_path("qoder", "memory/USER.md", "hermes"), - "memories/USER.md") + "memory/USER.md") self.assertEqual( _resolve_target_path("qoder", "memory/USER.md", "nanobot"), - "USER.md") + "memory/USER.md") + # Frameworks WITH a real USER slot keep their group mappings. + self.assertEqual( + _resolve_target_path("openclaw", "USER.md", "hermes"), + "memories/USER.md") self.assertEqual( - _resolve_target_path("qoder", "memory/USER.md", "ms-agent"), + _resolve_target_path("nanobot", "USER.md", "ms-agent"), "PROFILE.md") - # openhuman has no USER slot: the merger re-homes it into the wiki - # vault instead of dropping it. - self.assertIsNone( - _resolve_target_path("qoder", "memory/USER.md", "openhuman")) def test_cross_product_ms_agent_no_memory_slot(self): # ms-agent has NO memory slot (memory is project-level at runtime, not @@ -336,11 +341,11 @@ def test_same_product_imports_directly(self): self.assertEqual(result.merged_files["SOUL.md"], "my soul") def test_qoder_memory_maps_and_topic_files_pass_through(self): - """Cross-framework, qoder's user-level memory index maps onto the - target's MEMORY.md slot while loose topic files re-home onto the - target's memory layout (openclaw keeps ``memory/*.md``, so the path - is unchanged here); both travel verbatim -- memory is user data, - never rebased onto a target template.""" + """The memory index maps onto the target's MEMORY.md slot while loose + topic files land under openclaw's import convention + ``memory/imports//``; index links are rewritten to keep + resolving, and both travel verbatim (memory is user data, never + rebased onto a target template).""" result = merge_resources( incoming={ "memory/MEMORY.md": "# Memory Index\n\n- [t](t.md) — x\n", @@ -352,33 +357,63 @@ def test_qoder_memory_maps_and_topic_files_pass_through(self): target_defaults={}, ) self.assertEqual(result.merged_files["MEMORY.md"], - "# Memory Index\n\n- [t](t.md) — x\n") - self.assertEqual(result.merged_files["memory/t.md"], + "# Memory Index\n\n- [t](memory/imports/qoder/t.md) — x\n") + self.assertEqual(result.merged_files["memory/imports/qoder/t.md"], "---\nname: t\n---\n\ntopic body\n") - def test_loose_memory_rehomed_for_hermes(self): - """hermes keeps memory in ``memories/`` (plural): loose detail files - re-home there so the index and its details stay co-located and the - index links resolve, instead of dying on the target-spec filter.""" + def test_loose_memory_inlined_for_hermes_as_entry_store(self): + """hermes reads two fixed ``§``-delimited entry stores and never + scans its memory directory: loose detail inlines into + ``memories/MEMORY.md`` and the file is converted to entries -- more + than one, round-trip stable, within the char budget.""" result = merge_resources( incoming={ - "memory/MEMORY.md": "# Memory Index\n", - "memory/t.md": "topic body\n", + "memory/MEMORY.md": "# Memory Index\n\n- [t](t.md) — x\n", + "memory/t.md": "topic body MARKER\n", }, source_product="qoder", target_product="hermes", source_defaults={}, target_defaults={}, ) - self.assertEqual(result.merged_files["memories/MEMORY.md"], - "# Memory Index\n") - self.assertEqual(result.merged_files["memories/t.md"], "topic body\n") + store = result.merged_files["memories/MEMORY.md"] + self.assertNotIn("memories/t.md", result.merged_files) self.assertNotIn("memory/t.md", result.merged_files) - - def test_loose_memory_routed_into_openhuman_wiki(self): - """openhuman injects MEMORY.md every session and keeps bulk memory in - the Obsidian-style ``wiki/`` vault, so loose detail routes into - ``wiki/memory/`` -- including an unmapped USER profile.""" + entries = [e.strip() for e in store.split("\n§\n") if e.strip()] + # The index bullet AND the inlined topic both became entries. + self.assertGreater(len(entries), 1) + self.assertTrue(any("MARKER" in e for e in entries)) + # The dangling link was de-linked (its file no longer exists). + self.assertNotIn("](t.md)", store) + # Drift-guard round trip: parsing the written file reproduces it. + self.assertEqual(store.strip(), "\n§\n".join(entries)) + self.assertLessEqual(max(map(len, entries)), 2200) + + def test_hermes_inlined_frontmatter_not_leaked_as_entry(self): + """A topic file's YAML frontmatter sits mid-document once inlined, + where the extractor's head-strip cannot reach it: it must be removed + per file so metadata never becomes a junk entry.""" + result = merge_resources( + incoming={ + "memory/MEMORY.md": "# idx\n", + "memory/t.md": "---\nname: t\nmetadata:\n type: user\n---\n\nreal content HERE\n", + }, + source_product="qoder", + target_product="hermes", + source_defaults={}, + target_defaults={}, + ) + store = result.merged_files["memories/MEMORY.md"] + entries = [e.strip() for e in store.split("\n§\n") if e.strip()] + self.assertTrue(any("real content HERE" in e for e in entries)) + self.assertFalse( + any("name: t" in e or e.strip().startswith("---") + for e in entries), str(entries)) + + def test_loose_memory_inlined_for_openhuman_memory_md(self): + """openhuman's memory is the root ``MEMORY.md`` alone (its wiki vault + is derived output), so loose detail -- including an unmapped USER + profile -- inlines into it instead of being written beside it.""" result = merge_resources( incoming={ "memory/MEMORY.md": "# Memory Index\n", @@ -390,11 +425,12 @@ def test_loose_memory_routed_into_openhuman_wiki(self): source_defaults={}, target_defaults={}, ) - self.assertEqual(result.merged_files["MEMORY.md"], "# Memory Index\n") - self.assertEqual(result.merged_files["wiki/memory/t.md"], - "topic body\n") - self.assertEqual(result.merged_files["wiki/memory/USER.md"], - "profile body\n") + merged = result.merged_files["MEMORY.md"] + self.assertIn("# Memory Index", merged) + self.assertIn("topic body", merged) + self.assertIn("profile body", merged) + self.assertFalse(any(k.startswith("wiki/") + for k in result.merged_files)) def test_loose_memory_inlined_for_nanobot(self): """nanobot's runtime reads ONLY ``memory/MEMORY.md`` (fixed file @@ -474,6 +510,130 @@ def test_loose_memory_keeps_path_for_ms_agent(self): self.assertEqual(result.merged_files.get("memory/t.md"), "topic body\n") + def test_hermes_entry_budget_skips_overflow(self): + """An entry that would bust hermes' 2200-char budget is SKIPPED + (never truncated) and counted, so the written store stays within the + budget its memory tools enforce.""" + result = merge_resources( + incoming={ + "memory/MEMORY.md": "# idx\n", + "memory/big.md": "x" * 2500 + "\n", + "memory/small.md": "small note\n", + }, + source_product="qoder", + target_product="hermes", + source_defaults={}, + target_defaults={}, + ) + store = result.merged_files["memories/MEMORY.md"] + entries = [e.strip() for e in store.split("\n§\n") if e.strip()] + self.assertLessEqual(max(map(len, entries)), 2200) + self.assertTrue(any("small note" in e for e in entries)) + self.assertFalse(any("x" * 100 in e for e in entries)) + self.assertTrue( + any(a.action == "merged" and "skipped over" in a.detail + for a in result.actions)) + + def test_hermes_source_entries_rendered_as_markdown(self): + """hermes as SOURCE: its ``§`` entry stores are rendered as plain + Markdown paragraphs for the target (no stray ``§`` noise), and a + source with no index gets a minimal qoder index built so the moved + detail files are discoverable (qoder reads ONLY its index).""" + result = merge_resources( + incoming={ + "memories/MEMORY.md": "entry one\n§\nentry two\n", + "memories/note.md": "note body\n", + }, + source_product="hermes", + target_product="qoder", + source_defaults={}, + target_defaults={}, + ) + index = result.merged_files["memory/MEMORY.md"] + self.assertNotIn("§", index) + self.assertIn("entry one", index) + self.assertIn("entry two", index) + # The loose note moved to memory/note.md and the created index + # references it (qoder discovers detail ONLY through the index). + self.assertEqual(result.merged_files["memory/note.md"], "note body\n") + self.assertIn("](note.md)", index) + + def test_nanobot_inlined_index_links_stripped(self): + """Inline targets leave no file on disk for the index links to point + at, so the links are de-linked to plain text (no dangling index).""" + result = merge_resources( + incoming={ + "memory/MEMORY.md": "# idx\n\n- [pref](pref.md) — zh\n", + "memory/pref.md": "prefers Chinese\n", + }, + source_product="qoder", + target_product="nanobot", + source_defaults={}, + target_defaults={}, + ) + merged = result.merged_files["memory/MEMORY.md"] + self.assertNotIn("](pref.md)", merged) + self.assertIn("pref", merged) + self.assertIn("prefers Chinese", merged) + + def test_user_content_folds_into_qwenpaw_catch_all(self): + """USER content for a target with no USER slot (qwenpaw) folds into + the catch-all AGENTS.md with a merged hint -- never a dead + memory/USER.md file (qwenpaw injects AGENTS/SOUL/PROFILE only).""" + result = merge_resources( + incoming={"USER.md": "# User\nCall me CAPTAIN-USER.\n"}, + source_product="openclaw", + target_product="qwenpaw", + source_defaults={}, + target_defaults={}, + ) + self.assertNotIn("memory/USER.md", result.merged_files) + self.assertIn("CAPTAIN-USER", result.merged_files.get("AGENTS.md", "")) + self.assertIn(("USER.md", "AGENTS.md"), merged_away_pairs(result)) + + def test_openhuman_memory_goals_folds_cross_framework(self): + """MEMORY_GOALS.md has no counterpart elsewhere: same-framework it + travels verbatim, cross-framework it folds into the catch-all with a + merged hint instead of being silently dropped.""" + same = merge_resources( + incoming={"MEMORY_GOALS.md": "[g1] goal\n"}, + source_product="openhuman", + target_product="openhuman", + source_defaults={}, + target_defaults={}, + ) + self.assertEqual(same.merged_files["MEMORY_GOALS.md"], "[g1] goal\n") + cross = merge_resources( + incoming={"MEMORY_GOALS.md": "[g1] GOAL-MARKER\n"}, + source_product="openhuman", + target_product="openclaw", + source_defaults={}, + target_defaults={}, + ) + self.assertNotIn("MEMORY_GOALS.md", cross.merged_files) + self.assertIn("GOAL-MARKER", cross.merged_files.get("AGENTS.md", "")) + + def test_openhuman_injection_cap_skips_and_reports(self): + """openhuman injects MEMORY.md under a hard 2000-char cap: inlined + detail beyond the cap is skipped and reported, not written where it + would never be read.""" + result = merge_resources( + incoming={ + "memory/MEMORY.md": "# idx\n", + "memory/huge.md": "y" * 2500 + "\n", + }, + source_product="qoder", + target_product="openhuman", + source_defaults={}, + target_defaults={}, + ) + merged = result.merged_files["MEMORY.md"] + self.assertLessEqual(len(merged), 2000) + self.assertNotIn("yyyy", merged) + self.assertTrue( + any(a.action == "skip" and "injection cap" in a.detail + for a in result.actions)) + def test_fills_missing_from_target_defaults(self): """merge_resources fills target defaults for absent source files.""" result = merge_resources( diff --git a/tests/agent_hub/test_upload_download.py b/tests/agent_hub/test_upload_download.py index 86022e72a..afcca981d 100644 --- a/tests/agent_hub/test_upload_download.py +++ b/tests/agent_hub/test_upload_download.py @@ -572,7 +572,7 @@ def test_23_upload_each_framework(self): elif fw == "hermes": files = {"SOUL.md": "# Soul\n"} elif fw == "openhuman": - files = {"wiki/identity.md": "# Identity\n"} + files = {"IDENTITY.md": "# Identity\n"} elif fw == "ms-agent": files = {"profile.md": "# Profile\n", "MEMORY.md": "# Memory\n"} else: diff --git a/tests/agent_hub/test_workspace.py b/tests/agent_hub/test_workspace.py index a27cf6401..6128a9cff 100644 --- a/tests/agent_hub/test_workspace.py +++ b/tests/agent_hub/test_workspace.py @@ -386,9 +386,12 @@ def tearDown(self): def test_resolves_per_device_user_workspace(self): spec = build_spec("openhuman", "default", str(self.root)) self.assertEqual(spec.workspace_root, self.ws) - self.assertEqual( - sorted(spec.collect_bytes()), - ["IDENTITY.md", "SOUL.md", "config.toml", "wiki/note.md"]) + collected = sorted(spec.collect_bytes()) + # wiki/ is derived Memory Tree output (its real location is + # memory_tree/content/wiki/ anyway) and is no longer collected. + self.assertEqual(collected, + ["IDENTITY.md", "SOUL.md", "config.toml"]) + self.assertNotIn("wiki/note.md", collected) def test_default_root_probes_users_dir(self): from ms_agent.agent_hub.frameworks.openhuman import OpenhumanWorkspace @@ -428,6 +431,85 @@ def test_fresh_install_without_users_dir_is_not_an_error(self): self.assertEqual(spec.collect_bytes(), {}) +class TestOpenhumanRealLayout(unittest.TestCase): + """openhuman's REAL install layout: + + * ``config.toml`` lives at ``users//config.toml`` -- ONE LEVEL ABOVE + the workspace -- so workspace-relative patterns never match it; + * a 0-byte Profile ``MEMORY.md`` must NOT shadow the workspace-level + copy (the app's resolvers treat an empty file as absent); + * ``MEMORY_GOALS.md`` (human-authored long-term goals) is collected. + """ + + USER_ID = "local-u-real" + + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.user_dir = Path(self.tmp.name) / ".openhuman" / "users" / self.USER_ID + self.ws = self.user_dir / "workspace" + (self.ws / "personalities" / "p1").mkdir(parents=True) + (self.ws / "SOUL.md").write_text("# global soul\n") + (self.ws / "MEMORY.md").write_text("# root memory REAL-ROOT-MEM\n") + (self.ws / "MEMORY_GOALS.md").write_text("[g1] keep the lab running\n") + (self.ws / "personalities" / "p1" / "SOUL.md").write_text("# p1 soul\n") + # 0-byte profile memory: the runtime falls back to the workspace copy. + (self.ws / "personalities" / "p1" / "MEMORY.md").write_text("") + # The real config location: one level above the workspace. + (self.user_dir / "config.toml").write_text( + '[model]\napi_key = "sk-secret-value"\nname = "bot"\n') + + def tearDown(self): + self.tmp.cleanup() + + def test_config_toml_collected_from_parent_level(self): + spec = build_spec("openhuman", "default", + str(self.user_dir.parent.parent)) + files = spec.collect() + self.assertIn("config.toml", files) + self.assertIn("sk-secret-value", files["config.toml"]) + # A named profile agent carries the shared user config too. + p1 = build_spec("openhuman", "p1", str(self.user_dir.parent.parent)) + self.assertIn("config.toml", p1.collect()) + + def test_all_mode_does_not_duplicate_config(self): + spec = build_spec("openhuman", "all", str(self.user_dir.parent.parent)) + self.assertNotIn("config.toml", spec.collect()) + + def test_memory_goals_collected(self): + spec = build_spec("openhuman", "default", + str(self.user_dir.parent.parent)) + self.assertEqual(spec.collect()["MEMORY_GOALS.md"], + "[g1] keep the lab running\n") + + def test_apply_redirects_config_to_parent_and_scrubs(self): + spec = build_spec("openhuman", "default", + str(self.user_dir.parent.parent)) + written = spec.apply({ + "SOUL.md": "# imported soul\n", + "config.toml": '[model]\napi_key = "sk-inbound"\n', + }) + # config lands beside the workspace (where the app reads it), NOT + # inside it, and the secret is scrubbed on the inbound write. + self.assertFalse((self.ws / "config.toml").exists()) + restored = (self.user_dir / "config.toml").read_text() + self.assertIn('api_key = ""', restored) + self.assertNotIn("sk-inbound", restored) + self.assertTrue(any(w.endswith("config.toml") for w in written)) + self.assertEqual((self.ws / "SOUL.md").read_text(), + "# imported soul\n") + + def test_blank_profile_memory_falls_back_to_workspace(self): + """An EMPTY profile MEMORY.md must not shadow the real workspace + memory (openhuman's resolver treats blank as absent).""" + spec = build_spec("openhuman", "p1", str(self.user_dir.parent.parent)) + files = spec.collect() + self.assertEqual(files["MEMORY.md"], "# root memory REAL-ROOT-MEM\n") + # A profile file WITH substance still wins. + (self.ws / "personalities" / "p1" / "MEMORY.md").write_text( + "# p1 own memory\n") + self.assertEqual(spec.collect()["MEMORY.md"], "# p1 own memory\n") + + class TestOpenhumanWorkspaceLiveness(unittest.TestCase): """BUG-0828: reinstalls / user-id migrations leave SEVERAL ``users/`` dirs behind. The resolver must pick the LIVE workspace by liveness score