Skip to content

Commit f56d58e

Browse files
committed
v1.1.0 — quality & robustness pass from systematic testing
Conversion: OCR via tesseract stdin/stdout (fixes silent vision fallthrough under sparse/sandboxed PATH), scanned-PDF OCR via pypdfium2, PATH self-heal for Desktop. Digest: accumulative digestion + working reset; hard-split oversize chunks (no silent loss); dedupe + low-value-chunk skip + reported chunk cap (fixes hang on pathological corpora); memory-aware extraction concurrency. Quality: fixed entity-resolution over-merge (embeddings now only confirm token-sharing merges), nomic search_document/search_query prefixes for sharper recall. Lifecycle: idle floor lowered to 5s so MTA_IDLE is honest. 11 regression tests, all green.
1 parent 6c8f1be commit f56d58e

19 files changed

Lines changed: 402 additions & 40 deletions

.claude-plugin/marketplace.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,15 @@
66
},
77
"metadata": {
88
"description": "Local, token-free file digestion → knowledge-graph memory for Claude.",
9-
"version": "1.0.1",
9+
"version": "1.1.0",
1010
"pluginRoot": "."
1111
},
1212
"plugins": [
1313
{
1414
"name": "memorised-them-all",
1515
"source": "./",
1616
"description": "Convert any attachment to Markdown locally, then digest it into a token-free knowledge graph + exportable memory + offline mind map. 100% local & open-source; auto-installing; Apple-silicon first.",
17-
"version": "1.0.1",
17+
"version": "1.1.0",
1818
"license": "MIT",
1919
"homepage": "https://github.com/GRU-953/memorised-them-all",
2020
"keywords": ["memory", "knowledge-graph", "mind-map", "markitdown", "ocr",

.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "memorised-them-all",
3-
"version": "1.0.1",
3+
"version": "1.1.0",
44
"description": "Locally convert any attachment to Markdown and digest it into a token-free knowledge-graph memory for Claude — global synopsis, per-theme summaries, exportable Markdown notes, and an offline interactive mind map. 100% local, free & open-source, auto-installing, Apple-silicon first.",
55
"author": {
66
"name": "Aninda Sundar Howlader",

CHANGELOG.md

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,42 @@ All notable changes to **Memorised them All** are documented here. This project
44
adheres to [Semantic Versioning](https://semver.org/) and
55
[Keep a Changelog](https://keepachangelog.com/).
66

7+
## [1.1.0] — 2026-06-01
8+
9+
### Added
10+
- **Accumulative digestion**: digesting another folder into the same project now
11+
*extends* the memory (rebuilt from the full converted corpus on disk) instead
12+
of replacing it. `reset=True` clears the project first.
13+
- **Scanned-PDF OCR**: image-only PDFs are rasterised with pypdfium2 and OCR'd
14+
page-by-page (previously a no-op).
15+
- **Parallel extraction**: knowledge extraction runs across a memory-aware thread
16+
pool (`MTA_EXTRACT_WORKERS`, default auto: 1–3 by RAM) — the LLM calls are
17+
I/O-bound, but too much concurrency thrashes a unified-memory Mac running a 7B
18+
model, so the default scales with available memory.
19+
- **Workload guards**: identical chunks are de-duplicated, degenerate low-
20+
information passages are skipped, and a reported cap (`MTA_MAX_CHUNKS`, default
21+
1500) prevents pathological corpora from hanging — all surfaced in stats, never
22+
silent.
23+
- **PATH self-heal**: the server/CLI prepend common Homebrew/system bin dirs so
24+
`tesseract`/`ffmpeg`/`ollama` resolve under a host app's sparse PATH (Claude
25+
Desktop).
26+
27+
### Fixed
28+
- **Entity resolution no longer over-merges**: short proper nouns embed almost
29+
identically with nomic-embed-text (e.g. two unrelated organisations can score
30+
cosine ≈ 1.0), which previously collapsed distinct entities into a single node.
31+
Embeddings now only *confirm* a merge that also shares tokens (fuzzy floor), and
32+
the cosine threshold was raised. Distinct entities stay distinct.
33+
- **Better recall ranking**: prefix-aware embedding models (nomic) now receive
34+
their `search_document:` / `search_query:` task prefixes, sharpening retrieval.
35+
- **Honest idle timeout**: the on-demand Ollama idle-stop floor was lowered from
36+
30 s to 5 s so small `MTA_IDLE` values are respected (default remains 300 s).
37+
- **OCR robustness**: image OCR now pipes PNG bytes to `tesseract stdin stdout`
38+
(PSM 1 auto-orientation) instead of using temp files, which failed under
39+
sandboxed/sparse environments and silently fell through to vision captioning.
40+
- **No silent content loss**: an over-long unpunctuated passage is hard-split into
41+
chunk-sized windows, so the extractor's input cap no longer drops the tail.
42+
743
## [1.0.1] — 2026-06-01
844

945
### Fixed
@@ -45,5 +81,6 @@ The first public release.
4581
- **Distribution**: Claude Desktop `.mcpb`, Claude Code plugin/marketplace, PyPI
4682
package, and a Homebrew tap; CI and tagged releases with assets.
4783

84+
[1.1.0]: https://github.com/GRU-953/memorised-them-all/releases/tag/v1.1.0
4885
[1.0.1]: https://github.com/GRU-953/memorised-them-all/releases/tag/v1.0.1
4986
[1.0.0]: https://github.com/GRU-953/memorised-them-all/releases/tag/v1.0.0

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ mta update # pull the latest MarkItDown + dependencies
161161

162162
| Tool | What it does | Returns |
163163
| --- | --- | --- |
164-
| `digest(paths, project?, reset?)` | convert + digest files/dirs/globs | counts, paths, graph stats |
164+
| `digest(paths, project?, reset?)` | convert + digest files/dirs/globs (**accumulates** into the project; `reset=true` starts fresh) | counts, paths, graph stats |
165165
| `recall(query, project?, k?)` | answer from memory | a small, citable slice |
166166
| `memory_overview(project?)` | synopsis + themes | compact overview |
167167
| `export_memory(dest, project?)` | export portable Markdown | files written |
@@ -187,6 +187,8 @@ settings (Desktop).
187187
| `MTA_WHISPER_MODEL` | `base` | on-device transcription model |
188188
| `MTA_IDLE` | `300` | seconds of idle before Ollama is stopped |
189189
| `MTA_WORKERS` | `0` (auto) | parallel conversion workers |
190+
| `MTA_EXTRACT_WORKERS` | `0` (auto) | parallel extraction workers (memory-aware: 1–3 by RAM) |
191+
| `MTA_MAX_CHUNKS` | `1500` | safety cap on chunks per digest (truncation is reported) |
190192
| `MTA_COMMUNITY_ALGO` | `auto` | `leiden` · `louvain` · `greedy` |
191193
| `MTA_AUTO_UPDATE` | `on` | auto-update MarkItDown & dependencies |
192194
| `MTA_NO_OLLAMA` | unset | hard offline switch (classical + hashing) |

manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"manifest_version": "0.3",
33
"name": "memorised-them-all",
44
"display_name": "Memorised them All (local, token-free)",
5-
"version": "1.0.1",
5+
"version": "1.1.0",
66
"description": "Convert any attachment to Markdown and digest it into a token-free knowledge-graph memory — locally.",
77
"long_description": "Memorised them All turns a folder of documents into durable, **token-free** memory for Claude, entirely on your machine. It converts every attachment (PDF, Word, Excel, PowerPoint, HTML, EPub, images, audio, and more) to Markdown locally using Microsoft's MarkItDown (kept up to date from upstream automatically), Tesseract OCR, on-device Whisper, and a local Ollama vision model. It then runs an original local pipeline — semantic chunking, local embeddings, local-LLM (with a classical fallback) extraction of entities/relations/facts, embedding+fuzzy entity resolution, and community detection — to build a layered knowledge graph: a global synopsis, per-theme summaries, per-document Markdown notes, an exportable memory bundle, and an offline interactive mind map. Every tool returns only compact metadata or a tiny relevant slice, never document contents, so digesting and recalling whole folders costs ~0 Claude context tokens. Everything is free and open-source and runs locally; the Ollama model server starts on demand and stops after 5 minutes idle. Tuned for Apple M-series silicon (performance-core parallelism, GPU Whisper via MLX, unified-memory-aware concurrency).\n\nTools: digest, recall, memory_overview, export_memory, list_digestible, memory_status, open_mindmap.",
88
"author": {

mta/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,5 @@
33
Clean-room implementation by GRU-953 (Aninda Sundar Howlader).
44
"""
55

6-
__version__ = "1.0.1"
6+
__version__ = "1.1.0"
77
__author__ = "Aninda Sundar Howlader"

mta/cli.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,8 @@ def main(argv: list[str] | None = None) -> int:
5757
sub.add_parser("serve", help="run the MCP server (stdio)")
5858

5959
args = p.parse_args(argv)
60+
from .core.platform import bootstrap_path
61+
bootstrap_path()
6062
cfg = load_config().with_project(args.project)
6163

6264
if args.cmd == "digest":

mta/core/config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ class Config:
5353
community_algo: str = field(default_factory=lambda: _env("MTA_COMMUNITY_ALGO", "auto")) # auto|leiden|louvain|greedy
5454
chunk_chars: int = field(default_factory=lambda: _env_int("MTA_CHUNK_CHARS", 1200))
5555
recall_k: int = field(default_factory=lambda: _env_int("MTA_RECALL_K", 8))
56+
max_chunks: int = field(default_factory=lambda: _env_int("MTA_MAX_CHUNKS", 1500))
57+
extract_workers: int = field(default_factory=lambda: _env_int("MTA_EXTRACT_WORKERS", 0)) # 0=auto
5658

5759
# Lifecycle & maintenance.
5860
idle_seconds: int = field(default_factory=lambda: _env_int("MTA_IDLE", 300))

mta/core/convert.py

Lines changed: 64 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -65,21 +65,76 @@ def _try_markitdown(path: Path, cfg: Config) -> tuple[str | None, str]:
6565
return None, f"markitdown-error:{type(e).__name__}"
6666

6767

68-
def _try_ocr(path: Path, cfg: Config) -> tuple[str | None, str]:
68+
def _tesseract_bin() -> str | None:
69+
import shutil
70+
from .platform import bootstrap_path
71+
bootstrap_path()
72+
return shutil.which("tesseract")
73+
74+
75+
def _ocr_image_bytes(png_bytes: bytes, cfg: Config) -> str | None:
76+
"""OCR via piping PNG bytes to `tesseract stdin stdout` — no temp files.
77+
78+
The temp-file path pytesseract uses by default breaks under sandboxed/sparse
79+
environments; piping is robust everywhere and supports PSM 1 auto-orientation.
80+
"""
81+
import subprocess
82+
tess = _tesseract_bin()
83+
if not tess:
84+
return None
85+
lang = cfg.ocr_lang or "eng"
6986
try:
70-
import pytesseract
71-
from PIL import Image
72-
except Exception:
87+
proc = subprocess.run([tess, "stdin", "stdout", "-l", lang, "--psm", "1"],
88+
input=png_bytes, capture_output=True, timeout=180)
89+
return (proc.stdout.decode("utf-8", "replace").strip() or None)
90+
except (OSError, subprocess.SubprocessError):
91+
return None
92+
93+
94+
def _try_ocr(path: Path, cfg: Config) -> tuple[str | None, str]:
95+
if not _tesseract_bin():
7396
return None, "ocr-missing"
7497
try:
98+
import io
99+
100+
from PIL import Image
75101
with Image.open(path) as im:
76-
text = pytesseract.image_to_string(im, lang=cfg.ocr_lang or "eng")
77-
text = (text or "").strip()
78-
return (text or None), "tesseract"
102+
buf = io.BytesIO()
103+
im.convert("RGB").save(buf, format="PNG")
104+
text = _ocr_image_bytes(buf.getvalue(), cfg)
105+
return text, "tesseract"
79106
except Exception as e: # noqa: BLE001
80107
return None, f"ocr-error:{type(e).__name__}"
81108

82109

110+
def _ocr_pdf(path: Path, cfg: Config, max_pages: int = 50) -> tuple[str | None, str]:
111+
"""OCR a scanned/image-only PDF by rasterising pages with pypdfium2."""
112+
if not _tesseract_bin():
113+
return None, "ocr-missing"
114+
try:
115+
import io
116+
117+
import pypdfium2 as pdfium
118+
except Exception:
119+
return None, "pdf-ocr-missing"
120+
try:
121+
pdf = pdfium.PdfDocument(str(path))
122+
pages = []
123+
for i in range(min(len(pdf), max_pages)):
124+
bitmap = pdf[i].render(scale=2.0)
125+
pil = bitmap.to_pil()
126+
buf = io.BytesIO()
127+
pil.convert("RGB").save(buf, format="PNG")
128+
txt = _ocr_image_bytes(buf.getvalue(), cfg)
129+
if txt:
130+
pages.append(txt)
131+
pdf.close()
132+
joined = "\n\n".join(pages).strip()
133+
return (joined or None), "tesseract-pdf"
134+
except Exception as e: # noqa: BLE001
135+
return None, f"pdf-ocr-error:{type(e).__name__}"
136+
137+
83138
def _try_vision(path: Path, cfg: Config, ollama: OllamaManager) -> tuple[str | None, str]:
84139
if cfg.vision_mode == "off":
85140
return None, "vision-off"
@@ -165,8 +220,8 @@ def convert_file(path: Path, out_dir: Path, cfg: Config,
165220
text, method = _native_text(path)
166221
elif ext in _MARKITDOWN_EXTS:
167222
text, method = _try_markitdown(path, cfg)
168-
if not text and ext == ".pdf": # scanned PDF → OCR
169-
text, method = _try_ocr(path, cfg)
223+
if not text and ext == ".pdf" and cfg.ocr_mode != "off": # scanned PDF → OCR
224+
text, method = _ocr_pdf(path, cfg)
170225
elif ext in _IMAGE_EXTS:
171226
if cfg.ocr_mode != "off":
172227
text, method = _try_ocr(path, cfg)

mta/core/digest.py

Lines changed: 115 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -119,24 +119,58 @@ def digest(cfg: Config, paths: list[str], reset: bool = False,
119119
ollama = ollama or OllamaManager(cfg)
120120
t0 = time.time()
121121

122+
if reset:
123+
_reset_project(cfg)
124+
cfg.ensure_dirs()
125+
122126
files = _expand(paths)
123127
if not files:
124128
return {"status": "no_input", "project": cfg.project,
125129
"message": "No convertible files found.", "paths": paths}
126130

127131
conv = _convert_all(files, cfg, ollama)
128-
ok_md = [Path(c["output"]) for c in conv if c.get("status") == "ok" and c.get("output")]
132+
133+
# Accumulative: rebuild the graph from the FULL markdown corpus on disk, so
134+
# digesting another folder into the same project extends the memory rather
135+
# than replacing it. `reset=True` clears the corpus first (above).
136+
all_md = sorted(cfg.markdown_dir.glob("*.md"))
129137

130138
# Segment + extract.
131139
embedder = Embedder(cfg, ollama)
132140
all_chunks = []
133-
for md in ok_md:
141+
for md in all_md:
134142
all_chunks.extend(segment_file(md, cfg.chunk_chars))
135143

144+
# Dedupe identical chunks, drop degenerate low-information passages (repetitive
145+
# filler whose boundary-shifted windows defeat exact dedupe), then cap the
146+
# workload with explicit reporting (never silently truncate).
147+
unique: dict[str, object] = {}
148+
skipped_low_value = 0
149+
for ch in all_chunks:
150+
if _low_value(ch.text):
151+
skipped_low_value += 1
152+
continue
153+
unique.setdefault(ch.text, ch)
154+
unique_chunks = list(unique.values())
155+
truncated = 0
156+
if len(unique_chunks) > cfg.max_chunks:
157+
truncated = len(unique_chunks) - cfg.max_chunks
158+
unique_chunks = unique_chunks[:cfg.max_chunks]
159+
160+
# Extract across a modest, memory-aware pool — LLM calls are I/O-bound (HTTP
161+
# to Ollama); too much concurrency thrashes a unified-memory Mac running a 7B
162+
# model, so the default scales with RAM.
136163
extractions: list[tuple] = []
137164
mentions: list[dict] = []
138-
for ch in all_chunks:
139-
ex = extract_chunk(ch, cfg, ollama)
165+
workers = max(1, min(_auto_extract_workers(cfg), len(unique_chunks))) if unique_chunks else 1
166+
if workers > 1:
167+
from concurrent.futures import ThreadPoolExecutor
168+
with ThreadPoolExecutor(max_workers=workers) as tp:
169+
results = list(tp.map(lambda c: (c, extract_chunk(c, cfg, ollama)),
170+
unique_chunks))
171+
else:
172+
results = [(c, extract_chunk(c, cfg, ollama)) for c in unique_chunks]
173+
for ch, ex in results:
140174
extractions.append((ch, ex))
141175
mentions.extend(ex.entities)
142176

@@ -178,13 +212,14 @@ def digest(cfg: Config, paths: list[str], reset: bool = False,
178212
"edges": [{"source": u, "target": v, "weight": d["weight"],
179213
"labels": sorted(d.get("labels", []))} for u, v, d in G.edges(data=True)],
180214
"communities": communities,
181-
"documents": [{"name": c.get("source"), "output": c.get("output"),
182-
"status": c.get("status"), "method": c.get("method", ""),
183-
"chars": c.get("chars", 0)} for c in conv],
215+
"documents": _documents(cfg, all_md, conv),
184216
"stats": {
185217
"files": len(files),
186-
"converted": len(ok_md),
218+
"converted": len(all_md),
187219
"chunks": len(all_chunks),
220+
"unique_chunks": len(unique_chunks),
221+
"chunks_truncated": truncated,
222+
"chunks_skipped_low_value": skipped_low_value,
188223
"entities": G.number_of_nodes(),
189224
"relations": G.number_of_edges(),
190225
"communities": len(communities),
@@ -250,6 +285,78 @@ def _recall_units(graph_doc: dict) -> tuple[list[dict], list[str]]:
250285
return units, texts
251286

252287

288+
def _low_value(text: str) -> bool:
289+
"""True for degenerate, near-zero-information passages (repetitive filler).
290+
291+
Real prose has high lexical diversity; a window of one word repeated hundreds
292+
of times does not and is not worth an LLM call.
293+
"""
294+
words = text.split()
295+
if len(words) < 40:
296+
return False
297+
uniq = len(set(w.lower() for w in words))
298+
return (uniq / len(words)) < 0.12
299+
300+
301+
def _auto_extract_workers(cfg: Config) -> int:
302+
if cfg.extract_workers > 0:
303+
return cfg.extract_workers
304+
from .platform import memory_gb
305+
gb = memory_gb()
306+
# Conservative on unified-memory Macs running a 7B extractor.
307+
return 1 if gb < 16 else (2 if gb < 48 else 3)
308+
309+
310+
def _reset_project(cfg: Config) -> None:
311+
"""Wipe a project's converted corpus and derived memory (for reset=True)."""
312+
import shutil
313+
for path in (cfg.markdown_dir, cfg.memory_dir):
314+
shutil.rmtree(path, ignore_errors=True)
315+
for f in (cfg.graph_path, cfg.vectors_path,
316+
cfg.vectors_path.with_suffix(".json"), cfg.memory_md, cfg.mindmap_html):
317+
try:
318+
f.unlink()
319+
except OSError:
320+
pass
321+
322+
323+
def _parse_md_header(md: Path) -> tuple[str, str]:
324+
"""Recover (source name, method) from the provenance comment we write."""
325+
try:
326+
first = md.read_text(encoding="utf-8", errors="replace").splitlines()[0]
327+
except (OSError, IndexError):
328+
first = ""
329+
src, method = md.name[:-3] if md.name.endswith(".md") else md.name, ""
330+
if first.startswith("<!-- source:"):
331+
body = first.strip("<!-> ")
332+
for part in body.split("·"):
333+
part = part.strip()
334+
if part.startswith("source:"):
335+
src = part[len("source:"):].strip()
336+
elif part.startswith("method:"):
337+
method = part[len("method:"):].strip()
338+
return src, method
339+
340+
341+
def _documents(cfg: Config, all_md: list[Path], conv: list[dict]) -> list[dict]:
342+
"""Document manifest across the FULL corpus, plus this call's non-ok files."""
343+
docs = []
344+
for md in all_md:
345+
src, method = _parse_md_header(md)
346+
try:
347+
chars = max(0, len(md.read_text(encoding="utf-8", errors="replace")) - 60)
348+
except OSError:
349+
chars = 0
350+
docs.append({"name": src, "output": str(md), "status": "ok",
351+
"method": method, "chars": chars})
352+
for c in conv: # surface files that failed/were unsupported this run
353+
if c.get("status") != "ok":
354+
docs.append({"name": Path(c.get("source", "")).name, "output": None,
355+
"status": c.get("status"), "method": c.get("method", ""),
356+
"chars": 0})
357+
return docs
358+
359+
253360
def _conv_tally(conv: list[dict]) -> dict:
254361
tally: dict[str, int] = {}
255362
for c in conv:

0 commit comments

Comments
 (0)