diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c186316..90d2c99 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,8 +16,8 @@ jobs: env: PYTHONUTF8: "1" steps: - - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0 - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 with: python-version: ${{ matrix.python }} - run: python3 -m unittest discover -s tests -v diff --git a/CHANGELOG.md b/CHANGELOG.md index 535637e..7427bc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,45 @@ # Changelog +## 6.2.8 — 2026-07-10 + +Exhaustive line-by-line audit of code, tests, benchmarks, documentation, +release metadata, and the rendered presentation. Every item below was +reproduced against 6.2.7 before the fix: + +- **Concurrent edges no longer lose data.** Save merging is now per + directional edge pair instead of whole adjacency maps; concurrent links + remain symmetric, stale unrelated writers cannot erase a fresh edge boost, + reinforcement merges as a delta, and daily edge decay is decided inside the + graph lock so concurrent dreams decay only once. +- **Atomic writes reject truncation.** Unique O_EXCL temporary files replace + predictable per-pid names, short `os.write` calls are completed instead of + silently replacing the graph with partial JSON, failed writes clean up their + temporary file, and append records reject partial syscalls. +- **Agent export treats memories as data.** Multiline memories are collapsed, + guard markers are escaped, and the hot section explicitly forbids executing + directives found inside remembered text. The standing contract now forbids + secrets, credentials, private personal data, and untrusted prompt text. +- **Provenance is complete for the queried id.** `link` belongs to both + endpoints, `why ` streams the full journal beyond the 10 MB status tail, + and journal reads refuse symlinks. +- **Hostile live/on-disk state is repaired consistently.** Structurally invalid + live graphs are quarantined before save; malformed created/access timestamps, + history entries, loaded control characters, edge metadata, and oversized + actor/session environment values are repaired or bounded. +- **Determinism and command behavior tightened.** Recall ties have explicit + id tie-breakers; hot-memory weight ties prefer confirmed and recently used + facts; duplicate confirm ids count once; `link` validates both endpoints + before either can persist; `link`/`confirm` refresh agent files; help and + usage errors carry the real script path and reject extra args. +- **Presentation made defensible.** The stale competitor screenshot/checklist + is replaced by a neutral scope comparison based on current official docs; + soak output is reported as the measured 7/8 hot slots and gated at >=7; + short-token, working-memory-size, fuzz, privacy, and dream-phase wording now + match the implementation. Benchmark temp directories are cleaned. +- GitHub Actions pins updated to checkout v7.0.0 and setup-python v6.3.0. + 213 tests. Mutation kill rate: 39% on the seeded 120-mutant sample. + + ## 6.2.7 — 2026-07-06 - **Windows: agent-file reads retry transient sharing violations** — the diff --git a/README.ar.md b/README.ar.md index dfe0890..4d86b94 100644 --- a/README.ar.md +++ b/README.ar.md @@ -16,8 +16,8 @@ كودكس، كرسور، زد)، و`CLAUDE.md` (كلاود كود)، و`GEMINI.md` (جيمناي) — وتُتبنّى تلقائيًّا في `.cursorrules` و`.windsurfrules` و`.clinerules` و`.roo/rules/mind.md` للمشاريع التي تستخدم تلك الأدوات أصلًا. ```bash -curl -fsSLO https://raw.githubusercontent.com/Da7-Tech/mind/v6.2.7/mind.py -python3 -c "import hashlib;h=hashlib.sha256(open('mind.py','rb').read()).hexdigest();assert h=='c55594585f8d1dbb944e3ec56b57d6e36dc7c639ceeb0f5621f19ce45c66451b',h;print('mind.py: OK')" +curl -fsSLO https://raw.githubusercontent.com/Da7-Tech/mind/v6.2.8/mind.py +python3 -c "import hashlib;h=hashlib.sha256(open('mind.py','rb').read()).hexdigest();assert h=='b3e944ce9103bd5e353e09a12f434721cb274bc919326361039104889c6c5e03',h;print('mind.py: OK')" python3 mind.py init python3 mind.py remember "قاعدة بيانات المشروع بوستغرس ١٦" python3 mind.py recall "ما قاعدة البيانات" @@ -28,13 +28,14 @@ python3 mind.py dream # بين الجلسات: نسيان وتوطيد و ## أرقام مقيسة لا كلام -كل رقم أدناه من `python3 bench/bench.py` — أعد تشغيله بنفسك +الجدول الأول من `python3 bench/bench.py`، وكل قياس لاحق يسمّي أمره القابل +لإعادة الإنتاج — أعد تشغيلها بنفسك (٢٠ استعلامًا ثنائي اللغة بإجابات معلومة ضد رسوم مليئة بالمشتتات؛ الكمون يعتمد على بيئتك — أعد القياس على عتادك): | حجم الرسم | دقة أول نتيجة | دقة أول ٥ | الكمون الوسيط | الشريحة ٩٥ | |---|---|---|---|---| -| ١٠٠ عقدة | **١٫٠٠** | ١٫٠٠ | **~٠٫٥–٠٫٧ م.ث** | ~٢–٤ م.ث | -| ١٠٠٠ عقدة | **١٫٠٠** | ١٫٠٠ | ~٢–٣ م.ث | ~١١–١٦ م.ث | +| ١٠٠ عقدة | **١٫٠٠** | ١٫٠٠ | **~٠٫٨ م.ث** | ~٢٫٧–٣٫٠ م.ث | +| ١٠٠٠ عقدة | **١٫٠٠** | ١٫٠٠ | ~٣٫٣–٣٫٩ م.ث | ~١٣٫٧–١٥٫٥ م.ث | (فجوة ٠٫٩٥ التاريخية — سؤال بالفئة «ما إطار css» عن ذكرى لا تذكر إلا «tailwind» — أغلقتها بذرة المفاهيم في ٥٫٥٫٠؛ الترادف خارج البذرة والنص ما يزال حدًّا موثقًا أدناه.) @@ -43,37 +44,35 @@ python3 mind.py dream # بين الجلسات: نسيان وتوطيد و **اختبار معمّر ١٨٠ يومًا** (`bench/soak.py` — الكود الحقيقي بساعة محقونة وعبء عمل واقعي: حقائق يومية/أسبوعية/شهرية + ٣٥٧ ملاحظة قمامة + حلم كل ليلة): بقاء الحقائق الجوهرية **١٥/١٥** في كل الإيقاعات، بقاء القمامة الأقدم من -نافذة السماح **٠/٢٥٦**، الرسم منضبط الحجم، والاسترجاع على رسم معمّر ٠٫٣٧ م.ث. +نافذة السماح **٠/٢٥٦**، و**٧/٨** خانات الذاكرة الساخنة حقائق جوهرية، +والرسم منضبط الحجم، والاسترجاع على رسم معمّر نحو ٠٫٤–٠٫٥ م.ث. هذا الاختبار اصطاد عيبَي معايرة حقيقيين قبل النشر وأُصلحا باختبارات انحدار. -الاستعلام الوحيد الفاشل في المقياس قصورٌ موثَّق في قسم الحدود، لا مخفي. **متعددة اللغات بالقياس** (`bench/multilang.py`، يعمل في CI): الأداة مهندَسة للعربية والإنجليزية، لكن نواتها محايدة لغويًّا، و٥٫٦٫٠ أضافت فهرسة ثنائيات الحروف للنصوص عديمة المسافات. دقة أول نتيجة على ٨ لغات لم تُضبط لها قط (مع مشتتات): الفرنسية والألمانية والإسبانية والروسية والتركية والصينية واليابانية والكورية — **٣/٣ لكل لغة (٢٤/٢٤)**. (الصينية واليابانية كانتا ٣/٦ قبل ثنائيات الحروف؛ ٣ استعلامات للغة مقياس دخانيّ لا كحزمة العربية/الإنجليزية العشرين، والتصريف الثقيل بلا تجذير سيفوّت حالات لا يغطيها.) **التمييز بالقياس** (`bench/discrim.py`، يعمل في CI): تدقيقان مستقلان أصابا حين قالا إن استرجاع الإبرة من ضجيج نظيف لا يقيس التفريق بين حقائق تتشارك المفردات — فهذا المقياس مشتِّتاته كلها متنافسة معجميًّا، وفيه حالات فشل التدقيقَين حرفيًّا («ما اسمي» ضد «اسم الملف يجب أن يطابق اسم الصنف» بالعربية والإنجليزية). النتيجة الحالية: **١٢/١٢** ببوابة ≥٠٫٨٥ في CI كي لا يتراجع التمييز صامتًا مجددًا. -الاختبارات: **١٩٠ اختبارًا** بمكتبة بايثون القياسية وحدها + مشوّش (٤٢٠ حالة كاملة/١٦٠ في CI) + مطفّر. +الاختبارات: **٢١٣ اختبارًا** بمكتبة بايثون القياسية وحدها + مشوّش (٤٢٠ حالة كاملة/١٦٠ في CI) + مطفّر بنسبة قتل **٣٩٪** على العينة المبذورة ذات ١٢٠ طفرة. -## لماذا لا تستخدم ___؟ +## موضعها بين الأدوات — وظائف مختلفة لا لوحة فائزين -| | استرجاع انتشاري | توطيد نومي | صلاحية زمنية | سجل مصدر | أي وكيل | صفر إعداد | توطيد بلا كلفة | -|---|---|---|---|---|---|---|---| -| mem0 | ✗ | ✗ | ✗ | ~ | ✗ | ✗ مفاتيح LLM | ✗ | -| Letta | ✗ | ✓ | ✗ | ✗ | ✗ | ✗ منصة كاملة | ✗ يحرق توكنات | -| Zep / Graphiti | ~ | ✗ | ✓ ثنائي-الزمن (قوّته الأصيلة) | ✓ | ✗ | ✗ Neo4j+مفاتيح | ✗ | -| HippoRAG 2 | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ GPU/API | ✗ | -| OpenClaw dreams | ✗ | ✓ | ✗ | ✗ | ✗ داخل أوبن كلو فقط | ✓ | ✗ يحرق توكنات | -| claude-mem | ✗ | ✗ | ✗ | ~ | ~ | ✗ | ✗ | -| **mind** | **✓** | **✓ حتمي** | **✓ valid-time + ‎--at** | **✓ يومية إلحاقية + why** | **✓** | **✓ ملف واحد** | **✓** | +| المشروع | نطاقه الأساسي | خيار `mind` الأضيق | +|---|---|---| +| [Mem0](https://docs.mem0.ai/open-source/overview) | طبقة ذاكرة قابلة للتهيئة كمكتبة أو خادم ذاتي | ملف قياسي واحد بلا نموذج أو تضمينات أو مخزن متجهات | +| [Letta](https://docs.letta.com/letta-agent/memory/) | بيئة وكيل بذاكرة قابلة للتحرير وMemFS ووكلاء نوم | ذاكرة مشروع محايدة عن بيئة الوكيل عبر ملفات القواعد | +| [Graphiti](https://help.getzep.com/graphiti/getting-started/overview) | إطار رسم معرفة زمني باسترجاع هجين | رسم محلي صغير بصلاحية زمنية وJSON واضح | +| [HippoRAG 2](https://github.com/OSU-NLP-Group/HippoRAG) | إطار RAG/ذاكرة بحثي لاسترجاع معرفة واسع | ذاكرة تشغيلية لمئات أو آلاف حقائق المشروع | -إحقاقًا للحق: نموذج Graphiti ثنائيّ-الزمن هو المرجع في هذا الباب — طبقة الصلاحية في mind هي الأخذة الحتمية صفرية-التبعيات من الفكرة نفسها، لا ادعاء تفوق عليه. +نموذج Graphiti الزمني مرجع مهم في هذا الباب؛ طبقة الصلاحية في `mind` قراءة +حتمية صفرية التبعيات للفكرة، لا ادعاء تفوق عليه. -**مُشوَّشة** (`bench/fuzz.py`، مبذور حتمي، يعمل في CI): ٤٢٠ حالة عدائية — ملفات رسم معطوبة (NaN/لانهاية/أنواع خاطئة/محارف تحكم/JSON مبتور) ومدخلات أوامر عدائية؛ العقد: لا انهيار، لا فقد بيانات، والرسم يُحمَّل نظيفًا دائمًا. أول تشغيل للمشوّش اصطاد عيبًا حقيقيًّا فاتته ست جولات تدقيق سابقة. +**مُشوَّشة** (`bench/fuzz.py`، مبذور حتمي، يعمل في CI): ٤٢٠ حالة عدائية — ملفات رسم معطوبة (NaN/لانهاية/أنواع خاطئة/محارف تحكم/JSON مبتور) ومدخلات أوامر عدائية؛ العقد: لا traceback، والمدخل التالف يُصلح أو يُعزل، والرسم الناتج يُحمَّل نظيفًا ويقبل كتابة جديدة. أول تشغيل للمشوّش اصطاد عيبًا حقيقيًّا فاتته ست جولات تدقيق سابقة. -**مُختبَرة بالطفرات** (`bench/mutate.py`): تُحقن عيوب من الدرجة الأولى (قلب مقارنات، كسر حساب، دفع ثوابت) ويجب أن تصطادها الحزمة؛ أول تشغيل كشف ١٧ سلوكًا لم تكن الاختبارات تعضّ عليه — كلٌّ مثبَّت الآن باختبار مخصص. +**مُختبَرة بالطفرات** (`bench/mutate.py`): تُحقن عيوب من الدرجة الأولى (قلب مقارنات، كسر حساب، دفع ثوابت) ويجب أن تصطادها الحزمة؛ أول تشغيل كشف ١٧ سلوكًا لم تكن الاختبارات تعضّ عليه — كلٌّ مثبَّت الآن باختبار مخصص. النسبة أداة تشخيص لا درجة جودة؛ والناجون المطبوعون يشملون طفرات مكافئة، وفروع منصات أخرى، وحدود عرض، وقيم معايرة تحرسها بوابات المقاييس. ## كيف تعمل — ثلاث طبقات كالدماغ -- **الطبقة العاملة** `‎.mind/ACTIVE.md` — نحو ٢٠٠ توكن يراها الوكيل دائمًا (أحرّ الذكريات + فهرس القشرة)، تُحقن في ملفات الوكلاء. +- **الطبقة العاملة** `‎.mind/ACTIVE.md` — حمولة حقائق ساخنة بنحو ٢٠٠ توكن، فوق عقد الذاكرة الدائم، وتُحقن في ملفات الوكلاء. - **الحُصين** `‎.mind/graph.json` — رسم موزون؛ الاسترجاع بانتشار التنشيط (≤٣ قفزات) مدموجًا رتبيًّا (RRF+IDF) مع المطابقة المباشرة، مع إكمال الأنماط (إشارة ناقصة تكفي) وفصل الأنماط (لا تكرار في النتائج). - **القشرة** `‎.mind/cortex/‎` — معرفة موطَّدة دائمة يغذيها الحالم. - **الحالم** — نوم خفيف (إشارات الجلسة) ← عميق (اضمحلال إيبنغهاوس + تشذيب مشبكي) ← حركي سريع (عنقدة وترقية + كشف تناقضات دون حذف تلقائي أبدًا). @@ -92,7 +91,7 @@ python3 mind.py correct "قاعدة ماي إس كيو إل" "قاعدة الب - **مصدر عند الكتابة:** كل عقدة تسجل `origin` (من كتبها — الوكلاء يصدّرون `MIND_BY`/`MIND_SESSION` — وعبر أي أمر)، وكل تحوير (تذكر/ربط/تأكيد/تصحيح/تقليم) يُلحق بـ**`.mind/journal.jsonl`** — سجل إلحاقي **لا يُدوَّر ولا يُمحى أبدًا** (بعكس signals.jsonl وهو قياس جلسة يُمسح). حتى بعد تقليم الحقيقة يبقى نسبها في اليومية. - **صحة منفصلة عن الانتباه:** `weight` يقيس البروز، و`valid_from`/`valid_to` يقيسان الصحة. الاضمحلال يلمس البروز فقط — **النسيان لا يجعل شيئًا كاذبًا أبدًا**؛ الإبطال لا يقع إلا بـ`correct` صريح. -- **اسأل الرسم:** `why ` (المنشأ والصلاحية وكل أحداث الحياة)، `entity "قاعدة"` (كل الحقائق عن مصطلح — الجارية والمنسوخة بفتراتها)، `recall "سؤال" --at 2026-01-15` (ما الذي كان صحيحًا حينها). +- **اسأل الرسم:** `why ` (المنشأ والصلاحية وعدد الأحداث الكامل مع آخر ثمانية؛ الملف الإلحاقي هو السجل الكامل)، `entity "قاعدة"` (كل الحقائق عن مصطلح — الجارية والمنسوخة بفتراتها)، `recall "سؤال" --at 2026-01-15` (ما الذي كان صحيحًا حينها). **حدّ صادق:** حلّ الكيانات **معجميّ** (التطبيع + التجذير + بذرة المفاهيم توحّد الهجاءات والتصريفات وثنائية العربية/الإنجليزية لنفس المصطلح) — الضمائر والأوصاف الحرة («الموظفة الجديدة» = «سارة») لا تُحل؛ ذلك يحتاج نموذجًا ويكسر وعد صفر التبعيات. @@ -112,10 +111,10 @@ python3 mind.py correct "قاعدة ماي إس كيو إل" "قاعدة الب ## خصائص الأمان -- كتابة ذرّية **معمّرة** ترفض الروابط الرمزية في كل مكان (fsync قبل الاستبدال — تصمد لانقطاع الطاقة، وملف القفل نفسه محصّن) +- كتابة ذرّية **معمّرة** بملف مؤقت فريد وفحص للكتابة الكاملة وfsync للملف والمجلد على POSIX، وترفض الروابط الرمزية - لا إتلاف صامت أبدًا: الرسم التالف يُعزل لا يُمحى، والمنسيّ بالاضمحلال يُؤرشف في `‎.mind/archive.md` لا يُدمر (وإن تعذرت الأرشفة لا يُحذف شيء أصلًا)، ومحتواك في ملفات الوكلاء محفوظ خارج علامات الحماية - `dream --dry-run` يعرض الخطة كاملة دون لمس القرص -- قفل ملفات عند الحفظ — آمن مع عمليات وكلاء متوازية +- قفل ملفات عند الحفظ — العقد تُدمج على مستوى الحقول، والحواف على مستوى كل اتجاه، واضمحلال الحواف اليومي يُحسم داخل القفل - الذاكرة ملفات نصية صرفة: راقبها بـ`git diff` وزامنها واقرأها ## حدود صادقة @@ -123,16 +122,17 @@ python3 mind.py correct "قاعدة ماي إس كيو إل" "قاعدة الب - الاسترجاع معجمي-بنيوي + **بذرة مفاهيم** منتقاة (٨٣ ربطة أداة←فئة لا لبس فيها: tailwind←css، hetzner←cloud...)؛ الترادف خارج البذرة والنص ما يزال يفوت، والكلمات متعددة المعاني مستبعدة عمدًا من البذرة (فئة كاذبة على جملة عادية أسوأ من ترادف فائت). - التجذير العربي خفيف (بادئات/لواحق + بذرة جموع تكسير)، ليس محللًا صرفيًّا كاملًا؛ وبقية اللغات بلا تجذير أو قوائم توقف أصلًا — IDF ومقاطع الحروف يحملانها (مقيس أعلاه) لكن الاستعلامات كثيفة التصريف ستفوت أكثر من العربية/الإنجليزية. - النصوص عديمة المسافات (صيني/ياباني/كوري/تايلندي) تُفهرس ثنائياتِ حروف لا تقطيعَ كلمات حقيقيًّا — مقايضة محركات البحث القياسية: استرجاع ممتاز مع تداخل ثنائيات كاذب أحيانًا. -- الرموز الأقصر من ٣ أحرف (`db`, `ai`, `os`) لا تُفهرَس — اكتبها كاملة مرة واحدة («database», «openai») وفهرس التلازم يجسر الباقي. +- الذكريات المؤلفة من رموز قصيرة (`db ai os`) تُفهرس بمسار حرفي احتياطي، لكنها تحمل دلالة أقل من الكلمات الموسعة، والرمز اللاتيني المفرد يُهمَل. - ذكرى استُرجعت أقل من مرتين ولم تُلمس بعد نافذة السماح (٤٥ يومًا) تنزل إلى `.mind/archive.md` بالتصميم (تُستعاد بـ`remember`). - الطوابع زمن محلي ساذج يقارَن معجميًّا (ISO): دقيق على جهاز واحد؛ مزامنة `.mind/` بين جهازين بمنطقتين زمنيتين قد تحرف ترتيب الصلاحية بفارق المنطقة. - معرفات العقد `md5[:12]` من النص — عنونة محتوى فقط، لا تُبنى عليها أي خاصية أمنية. +- الذاكرة والحقائق الساخنة المصدَّرة نص صريح؛ لا تحفظ أسرارًا أو مفاتيح أو بيانات شخصية خاصة أو نصوص prompt غير موثوقة. تُحيد البنية والعلامات عند العرض، لكنها تبقى مرئية للوكيل. - أكواد الخروج عقد ثابت: ٠ نجاح (حتى «لا نتائج»)، ١ فشل تشغيلي، ٢ خطأ استخدام. - مصممة لذاكرة وكيل شخصية/مشروعية (مئات إلى آلاف العقد)، لا لاسترجاع مؤسسي على ملايين الوثائق. ## التعزيز الصريح -`recall` قراءة خالصة لا تلمس القرص (الفحوص المتكررة لا تحرف الأوزان). حين تجيبك ذكرى فعلًا شغّل `confirm ` — تترسخ (+أسبوعا ثبات) وتقوى حوافها، وكل حلم يُضعف كل الحواف قليلًا (استتباب مشبكي) فتضمحل الروابط التي لا تكسب تأكيدًا وتُقلَّم. +`recall` قراءة خالصة لا تلمس القرص (الفحوص المتكررة لا تحرف الأوزان). حين تجيبك ذكرى فعلًا شغّل `confirm ` — تترسخ (+أسبوعين من الثبات) وتقوى حوافها، وكل حلم يُضعف كل الحواف قليلًا (استتباب مشبكي) فتضمحل الروابط التي لا تكسب تأكيدًا وتُقلَّم. ## التشغيل التلقائي (٦٫٢٫٠) — ذاكرة تعمل دون أن يُطلب منها @@ -140,11 +140,11 @@ python3 mind.py correct "قاعدة ماي إس كيو إل" "قاعدة الب ١. **العقد يركب الملف الذي يُحمَّل دائمًا.** `init` يزرع «أوامر دائمة» في `AGENTS.md`/`CLAUDE.md`/`GEMINI.md` — الملفات التي تحمّلها وكلاء البرمجة هذه تلقائيًّا كل جلسة (أي وكيل يقرؤها يحصل على العقد): احفظ الحقائق المستقرة أثناء العمل (مع قائمة منعٍ عدوانية ضد التعفن)، واحفظ ١-٣ حقائق قبل إنهاء أي مهمة جوهرية، واستدعِ قبل ادعاء الجهل — **دون استئذان المستخدم أبدًا**. والأوامر المصدَّرة تحمل المسار الحقيقي لـ`mind.py` فتعمل من أي مكان. -٢. **التوطيد ذاتي التشغيل.** بعد أوامر الكتابة يشتغل الحلم الكامل (اضمحلال/ترقية/فحص تناقضات) تلقائيًّا عند استحقاقه — يوميًّا أو كل ١٠ إشارات كتابة. لا كرون ولا خادم: يعمل في الحاويات وCI. للتعطيل: `MIND_AUTO_DREAM=0`. +٢. **التوطيد ذاتي التشغيل.** بعد أوامر الكتابة يشتغل الحلم الكامل (اضمحلال/تشذيب مشبكي/ترقية/فحص تناقضات) تلقائيًّا عند أول كتابة بلا حلم في اليوم أو كل ١٠ إشارات كتابة. لا كرون ولا خادم: يعمل في الحاويات وCI. للتعطيل: `MIND_AUTO_DREAM=0`. ٣. **الترسيخ يُكتسب بالاستعمال.** `recall` يطبع المعرفات والعقد يجعل الوكيل يؤكد ما أجابه فعلًا — فتترسخ الذكريات النافعة وتذوي المهملة. -مُختبَر ميدانيًّا بست محاكاة وكيل-في-مشروع (الوكيل يرى AGENTS.md المصدَّر فقط ولا يُخبَر عن الذاكرة): حفظ التفضيلات والدروس دون طلب، وصحّح حقيقة مهاجرة بـ`correct` بدل التكرار، واستدعى وعزّز عبر إعادة جلسة، وصفر نفايات في جلسة ثرثرة، وتوطيد تلقائي منتصف الجلسة عند العتبة. +خلال تطوير ٦٫٢٫٠ أُجريت يدويًّا ست محاكاة وكيل-في-مشروع (الوكيل يرى AGENTS.md المصدَّر فقط ولا يُخبَر عن الذاكرة): حفظ التفضيلات والدروس دون طلب، وصحّح حقيقة مهاجرة بـ`correct` بدل التكرار، واستدعى وعزّز عبر إعادة جلسة، وصفر نفايات في جلسة ثرثرة، وتوطيد تلقائي منتصف الجلسة عند العتبة. ## الاستخدام مع كيمي كود وهيرميس وكلاود كود وكودكس وجيمناي @@ -153,7 +153,7 @@ python3 mind.py correct "قاعدة ماي إس كيو إل" "قاعدة الب ## التطوير ```bash -python3 -m unittest discover -s tests # ١٩٠ اختبارًا +python3 -m unittest discover -s tests # ٢١٣ اختبارًا python3 bench/bench.py # أرقام العربية/الإنجليزية python3 bench/multilang.py # ٨ لغات غير مضبوطة python3 bench/discrim.py # التمييز بمشتتات متنافسة diff --git a/README.md b/README.md index d906723..d49e5b5 100644 --- a/README.md +++ b/README.md @@ -23,8 +23,8 @@ adopted automatically by `.cursorrules`, `.windsurfrules`, `.clinerules` and `.roo/rules/mind.md` in projects that already use those tools. ```bash -curl -fsSLO https://raw.githubusercontent.com/Da7-Tech/mind/v6.2.7/mind.py -python3 -c "import hashlib;h=hashlib.sha256(open('mind.py','rb').read()).hexdigest();assert h=='c55594585f8d1dbb944e3ec56b57d6e36dc7c639ceeb0f5621f19ce45c66451b',h;print('mind.py: OK')" +curl -fsSLO https://raw.githubusercontent.com/Da7-Tech/mind/v6.2.8/mind.py +python3 -c "import hashlib;h=hashlib.sha256(open('mind.py','rb').read()).hexdigest();assert h=='b3e944ce9103bd5e353e09a12f434721cb274bc919326361039104889c6c5e03',h;print('mind.py: OK')" python3 mind.py init python3 mind.py remember "the project database is postgres 16" python3 mind.py recall "which database do we use" @@ -39,15 +39,16 @@ No server, no vector store, no embedding model, no configuration file. ## Measured, not vibes -Every number below comes from `python3 bench/bench.py` — rerun it yourself +The first table comes from `python3 bench/bench.py`; each later measurement +names its own reproducible command — rerun them yourself (Python 3.14 on Apple M-series — latencies are environment-dependent, rerun for your hardware; 20 bilingual queries with known answers against distractor-filled graphs): | graph size | recall@1 | recall@5 | median latency | p95 | |---|---|---|---|---| -| 100 nodes | **1.00** | 1.00 | **~0.5–0.7 ms** | ~2–4 ms | -| 1,000 nodes | **1.00** | 1.00 | ~2–3 ms | ~11–16 ms | +| 100 nodes | **1.00** | 1.00 | **~0.8 ms** | ~2.7–3.0 ms | +| 1,000 nodes | **1.00** | 1.00 | ~3.3–3.9 ms | ~13.7–15.5 ms | (The long-standing 0.95 miss — a category question, "what css framework", against a memory that only said "tailwind" — is closed by the curated @@ -61,8 +62,8 @@ identical consolidation plan. clock with a realistic workload: daily/weekly/monthly facts + 357 junk notes + a dream every night): core-fact survival **15/15** across all cadence tiers, junk older than the grace window surviving: **0/256**, working -memory **8/8** hot slots held by core facts, graph size bounded -(~106 nodes), recall on the aged graph ~0.4 ms. The soak caught two +memory **7/8** hot slots held by core facts, graph size bounded +(~106 nodes), recall on the aged graph ~0.4–0.5 ms. The soak caught two real calibration bugs before release (facts pruned one day before their first monthly recall; decayed weight vetoing exact matches) — both fixed with regression tests. @@ -94,24 +95,27 @@ regress again. **Fuzzed** (`bench/fuzz.py`, seeded + deterministic): 420 adversarial cases in the full run (CI runs the 160-case quick set on every push) — hostile graph files (NaN/Infinity, wrong-typed fields, control characters, truncated JSON, dangling edges) and hostile CLI input. -Contract: never a traceback, never data loss, the graph always loads clean -afterwards. Its first run caught a real defect that six earlier audit +Contract: never a traceback; corrupt input is quarantined or repaired; the +resulting graph loads clean and accepts a new write. Its first run caught a +real defect that six earlier audit rounds had missed (see CHANGELOG 5.5.0). **Mutation-tested** (`bench/mutate.py`): first-order defects are injected (flipped comparisons, broken arithmetic, nudged constants) and the suite must catch them. Its first run exposed 17 behaviors the tests didn't actually pin down — each is now locked by a dedicated regression test -(raw kill rate on the seeded 120-mutant sample: 33% at first run, 45% +(raw kill rate on the seeded 120-mutant sample: 33% at first run, +**39%** on this release — the sample is re-drawn whenever the file changes, so the number moves a few points between releases; it is remeasured and republished each time because hiding it would be the exact sin this tool exists to catch). -Surviving mutants are triaged in the tool's output: unreachable `get()` -defaults, display-only constants, and ranking-calibration values guarded -by the CI benchmark gate (recall@1 ≥ 0.9) rather than unit assertions. +The rate is diagnostic, not a quality score. Survivors include equivalent +local calculations superseded by the locked merge, platform-only branches, +boundary/display constants, and ranking calibration guarded by the CI +benchmark gates rather than exact unit assertions; the tool prints every one. -Test suite: **190 tests**, stdlib `unittest`, `python3 -m unittest discover -s tests` — +Test suite: **213 tests**, stdlib `unittest`, `python3 -m unittest discover -s tests` — including regression tests for concurrency (parallel writers must not lose each other's memories), destructive-op gating, corrupt-graph recovery, and a mutation-kill class where every test pins a behavior the suite @@ -121,7 +125,7 @@ previously didn't bite on. ``` Layer 1 WORKING MEMORY .mind/ACTIVE.md → injected into agent rule files - the ~200-300 tokens the agent always sees: hottest memories + cortex index + a ~200-token hot-fact payload plus the standing memory contract Layer 2 HIPPOCAMPUS .mind/graph.json → weighted concept graph recall = spreading activation (≤3 hops) fused with direct keyword @@ -151,8 +155,9 @@ python3 mind.py correct "database mysql" "the database is postgres 16" # returning the old fact — but the graph still knows you migrated. # Closed facts stay in the graph through the 45-day grace window, then # archive; beyond that the lineage lives in the successor's history -# entries and the journal (forever), so `--at` answers within the -# retention window and `why` answers always. +# entries and the journal, so `--at` answers within the graph-retention +# window and `why` scans the full journal for that id even after pruning, +# reporting the full count and latest events. ``` ## Provenance & time — "where did this fact come from, and is it still true?" @@ -189,30 +194,22 @@ same term) — pronouns and free descriptions ("the new hire" = "Sara") are not resolved; that needs a model and would break the zero-dependency promise. -## Why not just use ___? +## Positioning — different tools, different jobs -![why not just use](assets/why-not-just-use.jpeg) +This is not a winner board. The neighboring projects are broader systems with +different tradeoffs: -| | spreading-activation recall | sleep consolidation | temporal validity | provenance log | works with any agent | zero setup / zero keys | consolidation costs $0 (no LLM) | -|---|---|---|---|---|---|---|---| -| mem0 | ✗ | ✗ | ✗ | ~ metadata | ✗ (SDK) | ✗ needs LLM + embedder keys | ✗ | -| Letta (MemGPT) | ✗ | ✓ sleep-time compute | ✗ | ✗ | ✗ (own server) | ✗ full platform | ✗ burns tokens | -| Zep / Graphiti | ~ graph traversal | ✗ | ✓ bi-temporal (its core strength) | ✓ | ✗ | ✗ Neo4j + LLM keys | ✗ | -| HippoRAG 2 | ✓ PageRank | ✗ | ✗ | ✗ | ✗ (batch RAG lib) | ✗ GPU/API | ✗ | -| OpenClaw dreams | ✗ | ✓ | ✗ | ✗ | ✗ (OpenClaw only) | ✓ inside OpenClaw | ✗ burns tokens | -| claude-mem | ✗ | ✗ compression | ✗ | ~ session refs | ~ several agents | ✗ Bun + worker + Chroma | ✗ | -| **mind** | **✓** | **✓ deterministic** | **✓ valid-time + `--at`** | **✓ append-only journal + `why`** | **✓ AGENTS/CLAUDE/GEMINI** | **✓ one file** | **✓** | - -Credit where due: Graphiti's bi-temporal model is the reference point for -temporal knowledge graphs — mind's valid-time layer (6.0.0) is the -zero-dependency, deterministic take on the same idea, not a claim to -have out-modeled it. - -Honest note: [Brain Memory](https://github.com/omelas-tech/brain) is the -closest project in spirit (files + decay + sleep phases) — credit where due. -`mind` differs in being a single copy-able file, bilingual EN/AR at the -tokenizer level, fully deterministic in consolidation, and shipping with a -reproducible benchmark instead of claims. +| project | primary scope | `mind`'s narrower choice | +|---|---|---| +| [Mem0](https://docs.mem0.ai/open-source/overview) | configurable memory layer, library or self-hosted server | one copied stdlib file; no LLM, embedder, vector store, or service | +| [Letta](https://docs.letta.com/letta-agent/memory/) | agent runtime with editable memory, MemFS, and sleep-time agents | runtime-agnostic project memory exported through common rule files | +| [Graphiti](https://help.getzep.com/graphiti/getting-started/overview) | temporal knowledge-graph framework with hybrid retrieval | small local lexical graph with valid-time and a plain JSON format | +| [HippoRAG 2](https://github.com/OSU-NLP-Group/HippoRAG) | research RAG/memory framework for large knowledge retrieval | operational memory for 10²–10³ project facts | + +Graphiti's temporal model is the reference point for this class of problem; +`mind`'s valid-time layer is a deterministic zero-dependency interpretation, +not a claim to have out-modeled it. [Brain Memory](https://github.com/omelas-tech/brain) +is the closest project in spirit (files + decay + sleep phases). ## Commands @@ -237,8 +234,9 @@ so connections that never earn a confirmation decay and prune away. ## Safety properties -- **Atomic, durable, symlink-refusing writes** everywhere — O_NOFOLLOW + - fsync-before-rename (survives power loss), the lock file itself is opened +- **Atomic, durable, symlink-refusing writes** everywhere — unique O_EXCL + temporary files, full-write checks, fsync-before-rename + directory fsync + on POSIX (survives power loss), the lock file itself is opened symlink-safe, and every internal write also rejects a symlinked *parent* directory so nothing can escape the `.mind/` boundary - **Never silently destroys data**: corrupt graphs are quarantined, not erased; @@ -246,7 +244,8 @@ so connections that never earn a confirmation decay and prune away. archive cannot be written, nothing is pruned at all; user content in `AGENTS.md`/`CLAUDE.md` is preserved outside guard markers - **`dream --dry-run`** previews the full plan without touching disk -- **File-locked saves** — safe under concurrent agent processes +- **File-locked saves** — nodes merge per field, edges per directional pair, + reinforcement as deltas, and daily edge decay is decided inside the lock - Memory files are plain JSON + Markdown: `git diff` them, sync them, read them ## Honest limitations @@ -269,14 +268,17 @@ so connections that never earn a confirmation decay and prune away. overlap between unrelated phrases. - Optimized for personal/project agent memory (10²–10³ nodes), not enterprise RAG over millions of documents — use a real graph DB for that. -- Tokens shorter than 3 characters (`db`, `ai`, `os`) are not indexed — - write them out once ("database", "openai") and the co-occurrence index - bridges the rest. +- Short-token-only memories (`db ai os`) use a literal fallback and are + recallable, but abbreviations carry less semantic evidence than expanded + terms and single-character Latin tokens are ignored. - Timestamps are naive local time compared lexicographically (ISO). On a single machine this is exact; syncing one `.mind/` across machines in different time zones can skew validity ordering by the zone offset. - Node ids are `md5[:12]` of the text — content addressing only; no security property is (or should be) derived from them. +- Memory and exported hot facts are plain text. Never store credentials, + secrets, private personal data, or untrusted prompt text; hot memories are + structurally escaped and labeled as data, but they remain agent-visible. - A fact recalled fewer than twice and untouched for longer than the 45-day grace window decays out of the graph (into the archive). Facts you need less often than ~every six weeks should live in cortex notes, not the @@ -298,15 +300,15 @@ and OpenClaw uses for its workspace memory: for permission**. The exported commands carry the real path to your `mind.py`, so they work from anywhere. 2. **Consolidation self-runs.** After write commands, a full dream cycle - (decay, dedup, promotion, conflict scan) fires automatically when due — - at most once per `MIND_AUTO_DREAM` window (default: daily, or every 10 - pending write signals). No cron, no server: it works in containers and + (decay, synaptic pruning, promotion, conflict scan) fires automatically + when due — on the first write without a dream that day, or after every 10 + pending write signals. No cron, no server: it works in containers and CI. Disable with `MIND_AUTO_DREAM=0`; force one with `mind dream`. 3. **Reinforcement is earned by use.** `recall` prints ids; the contract has the agent `confirm` hits that actually answered — so exactly the memories that keep proving useful harden, and unused ones fade. -Field-tested end-to-end: six simulated agent-in-project scenarios (an +During 6.2.0 development, six manual agent-in-project simulations (an agent given ONLY the exported `AGENTS.md`, never told about memory) saved stated preferences and technical lessons unprompted, corrected a migrated fact via `correct` instead of duplicating it, recalled and @@ -339,7 +341,7 @@ optional, since auto-dream already covers it. ## Development ```bash -python3 -m unittest discover -s tests # 190 tests +python3 -m unittest discover -s tests # 213 tests python3 bench/bench.py # reproduce the EN/AR numbers python3 bench/multilang.py # 8 untuned languages python3 bench/soak.py # 180 simulated days diff --git a/SECURITY.md b/SECURITY.md index 68f9977..7c7c988 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -13,27 +13,34 @@ hardening suggestions. You can expect an initial response within a few days. ## Security properties (and their tests) -- Atomic, durable writes: every replace-path write is O_NOFOLLOW + - fsync-before-rename; the append-only logs (provenance journal, - archive, dream journal, signals) use single O_NOFOLLOW+O_APPEND - writes, fsynced on the permanence-bearing ones, - including the lock file (`tests/test_mind.py` — the audit-regression classes - `TestAuditFindings2`, `TestThirdAudit`, `TestFourthAudit`). +- Atomic, durable writes: every replace-path write uses an unpredictable + O_EXCL temporary file, checks that every byte was written, fsyncs before + rename, and fsyncs the destination directory on POSIX. Append-only logs + (provenance journal, archive, dream journal, signals) use one checked + O_NOFOLLOW+O_APPEND write, fsynced on the permanence-bearing ones. + The lock file is opened O_NOFOLLOW (`tests/test_mind.py` — the regression + classes `TestAuditFindings2`, `TestThirdAudit`, `TestFourthAudit`). - Symlinked agent files, lock files, archive files and export parents are refused or skipped — never written through. The lock file's parent chain is checked before creation, so even a symlinked `.mind/` root cannot cause a single file to be created outside the project (regression test: `test_symlinked_mind_root_refused_entirely`). - Memory text is stripped of terminal control characters on every write - path, including `correct`. + and load path, including `correct`. Hot memories are collapsed to one + data-labeled line and HTML guard markers are escaped before agent export. - Hostile on-disk state is repaired, not trusted: a seeded fuzzer (`bench/fuzz.py`, 420 adversarial cases full / 160 quick in CI) holds - the contract - "no traceback, no data loss, graph always loads clean" against corrupt - graph files and hostile CLI input. -- The provenance journal (`journal.jsonl`) is append-only and written - behind the same symlink/parent-boundary checks as every other file. + the contract: no traceback; corrupt input is quarantined or repaired; the + resulting graph loads clean and accepts a new write. +- The provenance journal (`journal.jsonl`) is append-only; both writes and + reads refuse symlinks. `why ` streams the full file, while unfiltered + status reads stay tail-bounded. +- Concurrent graph saves merge nodes per field and edges per directional + pair; reinforcement is a delta, and once-per-day edge decay is decided + while holding the graph lock. +- The memory is plain text and exported hot facts are agent-visible. Do not + store secrets, credentials, private personal data, or untrusted prompt text. - Node ids are `md5[:12]` content addresses — no security property is derived from them. -- No network access, no subprocess execution, no eval — the file can be - fully audited in one sitting (~2,600 lines). +- No network access, subprocess execution, or eval — the file can be fully + audited in one sitting (~2,900 lines). diff --git a/SKILL.md b/SKILL.md index a83675b..914141b 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,7 +1,7 @@ --- name: mind description: Project memory graph with recall, provenance, and dreams. -version: 6.2.7 +version: 6.2.8 author: Da7 (Da7-Tech) license: MIT platforms: [linux, macos, windows] @@ -36,8 +36,9 @@ built-in `memory` tool — and it is not a RAG system for large corpora. - `python3` (3.9+) and `curl` on PATH — nothing else: no API keys, no server, no packages. The tool is one stdlib-only file, MIT-licensed, - from https://github.com/Da7-Tech/mind (190 tests + benchmarks incl. 10 languages + discrimination + fuzzer + 180-day - soak test run in its CI on Linux/macOS/Windows). + from https://github.com/Da7-Tech/mind (213 tests + benchmarks incl. + 10 languages + discrimination + fuzzer + 180-day soak test run in its CI + on Linux/macOS/Windows). ## How to Run @@ -46,8 +47,8 @@ tag and integrity-checked: ```bash cd -curl -fsSLO https://raw.githubusercontent.com/Da7-Tech/mind/v6.2.7/mind.py -python3 -c "import hashlib;h=hashlib.sha256(open('mind.py','rb').read()).hexdigest();assert h=='c55594585f8d1dbb944e3ec56b57d6e36dc7c639ceeb0f5621f19ce45c66451b',h;print('mind.py: OK')" +curl -fsSLO https://raw.githubusercontent.com/Da7-Tech/mind/v6.2.8/mind.py +python3 -c "import hashlib;h=hashlib.sha256(open('mind.py','rb').read()).hexdigest();assert h=='b3e944ce9103bd5e353e09a12f434721cb274bc919326361039104889c6c5e03',h;print('mind.py: OK')" python3 mind.py init ``` @@ -86,13 +87,17 @@ their rule files synced too (adopted only when present). 4. Provenance is automatic (append-only `.mind/journal.jsonl`, never cleared). Set `MIND_BY` and `MIND_SESSION` env vars when running commands so `why` can attribute facts to you/this session. -5. Consolidation is SELF-RUNNING (6.2.0): after write commands, a full +5. Never put credentials, tokens, private personal data, or untrusted prompt + text in project memory. Hot facts are exported into agent instruction files. + Durable user facts unrelated to this project belong in Hermes' built-in + `memory` tool, not here. +6. Consolidation is SELF-RUNNING (6.2.0): after write commands, a full dream cycle fires automatically when >= 10 signals pend or no dream has happened yet today (including a fresh project's very first write) — you normally never schedule anything. `dream` forces a cycle; it is deterministic and reversible (archive, never delete); use `--dry-run` only when the user explicitly asks to review the plan. Every action is explained in `.mind/dreams/.md`. -6. Optional belt-and-suspenders for projects that go DAYS without any +7. Optional belt-and-suspenders for projects that go DAYS without any write (auto-dream piggybacks on writes): a zero-token nightly cron via the `cronjob` tool in no-agent mode. Use the `write_file` tool to create `~/.hermes/scripts/mind_dream.sh` with this body: @@ -124,7 +129,7 @@ their rule files synced too (adopted only when present). ## Verification ```bash -cd "$(mktemp -d)" && curl -fsSLO https://raw.githubusercontent.com/Da7-Tech/mind/v6.2.7/mind.py && python3 -c "import hashlib;h=hashlib.sha256(open('mind.py','rb').read()).hexdigest();assert h=='c55594585f8d1dbb944e3ec56b57d6e36dc7c639ceeb0f5621f19ce45c66451b',h;print('OK')" && python3 mind.py init >/dev/null && python3 mind.py remember "the sky signal is 7413" >/dev/null && python3 mind.py recall "sky signal" +cd "$(mktemp -d)" && curl -fsSLO https://raw.githubusercontent.com/Da7-Tech/mind/v6.2.8/mind.py && python3 -c "import hashlib;h=hashlib.sha256(open('mind.py','rb').read()).hexdigest();assert h=='b3e944ce9103bd5e353e09a12f434721cb274bc919326361039104889c6c5e03',h;print('OK')" && python3 mind.py init >/dev/null && python3 mind.py remember "the sky signal is 7413" >/dev/null && python3 mind.py recall "sky signal" ``` Expected: one result containing `7413` with a printed memory id. diff --git a/assets/why-not-just-use.jpeg b/assets/why-not-just-use.jpeg deleted file mode 100644 index a8a50e0..0000000 Binary files a/assets/why-not-just-use.jpeg and /dev/null differ diff --git a/bench/discrim.py b/bench/discrim.py index 35f0326..e12cd06 100644 --- a/bench/discrim.py +++ b/bench/discrim.py @@ -72,11 +72,12 @@ def main(): print("=" * 58) hits = 0 for facts, q, marker in CASES: - h = Hippocampus(Path(tempfile.mkdtemp(prefix="mind-dx-")) / "g.json") - for f in facts: - h.remember(f) - r, _, _ = h.recall(q) - ok = bool(r) and marker in r[0][2]["text"] + with tempfile.TemporaryDirectory(prefix="mind-dx-") as tmp: + h = Hippocampus(Path(tmp) / "g.json") + for f in facts: + h.remember(f) + r, _, _ = h.recall(q) + ok = bool(r) and marker in r[0][2]["text"] hits += ok print("%s %s" % ("PASS" if ok else "MISS", q)) if not ok and r: diff --git a/bench/multilang.py b/bench/multilang.py index 9fa5997..7e4ea0b 100644 --- a/bench/multilang.py +++ b/bench/multilang.py @@ -100,20 +100,20 @@ def main(): total_hits = total_q = 0 ok = True for lang, cases in CASES.items(): - tmp = Path(tempfile.mkdtemp(prefix="mind-ml-")) - h = Hippocampus(tmp / "graph.json") - for fact, _, _ in cases: - h.remember(fact) - for i, d in enumerate(DISTRACTORS * 4): - h.remember(d % i) - hits = 0 - misses = [] - for fact, q, marker in cases: - results, _, _ = h.recall(q) - if results and marker in results[0][2]["text"]: - hits += 1 - else: - misses.append(q) + with tempfile.TemporaryDirectory(prefix="mind-ml-") as tmp: + h = Hippocampus(Path(tmp) / "graph.json") + for fact, _, _ in cases: + h.remember(fact) + for i, d in enumerate(DISTRACTORS * 4): + h.remember(d % i) + hits = 0 + misses = [] + for fact, q, marker in cases: + results, _, _ = h.recall(q) + if results and marker in results[0][2]["text"]: + hits += 1 + else: + misses.append(q) total_hits += hits total_q += len(cases) if hits < 2: # per-language gate: >= 2/3 diff --git a/bench/soak.py b/bench/soak.py index 96e493c..bd7cd86 100644 --- a/bench/soak.py +++ b/bench/soak.py @@ -159,6 +159,8 @@ def main(): hot_core = sum(1 for ln in hot_lines if any(m in ln for m in core_markers)) print("working memory: %d/%d hot slots are core facts" % (hot_core, len(hot_lines))) + if hot_core < 7: + all_ok = False lat = [] for tier, facts in CORE.items(): diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 7df3ce2..2e0cfa8 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -29,7 +29,8 @@ dreams between sessions, and portable to any agent through one standard file. (who/via/session), and every mutation appends to `journal.jsonl`, which is never cleared — "where did this fact come from" stays answerable after pruning, unlike the session telemetry in - `signals.jsonl`. `why`/`entity`/`recall --at` expose it. + `signals.jsonl`. Targeted `why` scans the full journal as a stream; + unfiltered `status` reads only the last 10 MB to stay bounded. 6. **One `init` + automatic export** for every agent (`AGENTS.md`, `CLAUDE.md`, `GEMINI.md`) behind guard markers. 7. **Writes during the session, dreams between sessions** — the agent @@ -71,15 +72,15 @@ restrengthens its edges, while every dream weakens all edges slightly | deep sleep | slow-wave consolidation + synaptic homeostasis | Ebbinghaus decay; prune weak nodes and weak edges | | REM | recombination | deterministic clustering of related memories → promote recurring clusters to cortex; scan for contradictions and *flag* them | -Everything is deterministic (offline hash embeddings, fixed thresholds): -the same memory state always yields the same plan, `--dry-run` previews it, -and the journal explains every action. No LLM is consulted — consolidation -costs zero tokens and can run in cron forever. +Everything is deterministic (offline hash embeddings, fixed thresholds, +sorted node traversal, explicit tie-breakers): the same memory state always +yields the same plan and recall order. `--dry-run` previews it, and the journal +explains every action. No LLM is consulted — consolidation costs zero tokens. ## Failure modes we accept (and why) - **No true semantic embeddings by default.** Cross-domain synonymy with no - corpus evidence is missed (the benchmark's one failing query). The + concept-seed or corpus evidence can still be missed. The alternative — bundling a model or requiring API keys — breaks the zero-setup promise that makes the tool spread. Pluggable backends can come later without breaking the file format. diff --git a/mind.py b/mind.py index 955b16d..23078e6 100644 --- a/mind.py +++ b/mind.py @@ -25,12 +25,12 @@ Design: docs/DESIGN.md | License: MIT | https://github.com/Da7-Tech/mind """ -import sys, os, json, re, time, math, hashlib +import sys, os, json, re, time, math, hashlib, tempfile, shlex from datetime import datetime, timedelta from pathlib import Path from collections import Counter, defaultdict -__version__ = "6.2.7" +__version__ = "6.2.8" # ──────────────────────────────────────────────────────────────── # Tunables (see docs/DESIGN.md for the reasoning behind each value) @@ -122,6 +122,24 @@ def _read_text_retry(path): time.sleep(0.05) +def _write_all(fd, data): + """Write every byte or raise. os.write() may legally return short.""" + view = memoryview(data) + while view: + written = os.write(fd, view) + if written <= 0: + raise OSError("short write: os.write made no progress") + view = view[written:] + + +def _write_once(fd, data): + """One append syscall; reject a short record instead of hiding it.""" + written = os.write(fd, data) + if written != len(data): + raise OSError("partial append: wrote %d of %d bytes" % ( + written, len(data))) + + def _atomic_write(path, data, boundary=None): """Atomic, symlink-safe, durable write: O_NOFOLLOW + fsync + os.replace. @@ -137,34 +155,52 @@ def _atomic_write(path, data, boundary=None): raise ValueError(f"refusing to write through a symlink: {path}") if boundary is not None: _reject_symlinked_parents(path, boundary) - tmp = "%s.%d.tmp" % (path, os.getpid()) - nofollow = getattr(os, "O_NOFOLLOW", 0) - fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | nofollow | os.O_TRUNC, 0o644) + # mkstemp is O_EXCL and unpredictable: no same-process collision and no + # pre-created hard-link/symlink can be truncated through a predictable + # `..tmp` name. + fd, tmp = tempfile.mkstemp(prefix=path.name + ".", suffix=".tmp", + dir=str(path.parent)) + replaced = False try: - os.write(fd, data.encode("utf-8") if isinstance(data, str) else data) - os.fsync(fd) - finally: - os.close(fd) - # Windows: os.replace raises PermissionError while another process - # momentarily holds the destination open (Python's open() does not - # grant FILE_SHARE_DELETE). Readers are short-lived — retry briefly - # (CI finding: 1/12 parallel writers lost this race on - # windows-latest; POSIX never enters the loop). - for attempt in range(200): try: - os.replace(tmp, str(path)) - break - except PermissionError: - if os.name != "nt" or attempt == 199: - raise - time.sleep(0.05) + try: + os.fchmod(fd, 0o644) + except (AttributeError, OSError): + pass + payload = data.encode("utf-8") if isinstance(data, str) else data + _write_all(fd, payload) + os.fsync(fd) + finally: + os.close(fd) + fd = None + # Windows: os.replace raises PermissionError while another process + # momentarily holds the destination open (Python's open() does not + # grant FILE_SHARE_DELETE). Readers are short-lived — retry briefly + # (CI finding: 1/12 parallel writers lost this race on + # windows-latest; POSIX never enters the loop). + for attempt in range(200): + try: + os.replace(tmp, str(path)) + replaced = True + break + except PermissionError: + if os.name != "nt" or attempt == 199: + raise + time.sleep(0.05) + finally: + if fd is not None: + os.close(fd) + if not replaced: + try: + os.unlink(tmp) + except OSError: + pass # full durability: fsync the DIRECTORY too, or the rename itself can # be lost on power failure (auditor finding — the docs promised more # than fsync-the-file delivers). Windows has no dir-fsync; skip. if os.name != "nt": try: - dfd = os.open(os.path.dirname(os.path.abspath(str(path))) or ".", - os.O_RDONLY) + dfd = os.open(str(path.parent.resolve()), os.O_RDONLY) try: os.fsync(dfd) finally: @@ -646,6 +682,10 @@ def __init__(self, path): # without this, an edge decayed to empty and removed from # self.edges is silently REVIVED from disk by the read-merge-write # (edge deletions, unlike node deletions, weren't tracked) + self._edge_updates = set() # directional edges created/replaced here + self._edge_bumps = Counter() # directional reinforcement deltas + self._edge_decay_day = None # daily homeostasis request, applied in lock + self._last_edge_pruned = 0 self._load() # -- persistence ------------------------------------------------- @@ -681,6 +721,26 @@ def _finite(value, default, lo=None, hi=None): f = min(hi, f) return f + @staticmethod + def _iso_timestamp(value, default): + """Return a naive-local ISO timestamp or `default` when malformed.""" + if not isinstance(value, str): + return default + try: + parsed = datetime.fromisoformat(value) + except ValueError: + return default + if parsed.tzinfo is not None: + parsed = parsed.astimezone().replace(tzinfo=None) + return parsed.isoformat() + + @classmethod + def _metadata_text(cls, value, default, cap): + if not isinstance(value, str): + return default + cleaned = " ".join(cls._clean_text(value).split())[:cap] + return cleaned or default + def _repair_nodes(self, raw): """Repair a nodes dict fresh off the disk — shared by _load AND the _save read-merge-write. The merge used to import raw disk nodes @@ -688,23 +748,26 @@ def _repair_nodes(self, raw): or another (buggier) process re-poisoned a healthy session's graph on its next save (fuzzer finding).""" out = {} + now_iso = _now().isoformat() for nid, n in raw.items(): if not isinstance(n, dict) or not isinstance(n.get("text", ""), str): continue + n["text"] = self._clean_text(n.get("text", "")) + if not n["text"]: + continue n.setdefault("text", "") n.setdefault("weight", 1.0) n.setdefault("peak_weight", n.get("weight", 1.0)) n.setdefault("confidence", 1.0) n.setdefault("access_count", 0) n.setdefault("keys", []) - n.setdefault("last_accessed", _now().isoformat()) - n.setdefault("created", _now().isoformat()) + n.setdefault("last_accessed", now_iso) + n.setdefault("created", now_iso) # timestamps must be ISO strings: a hand-edit that leaves a # number here would crash decay with TypeError, not ValueError # (auditor finding) — repair to "now" like the numeric fields for f in ("last_accessed", "created"): - if not isinstance(n.get(f), str): - n[f] = _now().isoformat() + n[f] = self._iso_timestamp(n.get(f), now_iso) # numeric fields: repair non-numeric AND non-finite values, and # clamp to the range every write path maintains (auditor + # fuzzer findings) @@ -714,26 +777,36 @@ def _repair_nodes(self, raw): n["access_count"] = int(self._finite(n["access_count"], 0, 0)) # history must be a list — correct() appends to it (fuzzer # finding: a scalar history crashed reconsolidation) - if not isinstance(n.get("history", []), list): - n["history"] = [] + raw_history = n.get("history", []) + if not isinstance(raw_history, list): + raw_history = [] + history = [] + for h in raw_history: + if not isinstance(h, dict): + continue + old_text = self._clean_text(h.get("text", "")) + if not old_text: + continue + history.append({ + "text": old_text[:MAX_TEXT_CHARS], + "replaced": self._iso_timestamp( + h.get("replaced"), "unknown"), + }) + n["history"] = history # provenance + validity (6.0.0): older graphs get honest # defaults — origin "unknown", validity open since creation - if not isinstance(n.get("origin"), dict): - n["origin"] = {"by": "unknown", "session": None, - "via": "unknown"} - if not isinstance(n.get("valid_from"), str): - n["valid_from"] = n["created"] - if not isinstance(n.get("valid_to"), str): - n["valid_to"] = None - # lexicographic comparison assumes ISO format — repair any - # hand-edited non-ISO value instead of comparing garbage - for f, d in (("valid_from", n["created"]), ("valid_to", None)): - v = n.get(f) - if isinstance(v, str): - try: - datetime.fromisoformat(v) - except ValueError: - n[f] = d + origin = n.get("origin") + if not isinstance(origin, dict): + origin = {} + n["origin"] = { + "by": self._metadata_text(origin.get("by"), "unknown", 80), + "session": self._metadata_text( + origin.get("session"), None, 120), + "via": self._metadata_text(origin.get("via"), "unknown", 40), + } + n["valid_from"] = self._iso_timestamp( + n.get("valid_from"), n["created"]) + n["valid_to"] = self._iso_timestamp(n.get("valid_to"), None) if not (n.get("superseded_by") is None or isinstance(n.get("superseded_by"), str)): n.pop("superseded_by", None) @@ -743,7 +816,8 @@ def _repair_nodes(self, raw): raw_keys = n.get("keys", []) if not isinstance(raw_keys, list): raw_keys = [] - n["keys"] = [re.sub(r'[،؛؟!."\']', '', k).strip()[:100] + n["keys"] = [re.sub(r'[،؛؟!."\']', '', self._clean_text(k)) + .strip()[:100] for k in raw_keys if isinstance(k, str)] n["keys"] = [k for k in n["keys"] if k][:24] # the write-path text cap must hold on the LOAD path too — a @@ -768,8 +842,14 @@ def _repair_edges(self, raw, nodes): if nbr not in nodes or not isinstance(e, dict): continue e["weight"] = self._finite(e.get("weight", 1.0), 1.0, 0.0, 1.0) - if not isinstance(e.get("relation", "related"), str): - e["relation"] = "related" + e["relation"] = self._metadata_text( + e.get("relation"), "related", 60) + if "created" in e: + created = self._iso_timestamp(e.get("created"), None) + if created is None: + e.pop("created", None) + else: + e["created"] = created clean[nbr] = e if clean: out[nid] = clean @@ -855,9 +935,15 @@ def _save(self): "process hung?") lock_backend = ("msvcrt", msvcrt) try: + disk = {} if self.path.exists(): try: disk = json.loads(_read_text_retry(self.path)) + if not isinstance(disk, dict): + raise ValueError("graph.json is not a JSON object") + if not isinstance(disk.get("nodes", {}), dict) or \ + not isinstance(disk.get("edges", {}), dict): + raise ValueError("nodes/edges have the wrong structure") except (json.JSONDecodeError, ValueError) as e: # never silently overwrite a corrupt graph during a # live save: quarantine it exactly like _load does @@ -865,85 +951,103 @@ def _save(self): # on the load path) self._quarantine(e) disk = {} - dn = disk.get("nodes", {}) if isinstance(disk, dict) else {} - de = disk.get("edges", {}) if isinstance(disk, dict) else {} - if isinstance(dn, dict) and isinstance(de, dict): - # repair the disk copy BEFORE merging: without this - # the merge imported raw disk content past all of - # _load's validation, so one corrupt file poisoned - # a healthy session's next save (fuzzer finding) - merged_n = {k: v - for k, v in self._repair_nodes(dn).items() - if k not in self._deleted} - # field-freshness: only nodes this session touched - # overwrite the disk copy; for everything else the - # DISK is fresher (auditor finding) - for k in self._dirty: - if k in self.nodes: - merged_n[k] = self.nodes[k] - for k, v in self.nodes.items(): - merged_n.setdefault(k, v) - # reinforcement deltas: re-apply OUR confirms on - # top of the freshest copy, so concurrent confirms - # from other processes are added to, never raced - now_iso = _now().isoformat() - for k, d in self._bumped.items(): - n = merged_n.get(k) - if n is None or k in self._dirty: - continue # dirty copy already includes it - n["access_count"] = int(self._finite( - n.get("access_count", 0), 0, 0)) + d - n["weight"] = min(1.0, self._finite( - n.get("weight", 1.0), 1.0, 0.0, 1.0) - + BOOST_PER_ACCESS * d) - n["peak_weight"] = max( - self._finite(n.get("peak_weight", 1.0), - 1.0, 0.0, 1.0), n["weight"]) - n["last_accessed"] = now_iso - # decay updates: salience only, on the fresh copy — - # min() because decay never raises a weight, and a - # concurrent confirm's boost must win the tie - for k, w in self._decayed.items(): - n = merged_n.get(k) - if n is None or k in self._dirty \ - or k in self._bumped: - continue - n["weight"] = min(self._finite( - n.get("weight", 1.0), 1.0, 0.0, 1.0), w) - # Build the merged edges from the DISK copy, stripping - # both deleted nodes and edges this process pruned, - # THEN apply our live edges. Order matters: pruned - # pairs must be removed from the disk copy *before* - # update, so a live edge we legitimately (re)created - # this session still wins — filtering after update - # would wrongly clobber it (auditor finding). - merged_e = {} - for k, v in self._repair_edges(de, merged_n).items(): - if k in self._deleted: - continue - merged_e[k] = {nbr: e for nbr, e in v.items() - if nbr not in self._deleted - and (k, nbr) not in self._pruned_edges} - merged_e.update(self.edges) - # drop node-keys the disk left empty after stripping - merged_e = {k: v for k, v in merged_e.items() if v} - self.nodes, self.edges = merged_n, merged_e - # meta merge (max-wins: ISO dates compare) — inside - # the same disk-copy scope as nodes/edges - disk_meta = (disk.get("meta", {}) - if isinstance(disk, dict) and - isinstance(disk.get("meta", {}), dict) - else {}) - merge_today = str(_now().date()) - for mk, mv in disk_meta.items(): - if mk not in _META_KEYS or not ( - isinstance(mv, str) and mv.isprintable()): - continue - mv = mv[:64] - if mk == "last_edge_decay" and mv > merge_today: - mv = merge_today # future marker = skew garbage - if mv > self.meta.get(mk, ""): - self.meta[mk] = mv + dn = disk.get("nodes", {}) + de = disk.get("edges", {}) + # repair the disk copy BEFORE merging: without this the merge + # imported raw disk content past _load's validation. + merged_n = {k: v for k, v in self._repair_nodes(dn).items() + if k not in self._deleted} + # Field freshness is per node: untouched disk copies are newer. + for k in self._dirty: + if k in self.nodes: + merged_n[k] = self.nodes[k] + for k, v in self.nodes.items(): + merged_n.setdefault(k, v) + # Reinforcement deltas apply on top of the freshest disk copy. + now_iso = _now().isoformat() + for k, d in self._bumped.items(): + n = merged_n.get(k) + if n is None or k in self._dirty: + continue + n["access_count"] = int(self._finite( + n.get("access_count", 0), 0, 0)) + d + n["weight"] = min(1.0, self._finite( + n.get("weight", 1.0), 1.0, 0.0, 1.0) + + BOOST_PER_ACCESS * d) + n["peak_weight"] = max( + self._finite(n.get("peak_weight", 1.0), + 1.0, 0.0, 1.0), n["weight"]) + n["last_accessed"] = now_iso + # Decay touches salience only on the fresh disk copy. + for k, w in self._decayed.items(): + n = merged_n.get(k) + if n is None or k in self._dirty or k in self._bumped: + continue + n["weight"] = min(self._finite( + n.get("weight", 1.0), 1.0, 0.0, 1.0), w) + + # Edge freshness is per DIRECTIONAL PAIR. Whole-adjacency + # update() lost concurrent links and could leave an asymmetric + # graph (A->B gone while B->A survived). + merged_e = {} + for k, v in self._repair_edges(de, merged_n).items(): + if k in self._deleted: + continue + kept = {nbr: e for nbr, e in v.items() + if nbr not in self._deleted + and (k, nbr) not in self._pruned_edges} + if kept: + merged_e[k] = kept + for a, b in self._edge_updates: + edge = self.edges.get(a, {}).get(b) + if edge is not None and a in merged_n and b in merged_n: + merged_e.setdefault(a, {})[b] = dict(edge) + for (a, b), count in self._edge_bumps.items(): + edge = merged_e.get(a, {}).get(b) + if edge is None: + continue + edge["weight"] = min( + 1.0, self._finite(edge.get("weight", 1.0), + 1.0, 0.0, 1.0) + + EDGE_BOOST * count) + + # Meta merge happens before daily edge decay so the decision + # is made under the lock against the freshest marker. Two + # concurrent dreams can therefore decay a day only once. + disk_meta = disk.get("meta", {}) + if not isinstance(disk_meta, dict): + disk_meta = {} + merge_today = str(_now().date()) + for mk, mv in disk_meta.items(): + if mk not in _META_KEYS or not ( + isinstance(mv, str) and mv.isprintable()): + continue + mv = mv[:64] + if mk == "last_edge_decay" and mv > merge_today: + mv = merge_today + if mv > self.meta.get(mk, ""): + self.meta[mk] = mv + + self._last_edge_pruned = 0 + decay_day = self._edge_decay_day + if decay_day is not None: + decay_day = min(decay_day, merge_today) + if decay_day and self.meta.get( + "last_edge_decay", "") < decay_day: + for a in list(merged_e): + for b in list(merged_e[a]): + edge = merged_e[a][b] + edge["weight"] = round(self._finite( + edge.get("weight", 1.0), 1.0, 0.0, 1.0) + * EDGE_DECAY_PER_DREAM, 4) + if edge["weight"] < EDGE_PRUNE_THRESHOLD: + del merged_e[a][b] + self._last_edge_pruned += 1 + if not merged_e[a]: + del merged_e[a] + self.meta["last_edge_decay"] = decay_day + + self.nodes, self.edges = merged_n, merged_e _atomic_write(self.path, json.dumps( {"nodes": self.nodes, "edges": self.edges, "meta": self.meta}, @@ -958,6 +1062,9 @@ def _save(self): self._dirty.clear() self._bumped.clear() self._decayed.clear() + self._edge_updates.clear() + self._edge_bumps.clear() + self._edge_decay_day = None finally: if lock_backend is not None: name, module = lock_backend @@ -967,6 +1074,20 @@ def _save(self): lockf.seek(0) module.locking(lockf.fileno(), module.LK_UNLCK, 1) + def decay_edges(self, dry_run=False): + """Apply synaptic homeostasis once per day, decided under the lock.""" + today = str(_now().date()) + if self.meta.get("last_edge_decay", "") >= today: + return 0 + if dry_run: + return sum( + 1 for nbrs in self.edges.values() for e in nbrs.values() + if self._finite(e.get("weight", 1.0), 1.0, 0.0, 1.0) + * EDGE_DECAY_PER_DREAM < EDGE_PRUNE_THRESHOLD) + self._edge_decay_day = today + self._save() + return self._last_edge_pruned + @staticmethod def _id(text): # content addressing only — no security property is derived from @@ -1079,17 +1200,22 @@ def _clean_text(text): return re.sub(u"[\x00-\x08\x0b-\x1f\x7f\u202a-\u202e\u2066-\u2069]", "", text or "").strip() + @classmethod + def _validated_text(cls, text, label="memory"): + if not isinstance(text, str): + raise ValueError("%s text must be a string" % label) + cleaned = cls._clean_text(text) + if not cleaned: + raise ValueError("%s text must not be empty" % label) + if len(cleaned) > MAX_TEXT_CHARS: + raise ValueError("%s text exceeds %d chars" % ( + label, MAX_TEXT_CHARS)) + return cleaned + # -- write path --------------------------------------------------- def remember(self, text, confidence=1.0): - if not text or not text.strip(): - raise ValueError("cannot remember empty text") - text = self._clean_text(text) - if not text: - raise ValueError("cannot remember control-characters-only text") - if len(text) > MAX_TEXT_CHARS: - raise ValueError("memory text exceeds %d chars — store the " - "document in a file and remember its path" - % MAX_TEXT_CHARS) + text = self._validated_text(text) + confidence = self._finite(confidence, 1.0, 0.0, 1.0) nid = self._id(text) now = _now().isoformat() is_new = nid not in self.nodes @@ -1099,8 +1225,11 @@ def remember(self, text, confidence=1.0): n["peak_weight"] = max(n.get("peak_weight", 1.0), n["weight"]) n["access_count"] = n.get("access_count", 0) + 1 n["last_accessed"] = now - n["confidence"] = max(n.get("confidence", 1.0), confidence) + old_confidence = n.get("confidence", 1.0) + n["confidence"] = max(old_confidence, confidence) self._bumped[nid] = self._bumped.get(nid, 0) + 1 + if n["confidence"] != old_confidence: + self._dirty.add(nid) # a re-remembered superseded fact is an explicit re-assertion: # the user says it IS true again — reopen a NEW validity # segment starting now (the closed segment stays queryable in @@ -1149,7 +1278,10 @@ def link(self, text_a, text_b, relation="related"): # hash the CLEANED text, exactly as remember() does, so the edge is # stored under the same id the node gets — otherwise the edge points # at a phantom id and is dropped on next load (auditor finding) - text_a, text_b = self._clean_text(text_a), self._clean_text(text_b) + # Validate BOTH endpoints before remember() can persist either one: + # a rejected second endpoint must not leave a partial first memory. + text_a = self._validated_text(text_a, "first link endpoint") + text_b = self._validated_text(text_b, "second link endpoint") id_a, id_b = self._id(text_a), self._id(text_b) # a self-loop would feed a node its own activation on every hop of # spreading recall, silently inflating its rank (auditor finding) @@ -1164,6 +1296,7 @@ def link(self, text_a, text_b, relation="related"): "weight": 1.0, "created": now} self.edges.setdefault(id_b, {})[id_a] = {"relation": relation, "weight": 1.0, "created": now} + self._edge_updates.update(((id_a, id_b), (id_b, id_a))) self._save() self._journal("link", id=id_a, other=id_b, relation=relation) self._log_signal("link", "%s --%s--> %s" % (text_a, relation, text_b)) @@ -1213,11 +1346,7 @@ def correct(self, old_hint, new_text): # write path too, and an uncleaned new_text would store text under # an id remember() would never produce (auditor finding). An empty # replacement would silently blank a memory — refuse it. - new_text = self._clean_text(new_text) - if not new_text: - raise ValueError("corrected text must not be empty") - if len(new_text) > MAX_TEXT_CHARS: - raise ValueError("corrected text exceeds %d chars" % MAX_TEXT_CHARS) + new_text = self._validated_text(new_text, "corrected") if not self.nodes: return None results, _, _ = self.recall(old_hint, top_k=1) @@ -1275,16 +1404,21 @@ def correct(self, old_hint, new_text): continue if e.get("relation") in ("supersedes", "superseded-by"): continue - self.edges.setdefault(new_nid, {}).setdefault(nbr, dict(e)) + if nbr not in self.edges.setdefault(new_nid, {}): + self.edges[new_nid][nbr] = dict(e) + self._edge_updates.add((new_nid, nbr)) rev = self.edges.get(nbr, {}).get(nid) if rev is not None and rev.get("relation") not in ( "supersedes", "superseded-by"): - self.edges.setdefault(nbr, {}).setdefault(new_nid, dict(rev)) + if new_nid not in self.edges.setdefault(nbr, {}): + self.edges[nbr][new_nid] = dict(rev) + self._edge_updates.add((nbr, new_nid)) # the explicit, timestamped state transition self.edges.setdefault(new_nid, {})[nid] = { "relation": "supersedes", "weight": 0.5, "created": now} self.edges.setdefault(nid, {})[new_nid] = { "relation": "superseded-by", "weight": 0.5, "created": now} + self._edge_updates.update(((new_nid, nid), (nid, new_nid))) # close the old fact — invalidation is explicit and reversible, # never conflated with attention decay node["valid_to"] = now @@ -1394,9 +1528,9 @@ def recall(self, query, top_k=RECALL_TOP_K, max_hops=RECALL_RADIUS, # RRF fusion: absent nodes get rank len(list)+1, not infinity, so the # spreading channel keeps real influence in the fused score. dr = {n: i for i, (n, _) in enumerate( - sorted(direct.items(), key=lambda x: -x[1]))} + sorted(direct.items(), key=lambda x: (-x[1], x[0])))} sr = {n: i for i, (n, _) in enumerate( - sorted(spread.items(), key=lambda x: -x[1]))} + sorted(spread.items(), key=lambda x: (-x[1], x[0])))} dr_default, sr_default = len(dr) + 1, len(sr) + 1 fused = {} for nid in set(direct) | set(spread): @@ -1408,7 +1542,7 @@ def recall(self, query, top_k=RECALL_TOP_K, max_hops=RECALL_RADIUS, # self.nodes if the graph was mutated externally mid-flight — # drop them instead of raising. fused = {nid: s for nid, s in fused.items() if nid in alive} - ranked = sorted(fused.items(), key=lambda x: -x[1]) + ranked = sorted(fused.items(), key=lambda x: (-x[1], x[0])) # identity questions ("what is my name") are decided by lexical # identity evidence; the char-gram rerank favors token repetition # ("file name ... class name") and must sit this one out @@ -1418,7 +1552,7 @@ def recall(self, query, top_k=RECALL_TOP_K, max_hops=RECALL_RADIUS, for nid, base in ranked[:top_k * 3]: sim = self.embedder.similarity(query, self.nodes[nid]["text"]) reranked.append((nid, base * (1.0 + sim))) - reranked.sort(key=lambda x: -x[1]) + reranked.sort(key=lambda x: (-x[1], x[0])) ranked = reranked # pattern separation: drop near-duplicate results from the head so @@ -1447,6 +1581,7 @@ def bump(self, node_ids): agent-facing path here). Tracks peak_weight for Ebbinghaus, and restrengthens the confirmed node's edges — connections you actually use stay strong, unused ones decay away dream by dream.""" + node_ids = list(dict.fromkeys(node_ids)) now = _now() changed = False for nid in node_ids: @@ -1458,9 +1593,11 @@ def bump(self, node_ids): n["last_accessed"] = now.isoformat() for nbr, e in self.edges.get(nid, {}).items(): e["weight"] = min(1.0, e.get("weight", 1.0) + EDGE_BOOST) + self._edge_bumps[(nid, nbr)] += 1 rev = self.edges.get(nbr, {}).get(nid) if rev is not None: rev["weight"] = e["weight"] + self._edge_bumps[(nbr, nid)] += 1 self._bumped[nid] = self._bumped.get(nid, 0) + 1 changed = True if changed: @@ -1564,8 +1701,8 @@ def _archive(self, texts, now): os.O_WRONLY | os.O_CREAT | os.O_APPEND | getattr(os, "O_NOFOLLOW", 0), 0o644) try: - os.write(fd, (header + "\n".join(lines) + "\n") - .encode("utf-8")) + _write_once(fd, (header + "\n".join(lines) + "\n") + .encode("utf-8")) os.fsync(fd) # archived text is a durability promise finally: os.close(fd) @@ -1586,7 +1723,7 @@ def _log_signal(self, kind, content): os.O_WRONLY | os.O_CREAT | os.O_APPEND | getattr(os, "O_NOFOLLOW", 0), 0o644) try: - os.write(fd, (json.dumps( + _write_once(fd, (json.dumps( {"kind": kind, "content": content, "ts": _now().isoformat()}, ensure_ascii=False) + "\n").encode("utf-8")) @@ -1596,12 +1733,14 @@ def _log_signal(self, kind, content): pass # telemetry only — never block the write it rode on # -- provenance ----------------------------------------------------- - @staticmethod - def _actor(): + @classmethod + def _actor(cls): """Who is writing. Agents/harnesses set MIND_BY and MIND_SESSION in the environment; the zero-setup default is 'agent'.""" - return (os.environ.get("MIND_BY", "agent"), - os.environ.get("MIND_SESSION")) + return ( + cls._metadata_text(os.environ.get("MIND_BY"), "agent", 80), + cls._metadata_text(os.environ.get("MIND_SESSION"), None, 120), + ) def _journal(self, op, **fields): """Append-only provenance log (journal.jsonl). Unlike @@ -1637,8 +1776,8 @@ def _journal(self, op, **fields): fd = os.open(str(jf), os.O_WRONLY | os.O_CREAT | os.O_APPEND | getattr(os, "O_NOFOLLOW", 0), 0o644) try: - os.write(fd, (json.dumps(entry, ensure_ascii=False) - + "\n").encode("utf-8")) + _write_once(fd, (json.dumps(entry, ensure_ascii=False) + + "\n").encode("utf-8")) os.fsync(fd) # provenance is permanent — survive power loss finally: os.close(fd) @@ -1646,33 +1785,46 @@ def _journal(self, op, **fields): print("warning: journal.jsonl not writable (%s); provenance " "entry lost." % e, file=sys.stderr) + @staticmethod + def _event_mentions(event, node_id): + if node_id in (event.get("id"), event.get("old_id"), + event.get("new_id"), event.get("other")): + return True + ids = event.get("ids") + return isinstance(ids, list) and node_id in ids + def journal_entries(self, node_id=None, tail_bytes=10_000_000): """Read the provenance log, optionally filtered to one node. - The log is append-only and never cleared, so reads are capped to - the last `tail_bytes` (10MB ≈ years of normal use) — `why` and - `status` must not O(file) forever (auditor finding).""" + Targeted provenance scans the full file incrementally so `why ` + remains answerable after years without loading unrelated events. + Unfiltered status reads stay tail-capped to avoid loading an + ever-growing journal into memory.""" jf = self.path.parent / JOURNAL_FILE - if not jf.exists(): + if not jf.exists() or jf.is_symlink(): return [] - size = jf.stat().st_size - with jf.open("rb") as f: - if size > tail_bytes: + try: + fd = os.open(str(jf), os.O_RDONLY | + getattr(os, "O_NOFOLLOW", 0)) + except OSError: + return [] + with os.fdopen(fd, "rb") as f: + size = os.fstat(f.fileno()).st_size + if node_id is None and size > tail_bytes: f.seek(size - tail_bytes) - f.readline() # skip the partial line + f.readline() print("note: journal is %.1f MB; reading the last 10 MB." % (size / 1e6), file=sys.stderr) - data = f.read().decode("utf-8", "replace") - out = [] - for line in data.splitlines(): - try: - e = json.loads(line) - except json.JSONDecodeError: - continue - if node_id is None or node_id in (e.get("id"), e.get("old_id"), - e.get("new_id")) \ - or node_id in (e.get("ids") or []): - out.append(e) - return out + out = [] + for raw in f: + try: + event = json.loads(raw.decode("utf-8", "replace")) + except (json.JSONDecodeError, UnicodeDecodeError): + continue + if not isinstance(event, dict): + continue + if node_id is None or self._event_mentions(event, node_id): + out.append(event) + return out # -- temporal validity ---------------------------------------------- @staticmethod @@ -1711,10 +1863,17 @@ def _valid_at(node, at=None): class Cortex: def __init__(self, path): self.path = path - self.path.mkdir(parents=True, exist_ok=True) + if not self.path.is_symlink(): + try: + self.path.mkdir(parents=True, exist_ok=True) + except OSError: + pass def files(self): - return sorted(self.path.glob("*.md")) + if self.path.is_symlink() or not self.path.is_dir(): + return [] + return sorted(p for p in self.path.glob("*.md") + if p.is_file() and not p.is_symlink()) def promote(self, topic, content): base = re.sub(r'[^\w؀-ۿ]+', '_', topic).strip('_')[:40] @@ -1723,6 +1882,8 @@ def promote(self, topic, content): # two distinct topics can sanitize to the same filename — never # silently overwrite "durable" knowledge about a different topic # (auditor finding): disambiguate with a short content hash. + if fpath.is_symlink(): + raise ValueError("cortex target is a symlink") if fpath.exists(): first = fpath.read_text("utf-8").splitlines()[:1] if first and first[0] != "# %s" % topic: @@ -1748,7 +1909,8 @@ class Dreamer: def __init__(self, mind_dir, hippo, cortex): self.dir = mind_dir / DREAMS_DIR try: - self.dir.mkdir(parents=True, exist_ok=True) + if not self.dir.is_symlink(): + self.dir.mkdir(parents=True, exist_ok=True) except OSError: # a dangling-symlink dreams/ raises EEXIST here (exists() is # False so exist_ok can't swallow it). Constructing the Dreamer @@ -1792,35 +1954,13 @@ def dream(self, dry_run=False): # the once-a-day marker is persisted INSIDE graph.json — the old # journal-file-existence heuristic re-decayed whenever the day's # memo was deleted or lost (auditor finding, 6.2.2) - today = str(_now().date()) - first_cycle_today = self.hippo.meta.get("last_edge_decay", "") < today - if not dry_run and first_cycle_today: - for nbrs in self.hippo.edges.values(): - for e in nbrs.values(): - e["weight"] = round(e.get("weight", 1.0) * EDGE_DECAY_PER_DREAM, 4) - self.hippo.meta["last_edge_decay"] = today - # ...and synaptic pruning removes the ones that decayed away - pruned_edges = 0 - for nid in list(self.hippo.edges.keys()): - for neighbor in list(self.hippo.edges[nid].keys()): - if self.hippo.edges[nid][neighbor].get("weight", 1.0) < EDGE_PRUNE_THRESHOLD: - if not dry_run: - del self.hippo.edges[nid][neighbor] - self.hippo._pruned_edges.add((nid, neighbor)) - pruned_edges += 1 - if not dry_run and nid in self.hippo.edges and not self.hippo.edges[nid]: - del self.hippo.edges[nid] + # The once-per-day decision and weight updates happen inside the graph + # lock. A stale concurrent dream therefore cannot double-decay the day + # or overwrite a link/confirmation that landed while it was waiting. + pruned_edges = self.hippo.decay_edges(dry_run=dry_run) if pruned_edges: log.append("- synaptic pruning: %s %d weak edges." % ("would remove" if dry_run else "removed", pruned_edges)) - # persist the edge-weight changes: decay() only saves when a node is - # pruned, so on a steady-state night (no node/conflict change) the - # in-memory edge decay would be discarded and each fresh `dream` - # process would reload weight 1.0 and re-decay to no effect — edges - # would never actually weaken or prune across real runs (auditor - # finding: the claim was true in-process, false on disk). - if not dry_run: - self.hippo._save() # 3. REM: cluster related memories and promote recurring themes. # Clustering uses offline hash embeddings — deterministic, no network. @@ -1861,7 +2001,7 @@ def dream(self, dry_run=False): os.O_WRONLY | os.O_CREAT | os.O_APPEND | getattr(os, "O_NOFOLLOW", 0), 0o644) try: - os.write(fd, payload.encode("utf-8")) + _write_once(fd, payload.encode("utf-8")) os.fsync(fd) finally: os.close(fd) @@ -1870,13 +2010,17 @@ def dream(self, dry_run=False): "skipping dream journal for this run.", file=sys.stderr) return None, memo_text if self.signals_file.exists() and not self.signals_file.is_symlink(): - self.signals_file.unlink() + try: + self.signals_file.unlink() + except FileNotFoundError: + pass return str(memo.relative_to(self.hippo.path.parent)), memo_text def _rem_promote(self, log, dry_run): emb = self.hippo.embedder clusters = [] - for nid, n in self.hippo.nodes.items(): + for nid in sorted(self.hippo.nodes): + n = self.hippo.nodes[nid] if not self.hippo._valid_at(n): # closed facts don't cluster continue placed = False @@ -1918,8 +2062,9 @@ def _rem_conflicts(self, log, dry_run): emb = self.hippo.embedder # a superseded fact conflicting with its successor is not a # contradiction — it's history. Scan only currently-valid facts. - nodes = [(nid, n) for nid, n in self.hippo.nodes.items() - if self.hippo._valid_at(n)] + nodes = [(nid, self.hippo.nodes[nid]) + for nid in sorted(self.hippo.nodes) + if self.hippo._valid_at(self.hippo.nodes[nid])] N = max(1, len(nodes)) df = defaultdict(int) for _, n in nodes: @@ -1946,6 +2091,8 @@ def _rem_conflicts(self, log, dry_run): self.hippo.edges.setdefault(idb, {})[ida] = { "relation": "possible-conflict", "weight": 0.5, "created": now_iso} + self.hippo._edge_updates.update( + ((ida, idb), (idb, ida))) log.append("- possible conflict (sim %.2f):" % sim) log.append(" a: %s" % a["text"][:80]) log.append(" b: %s" % b["text"][:80]) @@ -1967,7 +2114,8 @@ def _read_signals(self): "ignoring it this cycle.", file=sys.stderr) return [] out = [] - for line in self.signals_file.read_text("utf-8").splitlines(): + for line in self.signals_file.read_text( + "utf-8", errors="replace").splitlines(): try: out.append(json.loads(line)) except json.JSONDecodeError: @@ -2000,9 +2148,10 @@ def _invocation(project_root=None): cmd = str(script) else: cmd = str(script) - if " " in cmd: - cmd = '"%s"' % cmd - return "python3 %s" % cmd + if os.name == "nt": + import subprocess + return subprocess.list2cmdline(["python3", cmd]) + return shlex.join(["python3", cmd]) # ──────────────────────────────────────────────────────────────── @@ -2029,12 +2178,30 @@ def generate(self, project_root): # working memory shows only facts that are currently TRUE: # superseded facts keep their lineage in the graph but never # occupy the agent's always-on context - nodes_sorted = sorted((n for n in self.hippo.nodes.values() - if self.hippo._valid_at(n)), - key=lambda n: -n["weight"]) + # Weight remains primary, but a saturated weight is common: newly + # remembered trivia and repeatedly useful facts can both be 1.0. + # Break those ties by earned confirmations, then recency, then id. + # Without this, making the order hash-seed-independent accidentally + # let arbitrary fresh noise displace confirmed core facts in the + # 180-day soak (5/8 hot slots instead of a usage-driven selection). + nodes_sorted = sorted( + ((nid, n) for nid, n in self.hippo.nodes.items() + if self.hippo._valid_at(n)), + key=lambda item: ( + item[1]["weight"], + item[1].get("access_count", 0), + item[1].get("last_accessed", ""), + item[0], + ), + reverse=True) hot, used = [], 0 - for n in nodes_sorted: - line = "- %s" % n["text"] + for _, n in nodes_sorted: + # Memories are data inside an instruction file. Collapse structural + # newlines and neutralize HTML-comment markers so a remembered + # string cannot forge headings or terminate our export guard. + display = " ".join(n["text"].split()) + display = display.replace("", second) + + def test_link_rejects_both_inputs_before_any_write(self): + h = self.hippo() + with self.assertRaises(ValueError): + h.link("valid first fact", " ") + self.assertEqual(Hippocampus( + self.mind_dir / "graph.json").nodes, {}) + + def test_malformed_created_and_history_are_repaired(self): + gpath = self.mind_dir / "graph.json" + gpath.write_text(json.dumps({"nodes": {"aaa": { + "text": "recoverable temporal fact", + "created": "garbage", + "last_accessed": "garbage", + "history": ["bad", {"text": "old fact", "replaced": 7}], + }}, "edges": {}}), "utf-8") + h = self.hippo() + datetime.fromisoformat(h.nodes["aaa"]["created"]) + datetime.fromisoformat(h.nodes["aaa"]["valid_from"]) + self.assertEqual(h.nodes["aaa"]["history"], + [{"text": "old fact", "replaced": "unknown"}]) + results, _, _ = h.recall("recoverable temporal fact") + self.assertTrue(results) + + def test_why_tolerates_repaired_history(self): + gpath = self.mind_dir / "graph.json" + gpath.write_text(json.dumps({"nodes": {"aaa": { + "text": "history display fact", "history": ["bad"], + }}, "edges": {}}), "utf-8") + import io + from contextlib import redirect_stdout, redirect_stderr + cwd = os.getcwd() + os.chdir(self.tmp) + try: + out, err = io.StringIO(), io.StringIO() + with redirect_stdout(out), redirect_stderr(err): + code = M.main(["why", "aaa"]) + finally: + os.chdir(cwd) + self.assertEqual(code, 0, err.getvalue()) + self.assertIn("history display fact", out.getvalue()) + + def test_link_event_is_provenance_for_both_endpoints(self): + h = self.hippo() + a = "alpha linked fact" + b = "beta linked fact" + h.link(a, b, "peer") + self.assertIn("link", [e["op"] for e in h.journal_entries(h._id(a))]) + self.assertIn("link", [e["op"] for e in h.journal_entries(h._id(b))]) + + def test_targeted_provenance_scans_beyond_journal_tail(self): + target = "oldtarget123" + jf = self.mind_dir / "journal.jsonl" + with jf.open("w", encoding="utf-8") as f: + f.write(json.dumps({"ts": "2000", "op": "remember", + "id": target, "text": "old target"}) + "\n") + filler = json.dumps({"ts": "2026", "op": "remember", + "id": "filler", "text": "x" * 900}) + "\n" + for _ in range(12000): + f.write(filler) + self.assertGreater(jf.stat().st_size, 10_000_000) + events = self.hippo().journal_entries(target) + self.assertEqual(events[0]["text"], "old target") + + def test_unfiltered_journal_reads_recent_tail_of_large_file(self): + jf = self.mind_dir / "journal.jsonl" + old = {"ts": "2000", "op": "remember", + "id": "old", "text": "outside tail"} + recent = {"ts": "2026", "op": "remember", + "id": "recent", "text": "inside tail"} + filler = json.dumps({"ts": "2026", "op": "remember", + "id": "filler", "text": "x" * 900}) + "\n" + with jf.open("w", encoding="utf-8") as f: + f.write(json.dumps(old) + "\n") + for _ in range(12000): + f.write(filler) + f.write(json.dumps(recent) + "\n") + self.assertGreater(jf.stat().st_size, 10_000_000) + events = self.hippo().journal_entries() + self.assertNotIn("old", [e.get("id") for e in events]) + self.assertEqual(events[-1]["id"], "recent") + + @unittest.skipIf(os.name == "nt", "Windows symlinks require privileges") + def test_journal_read_refuses_symlink(self): + outside = self.tmp / "outside.jsonl" + outside.write_text(json.dumps( + {"op": "remember", "id": "secret", "text": "outside"}) + "\n", + "utf-8") + (self.mind_dir / "journal.jsonl").symlink_to(outside) + self.assertEqual(self.hippo().journal_entries("secret"), []) + + @unittest.skipIf(os.name == "nt", "Windows symlinks require privileges") + def test_auto_dream_ignores_symlinked_signal_file(self): + outside = self.tmp / "outside-signals.jsonl" + outside.write_text( + "".join(json.dumps({"op": "remember"}) + "\n" + for _ in range(M.AUTO_DREAM_SIGNALS)), + "utf-8") + (self.mind_dir / "signals.jsonl").symlink_to(outside) + mind = Mind(self.tmp) + mind._ensure() + + def must_not_run(*_args, **_kwargs): + raise AssertionError("auto-dream followed a symlinked signal file") + + mind.dreamer.dream = must_not_run + self.assertFalse(mind._auto_dream()) + + def test_link_refreshes_active_without_auto_dream(self): + import io + from contextlib import redirect_stdout, redirect_stderr + old = os.environ.get("MIND_AUTO_DREAM") + os.environ["MIND_AUTO_DREAM"] = "0" + cwd = os.getcwd() + os.chdir(self.tmp) + try: + with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()): + M.main(["init"]) + M.main(["link", "alpha fresh link memory", + "beta fresh link memory", "peer"]) + finally: + os.chdir(cwd) + if old is None: + os.environ.pop("MIND_AUTO_DREAM", None) + else: + os.environ["MIND_AUTO_DREAM"] = old + active = (self.mind_dir / "ACTIVE.md").read_text("utf-8") + self.assertIn("alpha fresh link memory", active) + self.assertIn("beta fresh link memory", active) + + def test_confirm_refreshes_hot_memory_order(self): + import io + from contextlib import redirect_stdout, redirect_stderr + old = os.environ.get("MIND_AUTO_DREAM") + os.environ["MIND_AUTO_DREAM"] = "0" + cwd = os.getcwd() + os.chdir(self.tmp) + try: + with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()): + M.main(["init"]) + M.main(["remember", "alpha lower weight fact"]) + M.main(["remember", "beta higher weight fact"]) + h = self.hippo() + a = h._id("alpha lower weight fact") + b = h._id("beta higher weight fact") + h.nodes[a]["weight"] = 0.70 + h.nodes[b]["weight"] = 0.80 + h._dirty.update((a, b)) + h._save() + with redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()): + Mind(self.tmp).confirm([a]) + finally: + os.chdir(cwd) + if old is None: + os.environ.pop("MIND_AUTO_DREAM", None) + else: + os.environ["MIND_AUTO_DREAM"] = old + hot = (self.mind_dir / "ACTIVE.md").read_text("utf-8") + hot = hot.split("## Hot memories")[1].split("##")[0] + self.assertLess(hot.index("alpha lower"), hot.index("beta higher")) + + def test_hot_weight_ties_prefer_confirmed_memories(self): + h = self.hippo() + useful = [] + for i in range(8): + nid = h.remember("confirmed core memory %d" % i) + h.bump([nid]) + useful.append(h.nodes[nid]["text"]) + for i in range(4): + h.remember("fresh unconfirmed trivia %d" % i) + Active(self.mind_dir, h, Cortex( + self.mind_dir / "cortex")).generate(self.tmp) + hot = (self.mind_dir / "ACTIVE.md").read_text("utf-8") + hot = hot.split("## Hot memories")[1].split("##")[0] + self.assertTrue(all(text in hot for text in useful)) + self.assertNotIn("fresh unconfirmed trivia", hot) + + def test_duplicate_confirm_id_reinforces_once(self): + h = self.hippo() + nid = h.remember("deduplicated confirm target") + h.bump([nid, nid]) + self.assertEqual(h.nodes[nid]["access_count"], 1) + + def test_recall_ties_are_hash_seed_independent(self): + import subprocess + gpath = self.mind_dir / "graph.json" + nodes = {} + for nid, text in (("111111111111", "alpha red fact"), + ("222222222222", "alpha blue fact")): + nodes[nid] = { + "text": text, "weight": 1.0, "peak_weight": 1.0, + "confidence": 1.0, "access_count": 0, + "keys": ["alpha", "fact"], + "last_accessed": "2026-01-01T00:00:00", + "created": "2026-01-01T00:00:00", + "origin": {"by": "x"}, + "valid_from": "2026-01-01T00:00:00", "valid_to": None, + } + gpath.write_text(json.dumps({"nodes": nodes, "edges": {}}), "utf-8") + root = str(Path(__file__).resolve().parents[1]) + snippet = ( + "import sys;from pathlib import Path;sys.path.insert(0,%r);" + "import mind;h=mind.Hippocampus(Path(%r));" + "print(h.recall('alpha fact')[0][0][0])" % (root, str(gpath))) + outputs = [] + for seed in ("0", "1", "3", "7"): + result = subprocess.run( + [sys.executable, "-c", snippet], + env=dict(os.environ, PYTHONHASHSEED=seed), + capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + outputs.append(result.stdout.strip()) + self.assertEqual(len(set(outputs)), 1) + + def test_help_and_usage_errors_carry_real_script_path(self): + import subprocess + tools = self.tmp / "tools with space" + project = self.tmp / "project" + tools.mkdir() + project.mkdir() + script = tools / "mind.py" + shutil.copy(str(Path(__file__).resolve().parents[1] / "mind.py"), + str(script)) + help_result = subprocess.run( + [sys.executable, str(script), "--help"], cwd=str(project), + capture_output=True, text=True) + self.assertIn(str(script.resolve()), help_result.stdout) + bad = subprocess.run( + [sys.executable, str(script), "status", "extra"], + cwd=str(project), capture_output=True, text=True) + self.assertEqual(bad.returncode, 2) + self.assertIn(str(script.resolve()), bad.stderr) + + def test_hostile_loaded_text_and_actor_metadata_are_sanitized(self): + gpath = self.mind_dir / "graph.json" + gpath.write_text(json.dumps({"nodes": {"aaa": { + "text": "safe\u202e\x1b[31m fact", + }}, "edges": {}}), "utf-8") + h = self.hippo() + self.assertNotIn("\u202e", h.nodes["aaa"]["text"]) + self.assertNotIn("\x1b", h.nodes["aaa"]["text"]) + old_by = os.environ.get("MIND_BY") + old_session = os.environ.get("MIND_SESSION") + os.environ["MIND_BY"] = "writer\n" + "x" * 500 + os.environ["MIND_SESSION"] = "session\n" + "y" * 500 + try: + nid = h.remember("actor metadata target") + finally: + if old_by is None: + os.environ.pop("MIND_BY", None) + else: + os.environ["MIND_BY"] = old_by + if old_session is None: + os.environ.pop("MIND_SESSION", None) + else: + os.environ["MIND_SESSION"] = old_session + origin = h.nodes[nid]["origin"] + self.assertLessEqual(len(origin["by"]), 80) + self.assertLessEqual(len(origin["session"]), 120) + self.assertNotIn("\n", origin["by"] + origin["session"]) + + if __name__ == "__main__": unittest.main(verbosity=2)