Skip to content

feat(retrieval-foundation): normalize ingest metadata via shared writer - #406

Closed
slapglif wants to merge 3 commits into
MemPalace:developfrom
slapglif:feat/hybrid-phase1-metadata
Closed

feat(retrieval-foundation): normalize ingest metadata via shared writer#406
slapglif wants to merge 3 commits into
MemPalace:developfrom
slapglif:feat/hybrid-phase1-metadata

Conversation

@slapglif

@slapglif slapglif commented Apr 9, 2026

Copy link
Copy Markdown

Summary

  • add a shared writer/metadata helper for normalized ingest fields
  • route miner.py, convo_miner.py, and mcp_server.py manual writes through the shared path
  • add focused tests for the three write surfaces

Why this PR

This is the Phase 1 slice discussed in #269.

It is intentionally limited to:

  • normalized ingest metadata
  • shared writer path
  • the three write entry points Milla called out:
    • miner.py
    • convo_miner.py
    • mcp_server.py

It does not attempt to land:

  • the searcher.py retrieval seam
  • hybrid retrieval
  • lexical candidate generation
  • query-aware reranking
  • graph-first retrieval

Metadata contract implemented here

This patch adds/stamps the following normalized metadata fields through the shared path.

Required fields written by all three Phase 1 entry points

Field Type Meaning
wing string top-level palace grouping
room string local topic grouping
source_file string original file/path when available, else empty string
chunk_index integer chunk position within the source grouping
added_by string writer identity / ingest path
filed_at ISO datetime string when the drawer was filed
closet_id string stable closet/group identifier for sibling drawers
source_group_id string source grouping key used to derive closet identity
source_type string origin type for the drawer
hall string metadata-only hall classification
memory_type string metadata-only memory classification
importance integer default ranking prior placeholder
confidence float default confidence prior placeholder
content_hash string content digest for dedup / incremental sync work
source_updated_at ISO datetime string or empty string source freshness signal when available

Source-type values used in this PR

Entry point source_type hall memory_type
miner.py project_file hall_project project_chunk
convo_miner.py conversation_file hall_conversation conversation_exchange or extracted memory type
mcp_server.py::tool_add_drawer manual_drawer hall_manual manual_drawer

Optional / source-specific additions in this PR

Field Type Where Notes
ingest_mode string convo_miner.py extra metadata, currently convos
extract_mode string convo_miner.py extra metadata, e.g. exchange / general
source_mtime float miner.py preserved from current main behavior for incremental re-mining

Backwards compatibility

This contract is additive.

Existing drawers may still only have the older baseline fields. This PR does not require a migration before reads continue to work.

The intent is:

  • normalize all new writes through one contract first
  • preserve current retrieval behavior
  • let later phases (searcher.py seam, hybrid retrieval, rerank, KG priors) build on a stable metadata substrate

Files in scope

  • mempalace/writer.py
  • mempalace/miner.py
  • mempalace/convo_miner.py
  • mempalace/mcp_server.py
  • tests/test_miner.py
  • tests/test_convo_miner.py
  • tests/test_mcp_server.py

Validation

Focused local validation on the three write surfaces:

  • tests/test_miner.py::test_project_mining
  • tests/test_convo_miner.py::test_convo_mining
  • tests/test_mcp_server.py::TestWriteTools::test_add_drawer

Result on the rebased branch:

  • 3 passed

Practical note:

  • first-time local mining in this environment triggers Chroma's ONNX model download, which makes full shell-based end-to-end runs noisy and slow
  • the intent of this PR is to keep the review surface on the write-path contract itself, with later retrieval behavior work following in separate PRs

Closes #269

@bgauryy

bgauryy commented Apr 9, 2026

Copy link
Copy Markdown

PR Review: feat(retrieval-foundation): normalize ingest metadata via shared writer

Executive Summary

Aspect Value
PR Goal Introduce a shared writer.py module that normalizes metadata construction and drawer writes across all three ingest entry points
Files Changed 13
Risk Level MEDIUM — Changes the core write path for all data ingest, expands metadata schema from 6 to 15+ fields
Review Mode Full
Review Effort 3 — Well-scoped refactor with clear intent
Recommendation REQUEST_CHANGES

Affected Areas: mempalace/writer.py (new), mempalace/miner.py, mempalace/convo_miner.py, mempalace/mcp_server.py, mempalace/cli.py, mempalace/hooks/

Business Impact: Normalizes all drawer metadata, enabling future retrieval improvements (hall-based graph traversal, content dedup via content_hash, importance/confidence scoring). Cosine space fix (#218) corrects similarity calculations.

Flow Changes: All three ingest surfaces (miner.py, convo_miner.py, mcp_server.py:tool_add_drawer) now delegate ID generation, metadata construction, and collection writes to writer.py. palace_graph.py:build_graph will start receiving hall metadata it previously lacked, enriching graph traversal.

Ratings

Aspect Score
Correctness 4/5
Security 4/5
Performance 5/5
Maintainability 4/5

PR Health

High Priority Issues

(Must fix before merge)

[Bug] #1: extra_metadata can silently overwrite core metadata fields

Location: mempalace/writer.py:125 | Confidence: HIGH

build_shared_metadata applies extra_metadata via metadata.update() after constructing the base dict. Any key in extra_metadata that collides with a core field (wing, room, filed_at, content_hash, etc.) will silently overwrite it. Currently only convo_miner.py passes extra_metadata with safe keys (ingest_mode, extract_mode), but this is a ticking time bomb for future callers.

     if extra_metadata:
-        metadata.update({key: value for key, value in extra_metadata.items() if value is not None})
+        _PROTECTED_KEYS = frozenset(metadata.keys())
+        for key, value in extra_metadata.items():
+            if value is not None:
+                if key in _PROTECTED_KEYS:
+                    raise ValueError(f"extra_metadata key '{key}' conflicts with core metadata field")
+                metadata[key] = value

[Architecture] #2: tool_diary_write bypasses the shared writer

Location: mempalace/mcp_server.py:507-520 | Confidence: HIGH

The PR routes tool_add_drawer through the shared writer but leaves tool_diary_write using a raw col.add() with its own handcrafted metadata dict. This creates schema inconsistency within the same module — diary entries lack source_type, memory_type, importance, confidence, closet_id, source_group_id, content_hash, and source_updated_at.

The PR description says it covers "the three write entry points Milla called out" — but tool_diary_write is also a write entry point in mcp_server.py. At minimum, add a # TODO: route through shared writer in Phase 2 comment, or route it now to avoid a second round of the same refactor.

+    # Route diary writes through the shared writer for metadata consistency
+    drawer_id = entry_id  # diary keeps its own ID scheme
+    metadata = build_shared_metadata(
+        wing=wing,
+        room=room,
+        content=entry,
+        added_by=agent_name,
+        filed_at=now.isoformat(),
+        source_type="diary_entry",
+        hall="hall_diary",
+        memory_type="diary_entry",
+        source_updated_at=now,
+        extra_metadata={"topic": topic, "agent": agent_name, "date": now.strftime("%Y-%m-%d")},
+    )
+    add_collection_drawer(col, drawer_id, entry, metadata)
-    col.add(
-        ids=[entry_id],
-        documents=[entry],
-        metadatas=[
-            {
-                "wing": wing,
-                "room": room,
-                "hall": "hall_diary",
-                "topic": topic,
-                "type": "diary_entry",
-                "agent": agent_name,
-                "filed_at": now.isoformat(),
-                "date": now.strftime("%Y-%m-%d"),
-            }
-        ],
-    )

Medium Priority Issues

(Should fix, not blocking)

[Bug] #3: Cosine space not applied to mempalace_compressed collection

Location: mempalace/cli.py:cmd_compress (line ~335 on main) | Confidence: MED

cmd_compress creates the compressed collection via client.get_or_create_collection("mempalace_compressed") without metadata={"hnsw:space": "cosine"}. If compressed entries are ever queried with the same 1 - dist similarity formula used in searcher.py, the results will be incorrect (L2 distances instead of cosine).

-        comp_col = client.get_or_create_collection("mempalace_compressed")
+        comp_col = client.get_or_create_collection(
+            "mempalace_compressed", metadata={"hnsw:space": "cosine"}
+        )

[Architecture] #4: No migration path documented for existing palaces

Location: Project-level | Confidence: HIGH

The cosine space fix only applies when collections are created (create_collection, get_or_create_collection). Existing palaces opened via get_collection retain L2 distance. The only migration path is mempalace repair (which now correctly recreates with cosine), but this isn't documented in the PR description, README, or changelog.

Users with existing palaces will continue getting wrong similarity scores until they manually run repair. Consider:

  • Adding a note in the PR description about the migration path
  • Logging a warning when an existing collection lacks cosine space metadata
  • Mentioning mempalace repair in the hooks README or a migration note

[Bug] #5: build_drawer_id edge case with empty source_file

Location: mempalace/writer.py:75-80 | Confidence: MED

build_drawer_id branches on source_file is not None. If source_file="" (empty string, not None), it enters the file-based branch and seeds the hash with f""{chunk_index}" → just the stringified chunk_index. Two different callers with empty source_file and the same chunk_index would generate identical drawer IDs, causing silent dedup/collision.

 def build_drawer_id(
     wing: str,
     room: str,
     *,
     source_file: Optional[str] = None,
     chunk_index: int = 0,
     content: Optional[str] = None,
     filed_at: Optional[str] = None,
 ) -> str:
-    if source_file is not None:
+    if source_file:
         seed = f"{source_file}{chunk_index}"
     else:
         seed = f"{(content or '')[:100]}{filed_at or ''}"
     return f"drawer_{wing}_{room}_{_md5_hexdigest(seed)[:16]}"

Low Priority Issues

(Nice to have)

[Architecture] #6: Scope creep — hooks CLI packaging bundled with metadata normalization

Location: mempalace/cli.py, mempalace/hooks/__init__.py, tests/test_hooks.py | Confidence: HIGH

~95 lines of hooks CLI commands + ~75 lines of tests are included in a PR described as "intentionally limited to normalized ingest metadata." The hooks feature is orthogonal and would benefit from a separate PR for clean review and independent revertability.


Flow Impact Analysis

BEFORE:
  miner.py ──────────┐
  convo_miner.py ─────┤──► collection.add() [each with own metadata dict]
  mcp_server.py ──────┘

AFTER:
  miner.py ──────────┐
  convo_miner.py ─────┤──► writer.py ──► collection.add() [normalized metadata]
  mcp_server.py ──────┘       │
    (tool_add_drawer)         ├── build_drawer_id()
                              ├── build_shared_metadata()
                              └── add_collection_drawer()

  mcp_server.py ──────────────────────► collection.add() [raw, NOT routed]
    (tool_diary_write)                   ⚠️ BYPASSES shared writer

Downstream consumers verified safe:

  • searcher.py — Uses .get("wing", "?"), .get("room", "?"), .get("source_file", "?") with defaults. New fields are purely additive. No breakage.
  • palace_graph.py — Reads hall via .get("hall", ""). Previously empty for all drawers; now populated for new drawers. Graph becomes richer over time. No breakage.
  • palace_graph.py reads date via .get("date", "") — this field was never in the miner/convo_miner metadata, only in diary entries. The new filed_at field doesn't map to date. Pre-existing gap, not introduced by this PR.

Drawer ID backward compatibility: Verified. The old seed source_file + str(chunk_index) equals the new f"{source_file}{chunk_index}" for all cases where source_file is a non-empty string. IDs are stable across the refactor.


Created by Octocode MCP https://octocode.ai

@milla-jovovich

Copy link
Copy Markdown
Collaborator

Hey @slapglif — I've taken a look and ran this through CLI and the scope is exactly right. Thank you for taking the time to carve Phase 1 down to the three write surfaces instead of the broader rewrite framing — that's the incrementalism we needed.

Two things before a full review:

  1. The PR is currently conflicting against main after the v3.1.0 bump — a quick rebase will get it reviewable. While you're in there, @bgauryy's review flagged the metadata-schema expansion (6 → 15+ fields) as the one place the contract needs more explicit docs. Even a short section in the PR body that lists the new fields, their types, and which are required vs. optional for backwards compatibility would make this a much easier land.

  2. I want to loop in one of our folks who's been looking at the same metadata contract question from a slightly different angle before we approve the merge. Not a delay — just a "make sure we don't land two overlapping contracts in the same week" check. Should be resolved within a couple of days.

The order you proposed in #269 (metadata contract → shared writer → retrieval seam → lexical+dense → rerank → KG priors → verbatim drawers) is the right order. Let's make this the first step.

Thank you from Milla and Lu✨

@slapglif
slapglif force-pushed the feat/hybrid-phase1-metadata branch from 1bddfb5 to 2a77a7a Compare April 9, 2026 19:45
@slapglif

slapglif commented Apr 9, 2026

Copy link
Copy Markdown
Author

@milla-jovovich thanks — I addressed the valid parts directly and updated the draft branch.

What I changed just now:

  • rebased the branch onto current main so the PR is reviewable again
  • kept the PR scoped to the Phase 1 write-path contract only
  • fixed a small semantic mismatch in source_updated_at for manual drawers so empty/manual writes stay empty rather than picking up a synthetic timestamp

I also agree with @bgauryy's most important concern:

  • the metadata contract needs to be more explicit in the PR body

That concern is valid.
What I do not want to do in this PR is broaden scope into unrelated follow-up work.
So my plan for this draft is:

  • keep the code surface Phase-1-only
  • make the contract/documentation explicit in the PR body
  • keep diary/search/hybrid/retrieval-seam work out of this patch

On the other review points:

  • routing diary writes through the shared writer is a reasonable future cleanup, but I think it is out of scope for the Phase 1 patch you asked for, since you explicitly scoped the first step to miner.py, convo_miner.py, and mcp_server.py write-path normalization around the shared contract
  • the old hooks/CLI spillover was valid as criticism of the earlier draft state; that is exactly why I trimmed/rebased the branch down to the Phase 1 contract slice

I also took your note seriously about not landing overlapping metadata contracts in the same week.

I’m not sure which teammate you meant to loop in from your comment, so I don’t want to guess and tag the wrong person. If you want me to pull them in explicitly, I’m happy to — just point me at the handle and I’ll coordinate directly.

I have a lot of availability right now, so if you or the other reviewer want this split, tightened, or amended further, I can turn changes around quickly.

If this Phase 1 cut now looks acceptable, I’m also ready to move immediately into the next step in the sequence once you give the directive.

@web3guru888 web3guru888 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Early directional feedback on this draft — this is exactly the kind of foundational work MemPalace needs. We normalize ingest metadata in our own pipeline (208 discoveries, 710 KG entities across 5 domains) so we have strong opinions here.

The Metadata Contract — Very Well Thought Out

The 15-field shared contract is comprehensive. A few observations:

Fields we independently found essential:

  • content_hash — we use this for tiered dedup (hard=0.86, soft=0.55 similarity thresholds). Having it in the base contract is great.
  • source_type / hall / memory_type — we use similar discriminators for retrieval profiling. Being able to filter by source type before similarity search is a huge performance win at scale.
  • closet_id / source_group_id — this is the right abstraction for grouping related drawers (e.g., all chunks from one file).

Fields worth reconsidering:

  • importance: int = 3 and confidence: float = 1.0 as defaults — these are placeholder values that will be written to every drawer. Downstream consumers can't distinguish "importance=3 because it was scored" from "importance=3 because it's the default." Consider using None/omitting the field when no scoring has been done, so searchers can tell the difference. (ChromaDB metadata doesn't support null, but you could use -1 as sentinel or simply not include the key.)
  • source_updated_at — the resolve_source_updated_at() function in writer.py has a subtle issue: when source is a file path string, it calls Path(source).exists() and stat().st_mtime. But in convo_miner.py, you're passing source_updated_at=source_file which is the source file path. This means every drawer creation triggers a filesystem stat call. At scale (thousands of chunks from hundreds of files), this adds up. Consider resolving the mtime once per file in the caller and passing the ISO string directly.

The writer.py Module — Clean Abstraction

build_shared_metadata(), build_drawer_id(), add_collection_drawer() — this is exactly the right factoring. One write path, one metadata shape, three entry points.

Minor: hash_content() uses MD5 (_md5_hexdigest). PR #293 and #380 both switched to SHA-256 for drawer IDs and content hashes. MD5 is fine for non-security hashing (dedup), but for consistency with the emerging convention in the codebase, SHA-256 might be worth matching.

Regression Concerns

  1. WAL logging removed from mcp_server.py: The draft strips out the write-ahead log (_wal_log()) and all security hardening (sanitize_name(), sanitize_content()) from the MCP server. These were added in #293 for good reason. The normalized writer should probably call through the sanitizers, not bypass them.

  2. --palace CLI arg removed: The draft removes argparse and _parse_args() from mcp_server.py, hardcoding _kg = KnowledgeGraph() at module level. This breaks the --palace /custom/path workflow.

  3. Protocol version negotiation removed: Replacing the version list with a hardcoded "2024-11-05" will break clients using newer MCP protocol versions.

  4. Symlink/MAX_FILE_SIZE guards removed from scan_project() and scan_convos(): These are security hardening that should be preserved.

Direction

The core idea — a shared writer module normalizing all ingest metadata — is the right architecture. The metadata contract table in the PR description is excellent documentation. For the final version, I'd suggest:

  • Keep the writer abstraction
  • Preserve existing security/audit layers (WAL, sanitizers, symlink guards)
  • Resolve the mtime-per-chunk performance issue
  • Coordinate with #293 and #380 which touch the same files

Looking forward to seeing this evolve.

🔭 Reviewed as part of the MemPalace-AGI integration project — autonomous research with perfect memory. Community interaction updates are posted regularly on the dashboard.

milla-jovovich
milla-jovovich previously approved these changes Apr 10, 2026

@milla-jovovich milla-jovovich left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good

@slapglif
slapglif marked this pull request as ready for review April 10, 2026 16:36
@slapglif
slapglif requested a review from bensig as a code owner April 10, 2026 16:36
Copilot AI review requested due to automatic review settings April 10, 2026 16:36

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a shared write/metadata path to normalize ingest metadata across the project miner, conversation miner, and MCP write surface, and adds targeted tests to validate the normalized metadata contract.

Changes:

  • Add mempalace/writer.py with helpers to build normalized metadata, stable IDs, and write drawers via a single path.
  • Route miner.py, convo_miner.py, and mcp_server.py write operations through the shared metadata builder.
  • Add focused tests asserting normalized metadata for project mining, convo mining, and MCP manual writes.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
mempalace/writer.py New shared metadata/id/write helpers for normalized ingest fields.
mempalace/miner.py Switch project mining writes to shared writer + metadata contract.
mempalace/convo_miner.py Switch convo mining writes to shared writer + metadata contract.
mempalace/mcp_server.py Switch MCP “add drawer” writes to shared writer + metadata contract; adjusts protocol handling.
tests/test_miner.py Assert normalized metadata fields are present for mined project drawers.
tests/test_convo_miner.py Assert normalized metadata fields are present for mined conversation drawers.
tests/test_mcp_server.py Assert normalized metadata fields for MCP manual drawers; minor test adjustments.
Comments suppressed due to low confidence (3)

mempalace/miner.py:629

  • Room counting in mine now recomputes room = detect_room(filepath, "", ...) after process_file. Because detect_room uses content keyword scoring as a fallback, passing an empty string can produce a different room than the one actually used during ingest, leading to incorrect room_counts output. Prefer returning the chosen room from process_file (as before) or computing it once with the real content.
    for i, filepath in enumerate(files, 1):
        drawers = process_file(
            filepath=filepath,
            project_path=project_path,
            collection=collection,
            wing=wing,
            rooms=rooms,

mempalace/mcp_server.py:715

  • handle_request now hard-codes protocolVersion to "2024-11-05" and removes version negotiation. This will break clients/tests that expect newer supported versions to be echoed when provided. Consider restoring an explicit supported-version list and negotiation logic (choose client version if supported, otherwise fall back predictably).
    if method == "initialize":
        return {
            "jsonrpc": "2.0",
            "id": req_id,
            "result": {
                "protocolVersion": "2024-11-05",
                "capabilities": {"tools": {}},
                "serverInfo": {"name": "mempalace", "version": __version__},
            },
        }

mempalace/mcp_server.py:312

  • The write path no longer logs deletions to the write-ahead log (WAL). Given this tool is a write surface from an external MCP client, removing the audit trail reduces the ability to investigate/rollback poisoning or accidental deletes. Consider restoring WAL logging (or an equivalent append-only audit log) for destructive operations like tool_delete_drawer.
def tool_delete_drawer(drawer_id: str):
    """Delete a single drawer by ID."""
    col = _get_collection()
    if not col:
        return _no_palace()
    existing = col.get(ids=[drawer_id])
    if not existing["ids"]:
        return {"success": False, "error": f"Drawer not found: {drawer_id}"}
    try:
        col.delete(ids=[drawer_id])
        logger.info(f"Deleted drawer: {drawer_id}")
        return {"success": True, "drawer_id": drawer_id}
    except Exception as e:
        return {"success": False, "error": str(e)}

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread mempalace/miner.py Outdated
Comment on lines +460 to +463
# Skip if already filed
source_file = str(filepath)
if not dry_run and file_already_mined(collection, source_file, check_mtime=True):
return 0, None
if not dry_run and file_already_mined(collection, source_file):
return 0

Copilot AI Apr 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

process_file now skips any file that has ever been mined (file_already_mined(collection, source_file)), which means modified source files will never be re-mined. This regresses the prior check_mtime=True behavior (and conflicts with the PR description mentioning source_mtime support). Consider restoring mtime-based invalidation (e.g., persist source_mtime/mtime and compare) so edits trigger re-ingest.

Copilot uses AI. Check for mistakes.
Comment thread mempalace/miner.py
Comment on lines 418 to 441
def add_drawer(
collection, wing: str, room: str, content: str, source_file: str, chunk_index: int, agent: str
):
"""Add one drawer to the palace."""
drawer_id = f"drawer_{wing}_{room}_{hashlib.sha256((source_file + str(chunk_index)).encode()).hexdigest()[:24]}"
drawer_id = build_drawer_id(wing, room, source_file=source_file, chunk_index=chunk_index)
metadata = build_shared_metadata(
wing=wing,
room=room,
content=content,
source_file=source_file,
chunk_index=chunk_index,
added_by=agent,
source_type="project_file",
hall="hall_project",
memory_type="project_chunk",
source_updated_at=source_file,
)
try:
metadata = {
"wing": wing,
"room": room,
"source_file": source_file,
"chunk_index": chunk_index,
"added_by": agent,
"filed_at": datetime.now().isoformat(),
}
# Store file mtime so we can detect modifications later.
try:
metadata["source_mtime"] = os.path.getmtime(source_file)
except OSError:
pass
collection.upsert(
documents=[content],
ids=[drawer_id],
metadatas=[metadata],
)
add_collection_drawer(collection, drawer_id, content, metadata)
return True
except Exception:
except Exception as e:
if "already exists" in str(e).lower() or "duplicate" in str(e).lower():
return False
raise

Copilot AI Apr 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add_drawer no longer records source_mtime, and writes via collection.add (through add_collection_drawer). With deterministic IDs based on (source_file, chunk_index), a changed file’s chunk will keep the same ID and add will fail, preventing updates. If re-mining is expected, switch this path to upsert (or delete existing IDs for the file before re-adding) and/or include source_mtime in metadata to support incremental refresh.

Copilot uses AI. Check for mistakes.
Comment thread mempalace/miner.py Outdated
Comment on lines +45 to +69
SKIP_DIRS = {
".git",
"node_modules",
"__pycache__",
".venv",
"venv",
"env",
"dist",
"build",
".next",
"coverage",
".mempalace",
".ruff_cache",
".mypy_cache",
".pytest_cache",
".cache",
".tox",
".nox",
".idea",
".vscode",
".ipynb_checkpoints",
".eggs",
"htmlcov",
"target",
}

Copilot AI Apr 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

miner.py now duplicates SKIP_DIRS, get_collection, and file_already_mined instead of reusing mempalace/palace.py. This risks configuration drift (and drops the palace_path chmod hardening present in palace.get_collection). Consider importing and reusing the shared implementations to keep behavior consistent across entry points.

Copilot uses AI. Check for mistakes.
Comment thread mempalace/convo_miner.py
Comment on lines 244 to 256
def scan_convos(convo_dir: str) -> list:
"""Find all potential conversation files."""
convo_path = Path(convo_dir).expanduser().resolve()
files = []
for root, dirs, filenames in os.walk(convo_path):
dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
for filename in filenames:
if filename.endswith(".meta.json"):
continue
filepath = Path(root) / filename
if filepath.suffix.lower() in CONVO_EXTENSIONS:
# Skip symlinks and oversized files
if filepath.is_symlink():
continue
try:
if filepath.stat().st_size > MAX_FILE_SIZE:
continue
except OSError:
continue
files.append(filepath)
return files

Copilot AI Apr 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

scan_convos no longer skips symlinks or enforces a max file size when walking the conversation directory. This can ingest unexpectedly large files or symlinks to special devices. Consider restoring the symlink/size checks (similar to what project mining used to do) before adding the file to the list.

Copilot uses AI. Check for mistakes.
Comment thread mempalace/convo_miner.py Outdated
Comment on lines +220 to +237
def get_collection(palace_path: str):
os.makedirs(palace_path, exist_ok=True)
client = chromadb.PersistentClient(path=palace_path)
try:
return client.get_collection("mempalace_drawers")
except Exception:
return client.create_collection(
"mempalace_drawers", metadata={"hnsw:space": "cosine"}
)


def file_already_mined(collection, source_file: str) -> bool:
try:
results = collection.get(where={"source_file": source_file}, limit=1)
return len(results.get("ids", [])) > 0
except Exception:
return False

Copilot AI Apr 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

convo_miner.py now defines its own get_collection/file_already_mined and SKIP_DIRS instead of using mempalace/palace.py. This increases drift risk and bypasses any shared hardening (e.g., directory perms). Prefer reusing the shared palace.get_collection and palace.file_already_mined helpers.

Copilot uses AI. Check for mistakes.
Comment thread mempalace/mcp_server.py
Comment on lines 253 to 259
def tool_add_drawer(
wing: str, room: str, content: str, source_file: str = None, added_by: str = "mcp"
):
"""File verbatim content into a wing/room. Checks for duplicates first."""
try:
wing = sanitize_name(wing, "wing")
room = sanitize_name(room, "room")
content = sanitize_content(content)
except ValueError as e:
return {"success": False, "error": str(e)}

col = _get_collection(create=True)
if not col:
return _no_palace()

Copilot AI Apr 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tool_add_drawer no longer validates/sanitizes wing, room, or content (the sanitize_name/sanitize_content checks were removed). Since these inputs come from an external MCP client, this is a security and robustness regression (path traversal chars, null bytes, excessive length, etc.). Reintroduce the shared sanitizers from mempalace.config before building IDs/metadata and writing to Chroma.

Copilot uses AI. Check for mistakes.
Comment thread mempalace/mcp_server.py Outdated
Comment on lines +261 to +289
# Duplicate check
dup = tool_check_duplicate(content, threshold=0.9)
if dup.get("is_duplicate"):
return {
"success": False,
"reason": "duplicate",
"matches": dup["matches"],
}

# Idempotency: if the deterministic ID already exists, return success as a no-op.
try:
existing = col.get(ids=[drawer_id])
if existing and existing["ids"]:
return {"success": True, "reason": "already_exists", "drawer_id": drawer_id}
except Exception:
pass
filed_at = datetime.now().isoformat()
drawer_id = build_drawer_id(
wing,
room,
content=content,
filed_at=filed_at,
)
metadata = build_shared_metadata(
wing=wing,
room=room,
content=content,
source_file=source_file or "",
chunk_index=0,
added_by=added_by,
filed_at=filed_at,
source_type="manual_drawer",
hall="hall_manual",
memory_type="manual_drawer",
source_updated_at="",
)

Copilot AI Apr 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

tool_add_drawer dropped idempotency: drawer IDs are now time-seeded (filed_at), and duplicates return {success: False, reason: "duplicate"}. This is a breaking behavior change vs the prior "already_exists" no-op semantics and makes retries non-idempotent without relying on vector duplicate detection. Consider restoring deterministic IDs (e.g., based on content_hash + wing/room) and returning a successful no-op when the same content has already been filed.

Copilot uses AI. Check for mistakes.
Comment thread mempalace/writer.py
Comment on lines +129 to +134
def add_collection_drawer(collection, drawer_id: str, content: str, metadata: Dict[str, Any]):
collection.add(
ids=[drawer_id],
documents=[content],
metadatas=[metadata],
)

Copilot AI Apr 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

add_collection_drawer uses collection.add, which fails on existing IDs and prevents updating content/metadata for deterministic IDs (e.g., file re-mines). If callers need idempotent writes or refresh-on-change semantics, consider using upsert here (or offering an upsert option) and handling duplicates consistently in one place.

Copilot uses AI. Check for mistakes.
Comment thread tests/test_mcp_server.py
assert w["success"] is True
assert w["agent"] == "TestAgent"

col = _get_collection(palace_path)

Copilot AI Apr 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line assigns the (client, collection) tuple returned by _get_collection to col and then never uses it. Either unpack the return value or remove the line to avoid confusion and keep the test intent clear.

Suggested change
col = _get_collection(palace_path)

Copilot uses AI. Check for mistakes.
Comment thread mempalace/miner.py
Comment on lines 503 to 508
drawers_added += 1

return drawers_added, room
return drawers_added


# =============================================================================

Copilot AI Apr 10, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

scan_project no longer enforces the prior symlink skip and max file size guardrails. Without these, a repository can include very large files (or symlinks to special devices) that will be read and chunked, impacting performance and potentially causing unexpected behavior. Consider reinstating the symlink and size checks (or an equivalent allowlist) as part of scanning.

Copilot uses AI. Check for mistakes.
@slapglif

slapglif commented Apr 11, 2026

Copy link
Copy Markdown
Author

Addressed the high-confidence, in-scope review concerns and pushed them to feat/hybrid-phase1-metadata in commit 1882fca.

What I changed

  • restored MCP protocol negotiation compatibility with a supported-version list and initialize fallback behavior
  • fixed tools/call handling for arguments: null
  • prevented extra_metadata from silently overwriting core metadata fields in writer.py
  • fixed the empty source_file edge case in build_drawer_id
  • fixed convo_miner.py so the normalized shared metadata path is actually used for writes
  • realigned tests to the intended Phase 1 scope and existing duplicate-write behavior

What I intentionally did not change

  • routing tool_diary_write through the shared writer
  • WAL/audit behavior
  • sanitizer plumbing
  • broader MCP/server architecture changes
  • retrieval or diary follow-on work

Reason: I kept this limited to issues that looked both valid and clearly in-bounds for the Phase 1 metadata-contract PR.

Validation run

  • tests/test_miner.py::test_project_mining
  • tests/test_miner.py::test_file_already_mined_checks_source_file_only
  • tests/test_convo_miner.py::test_convo_mining
  • tests/test_mcp_server.py::TestHandleRequest::test_initialize
  • tests/test_mcp_server.py::TestHandleRequest::test_initialize_negotiates_client_version
  • tests/test_mcp_server.py::TestHandleRequest::test_initialize_negotiates_older_supported_version
  • tests/test_mcp_server.py::TestHandleRequest::test_initialize_unknown_version_falls_back_to_latest
  • tests/test_mcp_server.py::TestHandleRequest::test_initialize_missing_version_uses_oldest
  • tests/test_mcp_server.py::TestHandleRequest::test_null_arguments_does_not_hang
  • tests/test_mcp_server.py::TestWriteTools::test_add_drawer
  • tests/test_mcp_server.py::TestWriteTools::test_add_drawer_duplicate_detection

Result: 11 passed

If helpful, I can also reply inline to the specific review threads, but I wanted one clean summary on the PR first.

@bensig
bensig changed the base branch from main to develop April 11, 2026 22:22
@bensig
bensig requested a review from igorls as a code owner April 11, 2026 22:22
@slapglif
slapglif force-pushed the feat/hybrid-phase1-metadata branch from 1882fca to 1e4d3ba Compare April 12, 2026 06:48
@slapglif

slapglif commented Apr 12, 2026

Copy link
Copy Markdown
Author

Validated the current Phase 1 metadata branch locally against the focused write-surface suite after refreshing to the latest fork head.

Validation run:

  • python3 -m venv .venv-e2e
  • .venv-e2e/bin/pip install -e '.[dev]'
  • .venv-e2e/bin/pytest tests/test_miner.py tests/test_convo_miner.py tests/test_mcp_server.py -q

Result:

  • 47 passed

Notes:

  • This still appears as CONFLICTING / DIRTY in GitHub's mergeability metadata right now.
  • A local merge-tree check against current upstream/develop did not surface textual conflict markers, so this may just need GitHub to recompute after a fresh push/rebase path.

@slapglif
slapglif force-pushed the feat/hybrid-phase1-metadata branch from 1e4d3ba to c705c3d Compare April 12, 2026 19:22
@slapglif slapglif closed this Apr 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Hybrid retrieval direction: make the palace wiring production-real without changing the conceptual design

5 participants