Commit Graph

64 Commits

Author SHA1 Message Date
Arnold Wender 8c2856266f fix(encoding): extend non-ASCII console sweep to the 11 remaining modules (#1034)
The original sweep in this PR was scoped against develop as of April. Since
then upstream added console output carrying the same characters, so the fix
had drifted into covering roughly half of the affected surface.

Re-measured against current develop and replaced the remaining occurrences of
the same five characters this PR already targets -- U+2014 em-dash, U+2500 box
drawing, U+2192 arrow, U+25CF/U+25CB circles -- on every print(), input() and
logger call reachable from a terminal:

  repair.py (10), cli.py (7), format_miner.py (7), mcp_server.py (5),
  miner.py (2), onboarding.py (2), migrate.py (2), searcher.py (2),
  service.py (1), embedding.py (1), dialect.py (1)

Substitutions match the ones already used in this branch: em-dash -> "--",
box drawing -> "-", arrow -> "->", filled/empty circles -> "#"/".".

Four assertions matched the previous literals and were updated alongside the
strings they cover (test_cli, test_miner x2, test_repair).

Docstrings, comments and stored content are deliberately untouched -- only
what reaches a terminal, where GBK/CP1252 consoles raise UnicodeEncodeError.
2026-08-08 22:29:59 +02:00
KeilerHirsch 8b5e372951 fix: preflight HNSW divergence before every col.count() call site the audit found
hnsw_capacity_status() (chroma.py) exists precisely to preflight the
#1222 SIGSEGV/pyo3-panic class before anything touches the HNSW
segment, but repo-wide it was wired into only 4 call sites while raw
count()/collection.count() is called at 20+ others -- a bare
except Exception around count() cannot catch a native crash, since
the process dies regardless of any Python try/except. This wires the
existing, already-tested probe into the 7 remaining call sites the
audit identified as CRITICAL:

- #89 palace.py::_enforce_embedder_identity -- the universal
  get_collection() chokepoint every tool passes through, previously
  guarded only by except Exception. Highest leverage: skips this
  bookkeeping-only check on divergence instead of risking count().
- #90 migrate.py::migrate -- routes straight to the same
  SQLite-extraction fallback the except branch already used, instead
  of ever reaching col.count() when diverged.
- #91 repair.py::scan_palace / prune_corrupt -- both abort with the
  existing from-sqlite recovery guidance instead of opening the
  collection.
- #10 repair.py::rebuild_index -- preflights divergence alongside its
  existing sqlite-integrity and poisoned-max-seq-id preflights, before
  opening the collection.
- #13 repair.py::rebuild_index never rebuilt or reported on the
  closets collection -- now warns when closets is still diverged
  after a drawers-only rebuild, pointing at --mode from-sqlite instead
  of letting 'Repair complete' stand unqualified.
- #92 dedup.py::get_source_groups -- takes an optional palace_path
  (threaded from both callers) to preflight before count(); omitted by
  existing tests, which keep their pre-existing behavior.
- #93 miner.py::status -- preflights before the ChromaDB-client
  fallback path (used when the direct sqlite read is unavailable).

7 new regression tests, each confirmed failing against the pre-fix
code (via git stash of the source files only) and passing after the
fix. One existing dedup.py test updated for the new palace_path kwarg
in its call-signature assertion. Full suite: 3154 passed, 1 unrelated
pre-existing flake (test_mcp_server.py peer-writer-lock module-global
state leaking across test files in full-suite ordering -- this diff
never touches mcp_server.py).
2026-07-28 17:18:02 +02:00
mvalentsev 2b5dc6e8cb perf(miner): O(N) incremental line-number tally in chunk_text (#2054)
chunk_text recomputed line_start/line_end with a full-prefix
content.count("\n", 0, pos) per chunk (O(N*K) overall), so large files
ground for days. Keep the emitted values byte-identical but tally
newlines incrementally over the newly-scanned span (O(N) total), with a
from-scratch fallback that keeps every value exact.
2026-07-21 20:56:46 +05:00
Igor Lins e Silva f68cb8d682 fix: scope derived graph state to explicit palace 2026-07-14 19:22:06 -03:00
Lochness b70f06a69e feat: add exclude_patterns config key to mempalace.yaml
Allow projects to specify .gitignore-style patterns that the miner should
skip, without relying on .gitignore for mining control.

A new optional exclude_patterns list in mempalace.yaml is parsed by the
existing GitignoreMatcher class via a new from_patterns() classmethod —
same syntax, same semantics as .gitignore, no new dependency.

  exclude_patterns:
    - '*.md'
    - '*.yaml'
    - 'docs/'        # dir-only: prunes entire tree without descending
    - 'dist/'
    - 'coverage/'

Key behaviour:
- Patterns follow .gitignore rules: anchoring (/pattern), dir-only
- dirs[:] pruning via GitignoreMatcher.matches(..., is_dir=True) so
  excluded subtrees are never walked
- Checked after .gitignore filtering; force_include (--include-ignored)
  bypasses exclude_patterns
- Pre-scanned files lists (init double-scan optimisation) are filtered too
- Backwards compatible: omitting exclude_patterns changes nothing

Changes:
- GitignoreMatcher.from_patterns(): new classmethod, same rule parser as
  from_dir(), reads from a list instead of a file on disk
- scan_project(): builds one exclude_matcher before os.walk; used for
  both dirs[:] pruning and per-file filtering
- _mine_impl(): applies the exclude matcher to pre-scanned files lists
  when provided by the caller
- tests/test_miner.py: three new tests
    test_scan_project_exclude_patterns_skips_matching_files
    test_scan_project_exclude_patterns_prunes_entire_directory
    test_scan_project_exclude_patterns_include_ignored_bypasses_exclusion
2026-07-07 15:29:30 -04:00
Igor Lins e Silva aff1ee8255
Merge pull request #924 from mvalentsev/fix/mine-log-oversized-skips
fix(mine): log warning when files exceed MAX_FILE_SIZE (#923)
2026-07-06 08:18:55 -03:00
Pim Messelink 6cec591c17 feat: add LaTeX (.tex, .bib) to readable and prose extensions
LaTeX source files and BibTeX bibliographies are prose-rich content that
benefits from both palace mining and entity detection. Adds the two
extensions to the two extension lists most relevant to them, each with a
matching test.

- ``mempalace/miner.py:READABLE_EXTENSIONS`` — ``.tex`` / ``.bib`` join the
  mining allowlist (parallel to the Swift/Kotlin PR #1368 and the PHP
  ecosystem PR #1819).

- ``mempalace/entity_detector.py:PROSE_EXTENSIONS`` — ``.tex`` / ``.bib``
  also join the *preferred* entity-detection bucket alongside ``.md`` /
  ``.rst`` / ``.csv``, NOT the broader code-file fallback. The reason
  ``PROSE_EXTENSIONS`` exists separately is documented in-code:
  programming-language files have lots of capitalized identifiers (class
  names, function names) that produce false-positive person matches.
  LaTeX/BibTeX don't have that problem — they're typesetting languages
  for prose documents. ``.bib`` in particular is almost entirely author
  names, one of the highest real-entity densities of any file type the
  detector scans.

Tests follow the patterns established by the prior extension PRs:
``tests/test_miner.py::test_scan_project_includes_latex_files`` mirrors
the Swift/Kotlin scan tests, and
``tests/test_entity_detector.py::test_scan_for_detection_includes_latex_prose``
mirrors ``test_scan_for_detection_finds_prose``. The existing
``test_prose_extensions`` was extended to assert the two new entries.

Full env-cleared suite: 3216 passed, 20 skipped. ``ruff check .`` and
``ruff format --check .`` both clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KC5Qsknh2zFRtRvVyjXiTA
2026-06-29 07:25:50 +00:00
mvalentsev c4ce4c89d2 fix(mine): route SKIP to stderr and cover stat() OSError arm (#923)
The original commit printed SKIP for oversized files to stdout but the
sibling SKIP for symlinks in the same scan_project / scan_convos already
went to stderr. Align the new line with that convention.

Also adds a SKIP-with-error log for the except OSError arm right below the
size check. Files whose stat() raises (permission denied, racing delete,
broken symlink that survived the earlier is_symlink check) were the same
bug class as the silent oversize drop.

Tests switched from captured.out to .err and tightened to the full
template; new test covers the OSError arm via a selective Path.stat
monkeypatch with a follow_symlinks gate for Python 3.10+.
2026-06-25 22:20:03 +05:00
mvalentsev c7428c3b71 fix(mine): log warning when files exceed MAX_FILE_SIZE (#923)
Both miner.py and convo_miner.py silently skip files larger than the
10 MB limit with a bare continue. This is especially painful for
conversation mining where long Claude/ChatGPT exports routinely
exceed 10 MB and vanish with no trace.

Print a SKIP warning per oversized file, matching the existing format
in split_mega_files.py.
2026-06-25 22:20:03 +05:00
Igor Lins e Silva d4391abb59
Merge pull request #1368 from EVSalomon/feat/add-support-swift-kotlin
feat(miner): add support for Swift and Kotlin file extensions
2026-06-18 12:37:40 -03:00
ManuelReschke c2f42cd57e feat(miner): add PHP ecosystem file extensions 2026-06-18 10:31:18 +02:00
mvalentsev c9dc4c466d fix(miner): count only new work toward --limit, not already-mined skips (#1535) 2026-06-06 17:56:08 +05:00
Igor Lins e Silva ed772a01f9 Merge remote-tracking branch 'origin/develop' into fix/wing-normalize-strip-sep 2026-06-06 03:24:08 -03:00
Igor Lins e Silva ab669fd3b1 test(miner): compare default wing to normalized dirname, not raw name
test_load_config_uses_defaults_when_yaml_missing asserted the derived
wing equals project_root.name. That only held when the random tempfile
name had no separators; tempfile's alphabet includes '_', so once
normalize_wing_name strips leading/trailing '_' (this PR), a name like
'tmpXXXX_' makes the derived wing diverge from the raw name. Compare
against normalize_wing_name(project_root.name) — the actual contract —
which is deterministic across platforms. (Surfaced as a test-windows
failure on this PR, but it was cross-platform flaky.)
2026-06-06 01:23:09 -03:00
Brian potter ce46747282 fix(status): count drawers from sqlite instead of cold-loading the HNSW index
`mempalace status` opened the ChromaDB collection purely to tally drawers by
wing/room — and opening it cold-loads the HNSW vector index. On a 398k-drawer
palace that load costs ~60s of CPU on every invocation, even though the counts
live in chroma.sqlite3's relational tables (`repair-status` already reads them
in <1s; `status` was the outlier).

Read the wing/room histogram directly from chroma.sqlite3 via a new
`_sqlite_wing_room_counts` helper, falling back to the existing ChromaDB-client
path when the sqlite read is unavailable (missing DB, un-bootstrapped
collection, sustained writer lock, or an unexpected schema) — preserving the
state-specific guidance for absent/empty palaces.

Measured on a 398,315-drawer / 3.5GB palace: status CPU ~60s -> ~1s.

Review hardening:
- PRAGMA busy_timeout so a transient checkpoint lock is waited out rather than
  instantly demoted to the slow path; a sustained lock still falls back.
- COALESCE over string/int/float so a numeric wing/room matches the ChromaDB
  path instead of dropping to "?".
- Explicit `s.scope = 'METADATA'` so the segment join can't silently
  double-count on a future ChromaDB layout.

Tests: exact-tally (anti fan-out), no-cold-load regression (proven failable by
reverting the fix), numeric-metadata, partial-metadata "?" bucketing,
locked-DB fallback, and collection-absent None routing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 02:08:19 -05:00
Milla J 13c38ac588 fix(palace): file_already_mined iterates all groups when source_file has multiple parent_drawer_id mining passes
Under the additive-mining model (drawer history preserved across re-mines),
a single source_file can have multiple parent_drawer_id groups in the
palace, one per mining pass. Each group carries its own stored
source_mtime and normalize_version.

`file_already_mined` previously used `collection.get(where={"source_file": X},
limit=1)` in the `extract_mode is None` branch and only checked the single
returned row's metadata. ChromaDB's `get(..., limit=1)` has no ordering
guarantee across multiple matching rows, so the returned row was effectively
arbitrary. When ChromaDB happened to return a stale group (older mining
pass with a different stored mtime), the function returned False, the
additive miner concluded the file had changed, and wrote yet another
duplicate group of drawers for a file that had not actually changed since
the last successful mine.

Steady-state failure: for any source_file that has ever been edited (so
that the palace contains groups with different stored mtimes), each re-mine
has a probability of spuriously concluding "file changed" and writing
another duplicate group. Each spurious group compounds the problem because
its stored mtime can also trigger the next spurious re-mine. Storage grows
without bound; search results, hallway counts, and entity-frequency stats
become inflated proportionally.

The fix mirrors the paginated-iteration pattern already used in the
`extract_mode is not None` branch — iterate every drawer for the
source_file in 1000-row pages, short-circuit on the first matching group.
A correct group is one that passes the existing checks: normalize_version
not stale; if check_mtime, stored source_mtime within 0.001 seconds of the
current file mtime. The two branches (extract_mode is None vs set) collapse
into one loop that skips the extract_mode check when no extract_mode was
specified.

Trade-off: average-case cost rises from O(1) limit=1 query to O(N/1000)
paginated scan. For typical sources (1-3 groups) the cost is unchanged
because the loop short-circuits on the first matching group within the
first page. For pathological sources with thousands of groups, the cost
is O(number-of-pages-until-match) — still bounded, no longer flaky.

RED test pins the failure space deterministically

`test_file_already_mined_handles_multiple_groups_under_one_source_file`
uses a MockCollection that simulates two parent_drawer_id groups under one
source_file: a STALE group (older mtime) and a CURRENT group (matching
mtime). The mock returns the STALE group when called with limit=1
(worst-case ChromaDB ordering) and returns BOTH groups when called with
limit=1000 (what a correctly-iterating implementation must do). Test asserts
file_already_mined returns True even when limit=1 picks the stale group.

  - Against pre-fix code: test FAILS (function returns False because
    limit=1 picks stale group, mtime mismatch returns False)
  - Against post-fix code: test PASSES (iteration finds the current group,
    short-circuits to True)

Verified RED-then-GREEN locally. Existing 4 file_already_mined tests in
tests/test_miner.py continue to pass:
  - test_file_already_mined_check_mtime
  - test_file_already_mined_scopes_convo_extract_mode
  - test_file_already_mined_extract_mode_paginates_large_sources
  - test_file_already_mined_returns_false_for_stale_normalize_version

Verification

  - macOS Python 3.12 (local) full pytest  : 2268 passed, 0 failed
  - Linux Python 3.9.25  (OrbStack)        : 2260 passed, 0 failed
  - Linux Python 3.11.15 (OrbStack)        : 2261 passed, 0 failed
  - Linux Python 3.13.13 (OrbStack)        : 2261 passed, 0 failed
  - ruff check + ruff format --check       : all clean

Provenance

Surfaced during the per-query audit on the PR #1628 amendment cycle (the
search for every bare `where={"source_file": ...}` query in the repo).
One of six sites identified. The other five are legitimately file-global
in intent (closet purges, full-rebuild deletes, paginated mode-filtered
scans). This site is the one whose failure mode mirrors the cross-group
stitching pattern PR #1628 fixed at the searcher layer.
2026-05-29 11:50:44 -07:00
Milla J 65f24b3bdd fix(closets): address PR #1584 Igor review — wire + harden Tier 6a
Igor's review on PR #1584 (2026-05-22) flagged four issues:

  1. The feature wasn't wired into any production caller — the new
     ``drawer_metas`` kwarg on ``build_closet_lines`` had no real
     consumer in ``miner.py`` / ``diary_ingest.py``, so the 4-segment
     pointer form only existed in tests. Real palaces kept emitting
     the legacy 3-segment shape.
  2. ``_extract_content_date`` hallucinated dates on benign inputs.
     ``dateutil.parser.parse(fuzzy=True)`` would accept anything with
     digits and return a plausible-looking but wrong date —
     ``Version 3.3.6`` → ``2006-03-03``, ``Tested with 1000 drawers``
     → ``1000-05-22``, ``tmp_random_file_5`` → ``2026-05-05``, etc.
     Mtime almost never got reached because fuzzy returned *something*
     from filename or body first. Bad dates were silently persisted
     to ChromaDB.
  3. ``python-dateutil`` was an undeclared dependency, available only
     transitively via ``chromadb → kubernetes → python-dateutil``. Not
     a contract — upstream kubernetes has been trending toward
     stdlib-only.
  4. Two-digit-year disambiguation (70 → 19xx / 00-69 → 20xx) had no
     test pinning the boundary.

This commit addresses all four.

## Changes

### Issue 2 — kill the hallucination (the load-bearing fix)

``mempalace/miner.py``:

- New ``_VALID_DATE_RE`` gate. Three accepted shapes (all require a
  4-digit year explicitly):

    1. Numeric YYYY-MM-DD with ``[-/.\\s]`` separators
       (covers ISO and space-normalized filenames)
    2. Month-name + day + year ("November 8 2024", "Nov 8 2024")
    3. Day + month-name + year ("8 November 2024")

  Partial dates ("2024-06", "April 6", "notes.2024") are
  DELIBERATELY rejected — without all three components we'd pad from
  today's date, which is hallucination not extraction.

- ``_try_filename_date`` and ``_try_content_body_date`` now run the
  gate BEFORE invoking dateutil, and pass ``fuzzy=True`` is REMOVED.
  Dateutil only runs in strict mode on a substring the gate already
  validated.

### Issue 1 — wire the feature into production

``mempalace/miner.py`` batched-upsert path:

- Accumulate ``batch_metas`` across all batches into ``all_metas``
- Pass ``drawer_metas=all_metas`` to ``build_closet_lines``

End-to-end integration test added that mines a real file with a
filename-derived content date and asserts the produced closet
documents contain the 4-segment pointer with that date.

``diary_ingest.py`` is left as-is for this PR. Diary entries are
entry-keyed, not chunk-keyed — they carry no natural
``line_start`` / ``line_end``, so the 4-segment form would return
None for them regardless. Wiring the diary path can land cleanly in
a follow-up once Tier 6a gains an "approximate line range for diary
entries" story.

### Issue 3 — declare the dateutil dependency

``pyproject.toml``: add ``python-dateutil>=2.8`` to
``[project].dependencies``. One-line change; cheaper than the
stdlib-only refactor alternative and keeps the natural-language
recall surface.

### Issue 4 — pin the two-digit-year boundary

Four new tests cover the 1969/1970/1999/2000 corner cases of the
slash-date locale heuristic.

## Tests added (RED-first then GREEN)

  tests/test_miner.py::TestExtractContentDate (11 new):
    Hallucination cases verbatim from Igor's review:
    - test_no_hallucination_junk_filename_with_trailing_digit
    - test_no_hallucination_untitled_with_index
    - test_no_hallucination_filename_year_only
    - test_no_hallucination_filename_year_and_month_only
    - test_no_hallucination_content_with_issue_number
    - test_no_hallucination_content_with_count
    - test_no_hallucination_content_with_version_number
    Two-digit-year boundary cases:
    - test_two_digit_year_69_is_2069
    - test_two_digit_year_70_is_1970
    - test_two_digit_year_99_is_1999
    - test_two_digit_year_00_is_2000

  tests/test_closets.py::TestMinerClosetRebuild (1 new):
    - test_production_miner_emits_4_segment_pointers_with_content_date
      (regression for Issue #1 — real ``mine()`` end-to-end produces
      4-segment closet pointers via the new ``drawer_metas`` wiring)

## Verification

  pytest tests/test_miner.py tests/test_closets.py
         tests/test_format_miner.py tests/test_palace.py
    → 242 passed, 2 skipped, 0 regressions

  pytest tests/test_miner.py::TestExtractContentDate
    → 26 passed (15 prior + 11 new)

  pytest tests/test_closets.py::TestMinerClosetRebuild
    → end-to-end wiring test GREEN

  Sanity (Igor's exact repros):
    "tmp_random_file_5"           → None (was: 2026-05-05)
    "untitled-1"                  → None (was: 2026-05-01)
    "notes.2024.md"               → None (was: 2024-05-22)
    "2024-06.md"                  → None (was: 2024-06-22)
    "Bug fix for issue 42 in module 7" → None (was: 2042-07-22)
    "Tested with 1000 drawers"    → None (was: 1000-05-22)
    "Version 3.3.6 released"      → None (was: 2006-03-03)

  Real dates still extract correctly:
    "2024-11-08.md"               → "2024-11-08"
    "April-6th-2011-notes.md"     → "2011-04-06"
    "Nov-8-2024.md"               → "2024-11-08"

  ruff check + ruff format --check (pinned 0.15.9)
    → All checks passed

  OrbStack triple-Linux verify (Py 3.9 / 3.11 / 3.13)
    → all targeted tests pass; python-dateutil installs explicitly
       via the new declared dependency.
2026-05-22 10:45:43 -07:00
Milla J 67c6ab59f2 style: apply ruff format with pinned 0.15.9 (CI lint) 2026-05-22 04:24:07 -07:00
Milla J 05da803f71 feat(closets): Tier 6a — date+line locators with content-date hierarchy
Closet pointer lines gain a 4th pipe-separated segment of shape
``YYYY-MM-DD:Lstart-Lend`` so retrieval can jump straight to the right
span in the source file instead of opening the whole drawer. The date
is selected via a content-aware hierarchy so legacy ingests (where
``filed_at`` is misleading) still produce honest pointers when the
content itself carries a date.

The legacy 3-segment format remains the fallback for drawers without
the new metadata, so existing palaces keep working without a re-mine.

## Pointer shape

Before:
  topic|entities|→drawer_ids

After (when drawer metadata carries line range + a date):
  topic|entities|2024-11-08:L42-L78|→drawer_ids

Backward compat: when ``drawer_metas`` is not passed, or the first
meta lacks ``line_start`` / ``line_end``, ``build_closet_lines`` emits
the legacy 3-segment form unchanged.

## Date-source hierarchy (NEW)

A new helper ``mempalace.miner._extract_content_date(source_file, content)``
selects a date once per file and stores it as ``content_date`` on every
drawer mined from that file. Priority order, first match wins:

  1. **Filename** — ISO regex on stem, then dateutil fuzzy parse for
     natural-language formats. Handles ``2024-11-08.md``,
     ``April-6th-2011-notes.md``, ``Nov-8-2024.md``, etc.

  2. **YAML frontmatter** — looks at ``date`` / ``created`` /
     ``published`` fields. yaml.safe_load already parses ISO dates as
     ``datetime.date`` objects; dateutil handles string values.

  3. **Content body, first ~10 lines** —
       a. ISO regex (``YYYY-MM-DD`` / ``YYYY/MM/DD`` / ``YYYY.MM.DD``)
       b. Slash dates with locale auto-disambiguation: if any
          first-number > 12 appears anywhere in the head, lock the
          file's locale to DD/MM (the only consistent reading);
          otherwise default to US MM/DD for two-digit-year compactness.
       c. dateutil fuzzy parse for natural-language
          (``November 8, 2024``, ``8th November 2024``, etc.)

  4. **Filesystem mtime** — ``os.path.getmtime`` formatted as
     ``YYYY-MM-DD``.

  5. **None** — caller (``_build_drawer_metadata``) leaves
     ``content_date`` absent; ``build_closet_lines`` falls back to the
     ``filed_at`` ingestion timestamp's date portion.

``_build_date_line_segment`` in ``palace.py`` now prefers ``content_date``
when present, otherwise uses ``filed_at[:10]``. This is the load-bearing
change that makes Tier 6a meaningful for legacy content — a Nov 8 2024
transcript mined today now emits ``2024-11-08:L42-L78`` instead of
``2026-05-22:L42-L78``.

## Changes

1. ``mempalace/miner.py::chunk_text`` — each chunk dict now also carries
   ``line_start`` / ``line_end`` (1-indexed line numbers in the stripped
   source). Computed via ``content[:offset].count("\n") + 1`` —
   approximate locator accurate to ±1 line at chunk boundaries.

2. ``mempalace/miner.py::_extract_content_date`` (NEW) + helpers
   (``_try_filename_date``, ``_try_frontmatter_date``,
   ``_try_content_body_date``, ``_try_mtime_date``). Pure functions, no
   I/O beyond what's named. Uses lazy imports of ``dateutil`` and
   ``yaml`` to keep top-level import surface unchanged.

3. ``mempalace/miner.py::_build_drawer_metadata`` — gains
   ``line_start`` / ``line_end`` / ``content_date`` optional kwargs.
   When provided, stored in drawer metadata; when omitted, keys are
   absent from the dict — backward compatible.

4. ``mempalace/miner.py`` batched-upsert path — calls
   ``_extract_content_date(source_file, content)`` ONCE per file (not
   once per chunk) and passes the result through to every chunk's
   ``_build_drawer_metadata`` call.

5. ``mempalace/format_miner.py::_file_chunks_locked`` — gains a
   ``content`` param so the format-miner path can also run
   ``_extract_content_date`` on the full extracted text. Plumbs
   ``content_date`` into every batched drawer's metadata. Caller passes
   ``text`` (the full extracted markdown).

6. ``mempalace/palace.py::build_closet_lines`` — accepts optional
   ``drawer_metas`` (parallel to ``drawer_ids``). When present and the
   first meta has both ``line_start`` and ``line_end``, the new
   segment is spliced in; otherwise legacy 3-segment form. Helper
   ``_build_date_line_segment`` prefers ``content_date`` over
   ``filed_at`` for the date portion.

## Tests added (RED-first, then GREEN)

  tests/test_miner.py::TestChunkTextLineRanges          (4 tests)
  tests/test_miner.py::TestBuildDrawerMetadataLineRange (2 tests)
  tests/test_miner.py::TestExtractContentDate           (15 tests)
    - filename ISO / natural-language / compact
    - YAML frontmatter date / created
    - Claude session preamble
    - ISO + natural-language content body
    - Slash-date locale disambiguation (DD/MM lock vs default MM/DD)
    - Priority order (filename > frontmatter > content > mtime)
    - mtime fallback
    - Graceful None returns for missing file / empty content

  tests/test_closets.py::TestBuildClosetLines           (5 new tests)
    - includes_date_line_segment_when_metas_provided
    - falls_back_to_3_segment_format_when_metas_missing  (compat)
    - falls_back_to_3_segment_format_when_metas_lack_line_keys (compat)
    - date_segment_uses_filed_at_date_portion_only
    - content_date_preferred_over_filed_at               (new — Tier 6a hierarchy)

26 new tests total. All RED before this commit, all GREEN after.

## Verification

  pytest tests/test_miner.py::TestChunkTextLineRanges
         tests/test_miner.py::TestBuildDrawerMetadataLineRange
         tests/test_miner.py::TestExtractContentDate
         tests/test_closets.py::TestBuildClosetLines
    → 32 passed

  pytest tests/test_miner.py tests/test_closets.py
         tests/test_format_miner.py tests/test_palace.py
    → 230 passed, 2 skipped, 0 regressions in directly-affected files

  ruff check + ruff format --check
    → All checks passed, 5 files already formatted

  End-to-end smoke test on a YAML-frontmatter file (date: 2024-11-08,
  mined "today"):
    content_date extracted: 2024-11-08
    chunks produced: 1
    drawer meta: content_date=2024-11-08, filed_at='2026-05-22...', lines=1-15
    closet pointer:
      "conversation with lumi about brands of dog food|Filler;Lumi;Aya|2024-11-08:L1-L15|→drawer_a,drawer_b"
    → content_date correctly preferred over filed_at end-to-end.

  OrbStack triple-Linux verify (Py 3.9 / 3.11 / 3.13)
    → 32 targeted Tier 6a + content-date tests pass on each.

## Out of scope (deliberate)

- No schema migration of existing drawers. Pre-Tier-6a drawers lack
  ``line_start`` / ``content_date`` and silently fall back to the
  legacy 3-segment pointer shape.
- No re-mine required. Users keep existing palaces; new files mined
  after this lands carry the new fields.
- No drawer ID change (Task #80, separate concern).
- No exact-quote line positioning within a chunk (future tier).
- No NLP-based date extraction (only regex + dateutil fuzzy).
- Linguistic-similarity / concept-recognition retrieval (L5 zettal
  layer) is the natural next step but lives in its own PR family.
2026-05-22 04:02:03 -07:00
Igor Lins e Silva 3b6c7986c9 Merge branch 'develop' into fix/tunnel-file-config-and-endpoint-validation
Resolves conflicts from 60-commit divergence:

- tests/test_closets.py: assertion reformat — kept develop's ruff-format-
  preferred multi-line shape (functionally identical).
- tests/test_palace_graph_tunnels.py: both branches added a new test
  class at end-of-file (this PR's TestTunnelFileFollowsConfig + develop's
  TestEntityTunnels from #1564). Kept both, no overlap.

Local: ruff check / format pass; full pytest suite passes
(2113 passed, 3 skipped).

Co-Authored-By: Igor Lins e Silva <4753812+igorls@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 22:31:41 -03:00
Igor Lins e Silva ed80f80cf9
Merge pull request #1565 from MemPalace/feat/entity-tunnels-from-hallways
feat(tunnels): cross-wing entity tunnels derived from hallways
2026-05-20 23:47:58 -03:00
Igor Lins e Silva ad875a8658
Merge pull request #1558 from MemPalace/feat/hallways-within-wing-connectors
feat(hallways): within-wing entity-to-entity connector primitive
2026-05-20 23:46:40 -03:00
Milla J c18879b5bc feat(tunnels): cross-wing entity tunnels derived from hallways
Adds the architectural counterpart to ``compute_topic_tunnels`` that
materializes cross-wing tunnels from the within-wing hallway records
introduced in PR #1558. When an entity (person, project, concept,
interest) has hallways in two wings, an entity tunnel bridges them —
anchored on the entity. This completes the v4 sequence: Wing →
Drawer-entities → Hallway → Tunnel.

Topic tunnels are NOT replaced. Both systems coexist for one release
cycle so existing palaces don't lose tunnels between mines. Deprecation
of topic tunnels is a separate follow-up PR after entity tunnels prove
out in real use.

## What this commit does

1. Adds ``entity_tunnels_for_wing(wing, hallways, label_prefix)`` to
   ``mempalace/palace_graph.py``. Pure function: groups hallway records
   by entity-and-wing, finds entities present in ``wing`` AND ≥1 other
   wing, and emits one ``create_tunnel`` call per (entity, other_wing)
   pair. Uses ``kind="entity"`` and synthetic endpoint room
   ``entity:<name>`` so the new tunnels are distinguishable from
   explicit/topic tunnels at read time but interchangeable with them
   via the standard ``list_tunnels`` / ``follow_tunnels`` API.

2. Adds ``_compute_entity_tunnels_for_wing(wing)`` wrapper to
   ``mempalace/miner.py``. Loads hallway records via
   ``hallways.list_hallways()`` and calls the algorithm. Module-level
   so tests can patch it as ``mempalace.miner._compute_entity_tunnels_for_wing``.

3. Wires the wrapper into ``_mine_impl`` immediately after the existing
   hallway-compute block. Same try/except fault-tolerance pattern as
   the topic-tunnel and hallway blocks — entity-tunnel computation is
   a derived analytic and must never fail a mine.

## Tests (RED-first)

Nine algorithm tests in ``tests/test_palace_graph_tunnels.py``
(new ``TestEntityTunnels`` class):

  test_entity_tunnels_creates_cross_wing_tunnel_for_shared_entity
  test_entity_tunnels_skips_entities_in_only_one_wing
  test_entity_tunnels_counts_entity_in_either_pair_position
  test_entity_tunnels_three_wings_pairwise_from_focus_wing
  test_entity_tunnels_idempotent_on_rerun
  test_entity_tunnels_retrievable_via_list_tunnels
  test_entity_tunnels_empty_hallways_is_noop
  test_entity_tunnels_unknown_wing_is_noop
  test_entity_tunnel_room_does_not_collide_with_literal_room

Two integration tests in ``tests/test_miner.py``:

  test_mine_computes_entity_tunnels_for_wing_post_mine
  test_mine_entity_tunnel_failure_does_not_crash_mine

All 11 RED before this commit (AttributeError on the missing names).
All 11 GREEN after.

## Out of scope (deferred to follow-up PRs)

- ``format_miner.py`` and ``convo_miner.py`` integration: separate PRs
  per the scope discipline used for #1560.
- Deprecating ``_compute_topic_tunnels_for_wing``: separate PR after
  entity tunnels prove out in real use.
- Surfacing ``kind="entity"`` in MCP / search-result UI: not yet
  required by any reader; behaviorally interchangeable with the other
  tunnel kinds today.

## Stacking

This PR stacks on PR #1558 (which introduces the hallway primitive and
its miner integration). Base branch is
``feat/hallways-within-wing-connectors``. When #1558 merges to develop,
GitHub auto-updates this PR's base to ``develop`` and the diff reduces
to just the entity-tunnel additions.

## Verification

  pytest tests/test_palace_graph_tunnels.py::TestEntityTunnels
    → 9 passed (RED before, GREEN after)
  pytest tests/test_miner.py::test_mine_computes_entity_tunnels_for_wing_post_mine
        tests/test_miner.py::test_mine_entity_tunnel_failure_does_not_crash_mine
    → 2 passed (RED before, GREEN after)
  pytest tests/test_palace_graph_tunnels.py
    → 39 passed (no regressions)
  pytest tests/test_miner.py
    → 49 passed (no regressions)
  pytest -q (full mempalace suite)
    → 1949 passed, 1 skipped, 0 regressions
  ruff check mempalace/palace_graph.py mempalace/miner.py tests/
    → All checks passed!
  ruff format --check ...
    → 4 files already formatted (pinned 0.15.9)
2026-05-20 13:55:52 -07:00
Igor Lins e Silva 498b22ffed
Merge pull request #1554 from mvalentsev/fix/1455-max-chunks-configurable
fix(miner): configurable + raised MAX_CHUNKS_PER_FILE (#1455)
2026-05-20 17:21:56 -03:00
Milla J fc13be568a feat(miner): integrate compute_hallways_for_wing into post-mine flow
Wires the hallway primitive (from PR #1558) into the project miner so
that every mine that touches a wing also materializes within-wing
entity hallways for that wing. Without this integration, the hallway
module is dead code — no miner triggers it, no hallways ever land in
~/.mempalace/hallways.json.

## What this commit does

1. Adds a module-level import of ``compute_hallways_for_wing`` from
   ``.hallways`` near the top of ``miner.py``. Module-level (not lazy)
   so tests can patch it as ``mempalace.miner.compute_hallways_for_wing``
   — lazy imports inside a function wouldn't expose the seam.

2. In ``_mine_impl``, immediately after the existing
   ``_compute_topic_tunnels_for_wing(wing)`` post-mine block, adds a
   parallel hallway block. The block:

   - calls ``compute_hallways_for_wing(wing, col=collection)``
   - prints the count if any hallways were materialized
   - wraps the whole thing in try/except so a hallway-compute failure
     is logged + degraded, never propagated. Mirrors the tunnel block's
     fault-tolerance pattern exactly. Hallway computation is a derived
     analytic, not load-bearing for the drawer write that already
     committed above.

## Stacking

This PR stacks on PR #1558 (which introduces the hallway primitive
module). PR #1558 is the prerequisite — without it, the import
``from .hallways import compute_hallways_for_wing`` doesn't resolve.

Base branch for this PR is ``feat/hallways-within-wing-connectors``
(PR #1558's branch). When PR #1558 merges to develop, GitHub will
auto-update this PR's base to develop and the diff will reduce to
just the miner.py and tests/test_miner.py changes.

## Out of scope (deferred to follow-up PRs)

- ``format_miner.py`` integration: lives on a different branch (PR #1555)
  so the parallel call there goes in as a follow-up amendment to that
  branch (or a separate post-merge PR).
- ``convo_miner.py`` integration: convo_miner currently doesn't call
  ``_compute_topic_tunnels_for_wing`` either. Adding both calls is a
  separate concerned PR about convo_miner parity, not just hallways.
- Refactoring ``_compute_topic_tunnels_for_wing`` to BUILD ON hallways
  (rather than computing from raw topic words): the architecturally
  meaningful follow-up that completes the Wing → Drawer-entities →
  Hallway → Tunnel sequence. Real refactor, separate PR.

## Tests

Two new RED-first tests in ``tests/test_miner.py``:

  test_mine_computes_hallways_for_wing_post_mine
      Stubs ``mempalace.miner.compute_hallways_for_wing`` via monkeypatch.
      Runs a real mine into a tmp palace. Asserts the stub was called
      exactly once, with the wing name from mempalace.yaml and a live
      ChromaDB collection (not None).

  test_mine_hallway_failure_does_not_crash_mine
      Stubs the hallway function to raise. Runs a real mine. Asserts
      mine() doesn't propagate, and that the drawer write (which
      happens BEFORE the hallway block) still committed.

Both RED before this commit (AttributeError — module had no attribute
``compute_hallways_for_wing``). Both GREEN after.

## Verification

  ruff check mempalace/miner.py tests/test_miner.py
    → All checks passed!
  ruff format --check ...
    → 2 files already formatted (pinned 0.15.9)
  pytest tests/test_miner.py
    → 47 passed (the 2 new + 45 pre-existing)
  pytest -q (full mempalace suite)
    → 1938 passed, 1 skipped, 0 regressions

Linux CI parity replicated locally via OrbStack containers
(Python 3.9, 3.11, 3.13): 2/2 new integration tests pass, ruff clean
on all three.
2026-05-20 04:08:02 -07:00
Milla J 0b9c5629b7 fix(miner): mirror init's case-insensitive entity matching in per-drawer tagger
mempalace's init-time entity scanner (`entity_detector.py:276`) already
matches names case-insensitively against corpus content:

    name_line_indices = [i for i, line in enumerate(lines)
                         if name_lower in line.lower()]

That's how a corpus that mentions "aya" all in lowercase still surfaces
"Aya" as a confirmed entity during `mempalace init`.

But the per-drawer tagger in `miner.py:_extract_entities_for_metadata`
was never updated to use the same flag — it walks the same
`known_entities.json` seed list and matches case-sensitively:

    for name in known:
        if re.search(r"(?<!\w)" + re.escape(name) + r"(?!\w)", content):
                                                              # ^ no IGNORECASE
            matched.add(name)

So "Aya" in `known_entities.json` matches "Aya" in drawer content but
silently misses every "aya" / "AYA" mention. Chat transcripts,
voice-typed journals, and any lowercase-style corpus get their drawers
under-tagged in ways that don't show up in init's "we found Aya/Lumi/..."
confirmation list.

## The fix

One regex flag, line 788:

    -if re.search(r"(?<!\w)" + re.escape(name) + r"(?!\w)", content):
    +if re.search(r"(?<!\w)" + re.escape(name) + r"(?!\w)", content,
    +             re.IGNORECASE):

Plus an explanatory comment so the parity with `entity_detector.py:276`
is documented at the call site.

## Empirical proof of the bug (and the fix)

Before this commit, with the actual installed `~/.mempalace/known_entities.json`
containing "Aya", "Lumi":

    Content: "Aya talked to Lumi. Lumi answered."      → tagged 'Aya;Lumi'  ✓
    Content: "aya talked to lumi. lumi answered."      → tagged ''           ✗ MISS
    Content: "AYA mentioned LuMi to ben."              → tagged ''           ✗ MISS

After this commit (same content):

    Content: "Aya talked to Lumi. Lumi answered."      → tagged 'Aya;Lumi'  ✓
    Content: "aya talked to lumi. lumi answered."      → tagged 'Aya;Lumi'  ✓
    Content: "AYA mentioned LuMi to ben."              → tagged 'Aya;Ben;Lumi'  ✓

## Tests

New RED-first test in `tests/test_miner.py`:

    test_entity_metadata_matches_known_names_case_insensitively

Stubs `_load_known_entities` to a controlled `{"Aya", "Lumi"}` seed
and asserts that lowercase + mixed-case mentions both produce the
correctly capitalized tag. Failed on the pre-fix regex (confirmed RED
in CI parity runs across Linux 3.9 / 3.11 / 3.13); passes after the
one-line fix.

## No regressions

Full mempalace test suite: 1920 passed, 1 skipped, 0 failed.
Targeted dependent suites (`test_known_entities_registry`, `test_closets`,
`test_readme_claims`): 123 passed.
ruff check + ruff format clean on pinned 0.15.9.

CI parity replicated locally via Linux containers (Python 3.9, 3.11,
3.13): all three pass the new test and the full `test_miner.py`
sweep (46 tests).

## Why this matters

This is a quiet bug — there's no error message, no failed assertion,
just an empty `entities` field on drawers whose content actually
mentions people. The init phase looks fine ("we detected Aya, Lumi,
Ben") because init does its own case-insensitive scan; mining silently
drops the matches that init's scanner already proved are real.

Personal / journal / chat-style corpora (which use lowercase
conversational style) are the most affected. Code / docs corpora
(which use proper capitalization for names) work either way and saw
no behavior change.
2026-05-20 03:15:45 -07:00
mvalentsev 5decc0c83b fix(miner): configurable + raised MAX_CHUNKS_PER_FILE (#1455)
Default raised from 500 to 50_000 so legitimate long-form content
(novels, scholarly editions) is not silently dropped on a typical
literary corpus. Cap configurable via MEMPALACE_MAX_CHUNKS_PER_FILE
env var or --max-chunks-per-file CLI flag; sentinel 0 disables.
Negative or non-int values from either source emit a stderr warning
and fall back to the default.

Separate counter (files_skipped_chunk_cap) and summary line surface
chunk-cap drops independently of the residual already-filed bucket;
counter fires under --dry-run too so a corpus audit shows the same
signal without writing. Skip notice routed to stderr alongside the
existing symlink-skip line.

ONNX bad_alloc protection preserved architecturally:
DRAWER_UPSERT_BATCH_SIZE=1000 bounds per-ONNX-call exposure
regardless of per-file cap. A 50_000-chunk file produces 50 forward
passes of 1_000 chunks each, identical to what 500-chunk files
already produced.

process_file return extended to a 3-tuple (drawers, room,
skip_reason); skip_reason is None on non-chunk-cap paths and
"chunk_cap" when the per-file cap triggered. Internal callers
(_mine_impl, benchmarks/mine_bench.py, tests) updated.

Closes #1455

Co-Authored-By: David Glidden <d@davidglidden.eu>
2026-05-20 02:20:29 +05:00
Igor Lins e Silva 5c9a5e04b1
Merge pull request #1528 from therahul-yo/fix/1505-extract-mode-aware-skip
fix(convo_miner): scope skip-check and drawer ids by extract_mode (#1505)
2026-05-17 23:16:10 -03:00
yonefive71 3171a1b4c1 fix(graph): resolve tunnel file from palace_path config (#1467)
palace_graph._TUNNEL_FILE was a module-level constant initialised from
os.path.expanduser("~") + "/.mempalace/tunnels.json", ignoring the
MempalaceConfig.palace_path config (and MEMPALACE_PALACE_PATH env var)
that drawers, KG, and every other piece of palace state honour. Under
any setup where $HOME and the configured palace diverge — subagent
profiles, sandboxes, multi-tenant hosts, container mounts moving the
palace to /srv/ — drawers landed in the configured palace while
tunnels silently landed in a different file invisible to other
processes touching the same palace.

Replace the constant with _get_tunnel_file(config=None) deriving the
path from a new MempalaceConfig.tunnel_file property (sibling of
palace_path). Default install unchanged because default palace_path
is ~/.mempalace/palace whose sibling tunnels.json is the legacy path.

Add a _legacy_tunnel_file() helper and a one-line WARNING in
_load_tunnels for the case where the configured tunnel file is missing
but the pre-fix hardcoded path has one. No auto-migration — silently
merging tunnel state across two locations risks clobbering newer data.

fix(graph): validate explicit-tunnel endpoints exist (#1468)

create_tunnel previously only validated that wing/room names were
non-empty strings; nothing queried chroma to confirm at least one
drawer carried matching {wing, room} metadata. Pointing an explicit
tunnel at a phantom room silently succeeded. Combined with #1467's
read-bubble, an agent could create_tunnel → list_tunnels and have both
calls return its own bogus write, self-confirming a tunnel that didn't
exist in the shared palace.

create_tunnel now calls _check_room_exists(wing, room, col) for both
endpoints before persisting an explicit tunnel; zero rows raises
ValueError naming the endpoint. Three deliberate carve-outs:

- kind != "explicit" skips validation because topic tunnels use
  synthetic topic:<name> room ids that don't correspond to real rooms
- _get_collection returning None (palace not yet created, transient
  failure, tests without backend) skips validation rather than
  fail-closed — matches tolerance pattern used throughout palace_graph
- Query exceptions are logged and treated as 'can't verify, allow' so
  a flaky index doesn't block legitimate writes

Behaviour change: callers that previously created tunnels pointing at
empty rooms (scaffolding before mining) will now raise. File the
drawer first, then create the tunnel.

Tests:
- _use_tmp_tunnel_file helper now also neutralises _get_collection so
  existing tests don't accidentally trip the new validation path when
  test-order pollution leaves a real chroma backend bound
- test_closets.py::TestTunnels setup/teardown updated to monkeypatch
  resolver functions instead of the removed constant; also neutralises
  _get_collection for the same reason
- Three tests in test_miner.py exercising compute_topic_tunnels are
  unchanged in intent — they monkeypatch the new resolvers and pass
  without stubbing _get_collection because kind=topic skips validation
- New TestTunnelFileFollowsConfig and TestCreateTunnelEndpointValidation
  classes cover the regression surface for both fixes
2026-05-17 20:19:56 +00:00
mvalentsev c933095a2f fix(palace): stratify state messages for empty/missing palace (#1498)
`mempalace status`, `search`, and `compress` printed the same misleading
"No palace found / Run: mempalace init" output for three distinct
palace states (no dir / no DB / no collection). Most user-visible on
the first-run state where `init` had run but `mine` had not: the hint
to re-run `init` is a no-op and wastes the user's time.

Backend gets a new typed exception `CollectionNotInitializedError`
(subclass of `PalaceNotFoundError`, transitively `FileNotFoundError`,
so legacy callers keep working). `ChromaBackend.get_collection(
create=False)` wraps chromadb's bare `NotFoundError` as the new typed
exception instead of leaking the chromadb-specific class to callers.

A new internal helper `_open_collection_or_explain` in `palace.py`
runs filesystem-first state checks before the backend call to avoid
chromadb's lazy `chroma.sqlite3` creation as a side-effect of a
read-only inspection, then catches the typed exceptions and prints a
state-specific actionable message. `BackendClosedError` is explicitly
re-raised so a programmer error is not masked as a UX hint.

Two CLI bug sites route through the helper: `miner.status` and
`cli.cmd_compress`. `searcher.search` catches the typed exceptions
directly so it can preserve the cause chain in `SearchError(...) from
e` for programmatic search-API consumers. `cli.cmd_sync` gained an
inline filesystem distinction (no helper needed: it does not use the
collection handle). `repair.status` (capacity check, which by design
must work on corrupted palaces without opening a chromadb client) got
the same distinction via `sqlite_drawer_count`-based empty detection.
The MCP `tool_status` is intentionally left alone: PR #831 already
fixed it there with a `create=True` bootstrap strategy appropriate
for programmatic clients.
2026-05-17 03:57:19 +05:00
Rahul 92de001c20 fix(convo_miner): scope skip-check and drawer ids by extract_mode (#1505)
Mining a transcript with --extract general was silently skipped when the
same file had already been mined with --extract exchange (or vice versa)
because file_already_mined() and prefetch_mined_set() only looked at
source_file. The two extraction modes produce different drawer content
and rooms, so they should coexist for the same source.

Changes:
- file_already_mined() and prefetch_mined_set() take an optional
  extract_mode arg and only return True when stored drawer metadata
  matches. Legacy drawers without extract_mode are treated as
  exchange-mode for back-compat.
- _file_chunks_locked() purges only same-mode drawers when rebuilding
  on a normalize-version bump, so a schema bump on one mode does not
  drop drawers filed under the other.
- Drawer ids and sentinel ids include extract_mode so the two modes
  cannot collide on hash.
- Pagination on the direct skip-check path so large transcripts (>1k
  drawers) are classified correctly when the bulk prefetch is skipped.

Adds regression coverage for the extract-mode-aware helper, the
pagination path, and an end-to-end mine_convos run that files
exchange then general for the same transcript without skipping.
2026-05-16 17:55:10 +05:30
Igor Lins e Silva 29d686e5d4
Merge pull request #1024 from techempower-org/pr/configurable-chunking
feat: configurable chunk_size, chunk_overlap, min_chunk_size
2026-05-15 10:39:10 -03:00
mvalentsev d7d9604887 fix(miner): polish symlink skip diagnostic (#1462)
* Move SKIP log from stdout to sys.stderr, matching the existing
  warning-channel pattern (the "No mempalace.yaml" notice and
  topic-tunnel warnings). Stdout stays clean for "Files: N" /
  "Drawers filed: N" markers that downstream callers parse.

* Show the path relative to the scan root via
  Path.relative_to(...).as_posix() instead of the leaf filename, so
  a nested symlink in a deep subdirectory is unambiguous and the log
  renders with forward slashes on every platform.

* Wrap the print in try/except OSError, mirroring the existing guard
  around filepath.stat() below; a broken pipe or closed stderr no
  longer aborts the scan mid-walk.

* Document the stderr side effect in both scan_convos and
  scan_project docstrings.

* Gate the new tests with @pytest.mark.skipif(sys.platform == "win32",
  ...) because Path.symlink_to requires SeCreateSymbolicLinkPrivilege
  on Windows.

* Add dangling-symlink and nested-subdirectory tests for both miners,
  locking the two-space "  SKIP:" prefix, the "(symlink)" reason
  marker, and the full relative path format.
2026-05-13 22:53:43 +05:00
mvalentsev becf56172a fix(miner): log skipped symlinks in mine paths (#1462)
Both scan_convos() and scan_project() silently drop symlinked files,
leaving callers staging a temp directory of symlinks with no diagnostic
for the "Files: 0" outcome. Keep the existing skip-symlinks behavior
and add a "  SKIP: {name} (symlink)" log per skipped file, matching
the convention already used by split_mega_files.py for the
oversized-file skip case.
2026-05-13 22:53:43 +05:00
jp fd63703686 feat: configurable chunk_size, chunk_overlap, min_chunk_size
Chunk sizing was hardcoded via module-level constants
(CHUNK_SIZE=800, CHUNK_OVERLAP=100, MIN_CHUNK_SIZE=50). One size
does not fit all — source material varies (dense code vs prose
transcripts vs sparse logs) and so do users' context-window
budgets.

This makes all three values overridable via ~/.mempalace/config.json:

    {
      "chunk_size": 1200,
      "chunk_overlap": 150,
      "min_chunk_size": 40
    }

Values are exposed as MempalaceConfig properties, threaded
through mine() -> process_file() -> chunk_text() as optional
keyword arguments. Defaults (800/100/50) are preserved when the
config keys are absent, so existing palaces behave identically.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-10 18:11:33 -07:00
Igor Lins e Silva 2fc47a52fc
Merge pull request #1004 from coogie/coogie/fix/miner-routing
fix(miner): use token-boundary matching in detect_room
2026-05-09 01:36:51 -03:00
Igor Lins e Silva 3a76360301 fix(hooks): per-target PID guard with atomic claim (#1212, #1206)
The hook PID guard used a single global ``~/.mempalace/hook_state/mine.pid``
file, which failed two ways:

1. ``_mine_already_running`` read-then-spawn was a TOCTOU race. Two
   near-simultaneous Stop hook fires both passed the existence/liveness
   check before either wrote — so both ended up calling
   ``_spawn_mine``.

2. ``_spawn_mine`` unconditionally overwrote the global PID file with
   the new child's PID. The first PID was lost, orphaning the first
   child. The user-visible result in #1212 was two concurrent
   ``mempalace mine`` processes running against the same source, both
   driving HNSW inserts in parallel — exactly the corruption pattern
   the guard was meant to prevent. #1206 reported the same shape from
   the perspective of the user (two mines hung on a 350MB folder).

Replace the global file with per-target slots under
``~/.mempalace/hook_state/mine_pids/``, keyed by sha256 of the mine
sub-arguments (everything after ``mine``). The slot is claimed via
``O_CREAT | O_EXCL`` so the claim is atomic — two simultaneous fires
can never both pass.  Stale slots (PID exists but is dead) are
reclaimed transparently. Different targets (e.g. project mine vs
transcript ingest, or two different MEMPAL_DIRs) get independent
slots and run in parallel.

The mine subprocess receives its slot path via
``MEMPALACE_MINE_PID_FILE`` env var; ``miner._cleanup_mine_pid_file``
reads that var on exit and removes the slot if it points at our PID,
so orphaned slots from crashed mines don't accumulate.

Also routes ``_ingest_transcript`` through ``_spawn_mine`` so the
transcript ingest path now participates in the same dedup — repeated
Stop fires for the same transcript no longer stack parallel mines.

Closes #1212
Closes #1206
2026-05-08 02:09:00 -03:00
Stephen Coogan ead2c5d299
fix(miner): use token-boundary matching in detect_room
Substring checks in path/filename routing caused systemic misrouting
in large monorepos — e.g., "views" ⊂ "interviews" sent every file
under views/ to the interviews room. Switch to separator-bounded
token matching (-, _, ., /) via a _name_matches helper, applied to
priority 1 (path parts) and priority 2 (filename).
2026-05-07 21:44:30 +01:00
Igor Lins e Silva 5488e7bb22 fix(miner): harden Windows mine against ONNX bad_alloc + silent partial exits
Three small changes that together address the failure modes in #1296:

1. Add pnpm-lock.yaml and yarn.lock to SKIP_FILENAMES, mirroring the
   existing package-lock.json rule. A 24K-line pnpm-lock.yaml produced
   ~1124 chunks in one batch and tripped onnxruntime bad_alloc on
   Windows; pnpm/yarn lockfiles are no more useful to mine than npm's.

2. Skip any file that produces more than MAX_CHUNKS_PER_FILE (500)
   chunks, with a clear log line. Catches the broader class — generated
   CSV/JSON, build artifacts, etc. — that the named-file SKIP list will
   never fully cover. The cap is conservative (500 chunks * 800 chars ≈
   400 KB of source) so legitimate hand-written content still mines.

3. Print a partial-progress summary on any exception in _mine_impl, not
   just KeyboardInterrupt, then re-raise. Without this, an arbitrary
   exception (ONNX bad_alloc, chromadb HNSW error, OS fault) propagates
   silently — the operator sees only the last progress line and assumes
   the mine succeeded. The new path mirrors the KeyboardInterrupt
   summary (files_processed, drawers_filed, last_file) plus the
   exception type and message, then re-raises so the original traceback
   surfaces and the exit code is non-zero.

Tests cover: SKIP_FILENAMES contents, the chunk-cap path returning
(0, room) with no upserts, and the new mine-aborted summary surfacing
both the partial counters and the exception class.
2026-05-07 08:56:41 -03:00
git e039a675af feat(miner): add support for Swift and Kotlin file extensions
- Updated READABLE_EXTENSIONS in miner.py to include ".swift", ".kt", and ".kts".
- Added tests in test_miner.py to ensure scanning includes Swift and Kotlin files.
2026-05-06 00:26:34 +02:00
Igor Lins e Silva 3bebef1503 fix(miner,convo_miner): close remaining wing-name normalization gaps (#1194)
Two follow-ups against the review on this PR:

1. ``miner.load_config`` no-yaml fallback was returning the raw dirname
   as the wing, while ``cmd_init`` writes ``topics_by_wing`` under the
   normalized slug. A hyphenated project mined without a ``mempalace.yaml``
   file silently lost every topic tunnel — same key-miss class as #1194,
   just down the no-yaml branch (raised by Qodo on this PR).

2. ``convo_miner`` was applying the lower/replace rule inline at one
   call site. Now folded through ``normalize_wing_name`` so all wing-slug
   producers — ``cmd_init``, ``room_detector_local``, ``miner.load_config``
   fallback, ``convo_miner`` — share a single source of truth. No
   behavior change for any input; pure consolidation.

Added ``test_load_config_no_yaml_normalizes_hyphenated_wing`` to lock
the fallback path to the normalized slug — fails on develop without
the miner change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 03:12:06 -03:00
Igor Lins e Silva c4eeec8642 test: use shlex.quote in resume-hint assertions for Windows
The pre-existing test_maybe_run_mine_prompt_declined_prints_hint
asserted the bare unquoted form `mempalace mine {tmp_path}`. After
the production code switched to shlex.quote on the resume hint, this
passed on Linux/macOS (POSIX paths have no characters that trigger
quoting) but failed on Windows where backslashes always get wrapped
in single quotes.

Mirror the production code in the assertion via shlex.quote so it's
portable across platforms; do the same for the two new
spaces-in-path tests for consistency.
2026-04-25 01:18:31 -03:00
Igor Lins e Silva 8faf0042b5 fix(cli,mine): shell-quote project_dir in resume hints
The "Skipped. Run mempalace mine <dir>" hint after declining the init
prompt and the "Re-run mempalace mine <dir> to resume" hint after a
Ctrl-C interruption both interpolated project_dir without shell-quoting.
A path containing spaces or metacharacters produced a copy-paste-broken
command.

Both spots now use shlex.quote(project_dir). Adds regression tests
covering each hint with a path that contains a space.
2026-04-25 01:10:17 -03:00
Igor Lins e Silva f13b9a46a2 feat(cli): init prompts to mine, mine handles Ctrl-C gracefully
`mempalace init` now ends with a `Mine this directory now? [Y/n]`
prompt and runs `mine()` in-process when accepted; `--yes` skips the
prompt and auto-mines for non-interactive callers. Declining prints
the resume command. Removes the "remember to type the next command"
friction since rooms + entities just got set up.

`mempalace mine` now wraps its main loop in `try / except
KeyboardInterrupt` and prints `files_processed`, `drawers_filed`, and
`last_file` before exiting with code 130 on Ctrl-C. Re-mining is safe
because deterministic drawer IDs make the upsert idempotent. The
hooks PID lock at `~/.mempalace/hook_state/mine.pid` is now actively
removed in a `finally` when its entry points at us, on clean exit,
error, or interrupt — preventing the next hook fire from briefly
waiting on a stale PID.

Closes #1181, #1182.
2026-04-25 01:01:24 -03:00
Igor Lins e Silva 865a36bc5c feat(graph): namespace topic-tunnel rooms with "topic:" prefix + kind field
Previously a cross-wing topic tunnel for "Angular" stored the room as
"Angular" — colliding with a wing's literal folder-derived "Angular" room
at follow_tunnels/list_tunnels read time, and exposing raw topic strings
(which may contain characters rejected by sanitize_name) to the MCP
surface.

Topic tunnels now store their room as "topic:<original-casing>" and carry
kind="topic" on the stored dict. Explicit tunnels get kind="explicit"
(default). follow_tunnels("wing", "Angular") on a literal Angular room
no longer surfaces topic connections for the same name, and any LLM
scanning list_tunnels has a visible discriminator.
2026-04-24 23:06:26 -03:00
Igor Lins e Silva fe051adc73 feat(graph): cross-wing tunnels by shared topics (#1180)
When two wings have one or more confirmed TOPIC labels in common, the
miner now drops a symmetric tunnel between them at mine time so the
palace graph reflects shared themes (frameworks, vendors, recurring
concepts).

- llm_refine: TOPIC label routes to a dedicated `topics` bucket so the
  signal survives confirmation instead of getting collapsed into
  `uncertain` and dropped.
- entity_detector / project_scanner: bucket plumbed through the
  detection pipeline; `confirm_entities` returns confirmed topics
  alongside people/projects.
- miner.add_to_known_entities: optional `wing` parameter records the
  confirmed topics under `topics_by_wing` in
  `~/.mempalace/known_entities.json`. Wing names do NOT leak into the
  flat known-name set used by drawer-tagging.
- palace_graph: `compute_topic_tunnels` and `topic_tunnels_for_wing`
  create symmetric tunnels via the existing `create_tunnel` API so they
  share dedup and persistence with explicit tunnels.
- miner.mine: post-file-loop pass calls `topic_tunnels_for_wing` for
  the freshly-mined wing. Failures are logged but never abort the mine.
- config: `topic_tunnel_min_count` knob (env
  `MEMPALACE_TOPIC_TUNNEL_MIN_COUNT` or `~/.mempalace/config.json`),
  default 1.

Tests cover topic persistence through init->mine, tunnel creation when
wings share a topic, no tunnel below threshold, cross-wing tunnel
retrieval via `list_tunnels`, dedup on recompute, case-insensitive
overlap, and the end-to-end mine-time wiring.

Out of scope for this PR (called out in the PR body): manifest-
dependency overlap, per-topic allow/deny lists, search-result surfacing.
2026-04-24 23:06:26 -03:00
copilot-swe-agent[bot] fbd0904799
test: cover embedding device fallback and bounded upserts
Agent-Logs-Url: https://github.com/MemPalace/mempalace/sessions/3213a67a-6871-4bb2-9ae0-23fa11001a22

Co-authored-by: igorls <4753812+igorls@users.noreply.github.com>
2026-04-24 23:06:50 +00:00
Igor Lins e Silva b150d33398 fix(mine): skip generated entities file 2026-04-24 01:42:19 -03:00
jp feba7e8043 fix(miner): same None-metadata guard for status() histogram loop
`status()` walks `col.get(include=["metadatas"])` and buckets each drawer
into a `wing_rooms[wing][room]` histogram. The same ChromaDB return shape
fixed in the search print path — `None` entries in the `metadatas` list
for drawers with no stored metadata — crashes the status command with:

    AttributeError: 'NoneType' object has no attribute 'get'

Applies the matching ``m = m or {}`` guard so None-metadata drawers roll
up under the existing `?/?` fallback bucket instead of killing the
command mid-tally. Reproduced on a 135K-drawer palace where two drawers
had `metadata=None`; both now show under `WING: ? / ROOM: ?` in the
tally while the command prints the full histogram as designed.

Adds a regression test that feeds `status()` a fake collection whose
`get()` returns a `None` in the middle of the metadatas list and asserts
both the fallback bucket and the real wing render.
2026-04-18 10:26:11 -07:00
mvalentsev 8bf940f861 fix: use i18n candidate patterns for entity extraction in miner and palace
entity_detector.py was refactored in #911 to load candidate patterns
from i18n locale JSON files, supporting non-Latin scripts (Cyrillic,
accented Latin, etc.). But three other code paths still hardcoded the
ASCII-only regex [A-Z][a-z]{2,}, silently missing non-Latin entity
names in metadata tagging, closet indexing, and registry lookups.

Replace the hardcoded regex with a shared _candidate_entity_words()
helper that reuses the same i18n candidate_patterns as entity_detector.
2026-04-16 10:35:40 +05:00