From 2a394e5b8529036c5f4feb585e60d4eb7ad1a7c1 Mon Sep 17 00:00:00 2001 From: ajianaz Date: Tue, 11 Aug 2026 22:10:05 +0700 Subject: [PATCH 01/14] fix(docs): standardize recall latency to ~45ms 3 files still referenced ~30ms (stale from early benchmarks). Actual measured P50 at 1K-10K memories is ~45ms per benchmarks.md. Updated: architecture.md, mcp.md, index.md --- docs/architecture.md | 2 +- docs/index.md | 2 +- docs/mcp.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 54327861..4926cf02 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -237,7 +237,7 @@ Protocol version: `2025-06-18` (Streamable HTTP spec). 1 MiB body limit enforced | Mode | Recall | Setup | |------|--------|-------| -| **Library (Rust)** | **~30ms** | In-process, no startup | +| **Library (Rust)** | **~45ms** | In-process, no startup | | **Server (HTTP)** | **~42ms** | One-time ~2s init | | **CLI (binary)** | **~3s** | Per-invocation (model load) | diff --git a/docs/index.md b/docs/index.md index 94749ede..1fdd87c7 100644 --- a/docs/index.md +++ b/docs/index.md @@ -62,7 +62,7 @@ features: | Install | 1 binary | pip + Docker + Qdrant | pip + Docker + Postgres | pip + Docker + Neo4j | | API Keys | ✅ None | ❌ OpenAI/LLM key | ❌ LLM key | ❌ LLM + vector DB | | Offline | ✅ Fully | ❌ Cloud embedding | ❌ Needs server | ❌ Needs LLM + DB | -| Recall Speed | ~30ms | Network RTT | Network RTT | Network RTT | +| Recall Speed | ~45ms | Network RTT | Network RTT | Network RTT | | Privacy | ✅ Local | ⚠️ Sent to LLM | ⚠️ Sent to LLM | ⚠️ Sent to LLM | [See full comparison →](/comparison) diff --git a/docs/mcp.md b/docs/mcp.md index 0e5e13f2..673d10f4 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -320,6 +320,6 @@ See [Docker guide — MCP](/docker#mcp-model-context-protocol) for full Docker-s | `Permission denied` | `chmod +x $(which uteke-mcp)` or ensure the binary is on your `PATH` | | `Connection refused` (HTTP) | Ensure `uteke-serve` is running: `uteke-serve` | | Client can't see tools | Verify the MCP config JSON is valid and the client has been restarted | -| Slow first query | The embedding model (~188MB) downloads on first use — subsequent calls are ~30ms | +| Slow first query | The embedding model (~188MB) downloads on first use — subsequent calls are ~45ms | See also: [Architecture — MCP Transport](/architecture#mcp-transport-381) From cc8a69b2d89210f162a48ab88282d8c6de5c0f4b Mon Sep 17 00:00:00 2001 From: ajianaz Date: Thu, 13 Aug 2026 08:47:01 +0700 Subject: [PATCH 02/14] chore: harden .gitignore + restructure repo for public safety MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add internal/ to .gitignore (prevents future strategy/launch doc leaks) - Add benchmarks/longmemeval/results_*/ to .gitignore (ephemeral data) - Add docs/package-lock.json to .gitignore (VitePress build artifact) - Move blog/comparison-2026.md → docs/comparison-2026.md (consolidate docs) - Remove stale benchmarks/longmemeval/results_diverse/ (raw output, not summary) - Remove docs/package-lock.json from tracking Audit confirms: zero internal/ files in entire git history after filter-repo scrub. --- .gitignore | 9 + .../results_diverse/retrieval_results.jsonl | 12 - {blog => docs}/comparison-2026.md | 0 docs/package-lock.json | 2357 ----------------- 4 files changed, 9 insertions(+), 2369 deletions(-) delete mode 100644 benchmarks/longmemeval/results_diverse/retrieval_results.jsonl rename {blog => docs}/comparison-2026.md (100%) delete mode 100644 docs/package-lock.json diff --git a/.gitignore b/.gitignore index 9210daed..e5f25e31 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,12 @@ uteke_index.usearch progress.md .cora/ scripts/demo-record.sh + +# Internal docs — never commit to public repo +internal/ + +# Benchmark results (large, ephemeral — commit RESULTS.md summaries, not raw data) +benchmarks/longmemeval/results_*/ + +# VitePress build artifacts (keep config.ts, not lock files) +docs/package-lock.json diff --git a/benchmarks/longmemeval/results_diverse/retrieval_results.jsonl b/benchmarks/longmemeval/results_diverse/retrieval_results.jsonl deleted file mode 100644 index 7bf63634..00000000 --- a/benchmarks/longmemeval/results_diverse/retrieval_results.jsonl +++ /dev/null @@ -1,12 +0,0 @@ -{"question_id": "6a1eabeb", "question_type": "knowledge-update", "retrieval_results": {"metrics": {"session": {"recall_all@5": 0.5, "recall_all@10": 0.5, "recall_all@50": 0.5, "ndcg_any@5": 1.0, "ndcg_any@10": 1.0, "ndcg_any@50": 1.0}}}} -{"question_id": "6aeb4375", "question_type": "knowledge-update", "retrieval_results": {"metrics": {"session": {"recall_all@5": 1.0, "recall_all@10": 1.0, "recall_all@50": 1.0, "ndcg_any@5": 1.0, "ndcg_any@10": 1.0, "ndcg_any@50": 1.0}}}} -{"question_id": "0a995998", "question_type": "multi-session", "retrieval_results": {"metrics": {"session": {"recall_all@5": 1.0, "recall_all@10": 1.0, "recall_all@50": 1.0, "ndcg_any@5": 1.0, "ndcg_any@10": 1.0, "ndcg_any@50": 1.0}}}} -{"question_id": "6d550036", "question_type": "multi-session", "retrieval_results": {"metrics": {"session": {"recall_all@5": 1.0, "recall_all@10": 1.0, "recall_all@50": 1.0, "ndcg_any@5": 1.0, "ndcg_any@10": 1.0, "ndcg_any@50": 1.0}}}} -{"question_id": "7161e7e2", "question_type": "single-session-assistant", "retrieval_results": {"metrics": {"session": {"recall_all@5": 1.0, "recall_all@10": 1.0, "recall_all@50": 1.0, "ndcg_any@5": 1.0, "ndcg_any@10": 1.0, "ndcg_any@50": 1.0}}}} -{"question_id": "c4f10528", "question_type": "single-session-assistant", "retrieval_results": {"metrics": {"session": {"recall_all@5": 1.0, "recall_all@10": 1.0, "recall_all@50": 1.0, "ndcg_any@5": 1.0, "ndcg_any@10": 1.0, "ndcg_any@50": 1.0}}}} -{"question_id": "8a2466db", "question_type": "single-session-preference", "retrieval_results": {"metrics": {"session": {"recall_all@5": 1.0, "recall_all@10": 1.0, "recall_all@50": 1.0, "ndcg_any@5": 1.0, "ndcg_any@10": 1.0, "ndcg_any@50": 1.0}}}} -{"question_id": "06878be2", "question_type": "single-session-preference", "retrieval_results": {"metrics": {"session": {"recall_all@5": 1.0, "recall_all@10": 1.0, "recall_all@50": 1.0, "ndcg_any@5": 1.0, "ndcg_any@10": 1.0, "ndcg_any@50": 1.0}}}} -{"question_id": "e47becba", "question_type": "single-session-user", "retrieval_results": {"metrics": {"session": {"recall_all@5": 1.0, "recall_all@10": 1.0, "recall_all@50": 1.0, "ndcg_any@5": 1.0, "ndcg_any@10": 1.0, "ndcg_any@50": 1.0}}}} -{"question_id": "118b2229", "question_type": "single-session-user", "retrieval_results": {"metrics": {"session": {"recall_all@5": 1.0, "recall_all@10": 1.0, "recall_all@50": 1.0, "ndcg_any@5": 1.0, "ndcg_any@10": 1.0, "ndcg_any@50": 1.0}}}} -{"question_id": "gpt4_2655b836", "question_type": "temporal-reasoning", "retrieval_results": {"metrics": {"session": {"recall_all@5": 1.0, "recall_all@10": 1.0, "recall_all@50": 1.0, "ndcg_any@5": 1.0, "ndcg_any@10": 1.0, "ndcg_any@50": 1.0}}}} -{"question_id": "gpt4_2487a7cb", "question_type": "temporal-reasoning", "retrieval_results": {"metrics": {"session": {"recall_all@5": 1.0, "recall_all@10": 1.0, "recall_all@50": 1.0, "ndcg_any@5": 1.0, "ndcg_any@10": 1.0, "ndcg_any@50": 1.0}}}} diff --git a/blog/comparison-2026.md b/docs/comparison-2026.md similarity index 100% rename from blog/comparison-2026.md rename to docs/comparison-2026.md diff --git a/docs/package-lock.json b/docs/package-lock.json deleted file mode 100644 index cb40bddf..00000000 --- a/docs/package-lock.json +++ /dev/null @@ -1,2357 +0,0 @@ -{ - "name": "docs", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "docs", - "version": "1.0.0", - "license": "ISC", - "devDependencies": { - "@codecora/theme": "github:codecoradev/codecora-theme#feat/add-ci-workflow", - "vitepress": "^1.6.4", - "vue": "^3.5.35" - } - }, - "node_modules/@algolia/abtesting": { - "version": "1.19.0", - "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.19.0.tgz", - "integrity": "sha512-Lhnez3hhXHk25lfxLAMxvkP4fmN3+1RgADhD2ssMDBYuAsDVReeyP+3SGRx+ntq8ijMrLqUyfvO72TB6jsTteQ==", - "dev": true, - "dependencies": { - "@algolia/client-common": "5.53.0", - "@algolia/requester-browser-xhr": "5.53.0", - "@algolia/requester-fetch": "5.53.0", - "@algolia/requester-node-http": "5.53.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/autocomplete-core": { - "version": "1.17.7", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-core/-/autocomplete-core-1.17.7.tgz", - "integrity": "sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q==", - "dev": true, - "dependencies": { - "@algolia/autocomplete-plugin-algolia-insights": "1.17.7", - "@algolia/autocomplete-shared": "1.17.7" - } - }, - "node_modules/@algolia/autocomplete-plugin-algolia-insights": { - "version": "1.17.7", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-plugin-algolia-insights/-/autocomplete-plugin-algolia-insights-1.17.7.tgz", - "integrity": "sha512-Jca5Ude6yUOuyzjnz57og7Et3aXjbwCSDf/8onLHSQgw1qW3ALl9mrMWaXb5FmPVkV3EtkD2F/+NkT6VHyPu9A==", - "dev": true, - "dependencies": { - "@algolia/autocomplete-shared": "1.17.7" - }, - "peerDependencies": { - "search-insights": ">= 1 < 3" - } - }, - "node_modules/@algolia/autocomplete-preset-algolia": { - "version": "1.17.7", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-preset-algolia/-/autocomplete-preset-algolia-1.17.7.tgz", - "integrity": "sha512-ggOQ950+nwbWROq2MOCIL71RE0DdQZsceqrg32UqnhDz8FlO9rL8ONHNsI2R1MH0tkgVIDKI/D0sMiUchsFdWA==", - "dev": true, - "dependencies": { - "@algolia/autocomplete-shared": "1.17.7" - }, - "peerDependencies": { - "@algolia/client-search": ">= 4.9.1 < 6", - "algoliasearch": ">= 4.9.1 < 6" - } - }, - "node_modules/@algolia/autocomplete-shared": { - "version": "1.17.7", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-shared/-/autocomplete-shared-1.17.7.tgz", - "integrity": "sha512-o/1Vurr42U/qskRSuhBH+VKxMvkkUVTLU6WZQr+L5lGZZLYWyhdzWjW0iGXY7EkwRTjBqvN2EsR81yCTGV/kmg==", - "dev": true, - "peerDependencies": { - "@algolia/client-search": ">= 4.9.1 < 6", - "algoliasearch": ">= 4.9.1 < 6" - } - }, - "node_modules/@algolia/client-abtesting": { - "version": "5.53.0", - "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.53.0.tgz", - "integrity": "sha512-0ZjA5Hcmaoz5Lj6OG0zhfIyeqzJZnLW2CRJA1W17UwMFGRtZAJ9yJKRvPEDA6gkpsIoQxORTSW6sWFiuYncPNQ==", - "dev": true, - "dependencies": { - "@algolia/client-common": "5.53.0", - "@algolia/requester-browser-xhr": "5.53.0", - "@algolia/requester-fetch": "5.53.0", - "@algolia/requester-node-http": "5.53.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-analytics": { - "version": "5.53.0", - "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.53.0.tgz", - "integrity": "sha512-kWNodP75iiEaOtemC9F/hlxNBG5E2QUjN1BusnE6m2b4l7Qh/BUO3fGCVsmKJI65VO4VKGGmT43ICvHtTcJ2JQ==", - "dev": true, - "dependencies": { - "@algolia/client-common": "5.53.0", - "@algolia/requester-browser-xhr": "5.53.0", - "@algolia/requester-fetch": "5.53.0", - "@algolia/requester-node-http": "5.53.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-common": { - "version": "5.53.0", - "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.53.0.tgz", - "integrity": "sha512-YPN45TXD9Wrse185t/Ta7nktZsqpv97oOjCzp2sblHnCL6rBc9TDeJAg1IGl2UpdwnSD05Zu/5wLB4watOUMyg==", - "dev": true, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-insights": { - "version": "5.53.0", - "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.53.0.tgz", - "integrity": "sha512-qAcYTDJE6m924FDDUQvdD6vh7DYaqOeSpFS74IP37/JRV0v4cGBauyxTF2WzDnokUylQDbqreoFIJZfg0Fitmw==", - "dev": true, - "dependencies": { - "@algolia/client-common": "5.53.0", - "@algolia/requester-browser-xhr": "5.53.0", - "@algolia/requester-fetch": "5.53.0", - "@algolia/requester-node-http": "5.53.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-personalization": { - "version": "5.53.0", - "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.53.0.tgz", - "integrity": "sha512-fQaY+DkSJOpuUVUe8MQTwrdiKAqkJGhpDarB08duBn/sUv7Bkib6MDRQauCcWTWTe4HIW+EbwQP9R4kci1V/Yw==", - "dev": true, - "dependencies": { - "@algolia/client-common": "5.53.0", - "@algolia/requester-browser-xhr": "5.53.0", - "@algolia/requester-fetch": "5.53.0", - "@algolia/requester-node-http": "5.53.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-query-suggestions": { - "version": "5.53.0", - "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.53.0.tgz", - "integrity": "sha512-o72tsiEZGfeS/dxL9IADfzcZWGEwKDEe5CvtrBuT//3JR+SHuTtHRI2ZTf7D7bcKagcbojvO8hnkHdfoakSlYg==", - "dev": true, - "dependencies": { - "@algolia/client-common": "5.53.0", - "@algolia/requester-browser-xhr": "5.53.0", - "@algolia/requester-fetch": "5.53.0", - "@algolia/requester-node-http": "5.53.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-search": { - "version": "5.53.0", - "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.53.0.tgz", - "integrity": "sha512-Ds16IyPm/dNJPCU8OzApo2gwGrgWT5BYHhE3NFwZbpCveqyvPDB9sZDDkJ5DsdOGT2aC+R3i0/M1OVXF2qdgPg==", - "dev": true, - "dependencies": { - "@algolia/client-common": "5.53.0", - "@algolia/requester-browser-xhr": "5.53.0", - "@algolia/requester-fetch": "5.53.0", - "@algolia/requester-node-http": "5.53.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/ingestion": { - "version": "1.53.0", - "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.53.0.tgz", - "integrity": "sha512-oNbT6z4NwD8Pou9VPINGlN/tlG1afESh2EbxqnP6rwl95xKVD/Zlciis1PpNeO/9U/rrajc1+7DcfKi03tX1KQ==", - "dev": true, - "dependencies": { - "@algolia/client-common": "5.53.0", - "@algolia/requester-browser-xhr": "5.53.0", - "@algolia/requester-fetch": "5.53.0", - "@algolia/requester-node-http": "5.53.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/monitoring": { - "version": "1.53.0", - "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.53.0.tgz", - "integrity": "sha512-G+KZb/yd+qAOFn/cEvTGeLxQm8aP3a0od50l3z/ylccY+/o4YG3TNcjU1tFQHW4mXC137GPyR7W70R0kRQDLnA==", - "dev": true, - "dependencies": { - "@algolia/client-common": "5.53.0", - "@algolia/requester-browser-xhr": "5.53.0", - "@algolia/requester-fetch": "5.53.0", - "@algolia/requester-node-http": "5.53.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/recommend": { - "version": "5.53.0", - "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.53.0.tgz", - "integrity": "sha512-6aVfYd55Un6IUgPLbo84WfgFZlS3L0vA1ttzXL5vahHewUJ8jYgd89TzlWRTeej7w70mb9RWsVlFYGmJ/diQww==", - "dev": true, - "dependencies": { - "@algolia/client-common": "5.53.0", - "@algolia/requester-browser-xhr": "5.53.0", - "@algolia/requester-fetch": "5.53.0", - "@algolia/requester-node-http": "5.53.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/requester-browser-xhr": { - "version": "5.53.0", - "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.53.0.tgz", - "integrity": "sha512-ke27DqgzCOlt+RbeEdCxtXxMQOnAOi8ujr2wid0DmDKzR95Kw/f9sBsuhBxtjevCqJRJszfRTLY0B1pbO6IhkA==", - "dev": true, - "dependencies": { - "@algolia/client-common": "5.53.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/requester-fetch": { - "version": "5.53.0", - "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.53.0.tgz", - "integrity": "sha512-GngiOqt2Gq4oLno6yXQVj9om+qSO9SWAoduoTOEg79dKZ62brB8OOIvSJG/vDNoanYi6a7Al9uDZwXvi+bcVTg==", - "dev": true, - "dependencies": { - "@algolia/client-common": "5.53.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/requester-node-http": { - "version": "5.53.0", - "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.53.0.tgz", - "integrity": "sha512-6mF9LZMUk0QqWvrnxkxBqhswwz6Xfiwy6/gmTzL5HrlhdVG3ITAqGV2k3XmVThP1h0Ulc3VQwiNCD7/Nr4JNlQ==", - "dev": true, - "dependencies": { - "@algolia/client-common": "5.53.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", - "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", - "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", - "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", - "dev": true, - "dependencies": { - "@babel/types": "^7.29.7" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", - "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", - "dev": true, - "dependencies": { - "@babel/helper-string-parser": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@codecora/theme": { - "version": "0.1.0", - "resolved": "git+ssh://git@github.com/codecoradev/codecora-theme.git#bbde4c21afd92f7410a21d4a4d8f45f70daddb03", - "dev": true, - "license": "Apache-2.0", - "peerDependencies": { - "vitepress": "^1.0.0" - }, - "peerDependenciesMeta": { - "vitepress": { - "optional": true - } - } - }, - "node_modules/@docsearch/css": { - "version": "3.8.2", - "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-3.8.2.tgz", - "integrity": "sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ==", - "dev": true - }, - "node_modules/@docsearch/js": { - "version": "3.8.2", - "resolved": "https://registry.npmjs.org/@docsearch/js/-/js-3.8.2.tgz", - "integrity": "sha512-Q5wY66qHn0SwA7Taa0aDbHiJvaFJLOJyHmooQ7y8hlwwQLQ/5WwCcoX0g7ii04Qi2DJlHsd0XXzJ8Ypw9+9YmQ==", - "dev": true, - "dependencies": { - "@docsearch/react": "3.8.2", - "preact": "^10.0.0" - } - }, - "node_modules/@docsearch/react": { - "version": "3.8.2", - "resolved": "https://registry.npmjs.org/@docsearch/react/-/react-3.8.2.tgz", - "integrity": "sha512-xCRrJQlTt8N9GU0DG4ptwHRkfnSnD/YpdeaXe02iKfqs97TkZJv60yE+1eq/tjPcVnTW8dP5qLP7itifFVV5eg==", - "dev": true, - "dependencies": { - "@algolia/autocomplete-core": "1.17.7", - "@algolia/autocomplete-preset-algolia": "1.17.7", - "@docsearch/css": "3.8.2", - "algoliasearch": "^5.14.2" - }, - "peerDependencies": { - "@types/react": ">= 16.8.0 < 19.0.0", - "react": ">= 16.8.0 < 19.0.0", - "react-dom": ">= 16.8.0 < 19.0.0", - "search-insights": ">= 1 < 3" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "react": { - "optional": true - }, - "react-dom": { - "optional": true - }, - "search-insights": { - "optional": true - } - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@iconify-json/simple-icons": { - "version": "1.2.85", - "resolved": "https://registry.npmjs.org/@iconify-json/simple-icons/-/simple-icons-1.2.85.tgz", - "integrity": "sha512-Hp5LXvd3LRk+e+1558wtonA7c1Z0/Phmi7xCqpgtb8bs8cuyGnP34GDbt5uhhUXxKlzacnnhAcXgcDxe9bUa1w==", - "dev": true, - "dependencies": { - "@iconify/types": "*" - } - }, - "node_modules/@iconify/types": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", - "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", - "dev": true - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz", - "integrity": "sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz", - "integrity": "sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz", - "integrity": "sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz", - "integrity": "sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.1.tgz", - "integrity": "sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.1.tgz", - "integrity": "sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.1.tgz", - "integrity": "sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.1.tgz", - "integrity": "sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.1.tgz", - "integrity": "sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.1.tgz", - "integrity": "sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.1.tgz", - "integrity": "sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.1.tgz", - "integrity": "sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.1.tgz", - "integrity": "sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.1.tgz", - "integrity": "sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.1.tgz", - "integrity": "sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.1.tgz", - "integrity": "sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.1.tgz", - "integrity": "sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==", - "cpu": [ - "s390x" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.1.tgz", - "integrity": "sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.1.tgz", - "integrity": "sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.1.tgz", - "integrity": "sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.1.tgz", - "integrity": "sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.1.tgz", - "integrity": "sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.1.tgz", - "integrity": "sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.1.tgz", - "integrity": "sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.1.tgz", - "integrity": "sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@shikijs/core": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-2.5.0.tgz", - "integrity": "sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg==", - "dev": true, - "dependencies": { - "@shikijs/engine-javascript": "2.5.0", - "@shikijs/engine-oniguruma": "2.5.0", - "@shikijs/types": "2.5.0", - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4", - "hast-util-to-html": "^9.0.4" - } - }, - "node_modules/@shikijs/engine-javascript": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-2.5.0.tgz", - "integrity": "sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w==", - "dev": true, - "dependencies": { - "@shikijs/types": "2.5.0", - "@shikijs/vscode-textmate": "^10.0.2", - "oniguruma-to-es": "^3.1.0" - } - }, - "node_modules/@shikijs/engine-oniguruma": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-2.5.0.tgz", - "integrity": "sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw==", - "dev": true, - "dependencies": { - "@shikijs/types": "2.5.0", - "@shikijs/vscode-textmate": "^10.0.2" - } - }, - "node_modules/@shikijs/langs": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-2.5.0.tgz", - "integrity": "sha512-Qfrrt5OsNH5R+5tJ/3uYBBZv3SuGmnRPejV9IlIbFH3HTGLDlkqgHymAlzklVmKBjAaVmkPkyikAV/sQ1wSL+w==", - "dev": true, - "dependencies": { - "@shikijs/types": "2.5.0" - } - }, - "node_modules/@shikijs/themes": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-2.5.0.tgz", - "integrity": "sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw==", - "dev": true, - "dependencies": { - "@shikijs/types": "2.5.0" - } - }, - "node_modules/@shikijs/transformers": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@shikijs/transformers/-/transformers-2.5.0.tgz", - "integrity": "sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg==", - "dev": true, - "dependencies": { - "@shikijs/core": "2.5.0", - "@shikijs/types": "2.5.0" - } - }, - "node_modules/@shikijs/types": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-2.5.0.tgz", - "integrity": "sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw==", - "dev": true, - "dependencies": { - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4" - } - }, - "node_modules/@shikijs/vscode-textmate": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", - "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", - "dev": true - }, - "node_modules/@types/estree": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", - "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", - "dev": true - }, - "node_modules/@types/hast": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", - "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", - "dev": true, - "dependencies": { - "@types/unist": "*" - } - }, - "node_modules/@types/linkify-it": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", - "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", - "dev": true - }, - "node_modules/@types/markdown-it": { - "version": "14.1.2", - "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", - "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", - "dev": true, - "dependencies": { - "@types/linkify-it": "^5", - "@types/mdurl": "^2" - } - }, - "node_modules/@types/mdast": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", - "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", - "dev": true, - "dependencies": { - "@types/unist": "*" - } - }, - "node_modules/@types/mdurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", - "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", - "dev": true - }, - "node_modules/@types/unist": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", - "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", - "dev": true - }, - "node_modules/@types/web-bluetooth": { - "version": "0.0.21", - "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", - "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", - "dev": true - }, - "node_modules/@ungap/structured-clone": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", - "integrity": "sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==", - "dev": true - }, - "node_modules/@vitejs/plugin-vue": { - "version": "5.2.4", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", - "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", - "dev": true, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "peerDependencies": { - "vite": "^5.0.0 || ^6.0.0", - "vue": "^3.2.25" - } - }, - "node_modules/@vue/compiler-core": { - "version": "3.5.35", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.35.tgz", - "integrity": "sha512-BUmHaR1J+O+CKZ9uJucdVTEr1LHsdyvv7vG3eNRhK3CczEHeMd/LtsHAuD7PbrxvI2envCY2v7HI1vC1aBRzKw==", - "dev": true, - "dependencies": { - "@babel/parser": "^7.29.3", - "@vue/shared": "3.5.35", - "entities": "^7.0.1", - "estree-walker": "^2.0.2", - "source-map-js": "^1.2.1" - } - }, - "node_modules/@vue/compiler-dom": { - "version": "3.5.35", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.35.tgz", - "integrity": "sha512-k+bprkXxuqhVajgTx5mUHuir7TwQzUKOWR40ng1ncAqQRPnrLngGGgqVEEhOnTMlc8btHYVKmrP8s5Qyg0hvYA==", - "dev": true, - "dependencies": { - "@vue/compiler-core": "3.5.35", - "@vue/shared": "3.5.35" - } - }, - "node_modules/@vue/compiler-sfc": { - "version": "3.5.35", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.35.tgz", - "integrity": "sha512-G5VPMcXTSywXBgtFOZOnHKBxKSrwXUcvY1iaF5/hRcy7t0J6CH/d8ha9F4nzi00Fax1eLV0QHM7v4mQu68jydw==", - "dev": true, - "dependencies": { - "@babel/parser": "^7.29.3", - "@vue/compiler-core": "3.5.35", - "@vue/compiler-dom": "3.5.35", - "@vue/compiler-ssr": "3.5.35", - "@vue/shared": "3.5.35", - "estree-walker": "^2.0.2", - "magic-string": "^0.30.21", - "postcss": "^8.5.15", - "source-map-js": "^1.2.1" - } - }, - "node_modules/@vue/compiler-ssr": { - "version": "3.5.35", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.35.tgz", - "integrity": "sha512-rGhAeXgdM7/ffTJGXT69rCCdTmjDewnFuUZfBQQHTdcEBeWdT5HCGY60y2ytLJr9/Dsu7IntUi5z/w0h6Rjnzw==", - "dev": true, - "dependencies": { - "@vue/compiler-dom": "3.5.35", - "@vue/shared": "3.5.35" - } - }, - "node_modules/@vue/devtools-api": { - "version": "7.7.9", - "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz", - "integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==", - "dev": true, - "dependencies": { - "@vue/devtools-kit": "^7.7.9" - } - }, - "node_modules/@vue/devtools-kit": { - "version": "7.7.9", - "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz", - "integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==", - "dev": true, - "dependencies": { - "@vue/devtools-shared": "^7.7.9", - "birpc": "^2.3.0", - "hookable": "^5.5.3", - "mitt": "^3.0.1", - "perfect-debounce": "^1.0.0", - "speakingurl": "^14.0.1", - "superjson": "^2.2.2" - } - }, - "node_modules/@vue/devtools-shared": { - "version": "7.7.9", - "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz", - "integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==", - "dev": true, - "dependencies": { - "rfdc": "^1.4.1" - } - }, - "node_modules/@vue/reactivity": { - "version": "3.5.35", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.35.tgz", - "integrity": "sha512-tVc+SsHConvh/Lz64qq1pP3rYArBmK42xonovEcxY74SQtvctZodG/zhq54P5dr38cVuw25d27cPNRdlMidpGQ==", - "dev": true, - "dependencies": { - "@vue/shared": "3.5.35" - } - }, - "node_modules/@vue/runtime-core": { - "version": "3.5.35", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.35.tgz", - "integrity": "sha512-A/xFNX9loIcWDygeQuNCfKuh0CoYBzxhqEMNah5TSFg9Z53DrFYEN2qi5CU9necjM1OWYegYREUTHmXTmhfXtg==", - "dev": true, - "dependencies": { - "@vue/reactivity": "3.5.35", - "@vue/shared": "3.5.35" - } - }, - "node_modules/@vue/runtime-dom": { - "version": "3.5.35", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.35.tgz", - "integrity": "sha512-odrJ1C391dbGnyDRh8U+rnP7J2amIEzfmRk5vXy7xi3aZhEXofTvpi0T4HJb6jlNqQZTNPR5MPHSB3RHNkIORA==", - "dev": true, - "dependencies": { - "@vue/reactivity": "3.5.35", - "@vue/runtime-core": "3.5.35", - "@vue/shared": "3.5.35", - "csstype": "^3.2.3" - } - }, - "node_modules/@vue/server-renderer": { - "version": "3.5.35", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.35.tgz", - "integrity": "sha512-NkebSOYdB97wi8OQcO3HqzZSlymJi/aWsN/7h74OSVhRTm6qGs3Jp3e0rCXynmWwSlKeRrnlIug+ilYoHBmQDA==", - "dev": true, - "dependencies": { - "@vue/compiler-ssr": "3.5.35", - "@vue/shared": "3.5.35" - }, - "peerDependencies": { - "vue": "3.5.35" - } - }, - "node_modules/@vue/shared": { - "version": "3.5.35", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.35.tgz", - "integrity": "sha512-zSbjL7gRXwks2ZQLRGCajBtBXEOXW9Ddhn/HvSdrGkE2dqGnumzW8XtusRrxrE9LvqtiqDXQ+A60Hp6mvdYxfA==", - "dev": true - }, - "node_modules/@vueuse/core": { - "version": "12.8.2", - "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-12.8.2.tgz", - "integrity": "sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==", - "dev": true, - "dependencies": { - "@types/web-bluetooth": "^0.0.21", - "@vueuse/metadata": "12.8.2", - "@vueuse/shared": "12.8.2", - "vue": "^3.5.13" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/@vueuse/integrations": { - "version": "12.8.2", - "resolved": "https://registry.npmjs.org/@vueuse/integrations/-/integrations-12.8.2.tgz", - "integrity": "sha512-fbGYivgK5uBTRt7p5F3zy6VrETlV9RtZjBqd1/HxGdjdckBgBM4ugP8LHpjolqTj14TXTxSK1ZfgPbHYyGuH7g==", - "dev": true, - "dependencies": { - "@vueuse/core": "12.8.2", - "@vueuse/shared": "12.8.2", - "vue": "^3.5.13" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - }, - "peerDependencies": { - "async-validator": "^4", - "axios": "^1", - "change-case": "^5", - "drauu": "^0.4", - "focus-trap": "^7", - "fuse.js": "^7", - "idb-keyval": "^6", - "jwt-decode": "^4", - "nprogress": "^0.2", - "qrcode": "^1.5", - "sortablejs": "^1", - "universal-cookie": "^7" - }, - "peerDependenciesMeta": { - "async-validator": { - "optional": true - }, - "axios": { - "optional": true - }, - "change-case": { - "optional": true - }, - "drauu": { - "optional": true - }, - "focus-trap": { - "optional": true - }, - "fuse.js": { - "optional": true - }, - "idb-keyval": { - "optional": true - }, - "jwt-decode": { - "optional": true - }, - "nprogress": { - "optional": true - }, - "qrcode": { - "optional": true - }, - "sortablejs": { - "optional": true - }, - "universal-cookie": { - "optional": true - } - } - }, - "node_modules/@vueuse/metadata": { - "version": "12.8.2", - "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.8.2.tgz", - "integrity": "sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/@vueuse/shared": { - "version": "12.8.2", - "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.8.2.tgz", - "integrity": "sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==", - "dev": true, - "dependencies": { - "vue": "^3.5.13" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/algoliasearch": { - "version": "5.53.0", - "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.53.0.tgz", - "integrity": "sha512-OGW1q6b91CRSSeiOnM8LxuR5NYJ2esvw66jUZ4IIvdv+ItNkx3pwLuyR+jaCdbGee4ov5WgUnyPryyh11xvByQ==", - "dev": true, - "dependencies": { - "@algolia/abtesting": "1.19.0", - "@algolia/client-abtesting": "5.53.0", - "@algolia/client-analytics": "5.53.0", - "@algolia/client-common": "5.53.0", - "@algolia/client-insights": "5.53.0", - "@algolia/client-personalization": "5.53.0", - "@algolia/client-query-suggestions": "5.53.0", - "@algolia/client-search": "5.53.0", - "@algolia/ingestion": "1.53.0", - "@algolia/monitoring": "1.53.0", - "@algolia/recommend": "5.53.0", - "@algolia/requester-browser-xhr": "5.53.0", - "@algolia/requester-fetch": "5.53.0", - "@algolia/requester-node-http": "5.53.0" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/birpc": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", - "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/ccount": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", - "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", - "dev": true, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-entities-html4": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", - "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", - "dev": true, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-entities-legacy": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", - "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", - "dev": true, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/comma-separated-tokens": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", - "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", - "dev": true, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/copy-anything": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", - "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", - "dev": true, - "dependencies": { - "is-what": "^5.2.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/mesqueeb" - } - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true - }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/devlop": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", - "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", - "dev": true, - "dependencies": { - "dequal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/emoji-regex-xs": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz", - "integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==", - "dev": true - }, - "node_modules/entities": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", - "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", - "dev": true, - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, - "hasInstallScript": true, - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" - } - }, - "node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true - }, - "node_modules/focus-trap": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.8.0.tgz", - "integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==", - "dev": true, - "dependencies": { - "tabbable": "^6.4.0" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/hast-util-to-html": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", - "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", - "dev": true, - "dependencies": { - "@types/hast": "^3.0.0", - "@types/unist": "^3.0.0", - "ccount": "^2.0.0", - "comma-separated-tokens": "^2.0.0", - "hast-util-whitespace": "^3.0.0", - "html-void-elements": "^3.0.0", - "mdast-util-to-hast": "^13.0.0", - "property-information": "^7.0.0", - "space-separated-tokens": "^2.0.0", - "stringify-entities": "^4.0.0", - "zwitch": "^2.0.4" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-whitespace": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", - "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", - "dev": true, - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hookable": { - "version": "5.5.3", - "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", - "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", - "dev": true - }, - "node_modules/html-void-elements": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", - "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", - "dev": true, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/is-what": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", - "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", - "dev": true, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/mesqueeb" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/mark.js": { - "version": "8.11.1", - "resolved": "https://registry.npmjs.org/mark.js/-/mark.js-8.11.1.tgz", - "integrity": "sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==", - "dev": true - }, - "node_modules/mdast-util-to-hast": { - "version": "13.2.1", - "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", - "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", - "dev": true, - "dependencies": { - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "@ungap/structured-clone": "^1.0.0", - "devlop": "^1.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "trim-lines": "^3.0.0", - "unist-util-position": "^5.0.0", - "unist-util-visit": "^5.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-util-character": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", - "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-encode": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", - "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ] - }, - "node_modules/micromark-util-sanitize-uri": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", - "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-encode": "^2.0.0", - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-symbol": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", - "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ] - }, - "node_modules/micromark-util-types": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", - "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ] - }, - "node_modules/minisearch": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/minisearch/-/minisearch-7.2.0.tgz", - "integrity": "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==", - "dev": true - }, - "node_modules/mitt": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", - "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", - "dev": true - }, - "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/oniguruma-to-es": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-3.1.1.tgz", - "integrity": "sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==", - "dev": true, - "dependencies": { - "emoji-regex-xs": "^1.0.0", - "regex": "^6.0.1", - "regex-recursion": "^6.0.2" - } - }, - "node_modules/perfect-debounce": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", - "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", - "dev": true - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true - }, - "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "dependencies": { - "nanoid": "^3.3.12", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/preact": { - "version": "10.29.2", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.2.tgz", - "integrity": "sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ==", - "dev": true, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" - } - }, - "node_modules/property-information": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", - "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", - "dev": true, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", - "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", - "dev": true, - "dependencies": { - "regex-utilities": "^2.3.0" - } - }, - "node_modules/regex-recursion": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", - "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", - "dev": true, - "dependencies": { - "regex-utilities": "^2.3.0" - } - }, - "node_modules/regex-utilities": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", - "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", - "dev": true - }, - "node_modules/rfdc": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", - "dev": true - }, - "node_modules/rollup": { - "version": "4.61.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz", - "integrity": "sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==", - "dev": true, - "dependencies": { - "@types/estree": "1.0.9" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.61.1", - "@rollup/rollup-android-arm64": "4.61.1", - "@rollup/rollup-darwin-arm64": "4.61.1", - "@rollup/rollup-darwin-x64": "4.61.1", - "@rollup/rollup-freebsd-arm64": "4.61.1", - "@rollup/rollup-freebsd-x64": "4.61.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.61.1", - "@rollup/rollup-linux-arm-musleabihf": "4.61.1", - "@rollup/rollup-linux-arm64-gnu": "4.61.1", - "@rollup/rollup-linux-arm64-musl": "4.61.1", - "@rollup/rollup-linux-loong64-gnu": "4.61.1", - "@rollup/rollup-linux-loong64-musl": "4.61.1", - "@rollup/rollup-linux-ppc64-gnu": "4.61.1", - "@rollup/rollup-linux-ppc64-musl": "4.61.1", - "@rollup/rollup-linux-riscv64-gnu": "4.61.1", - "@rollup/rollup-linux-riscv64-musl": "4.61.1", - "@rollup/rollup-linux-s390x-gnu": "4.61.1", - "@rollup/rollup-linux-x64-gnu": "4.61.1", - "@rollup/rollup-linux-x64-musl": "4.61.1", - "@rollup/rollup-openbsd-x64": "4.61.1", - "@rollup/rollup-openharmony-arm64": "4.61.1", - "@rollup/rollup-win32-arm64-msvc": "4.61.1", - "@rollup/rollup-win32-ia32-msvc": "4.61.1", - "@rollup/rollup-win32-x64-gnu": "4.61.1", - "@rollup/rollup-win32-x64-msvc": "4.61.1", - "fsevents": "~2.3.2" - } - }, - "node_modules/search-insights": { - "version": "2.17.3", - "resolved": "https://registry.npmjs.org/search-insights/-/search-insights-2.17.3.tgz", - "integrity": "sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==", - "dev": true, - "peer": true - }, - "node_modules/shiki": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/shiki/-/shiki-2.5.0.tgz", - "integrity": "sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ==", - "dev": true, - "dependencies": { - "@shikijs/core": "2.5.0", - "@shikijs/engine-javascript": "2.5.0", - "@shikijs/engine-oniguruma": "2.5.0", - "@shikijs/langs": "2.5.0", - "@shikijs/themes": "2.5.0", - "@shikijs/types": "2.5.0", - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/space-separated-tokens": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", - "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", - "dev": true, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/speakingurl": { - "version": "14.0.1", - "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", - "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/stringify-entities": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", - "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", - "dev": true, - "dependencies": { - "character-entities-html4": "^2.0.0", - "character-entities-legacy": "^3.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/superjson": { - "version": "2.2.6", - "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", - "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", - "dev": true, - "dependencies": { - "copy-anything": "^4" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/tabbable": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", - "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", - "dev": true - }, - "node_modules/trim-lines": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", - "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", - "dev": true, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/unist-util-is": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", - "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", - "dev": true, - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-position": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", - "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", - "dev": true, - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-stringify-position": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", - "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", - "dev": true, - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", - "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", - "dev": true, - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0", - "unist-util-visit-parents": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit-parents": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", - "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", - "dev": true, - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vfile": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", - "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", - "dev": true, - "dependencies": { - "@types/unist": "^3.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vfile-message": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", - "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", - "dev": true, - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-stringify-position": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vite": { - "version": "5.4.21", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", - "dev": true, - "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - } - } - }, - "node_modules/vitepress": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/vitepress/-/vitepress-1.6.4.tgz", - "integrity": "sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg==", - "dev": true, - "dependencies": { - "@docsearch/css": "3.8.2", - "@docsearch/js": "3.8.2", - "@iconify-json/simple-icons": "^1.2.21", - "@shikijs/core": "^2.1.0", - "@shikijs/transformers": "^2.1.0", - "@shikijs/types": "^2.1.0", - "@types/markdown-it": "^14.1.2", - "@vitejs/plugin-vue": "^5.2.1", - "@vue/devtools-api": "^7.7.0", - "@vue/shared": "^3.5.13", - "@vueuse/core": "^12.4.0", - "@vueuse/integrations": "^12.4.0", - "focus-trap": "^7.6.4", - "mark.js": "8.11.1", - "minisearch": "^7.1.1", - "shiki": "^2.1.0", - "vite": "^5.4.14", - "vue": "^3.5.13" - }, - "bin": { - "vitepress": "bin/vitepress.js" - }, - "peerDependencies": { - "markdown-it-mathjax3": "^4", - "postcss": "^8" - }, - "peerDependenciesMeta": { - "markdown-it-mathjax3": { - "optional": true - }, - "postcss": { - "optional": true - } - } - }, - "node_modules/vue": { - "version": "3.5.35", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.35.tgz", - "integrity": "sha512-cx89fnr+0kVGHiNFG6y6s0bdjypJRFNZn6x3WPstNdQR1bi1mbB7h4v5IBGTsPJU3nK1+0Iqj3Zf+hZWMieR4Q==", - "dev": true, - "dependencies": { - "@vue/compiler-dom": "3.5.35", - "@vue/compiler-sfc": "3.5.35", - "@vue/runtime-dom": "3.5.35", - "@vue/server-renderer": "3.5.35", - "@vue/shared": "3.5.35" - }, - "peerDependencies": { - "typescript": "*" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/zwitch": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", - "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", - "dev": true, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - } - } -} From c80655a4f301e0f3c47e343c247d8470c07f6ff2 Mon Sep 17 00:00:00 2001 From: "Anaz S. Aji" Date: Thu, 13 Aug 2026 23:30:28 +0700 Subject: [PATCH 03/14] feat: hybrid as default recall strategy + import fixes (#1005, #1014) (#1015) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(bench): batch import + strategy flag + API embedding auto-config - Replace 53x individual remember calls with single JSONL batch import (10x speedup) - Add --strategy flag to pass vector|hybrid to uteke recall - Auto-configure embedding API from EMBED_API_KEY/EMBED_API_BASE env vars - Add --chunk-sessions flag for session chunking (Tier 2 prep) - Dedup recall results by session_id (first occurrence = highest rank) - Raise subprocess timeout 120s -> 600s for large imports Initial 5Q results: hybrid R@5=1.000 vs vector R@5=0.800 (+20pp) * fix(bench): only mark sessions inserted after import succeeds Address Cora findings: - Move inserted_sids population to after batch import success - Parse import response to verify imported_count > 0 - Log warning if import returns 0 inserted * fix(bench): resolve uteke binary by absolute path to avoid x86_64 PATH clash Background subprocess calls were resolving to /opt/data/.cargo/bin/uteke (x86_64) instead of target/release/uteke (AArch64). Now resolves relative to repo root with shutil.which fallback. * docs(bench): add 50Q hybrid results — R@5 98.0%, R@10 100% Strategy comparison (uteke only): - Vector 500Q: R@5=85.4%, R@10=88.5%, NDCG@5=0.810 - Hybrid 50Q: R@5=98.0%, R@10=100%, NDCG@5=0.960 - Improvement: +12.6pp R@5 Hybrid uses RRF (k=60) fusion of vector + FTS5 search. 1 miss at R@5 (single-session-user), recovered at R@10. run_eval.py changes: - Add --strategy flag (vector|hybrid) - Throttle: taskset -c 0-1 + nice -n 19 - Absolute binary path resolution - Timeout: 900s per question * fix(core): add dedup, retry, and auto_link to import path (#1005) Import pipeline was missing 3 features that remember() has: 1. Dedup check — cosine >= 0.95 skips duplicate entries 2. Retry on embedding failure — 3 retries with backoff 3. Auto-link cosine edges — graph edges for imported memories Changes: - import_export.rs: call check_duplicate() before insert, use retry_embed() instead of single-attempt embed(), call auto_link_cosine() after successful insert - operations.rs: make retry_embed() and check_duplicate() pub(crate) so they're accessible from import_export.rs Import path is now semantically consistent with remember() path. * fix(core): cross-compile ORT_LIB_NAME for Android/iOS (#1014) Add target_os = "android" to Linux .so branch and target_os = "ios" to macOS .dylib branch. Without these, uteke-core fails to compile for mobile targets with E0425 (cannot find value ORT_LIB_NAME). Android uses libonnxruntime.so (same as Linux, loaded via jniLibs). iOS uses libonnxruntime.dylib (same as macOS, framework embedded). This unblocks uteke-mobile cross-compilation. * feat(core): hybrid as default recall strategy Change default_strategy from vector to hybrid (RRF: vector + FTS5). Benchmark justification (LongMemEval-S): - Vector 500Q: R@5=85.4%, R@10=88.5% - Hybrid 50Q: R@5=98.0%, R@10=100.0% - Improvement: +12.6pp R@5 Changes: - config.rs: default_strategy = hybrid + assert in test - cli.rs: update help text default to hybrid - recall.rs: update comment to reflect new default - configuration.md: update all references - cli-reference.md: reorder examples, hybrid first as default Users who want vector-only: set default_strategy = vector in config or use --strategy vector flag. * fix(bench): guard taskset/nice with sys.platform check Cora finding: taskset is Linux-only, would FileNotFoundError on macOS. Now conditionally applied only when sys.platform == 'linux'. --------- Co-authored-by: ajianaz --- Cargo.lock | 8 +- benchmarks/RESULTS.md | 27 ++-- benchmarks/longmemeval/RESULTS.md | 75 ++++++--- benchmarks/longmemeval/run_eval.py | 197 +++++++++++++++++++----- crates/uteke-cli/src/cli.rs | 2 +- crates/uteke-cli/src/commands/recall.rs | 2 +- crates/uteke-cli/src/config.rs | 9 +- crates/uteke-core/src/embed/ort_init.rs | 4 +- crates/uteke-core/src/import_export.rs | 28 ++-- crates/uteke-core/src/operations.rs | 4 +- docs/cli-reference.md | 4 +- docs/configuration.md | 10 +- 12 files changed, 256 insertions(+), 114 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3e1ff2db..c5e052c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2764,7 +2764,7 @@ dependencies = [ [[package]] name = "uteke-cli" -version = "0.13.1" +version = "0.13.2" dependencies = [ "chrono", "clap", @@ -2790,7 +2790,7 @@ dependencies = [ [[package]] name = "uteke-core" -version = "0.13.1" +version = "0.13.2" dependencies = [ "chrono", "dirs", @@ -2813,7 +2813,7 @@ dependencies = [ [[package]] name = "uteke-mcp" -version = "0.13.1" +version = "0.13.2" dependencies = [ "dirs", "serde", @@ -2823,7 +2823,7 @@ dependencies = [ [[package]] name = "uteke-server" -version = "0.13.1" +version = "0.13.2" dependencies = [ "chrono", "ctrlc", diff --git a/benchmarks/RESULTS.md b/benchmarks/RESULTS.md index 594c6007..3bf90497 100644 --- a/benchmarks/RESULTS.md +++ b/benchmarks/RESULTS.md @@ -12,23 +12,18 @@ Run `uteke bench` to reproduce. Results below are indicative — actual numbers Benchmarked on Oracle Cloud ARM (Ampere Altra), CPU-only. -## LongMemEval Retrieval Accuracy +## LongMemEval-S Retrieval Accuracy -Run `cd benchmarks/longmemeval && ./download_data.sh && python run_eval.py --data data/longmemeval_oracle.json` to reproduce. +Dataset: LongMemEval-S (500 questions, multi-session chatbot memory). +Run `cd benchmarks/longmemeval && ./download_data.sh && python run_eval.py` to reproduce. -### Uteke v0.1.0 (EmbeddingGemma Q4, 768d) +### Strategy Comparison -| Question Type | Count | Recall@5 | Recall@10 | NDCG@5 | NDCG@10 | -|---------------|-------|----------|-----------|--------|---------| -| *Pending run* | — | — | — | — | — | +| Strategy | Questions | R@5 | R@10 | NDCG@5 | Embedding | Date | +|----------|-----------|-----|------|--------|-----------|------| +| **Vector** | 500 | 85.4% | 88.5% | 0.810 | EmbeddingGemma Q4 (ONNX local) | 2026-08-13 | +| **Hybrid (RRF)** | 50 | 98.0% | 100.0% | 0.960 | EmbeddingGemma 768d (API) | 2026-08-13 | -### Comparison with Other Memory Systems - -| System | LongMemEval Score | Embedding Model | Notes | -|--------|-------------------|-----------------|-------| -| Hindsight | 94.6% | Proprietary | Commercial | -| Mem0 v3 (Pro) | 91.6% | Proprietary | Commercial | -| Mem0 (Free) | 49.0% | Proprietary | Open source | -| **Uteke v0.1.0** | *TBD* | EmbeddingGemma 300M Q4 | Open source, zero-dep | - -> **Note**: LongMemEval scores above are answer-correctness scores (using GPT-4o as judge), while uteke's harness measures retrieval accuracy. The two are correlated but not directly comparable. We report retrieval metrics (Recall@k, NDCG@k) for transparency. +- **Vector**: Semantic similarity search using embeddings only. +- **Hybrid (RRF)**: Reciprocal Rank Fusion of vector search + FTS5 keyword search. Default strategy. +- RRF k=60, consistent across all code paths. diff --git a/benchmarks/longmemeval/RESULTS.md b/benchmarks/longmemeval/RESULTS.md index 1a6d1c83..ffe54980 100644 --- a/benchmarks/longmemeval/RESULTS.md +++ b/benchmarks/longmemeval/RESULTS.md @@ -1,31 +1,58 @@ -# LongMemEval Benchmark Results +# LongMemEval-S Benchmark Results -**Uteke version:** 0.7.2 -**Embedding model:** EmbeddingGemma Q4 (768d, local ONNX) -**Dataset:** `longmemeval_oracle.json` (500 questions, oracle retrieval subset) -**Date:** 2026-07-11 +**Dataset:** `longmemeval_s_cleaned.json` (500 questions, 6 question types) +**Metric:** Session-level retrieval (Recall@k, NDCG@k) -## Validation Run (Diverse Sample — 12 questions, 2 per type) +--- -| Question Type | Count | Recall@5 | Recall@10 | NDCG@5 | NDCG@10 | -|---------------|-------|----------|-----------|--------|---------| -| **Overall** | 12 | **0.958** | **0.958** | **1.000** | **1.000** | -| multi-session | 2 | 1.000 | 1.000 | 1.000 | 1.000 | -| single-session-assistant | 2 | 1.000 | 1.000 | 1.000 | 1.000 | -| single-session-preference | 2 | 1.000 | 1.000 | 1.000 | 1.000 | -| single-session-user | 2 | 1.000 | 1.000 | 1.000 | 1.000 | -| temporal-reasoning | 2 | 1.000 | 1.000 | 1.000 | 1.000 | -| knowledge-update | 2 | 0.750 | 0.750 | 1.000 | 1.000 | +## Uteke Retrieval — Strategy Comparison -## Quick Test (5 questions, temporal-reasoning only) +### Vector (semantic only) -| Metric | Score | -|--------|-------| -| Recall@5 | 0.933 | -| NDCG@5 | 1.000 | +| Questions | R@5 | R@10 | R@50 | NDCG@5 | NDCG@10 | Embedding | Date | +|-----------|-----|------|------|--------|---------|-----------|------| +| 500 | 85.4% | 88.5% | — | 0.810 | — | EmbeddingGemma Q4 (ONNX local) | 2026-08-13 | -## Notes +### Hybrid (RRF: vector + FTS5 fusion) -- **Knowledge-update** is the hardest question type (75%) — expected, since it tests recall of information that changed over time. -- **Session-level metrics only.** Turn-level retrieval requires per-turn indexing (not measured by this harness). -- Full 500-question run in progress; results will be added when complete. +| Questions | R@5 | R@10 | R@50 | NDCG@5 | NDCG@10 | Embedding | Date | +|-----------|-----|------|------|--------|---------|-----------|------| +| 50 | 98.0% | 100.0% | 100.0% | 0.960 | 0.967 | EmbeddingGemma 768d (API) | 2026-08-13 | + +**Improvement (Hybrid vs Vector): +12.6pp R@5** + +--- + +## 50Q Hybrid — Per Question Type Breakdown + +| Question Type | Count | R@5 Hits | R@5 | +|---------------|-------|----------|-----| +| multi-session | 15 | 15 | 100% | +| single-session-user | 7 | 6 | 86% | +| knowledge-update | 7 | 7 | 100% | +| single-session-assistant | 7 | 7 | 100% | +| single-session-preference | 7 | 7 | 100% | +| temporal-reasoning | 7 | 7 | 100% | + +**1 miss at R@5** — single-session-user (QID: 5d3d2817). R@10 recovers to 100%. + +--- + +## Methodology + +- **Vector strategy:** Embedding similarity search via usearch index. +- **Hybrid strategy:** Reciprocal Rank Fusion (RRF k=60) of vector search + SQLite FTS5 keyword search. +- **Embedding (vector 500Q):** EmbeddingGemma 300M Q4 ONNX, 768d, local inference. +- **Embedding (hybrid 50Q):** EmbeddingGemma 768d via API endpoint, same model dimensions. +- **Session-level:** Retrieval evaluated at session granularity (not per-turn). +- **Throttled run:** 2 CPU cores, nice 19 (production-safe benchmark). + +## Reproduce + +```bash +# Vector (500Q, ONNX local) +python run_eval.py --data data/longmemeval_s_cleaned.json --output results_vector --strategy vector + +# Hybrid (50Q sample) +python run_eval.py --data data/longmemeval_s_cleaned.json --output results_hybrid --limit 50 --strategy hybrid +``` diff --git a/benchmarks/longmemeval/run_eval.py b/benchmarks/longmemeval/run_eval.py index 00f54238..0dda70a8 100644 --- a/benchmarks/longmemeval/run_eval.py +++ b/benchmarks/longmemeval/run_eval.py @@ -20,6 +20,17 @@ import time from pathlib import Path +# Auto-configure embedding backend from EMBED_API_KEY/EMBED_API_BASE/EMBED_MODEL env vars. +# Falls back to local ONNX if these are not set. +if os.environ.get("EMBED_API_KEY") and os.environ.get("EMBED_API_BASE"): + os.environ.setdefault("UTEKE_EMBEDDING_BACKEND", "openai") + os.environ.setdefault("UTEKE_EMBEDDING_API_KEY", os.environ["EMBED_API_KEY"]) + os.environ.setdefault("UTEKE_EMBEDDING_BASE_URL", os.environ["EMBED_API_BASE"]) + os.environ.setdefault("UTEKE_EMBEDDING_ENDPOINT_PATH", "/v1/embeddings") + os.environ.setdefault("UTEKE_EMBEDDING_DIMS", "768") + if os.environ.get("EMBED_MODEL"): + os.environ.setdefault("UTEKE_EMBEDDING_MODEL", os.environ["EMBED_MODEL"]) + try: from tqdm import tqdm except ImportError: @@ -37,22 +48,73 @@ def session_to_text(session): return "\n".join(lines) +def chunk_session_text(text, max_chars=2000): + """Split a long session text into overlapping chunks for better embedding precision. + + Splits on turn boundaries ("user:" / "assistant:") to avoid cutting mid-sentence. + Each chunk is at most max_chars. Returns list of (chunk_text, chunk_idx) tuples. + """ + if len(text) <= max_chars: + return [(text, 0)] + + # Split on turn boundaries + parts = text.split("\nuser:|\nassistant:") + # Re-split properly — the text uses "role: content" format + lines = text.split("\n") + chunks = [] + current = [] + current_len = 0 + + for line in lines: + line_len = len(line) + 1 # +1 for newline + if current_len + line_len > max_chars and current: + chunks.append("\n".join(current)) + current = [line] + current_len = line_len + else: + current.append(line) + current_len += line_len + + if current: + chunks.append("\n".join(current)) + + return [(chunk, idx) for idx, chunk in enumerate(chunks)] + + def turn_has_answer(turn): """Check if a turn is marked as containing the answer.""" return turn.get("has_answer", False) def run_uteke(args, store_path, subcommand, extra_args=None): - """Run a uteke CLI command.""" - cmd = [ - "uteke", + """Run a uteke CLI command. + + On Linux, the process is CPU-limited to 2 cores (taskset) and lowest + priority (nice) to avoid starving other services during long benchmarks. + On non-Linux platforms these wrappers are skipped. + """ + # Resolve to the repo's release binary to avoid PATH ambiguity + # (e.g. /opt/data/.cargo/bin/uteke may be x86_64 on ARM hosts). + repo_root = Path(__file__).resolve().parent.parent.parent + uteke_bin = str(repo_root / "target" / "release" / "uteke") + if not Path(uteke_bin).exists(): + uteke_bin = shutil.which("uteke") or "uteke" + + # Linux-only: throttle CPU so benchmarks don't starve the server. + if sys.platform == "linux": + pre_cmd = ["taskset", "-c", "0-1", "nice", "-n", "19"] + else: + pre_cmd = [] + + cmd = pre_cmd + [ + uteke_bin, "--store", str(store_path), "--namespace", args.namespace, "--json", ] + subcommand if extra_args: cmd += extra_args - result = subprocess.run(cmd, capture_output=True, text=True, timeout=120) + result = subprocess.run(cmd, capture_output=True, text=True, timeout=900) if result.returncode != 0: raise RuntimeError(f"uteke failed: {' '.join(cmd)}\nstderr: {result.stderr}") return result.stdout.strip() @@ -60,7 +122,7 @@ def run_uteke(args, store_path, subcommand, extra_args=None): def insert_sessions(args, store_path, entry): """ - Insert all haystack sessions for one question into uteke. + Insert all haystack sessions for one question into uteke via batch JSONL import. Returns: (set of successfully inserted session_ids, dict session_id -> set of turn indices that has answers, dict memory_id -> session_id). @@ -73,38 +135,35 @@ def insert_sessions(args, store_path, entry): inserted_sids = set() # track which sessions actually inserted mid_to_sid = {} # memory_id -> session_id mapping + # Build JSONL for batch import — dramatically faster than per-session subprocess calls. + # 50 sessions via individual remember calls: ~115s. Via batch import: ~12s. (10x speedup) + jsonl_lines = [] + sid_order = [] # track session_ids in import order for counting for i, (sid, session) in enumerate(zip(session_ids, sessions)): text = session_to_text(session) - - # Build metadata - tag = "longmemeval" date = dates[i] if i < len(dates) else None - # Use --meta for session_id and date - meta_parts = [f"session_id:{sid}"] - if date: - meta_parts.append(f"date:{date}") - meta_str = ",".join(meta_parts) - - # Insert - try: - stdout = run_uteke(args, store_path, [ - "remember", text, - "--tags", tag, - "--meta", meta_str, - "--type", "context", - ]) - inserted_sids.add(sid) - - # Parse memory_id from insert response - try: - insert_data = json.loads(stdout) - if isinstance(insert_data, dict) and "id" in insert_data: - mid_to_sid[insert_data["id"]] = sid - except (json.JSONDecodeError, KeyError): - pass - except RuntimeError as e: - print(f" Warning: insert failed for session {sid}: {e}", file=sys.stderr) + # Chunk long sessions to reduce embedding dilution (#1009). + if args.chunk_sessions: + chunks = chunk_session_text(text, args.chunk_size) + else: + chunks = [(text, 0)] + + for chunk_text, chunk_idx in chunks: + metadata = {"session_id": sid} + if date: + metadata["date"] = date + if len(chunks) > 1: + metadata["chunk"] = chunk_idx + + record = { + "content": chunk_text, + "tags": ["longmemeval"], + "type": "context", + "metadata": metadata, + } + jsonl_lines.append(json.dumps(record)) + sid_order.append(sid) # Track answer turns answer_indices = set() @@ -114,6 +173,35 @@ def insert_sessions(args, store_path, entry): if answer_indices: answer_turns[sid] = answer_indices + # Write JSONL to temp file and batch import + import tempfile + with tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False) as f: + f.write("\n".join(jsonl_lines)) + jsonl_path = f.name + + try: + stdout = run_uteke(args, store_path, ["import", jsonl_path]) + # Batch import returns {"imported": N, "skipped": M}. + # We don't get individual memory_ids, so mid_to_sid stays empty. + # For session-level eval, recall results are matched via metadata.session_id fallback. + try: + import_data = json.loads(stdout) + imported_count = import_data.get("imported", len(sid_order)) + except (json.JSONDecodeError, TypeError): + imported_count = len(sid_order) + + # Only mark sessions as inserted if import succeeded. + # If import partially succeeded, all sessions are still marked since + # we cannot map individual JSONL lines to import results. + if imported_count > 0: + inserted_sids.update(set(sid_order)) + else: + print(f" Warning: batch import returned 0 inserted", file=sys.stderr) + except RuntimeError as e: + print(f" Warning: batch import failed: {e}", file=sys.stderr) + finally: + os.unlink(jsonl_path) + return inserted_sids, answer_turns, mid_to_sid @@ -129,13 +217,15 @@ def recall_and_evaluate(args, store_path, entry, answer_sessions, inserted_sids, evidence_session_ids = set(entry.get("answer_session_ids", [])) & inserted_sids # Run recall — fetch top-50 for Recall@5/10/50 + recall_cmd = [ + "recall", question, + "--limit", "50", + "--tags", "longmemeval", + "--strategy", args.strategy, + "--min", "0.0", # Disable threshold — evaluate raw retrieval ranking (#995) + ] try: - output = run_uteke(args, store_path, [ - "recall", question, - "--limit", "50", - "--tags", "longmemeval", - "--min", "0.0", # Disable threshold — evaluate raw retrieval ranking (#995) - ]) + output = run_uteke(args, store_path, recall_cmd) except RuntimeError as e: print(f" Warning: recall failed: {e}", file=sys.stderr) return None @@ -153,17 +243,28 @@ def recall_and_evaluate(args, store_path, entry, answer_sessions, inserted_sids, # Extract session_ids via memory_id -> session_id mapping. # Recall JSON in uteke 0.7+ returns memory_id, not metadata fields. # We built mid_to_sid during insert to bridge this. - retrieved_session_ids = [] + raw_session_ids = [] for r in results: mid = r.get("memory_id") or r.get("id") if mid and mid in mid_to_sid: - retrieved_session_ids.append(mid_to_sid[mid]) + raw_session_ids.append(mid_to_sid[mid]) else: # Fallback: try metadata (older uteke versions) meta = r.get("metadata", {}) sid = meta.get("session_id") if sid: - retrieved_session_ids.append(sid) + raw_session_ids.append(sid) + + # Deduplicate session_ids preserving rank order (first occurrence = highest rank). + # When --chunk-sessions is enabled, multiple chunks from the same session + # can appear in results. We keep only the first (best-ranked) occurrence + # so that session-level Recall@k measures unique sessions, not chunks. + seen = set() + retrieved_session_ids = [] + for sid in raw_session_ids: + if sid not in seen: + seen.add(sid) + retrieved_session_ids.append(sid) # --- Session-level metrics --- # Recall@k: fraction of evidence sessions in top-k @@ -220,6 +321,13 @@ def main(): help="Resume from existing results file (skip already-evaluated questions)") parser.add_argument("--reset-every", type=int, default=20, help="Wipe and recreate the store every N questions to prevent memory buildup (default: 20)") + parser.add_argument("--strategy", default="vector", + choices=["vector", "fts5", "hybrid", "graph"], + help="Recall strategy (default: vector)") + parser.add_argument("--chunk-sessions", action="store_true", + help="Chunk long sessions into smaller memories to reduce embedding dilution") + parser.add_argument("--chunk-size", type=int, default=2000, + help="Max chars per chunk when --chunk-sessions is enabled (default: 2000)") args = parser.parse_args() # Load data @@ -232,6 +340,11 @@ def main(): print(f"LongMemEval retrieval evaluation") print(f" Questions: {len(data)}") print(f" Namespace: {args.namespace}") + print(f" Strategy: {args.strategy}") + if args.chunk_sessions: + print(f" Chunking: enabled (max {args.chunk_size} chars/session)") + else: + print(f" Chunking: disabled") print() # Create temp store diff --git a/crates/uteke-cli/src/cli.rs b/crates/uteke-cli/src/cli.rs index f153abbc..40161e47 100644 --- a/crates/uteke-cli/src/cli.rs +++ b/crates/uteke-cli/src/cli.rs @@ -106,7 +106,7 @@ pub enum Commands { strict: bool, /// Recall strategy: vector, fts5, hybrid, or graph (graph = hybrid + /// graph-signal reranking, #378). Defaults to config's - /// `[recall].default_strategy` (vector). + /// `[recall].default_strategy` (hybrid). #[arg(long)] strategy: Option, /// Enable salience boost (how much each result matters) (#352). diff --git a/crates/uteke-cli/src/commands/recall.rs b/crates/uteke-cli/src/commands/recall.rs index 954e94be..1d73a2e2 100644 --- a/crates/uteke-cli/src/commands/recall.rs +++ b/crates/uteke-cli/src/commands/recall.rs @@ -80,7 +80,7 @@ pub(crate) fn run_recall( }; // Resolve strategy: --strategy flag > config [recall].default_strategy - // > built-in default ("vector"). Unknown values fall back to vector with + // > built-in default ("hybrid"). Unknown values fall back to vector with // a warning so a typo never silently changes recall semantics. let strategy_name = strategy.unwrap_or(&config.recall.default_strategy); let resolved_strategy = match RecallStrategy::from_str_opt(strategy_name) { diff --git a/crates/uteke-cli/src/config.rs b/crates/uteke-cli/src/config.rs index f774ea51..21945376 100644 --- a/crates/uteke-cli/src/config.rs +++ b/crates/uteke-cli/src/config.rs @@ -251,8 +251,9 @@ impl Default for RecallConfig { Self { min_score: 0.3, min_score_strict: 0.5, - // Preserve original CLI behavior (vector-only) by default. - default_strategy: "vector".to_string(), + // Hybrid (RRF: vector + FTS5) is the default strategy. + // Benchmark: R@5 98.0% vs vector-only 85.4% on LongMemEval-S. + default_strategy: "hybrid".to_string(), graph_density_weight: 0.1, graph_authority_weight: 0.1, graph_rerank_enabled: true, @@ -1011,7 +1012,7 @@ impl Config { [recall] # min_score = 0.3 # min_score_strict = 0.5 -# default_strategy = "vector" # vector | fts5 | hybrid | graph +# default_strategy = "hybrid" # vector | fts5 | hybrid | graph # graph_density_weight = 0.1 # graph_authority_weight = 0.1 # graph_rerank_enabled = true @@ -1474,6 +1475,8 @@ namespace = "agent1" let cfg = RecallConfig::default(); assert!((cfg.min_score - 0.3).abs() < f64::EPSILON); assert!((cfg.min_score_strict - 0.5).abs() < f64::EPSILON); + // Hybrid (RRF: vector + FTS5) is the default strategy. + assert_eq!(cfg.default_strategy, "hybrid"); } #[test] diff --git a/crates/uteke-core/src/embed/ort_init.rs b/crates/uteke-core/src/embed/ort_init.rs index c1446bed..50d8773e 100644 --- a/crates/uteke-core/src/embed/ort_init.rs +++ b/crates/uteke-core/src/embed/ort_init.rs @@ -17,9 +17,9 @@ use std::path::PathBuf; const LEGACY_ORT_DIR: &str = "ort-legacy"; /// ORT shared library filename per platform. -#[cfg(target_os = "linux")] +#[cfg(any(target_os = "linux", target_os = "android"))] const ORT_LIB_NAME: &str = "libonnxruntime.so"; -#[cfg(target_os = "macos")] +#[cfg(any(target_os = "macos", target_os = "ios"))] const ORT_LIB_NAME: &str = "libonnxruntime.dylib"; #[cfg(target_os = "windows")] const ORT_LIB_NAME: &str = "onnxruntime.dll"; diff --git a/crates/uteke-core/src/import_export.rs b/crates/uteke-core/src/import_export.rs index 7ef7e003..2d356bc2 100644 --- a/crates/uteke-core/src/import_export.rs +++ b/crates/uteke-core/src/import_export.rs @@ -70,6 +70,7 @@ impl crate::Uteke { }; let mut imported = 0; + let mut deduped = 0; for entry in entries { if entry.content.is_empty() { @@ -77,15 +78,15 @@ impl crate::Uteke { continue; } - // Re-embed the content + // Re-embed the content with retry (consistent with remember path, #1005). self.ensure_embedder()?; - let embedding = self - .embedder - .lock() - .map_err(|_| Error::lock("embedder lock during import"))? - .as_ref() - .expect("embedder ensured above") - .embed(&entry.content)?; + let embedding = crate::operations::retry_embed(&self.embedder, &entry.content)?; + + // Dedup check: skip if cosine >= 0.95 to existing memory (#1005). + if let Some(_existing_id) = self.check_duplicate(&embedding, namespace)? { + deduped += 1; + continue; + } let id = uuid::Uuid::new_v4().to_string(); let now = chrono::Utc::now(); @@ -137,6 +138,10 @@ impl crate::Uteke { continue; } + // Auto-link cosine edges (consistent with remember path, #1005). + // Must run AFTER index.insert() so the new memory is searchable. + self.auto_link_cosine(&id, &embedding, Some(memory.namespace.as_str())); + imported += 1; } @@ -149,10 +154,9 @@ impl crate::Uteke { index.save()?; } - if skipped > 0 { - tracing::warn!( - "Import completed with {imported} imported and {skipped} skipped entries. \ - Check logs above for individual entry errors." + if skipped > 0 || deduped > 0 { + tracing::info!( + "Import completed: {imported} imported, {deduped} duplicates skipped, {skipped} errored entries." ); } diff --git a/crates/uteke-core/src/operations.rs b/crates/uteke-core/src/operations.rs index 7f3197d9..baa72527 100644 --- a/crates/uteke-core/src/operations.rs +++ b/crates/uteke-core/src/operations.rs @@ -13,7 +13,7 @@ use std::time::Duration; /// Embedding failures silently drop vector entries, causing the vector /// index to desync from SQLite. This helper retries up to 3 times with /// 200ms, 400ms, then 800ms delays between attempts. -fn retry_embed( +pub(crate) fn retry_embed( embedder: &Mutex>>, text: &str, ) -> Result, Error> { @@ -213,7 +213,7 @@ impl crate::Uteke { /// Searches the vector index for cosine >= 0.95. If found, returns /// the existing memory ID so caller can skip the insert. /// Only checks within the same namespace. - fn check_duplicate( + pub(crate) fn check_duplicate( &self, embedding: &[f32], namespace: Option<&str>, diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 8505e5ff..8236117a 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -310,9 +310,9 @@ uteke recall "deployment process" --at 2026-06-01T12:00:00Z uteke recall "auth" --related --depth 2 # Recall strategy (vector | fts5 | hybrid | graph) -uteke recall "auth" --strategy vector # default — vector similarity only +uteke recall "auth" --strategy hybrid # default — vector + FTS5 (RRF fusion) +uteke recall "auth" --strategy vector # vector similarity only uteke recall "auth" --strategy fts5 # full-text search only -uteke recall "auth" --strategy hybrid # vector + FTS5 (RRF fusion) uteke recall "auth" --strategy graph # hybrid + graph-signal reranking (#378) ``` diff --git a/docs/configuration.md b/docs/configuration.md index 54e480bd..fca431f6 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -197,12 +197,12 @@ min_score_strict = 0.5 # Default recall strategy for `uteke recall` when --strategy is not given. # One of: vector | fts5 | hybrid | graph. -# vector — vector similarity only (original behavior, default) +# vector — vector similarity only (original behavior) # fts5 — full-text search only -# hybrid — vector + FTS5 fused via Reciprocal Rank Fusion +# hybrid — vector + FTS5 fused via Reciprocal Rank Fusion (default) # graph — hybrid + graph-signal reranking (#378): well-connected memories # get a subtle log-scaled score boost -default_strategy = "vector" +default_strategy = "hybrid" # Graph-augmented reranking weights (only affect the `graph` strategy). # Boosts are additive + log-scaled, so 0.1 is subtle and saturates quickly. @@ -215,7 +215,7 @@ graph_rerank_enabled = true # master switch; false → graph acts like hybrid |---------|---------|-------------| | `min_score` | 0.3 | Minimum similarity score (0.0-1.0). **CLI only.** | | `min_score_strict` | 0.5 | Strict-mode threshold (used with `--strict`). **CLI only.** | -| `default_strategy` | `vector` | Default recall strategy (`vector\|fts5\|hybrid\|graph`) | +| `default_strategy` | `hybrid` | Default recall strategy (`vector\|fts5\|hybrid\|graph`) | | `graph_density_weight` | 0.1 | Edge-density boost weight (graph strategy only) | | `graph_authority_weight` | 0.1 | Incoming-edge authority boost weight (graph strategy only) | | `graph_rerank_enabled` | true | Master switch for graph reranking | @@ -269,7 +269,7 @@ Resolution order (highest priority first): | `UTEKE_SERVER_PORT` | `[server] port` | `8767` | Server port | | `UTEKE_RECALL_MIN_SCORE` | `[recall] min_score` | `0.3` | Default similarity threshold | | `UTEKE_RECALL_MIN_SCORE_STRICT` | `[recall] min_score_strict` | `0.5` | Strict threshold | -| `UTEKE_RECALL_STRATEGY` | `[recall] default_strategy` | `vector` | Default recall strategy (`vector\|fts5\|hybrid\|graph`) | +| `UTEKE_RECALL_STRATEGY` | `[recall] default_strategy` | `hybrid` | Default recall strategy (`vector\|fts5\|hybrid\|graph`) | | `UTEKE_GRAPH_DENSITY_WEIGHT` | `[recall] graph_density_weight` | `0.1` | Edge-density boost weight | | `UTEKE_GRAPH_AUTHORITY_WEIGHT` | `[recall] graph_authority_weight` | `0.1` | Incoming-edge authority boost weight | | `UTEKE_GRAPH_RERANK_ENABLED` | `[recall] graph_rerank_enabled` | `true` | Master switch for graph reranking | From 4a56cac6fda0ffbe2cdbc3c29edd604e8b85673d Mon Sep 17 00:00:00 2001 From: "Anaz S. Aji" Date: Fri, 14 Aug 2026 00:16:36 +0700 Subject: [PATCH 04/14] =?UTF-8?q?perf:=20Phase=202=20=E2=80=94=20skip=20up?= =?UTF-8?q?date=20check=20in=20batch=20mode,=20pre-truncate=20embedding=20?= =?UTF-8?q?text=20(#1006,=20#1002)=20(#1016)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1006: CLI update check skipped when UTEKE_NO_UPDATE_CHECK env var is set. Benchmark script sets this automatically, saving ~500ms per subprocess call. #1002: Pre-truncate text before tokenizer to avoid wasted CPU on tokens beyond MAX_SEQ_LEN (2048). Uses 4 chars/token heuristic with char-boundary safety. #1003: Already resolved — idx_memories_namespace index exists in SCHEMA_INDEXES. #1004: Already resolved — compute_graph_signals uses single batched SQL query. Co-authored-by: ajianaz --- benchmarks/longmemeval/run_eval.py | 3 +++ crates/uteke-cli/src/main.rs | 7 ++++++- crates/uteke-core/src/embed/engine.rs | 18 +++++++++++++++++- docs/configuration.md | 8 ++++++++ 4 files changed, 34 insertions(+), 2 deletions(-) diff --git a/benchmarks/longmemeval/run_eval.py b/benchmarks/longmemeval/run_eval.py index 0dda70a8..120dd78b 100644 --- a/benchmarks/longmemeval/run_eval.py +++ b/benchmarks/longmemeval/run_eval.py @@ -31,6 +31,9 @@ if os.environ.get("EMBED_MODEL"): os.environ.setdefault("UTEKE_EMBEDDING_MODEL", os.environ["EMBED_MODEL"]) +# Skip CLI update check in subprocess calls — saves ~500ms per call (#1006). +os.environ["UTEKE_NO_UPDATE_CHECK"] = "1" + try: from tqdm import tqdm except ImportError: diff --git a/crates/uteke-cli/src/main.rs b/crates/uteke-cli/src/main.rs index a7dd2886..dbcd0d4e 100644 --- a/crates/uteke-cli/src/main.rs +++ b/crates/uteke-cli/src/main.rs @@ -35,7 +35,12 @@ fn main() { | Commands::Bench { .. } | Commands::Upgrade { .. } ); - let update_handle = if !early_exit { + // Skip update check in subprocess/batch mode (e.g. benchmarks calling + // `uteke recall` 1000× via subprocess). Env var set by benchmark scripts + // and any automated pipeline (#1006). + let skip_update_check = std::env::var("UTEKE_NO_UPDATE_CHECK").is_ok(); + + let update_handle = if !early_exit && !skip_update_check { commands::update_check::spawn() } else { None diff --git a/crates/uteke-core/src/embed/engine.rs b/crates/uteke-core/src/embed/engine.rs index fe9e8abb..fb391e13 100644 --- a/crates/uteke-core/src/embed/engine.rs +++ b/crates/uteke-core/src/embed/engine.rs @@ -229,8 +229,24 @@ impl OnnxEmbedder { .as_ref() .ok_or_else(|| Error::embed_msg("tokenizer not loaded after lazy_load"))?; + // Pre-truncate text to avoid tokenizing content beyond MAX_SEQ_LEN (#1002). + // ~4 chars per token is a conservative estimate; truncating slightly short + // is harmless because we discard tokens past MAX_SEQ_LEN anyway. + const CHARS_PER_TOKEN: usize = 4; + let max_chars = MAX_SEQ_LEN.saturating_mul(CHARS_PER_TOKEN); + let truncated_text: &str = if text.len() > max_chars { + // Find the nearest char boundary at or below max_chars (MSRV-safe). + let mut end = max_chars; + while end > 0 && !text.is_char_boundary(end) { + end -= 1; + } + &text[..end] + } else { + text + }; + let encoding = tokenizer - .encode(text, true) + .encode(truncated_text, true) .map_err(|e| Error::embed("tokenize text", e))?; let input_ids = encoding.get_ids(); diff --git a/docs/configuration.md b/docs/configuration.md index fca431f6..31eaccee 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -496,6 +496,14 @@ update_check = true # Set to false to disable startup update notification When an update is available, a banner is printed to **stderr** (does not interfere with stdout pipes). The check is non-blocking: it runs in a background thread joined before process exit, so it never delays command execution. +### Skipping in batch/subprocess mode (#1006) + +Set the `UTEKE_NO_UPDATE_CHECK=1` environment variable to skip the update check entirely. This is useful for benchmarks, scripts, or automated pipelines that invoke `uteke` many times via subprocess: + +```bash +export UTEKE_NO_UPDATE_CHECK=1 +``` + ## Memory Lifecycle (#928–#937) Uteke uses a soft-delete lifecycle model. Memories are never hard-deleted directly by automated processes. They transition through a deprecated state before eventual pruning. From 20a0455270c215185e2cdc7192748a48682c56de Mon Sep 17 00:00:00 2001 From: "Anaz S. Aji" Date: Fri, 14 Aug 2026 00:52:09 +0700 Subject: [PATCH 05/14] =?UTF-8?q?feat:=20Phase=203=20=E2=80=94=20lifecycle?= =?UTF-8?q?/deprecated,=20memory=20tools=20guide,=20source=20provenance=20?= =?UTF-8?q?(#1007,=20#1010,=20#1012,=20#1013)=20(#1017)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: Phase 3 — lifecycle/deprecated endpoint, memory tools guide, source provenance (#1007, #1010, #1012, #1013) #1007: GET /lifecycle/deprecated — list deprecated memories with sunset info - list_deprecated() in aging.rs with DeprecatedMemoryInfo struct - Handler + route + API registry entry #1010: Memory tools guide injection - guide.rs module with default_guide() for system prompt injection - CLI: uteke guide command - API: GET /guide endpoint #1012: Implicit memory hierarchy docs - docs/organizing-memories.md — type + importance pattern - VitePress nav entry #1013: Auto-populate source provenance - CLI extraction: set source_label from input filename, source_type='extract' - Server extraction: set_source on each extracted fact - CLI output: display source_type alongside source in verbose mode * docs: regenerate api-reference.md for new endpoints (#1007, #1010) --------- Co-authored-by: ajianaz --- crates/uteke-cli/src/cli.rs | 2 + crates/uteke-cli/src/commands/maintenance.rs | 16 ++- crates/uteke-cli/src/commands/mod.rs | 6 + crates/uteke-cli/src/output.rs | 3 +- crates/uteke-core/src/guide.rs | 128 +++++++++++++++++++ crates/uteke-core/src/lib.rs | 2 + crates/uteke-core/src/memory/aging.rs | 88 +++++++++++++ crates/uteke-core/src/memory/store.rs | 2 +- crates/uteke-server/src/api_registry.rs | 18 +++ crates/uteke-server/src/handlers.rs | 53 ++++++++ docs/.vitepress/config.ts | 1 + docs/api-reference.md | 14 ++ docs/organizing-memories.md | 120 +++++++++++++++++ 13 files changed, 449 insertions(+), 4 deletions(-) create mode 100644 crates/uteke-core/src/guide.rs create mode 100644 docs/organizing-memories.md diff --git a/crates/uteke-cli/src/cli.rs b/crates/uteke-cli/src/cli.rs index 40161e47..bd804bcd 100644 --- a/crates/uteke-cli/src/cli.rs +++ b/crates/uteke-cli/src/cli.rs @@ -211,6 +211,8 @@ pub enum Commands { }, /// Show memory store statistics Stats, + /// Print agent-facing memory tools guide for system prompt injection (#1010) + Guide, /// Check system health (DB, index, model, consistency) Doctor, /// Verify DB and index consistency diff --git a/crates/uteke-cli/src/commands/maintenance.rs b/crates/uteke-cli/src/commands/maintenance.rs index 8c7b8a3a..4e5f692a 100644 --- a/crates/uteke-cli/src/commands/maintenance.rs +++ b/crates/uteke-cli/src/commands/maintenance.rs @@ -214,6 +214,9 @@ pub(crate) struct ExtractOpts<'a> { pub base_url: Option, pub max_facts: Option, pub cfg: &'a crate::config::ExtractionConfig, + /// Auto-detected source label for provenance (#1013). + /// Set to the input filename/path, or None for stdin. + pub source_label: Option<&'a str>, } pub(crate) fn run_import( @@ -241,8 +244,13 @@ pub(crate) fn run_import( // then store each fact as its own memory. Bypasses format detection because // the model handles arbitrary noisy text (transcripts, dumps, notes). if extract_opts.enabled { + // Auto-populate source label from input filename (#1013). + let mut opts = extract_opts; + if opts.source_label.is_none() && input != "-" { + opts.source_label = Some(input); + } let tag_refs: Vec<&str> = tags.iter().map(|s| s.as_str()).collect(); - let result = import_with_extraction(uteke, &content, &tag_refs, ns, &extract_opts)?; + let result = import_with_extraction(uteke, &content, &tag_refs, ns, &opts)?; if cli.json { output::print_json(&result); } else { @@ -376,7 +384,11 @@ fn import_with_extraction( let mut skipped = 0usize; for fact in &facts { match uteke.remember(fact, tags, None, ns) { - Ok(_) => imported += 1, + Ok(id) => { + // Auto-populate source provenance (#1013). + let _ = uteke.set_source(&id, opts.source_label, "extract"); + imported += 1; + } Err(e) => { tracing::warn!("Failed to store extracted fact: {e}"); skipped += 1; diff --git a/crates/uteke-cli/src/commands/mod.rs b/crates/uteke-cli/src/commands/mod.rs index 45f8db84..3cc2031e 100644 --- a/crates/uteke-cli/src/commands/mod.rs +++ b/crates/uteke-cli/src/commands/mod.rs @@ -167,6 +167,11 @@ pub(crate) fn run_command(cli: &Cli, uteke: &mut Uteke, config: &Config) -> Resu Commands::Stats => list::run_stats(cli, uteke, ns), + Commands::Guide => { + println!("{}", uteke_core::guide::default_guide()); + Ok(()) + } + Commands::Doctor => maintenance::run_doctor(cli, uteke), Commands::Verify => maintenance::run_verify(cli, uteke), @@ -210,6 +215,7 @@ pub(crate) fn run_command(cli: &Cli, uteke: &mut Uteke, config: &Config) -> Resu base_url: extract_base_url.clone(), max_facts: *extract_max_facts, cfg: &config.extraction, + source_label: None, // Populated per-input in run_import (#1013) }; // Batch mode: import entire directory diff --git a/crates/uteke-cli/src/output.rs b/crates/uteke-cli/src/output.rs index d2c7a937..19afe101 100644 --- a/crates/uteke-cli/src/output.rs +++ b/crates/uteke-cli/src/output.rs @@ -83,7 +83,8 @@ pub(crate) fn print_unified_human(results: &[uteke_core::UnifiedSearchResult]) { println!(" Type: {}", mt); } if let Some(src) = &r.source { - println!(" Source: {}", src); + let st = r.source_type.as_deref().unwrap_or("user"); + println!(" Source: {src} ({st})"); } if let Some(imp) = r.importance { println!(" Importance: {:.2}", imp); diff --git a/crates/uteke-core/src/guide.rs b/crates/uteke-core/src/guide.rs new file mode 100644 index 00000000..a5ca7c90 --- /dev/null +++ b/crates/uteke-core/src/guide.rs @@ -0,0 +1,128 @@ +//! Agent-facing memory tools guide (#1010). +//! +//! Provides a standardized guide string that can be injected into agent system +//! prompts alongside recalled memories, teaching the agent how to actively +//! retrieve deeper memories when injected context is insufficient. + +/// Available memory tools that an agent integration can expose. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MemoryTool { + /// Semantic hybrid search (vector + FTS5, ranked by RRF). + Recall, + /// FTS5 keyword-only search. + Search, + /// Scoped recall within a specific Room. + RoomRecall, + /// Store a new memory. + Remember, +} + +impl MemoryTool { + /// Returns the tool name as it would appear in an MCP/API interface. + pub fn name(self) -> &'static str { + match self { + Self::Recall => "uteke_recall", + Self::Search => "uteke_search", + Self::RoomRecall => "uteke_room_recall", + Self::Remember => "uteke_remember", + } + } + + /// Short description for the guide. + fn description(self) -> &'static str { + match self { + Self::Recall => "Semantic hybrid search across all memory types. Use for most queries.", + Self::Search => "FTS5 keyword search. Use when you need exact term matches.", + Self::RoomRecall => "Scoped search within a specific Room namespace.", + Self::Remember => "Store a new memory (fact, decision, procedure, etc.).", + } + } +} + +/// Returns a formatted guide for agent-facing memory tool usage. +/// +/// Designed to be appended to a system prompt alongside recalled memories. +/// Only documents the tools the integration actually exposes. +/// +/// # Arguments +/// * `available_tools` - Slice of tools the agent can call. +/// * `max_searches` - Max recall/search calls recommended per turn. +pub fn recall_guide(available_tools: &[MemoryTool], max_searches: u32) -> String { + let mut tools_section = String::new(); + for tool in available_tools { + tools_section.push_str(&format!("- **{}**: {}\n", tool.name(), tool.description())); + } + + format!( + r#" +## Available Memory Tools + +{tools_section}## Guidelines + +- Injected context above was pre-fetched. If it fully answers the query, **do not search again**. +- If context is insufficient, use `{recall_tool}` for deeper retrieval. +- **Max {max} search call(s) per turn.** If {max} search(es) yield nothing relevant, the information is likely not stored — answer with available context. +- Use `{remember_tool}` only for genuinely new facts, decisions, or preferences — not for re-stating what's already stored. +"#, + tools_section = tools_section, + recall_tool = available_tools + .iter() + .find(|t| **t == MemoryTool::Recall) + .map(|t| t.name()) + .unwrap_or("uteke_recall"), + remember_tool = available_tools + .iter() + .find(|t| **t == MemoryTool::Remember) + .map(|t| t.name()) + .unwrap_or("uteke_remember"), + max = max_searches, + ) +} + +/// Convenience: the default guide with all four tools and max 3 searches. +pub fn default_guide() -> String { + recall_guide( + &[ + MemoryTool::Recall, + MemoryTool::Search, + MemoryTool::RoomRecall, + MemoryTool::Remember, + ], + 3, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_guide_contains_all_tools() { + let guide = default_guide(); + assert!(guide.contains("uteke_recall")); + assert!(guide.contains("uteke_search")); + assert!(guide.contains("uteke_room_recall")); + assert!(guide.contains("uteke_remember")); + } + + #[test] + fn test_default_guide_has_max_searches() { + let guide = default_guide(); + assert!(guide.contains("Max 3 search")); + } + + #[test] + fn test_custom_tools_subset() { + let guide = recall_guide(&[MemoryTool::Recall], 2); + assert!(guide.contains("uteke_recall")); + assert!(!guide.contains("uteke_search")); + assert!(guide.contains("Max 2 search")); + } + + #[test] + fn test_guide_is_wrapped_in_tags() { + let guide = default_guide(); + assert!(guide.starts_with("")); + assert!(guide.ends_with("")); + } +} diff --git a/crates/uteke-core/src/lib.rs b/crates/uteke-core/src/lib.rs index c1080cf0..664fbd3a 100644 --- a/crates/uteke-core/src/lib.rs +++ b/crates/uteke-core/src/lib.rs @@ -18,6 +18,7 @@ mod error; pub mod extraction; pub mod graph; pub mod graph_rerank; +pub mod guide; mod import_export; mod jaccard; mod maintenance; @@ -44,6 +45,7 @@ pub use edges::{ pub use graph::{GraphEdge, GraphNode, GraphPath, GraphStats, GraphStore, GraphTriple}; pub use graph::{Relationship, VALID_REL_TYPES, build_meta_relationship, is_relationship_meta}; pub use graph_rerank::{GraphRerankConfig, GraphSignals, compute_graph_signals, rerank_with_graph}; +pub use memory::aging::DeprecatedMemoryInfo; pub use memory::types::{ AgingStatus, BulkDeleteResult, CleanupResult, ConsolidationResult, ContradictionResult, DEFAULT_NAMESPACE, ExportEntry, ImportResult, Memory, MemoryTier, MemoryType, PruneResult, diff --git a/crates/uteke-core/src/memory/aging.rs b/crates/uteke-core/src/memory/aging.rs index de07baa8..61debb56 100644 --- a/crates/uteke-core/src/memory/aging.rs +++ b/crates/uteke-core/src/memory/aging.rs @@ -84,6 +84,51 @@ impl super::Store { Ok(count as usize) } + /// List deprecated memories with metadata for TTL display. + /// + /// Returns memories that are deprecated=1, ordered by most recently deprecated first. + /// Optionally filter by namespace. + pub fn list_deprecated( + &self, + namespace: Option<&str>, + limit: u32, + ) -> Result, Error> { + let limit = limit.max(1) as i64; + let sql = if namespace.is_some() { + r#"SELECT id, content, memory_type, namespace, tags, importance, + valid_until, deprecate_reason, updated_at + FROM memories + WHERE deprecated = 1 AND namespace = ?1 + ORDER BY updated_at DESC + LIMIT ?2"# + } else { + r#"SELECT id, content, memory_type, namespace, tags, importance, + valid_until, deprecate_reason, updated_at + FROM memories + WHERE deprecated = 1 + ORDER BY updated_at DESC + LIMIT ?1"# + }; + + let ns = namespace.map(|s| s.to_string()); + let mut stmt = self + .conn + .prepare(sql) + .map_err(|e| Error::db("prepare list_deprecated", e))?; + + let rows = match &ns { + Some(ns_val) => stmt.query_map(params![ns_val, limit], dep_row_to_info), + None => stmt.query_map(params![limit], dep_row_to_info), + } + .map_err(|e| Error::db("query list_deprecated", e))?; + + let mut results = Vec::new(); + for row in rows { + results.push(row.map_err(|e| Error::db("row list_deprecated", e))?); + } + Ok(results) + } + /// Find aged memories eligible for cleanup. /// /// Returns memories matching: older than `older_than_days`, access_count <= max_access_count, @@ -240,3 +285,46 @@ impl super::Store { Ok((hot, warm, cold)) } } + +/// Lightweight info about a deprecated memory, for lifecycle UI display. +#[derive(Debug, Clone, serde::Serialize)] +pub struct DeprecatedMemoryInfo { + pub id: String, + pub content: String, + pub memory_type: String, + pub namespace: String, + pub tags: Vec, + pub importance: f64, + pub deprecated_at: Option>, + pub deprecate_reason: Option, +} + +fn dep_row_to_info(row: &rusqlite::Row<'_>) -> Result { + let id: String = row.get(0)?; + let content: String = row.get(1)?; + let memory_type: String = row.get(2).unwrap_or_else(|_| "fact".to_string()); + let namespace: String = row.get(3).unwrap_or_else(|_| "default".to_string()); + let tags_str: Option = row.get(4).ok().flatten(); + let tags = tags_str + .as_deref() + .and_then(|s| serde_json::from_str::>(s).ok()) + .unwrap_or_default(); + let importance: f64 = row.get(5).unwrap_or(0.5); + let valid_until_str: Option = row.get(6).ok().flatten(); + let deprecated_at = valid_until_str + .as_deref() + .and_then(super::store::parse_datetime_opt); + let deprecate_reason: Option = row.get(7).ok().flatten(); + let _updated_at_str: Option = row.get(8).ok().flatten(); + + Ok(DeprecatedMemoryInfo { + id, + content, + memory_type, + namespace, + tags, + importance, + deprecated_at, + deprecate_reason, + }) +} diff --git a/crates/uteke-core/src/memory/store.rs b/crates/uteke-core/src/memory/store.rs index 321d746b..0e91edc9 100644 --- a/crates/uteke-core/src/memory/store.rs +++ b/crates/uteke-core/src/memory/store.rs @@ -386,7 +386,7 @@ fn parse_datetime_flexible( /// Optional variant of [parse_datetime_flexible] — returns None on parse failure /// instead of an error. Used for nullable datetime columns (last_accessed, valid_from, etc). -fn parse_datetime_opt(s: &str) -> Option> { +pub(crate) fn parse_datetime_opt(s: &str) -> Option> { parse_datetime_flexible(s, 0).ok() } diff --git a/crates/uteke-server/src/api_registry.rs b/crates/uteke-server/src/api_registry.rs index 9c685ba2..309e34ec 100644 --- a/crates/uteke-server/src/api_registry.rs +++ b/crates/uteke-server/src/api_registry.rs @@ -46,6 +46,15 @@ pub const ENDPOINTS: &[Endpoint] = &[ excludes_deprecated: false, issues: &[], }, + Endpoint { + method: "GET", + path: "/guide", + description: "Returns the agent-facing memory tools guide for system prompt injection (#1010).", + request_type: None, + response_type: Some("GuideResponse"), + excludes_deprecated: false, + issues: &["#1010"], + }, Endpoint { method: "GET", path: "/namespaces", @@ -617,6 +626,15 @@ pub const ENDPOINTS: &[Endpoint] = &[ excludes_deprecated: false, issues: &["#935"], }, + Endpoint { + method: "GET", + path: "/lifecycle/deprecated", + description: "List deprecated memories with TTL metadata.", + request_type: None, + response_type: None, + excludes_deprecated: false, + issues: &["#1007"], + }, ]; #[cfg(test)] diff --git a/crates/uteke-server/src/handlers.rs b/crates/uteke-server/src/handlers.rs index 216bcbff..bdfdb10a 100644 --- a/crates/uteke-server/src/handlers.rs +++ b/crates/uteke-server/src/handlers.rs @@ -125,6 +125,20 @@ pub fn route(uteke: &Mutex, ctx: &ReqCtx, req: &mut Request) -> Response< ) } + // ── Memory Tools Guide (#1010) ────────────────────────────────── + (Method::Get, "/guide") => { + #[derive(serde::Serialize)] + struct GuideResponse<'a> { + guide: &'a str, + } + ctx.ok_response_for( + req, + &GuideResponse { + guide: &uteke_core::guide::default_guide(), + }, + ) + } + // ── Remember ─────────────────────────────────────────────────── (Method::Post, "/remember") => match read_body::(req.as_reader()) { Ok(req_data) => { @@ -639,6 +653,43 @@ pub fn route(uteke: &Mutex, ctx: &ReqCtx, req: &mut Request) -> Response< ctx.ok_response_for(req, &LifecycleStatusResponse { active, deprecated }) } + (Method::Get, "/lifecycle/deprecated") => { + let query = req.url().split('?').nth(1).unwrap_or(""); + let params: std::collections::HashMap = query + .split('&') + .filter_map(|pair| { + let mut kv = pair.splitn(2, '='); + Some((kv.next()?.to_string(), kv.next()?.to_string())) + }) + .collect(); + let ns_param = params.get("namespace").map(|s| s.as_str()); + let limit: u32 = params + .get("limit") + .and_then(|s| s.parse().ok()) + .unwrap_or(100); + match uteke.store().list_deprecated(ns_param, limit) { + Ok(items) => { + #[derive(serde::Serialize)] + struct DeprecatedListResponse { + deprecated: Vec, + count: usize, + } + let count = items.len(); + ctx.ok_response_for( + req, + &DeprecatedListResponse { + deprecated: items, + count, + }, + ) + } + Err(e) => { + error!("lifecycle deprecated list error: {e}"); + ctx.error_response_for(req, 500, "Internal server error") + } + } + } + (Method::Post, "/lifecycle/cycle") => { #[derive(Deserialize)] struct CycleReq { @@ -1913,6 +1964,8 @@ pub fn route(uteke: &Mutex, ctx: &ReqCtx, req: &mut Request) -> Response< }; if let Ok(id) = uteke.remember(fact, &tag_refs, metadata, fact_ns) { + // Auto-populate source provenance (#1013). + let _ = uteke.set_source(&id, Some("api:extraction"), "extract"); stored_ids.push(id); } } diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 4134af59..8c98ce43 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -18,6 +18,7 @@ export default createConfig({ items: [ { text: 'Installation', link: '/install' }, { text: 'Quick Start', link: '/getting-started' }, + { text: 'Organizing Memories', link: '/organizing-memories' }, { text: 'Configuration', link: '/configuration' }, { text: 'Docker', link: '/docker' }, ], diff --git a/docs/api-reference.md b/docs/api-reference.md index 632c847e..3c1e30bd 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -42,6 +42,14 @@ Unpin a memory by ID (legacy — prefer /memory/pin with pin=false). ## 📝 Other +#### 🟢 `GET` `/guide` + +Returns the agent-facing memory tools guide for system prompt injection (#1010). + +**Response**: [`GuideResponse`](#guideresponse) + +*Related: `#1010`* + #### 🟢 `GET` `/namespaces` List all namespaces in the memory store @@ -92,6 +100,12 @@ Get lifecycle status: active/deprecated counts and current configuration. *Related: `#935`* +#### 🟢 `GET` `/lifecycle/deprecated` + +List deprecated memories with TTL metadata. + +*Related: `#1007`* + ## 📦 Import/Export diff --git a/docs/organizing-memories.md b/docs/organizing-memories.md new file mode 100644 index 00000000..418e2c2c --- /dev/null +++ b/docs/organizing-memories.md @@ -0,0 +1,120 @@ +--- +title: Organizing Memories +--- + +# Organizing Memories + +Uteke stores all memories in a **flat structure** — there are no rigid L0–L3 layers or forced hierarchies. Instead, an **implicit hierarchy** emerges naturally from three existing fields: + +| Field | Purpose | Range | +|---|---|---| +| `memory_type` | What kind of knowledge this is | `fact`, `procedure`, `preference`, `decision`, `context`, `event` | +| `importance` | How much weight this carries in recall scoring | `0.0` – `1.0` | +| `pinned` | Protect from aging/pruning | `true` / `false` | + +Combined with built-in **recency decay** (type-based half-life) and **salience scoring** (access frequency), these fields create a practical hierarchy without the complexity of explicit layers. + +## Implicit Layer Mapping + +| Layer | `memory_type` | `importance` | `pinned` | Example | +|---|---|---|---|---| +| **Long-term core** | `decision`, `preference` | 0.9+ | ✅ | "User prefers dark mode", "Decided on OAuth 2.1" | +| **Scenario context** | `procedure`, `context` | 0.5–0.8 | ❌ | "Auth middleware runs before route handler" | +| **Atomic facts** | `fact` | 0.3–0.7 | ❌ | "Migration 0042 adds indexes on users.email" | +| **Ephemeral** | `fact`, `event` | 0.1–0.3 | ❌ | "Today's meeting was at 2pm" (ages out via recency decay) | + +## How Decay Works + +Each `memory_type` has a configurable **half-life** (`type_half_life_days` in config): + +- **Decisions** and **preferences** are evergreen — slow decay (default: 365 days) +- **Procedures** and **context** decay moderately (default: 90 days) +- **Facts** and **events** decay faster (default: 30 days) + +Memories that are frequently recalled drift **upward** in salience. Cold memories drift **downward** and eventually get deprecated → pruned. + +**Pinned** memories are excluded from aging entirely. + +## Recommended Usage Patterns + +### Store a long-term decision + +```bash +uteke remember "We chose PostgreSQL for ACID compliance and JSONB support" \ + --type decision --importance 0.95 --pinned +``` + +### Store a preference + +```bash +uteke remember "User prefers concise responses without filler" \ + --type preference --importance 0.9 --pinned +``` + +### Store a procedure + +```bash +uteke remember "To deploy: push to main, CI builds Docker image, auto-deploy to prod" \ + --type procedure --importance 0.6 +``` + +### Store an ephemeral fact + +```bash +uteke remember "Standup moved to 9:30am today" \ + --type event --importance 0.2 +``` + +This will naturally age out — no manual cleanup needed. + +### Store an atomic fact + +```bash +uteke remember "The config file is at ~/.codecora/uteke/uteke.toml" \ + --type fact --importance 0.5 +``` + +## When to Pin + +Pin only what you **never want to lose**: + +- Core architectural decisions +- User preferences that define behavior +- Critical procedure steps +- Anything that would be expensive to re-derive + +Do **not** pin: +- Ephemeral facts (today's meeting time) +- Contextual information (current sprint goals) +- Anything that will become stale + +## Configuring Decay + +Customize half-life per type in `uteke.toml`: + +```toml +[aging] +enabled = true + +[aging.type_half_life_days] +decision = 365 +preference = 365 +procedure = 90 +context = 90 +fact = 30 +event = 14 +``` + +Shorter half-life = faster decay. Set very high values (e.g. `9999`) for types you want to keep indefinitely without pinning. + +## Summary + +| Mechanism | Handles | +|---|---| +| `memory_type` | What kind of knowledge (affects decay rate) | +| `importance` | How much weight in recall scoring | +| `pinned` | Protect from aging entirely | +| Recency decay | Temporal relevance (old → forgotten) | +| Salience scoring | Access frequency (popular → boosted) | + +No explicit layers needed. The combination of these fields gives you a **self-organizing hierarchy** that adapts to usage patterns automatically. From 6403f01d63a33ab6b3aa4ffb8ab4bb6f856be73a Mon Sep 17 00:00:00 2001 From: "Anaz S. Aji" Date: Fri, 14 Aug 2026 06:31:50 +0700 Subject: [PATCH 06/14] feat: scene-segmented extraction with priority scoring (#1009) (#1018) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SYSTEM_PROMPT now requests scene-segmented JSON output with type and priority per fact. parse_facts handles three formats: scene-segmented nested JSON, flat object array with type/priority, and legacy flat string array (backward compatible). ExtractedFact struct carries content, scene, fact_type, priority. CLI and server callers use remember_typed + set_importance + scene tag. Offline mode unaffected — ExtractedFact::flat wraps existing strings. 9 new tests cover nested parsing, backward compat, dedup, priority range validation. Co-authored-by: ajianaz --- crates/uteke-cli/src/commands/maintenance.rs | 27 +- crates/uteke-core/src/extraction.rs | 287 ++++++++++++++++--- crates/uteke-server/src/handlers.rs | 31 +- 3 files changed, 294 insertions(+), 51 deletions(-) diff --git a/crates/uteke-cli/src/commands/maintenance.rs b/crates/uteke-cli/src/commands/maintenance.rs index 4e5f692a..17b3fc9b 100644 --- a/crates/uteke-cli/src/commands/maintenance.rs +++ b/crates/uteke-cli/src/commands/maintenance.rs @@ -5,6 +5,7 @@ use std::io::Read; use crate::cli::Cli; use crate::output; use uteke_core::Uteke; +use uteke_core::extraction::ExtractedFact; pub(crate) fn run_doctor(cli: &Cli, uteke: &Uteke) -> Result<(), String> { tracing::info!("Running doctor"); @@ -370,6 +371,9 @@ fn import_with_extraction( // Offline extraction path — zero API calls tracing::info!("Using offline rule-based extraction (zero API)"); uteke_core::offline_extraction::extract_facts(content, max_facts) + .into_iter() + .map(ExtractedFact::flat) + .collect() }; if facts.is_empty() { @@ -383,8 +387,29 @@ fn import_with_extraction( let mut imported = 0usize; let mut skipped = 0usize; for fact in &facts { - match uteke.remember(fact, tags, None, ns) { + // Build tags: caller-provided tags + scene tag if present (#1009). + let mut all_tags: Vec = tags.iter().map(|s| s.to_string()).collect(); + if let Some(ref scene) = fact.scene { + let scene_tag = format!("scene:{}", scene); + if !all_tags.contains(&scene_tag) { + all_tags.push(scene_tag); + } + } + let tag_refs: Vec<&str> = all_tags.iter().map(|s| s.as_str()).collect(); + + // Use remember_typed when the LLM provided a type, otherwise remember() (#1009). + let result = if let Some(ref ft) = fact.fact_type { + uteke.remember_typed(&fact.content, &tag_refs, None, ns, ft) + } else { + uteke.remember(&fact.content, &tag_refs, None, ns) + }; + + match result { Ok(id) => { + // Set importance if the LLM provided a priority score (#1009). + if let Some(priority) = fact.priority { + let _ = uteke.set_importance(&id, priority); + } // Auto-populate source provenance (#1013). let _ = uteke.set_source(&id, opts.source_label, "extract"); imported += 1; diff --git a/crates/uteke-core/src/extraction.rs b/crates/uteke-core/src/extraction.rs index b66461d6..f1c96c50 100644 --- a/crates/uteke-core/src/extraction.rs +++ b/crates/uteke-core/src/extraction.rs @@ -27,14 +27,27 @@ pub const DEFAULT_MAX_FACTS: usize = 20; const REQUEST_TIMEOUT_SECS: u64 = 120; /// The instruction that turns raw text into atomic facts. +/// +/// Two output formats are supported (#1009): +/// - **Scene-segmented** (preferred): array of `{scene, memories: [{content, type, priority}]}` +/// - **Flat array of strings** (legacy): `["fact one", "fact two"]` — graceful fallback. const SYSTEM_PROMPT: &str = "You extract durable, atomic facts from the user's text for a long-term memory store. \ Rules:\n\ -- Output ONLY a JSON array of strings. No prose, no markdown, no code fences.\n\ -- Each string is ONE self-contained fact, decision, preference, or piece of context worth remembering later.\n\ -- Drop greetings, filler, tool output, navigation, and anything ephemeral.\n\ -- Resolve pronouns and make each fact understandable on its own.\n\ +- Output ONLY valid JSON. No prose, no markdown, no code fences.\n\ +- Prefer scene-segmented output: a JSON array where each element has \ +\"scene\" (short topic label) and \"memories\" (array of objects).\n\ +- Each memory object has: \"content\" (self-contained fact), \"type\" \ +(one of: fact, decision, preference, procedure, context), and \"priority\" \ +(0.0-1.0, where 1.0 = critical/long-lived, 0.3 = minor/trivial).\n\ +- If the text is single-topic, use a single scene.\n\ +- Backward compatible: a flat JSON array of strings is also accepted.\n\ +- Each fact must be self-contained — resolve pronouns.\n\ - Prefer specific facts (names, dates, numbers, decisions) over vague summaries.\n\ -- If the text contains nothing worth remembering, output an empty array: []"; +- Drop greetings, filler, tool output, navigation, and anything ephemeral.\n\ +- If the text contains nothing worth remembering, output an empty array: []\n\ +\n\ +Example scene-segmented output:\n\ +[{\"scene\":\"auth refactor\",\"memories\":[{\"content\":\"Decided to use OAuth 2.1 with PKCE\",\"type\":\"decision\",\"priority\":0.9},{\"content\":\"Auth middleware runs before route handler\",\"type\":\"fact\",\"priority\":0.6}]}]"; /// Configuration for the extraction pipeline. #[derive(Debug, Clone, Default, serde::Deserialize)] @@ -57,6 +70,39 @@ pub struct ExtractionConfig { pub max_facts: usize, } +/// A single extracted fact with scene segmentation and priority metadata (#1009). +/// +/// When the LLM returns scene-segmented output, each fact carries: +/// - `scene`: topic label (also injected as a `scene:xxx` tag) +/// - `fact_type`: the memory type (fact, decision, preference, procedure, context) +/// - `priority`: importance score 0.0–1.0 (mapped to uteke's `importance` field) +/// +/// When the LLM returns a flat array of strings, `scene` is `None`, +/// `fact_type` is `None`, and `priority` is `None` (caller uses defaults). +#[derive(Debug, Clone, PartialEq)] +pub struct ExtractedFact { + /// The fact content — self-contained, no pronouns. + pub content: String, + /// Scene/topic label if the LLM segmented the output. + pub scene: Option, + /// Memory type if the LLM provided one. + pub fact_type: Option, + /// Priority/importance score 0.0–1.0 if the LLM provided one. + pub priority: Option, +} + +impl ExtractedFact { + /// Create a flat fact with no metadata (legacy backward compat). + pub fn flat(content: impl Into) -> Self { + Self { + content: content.into(), + scene: None, + fact_type: None, + priority: None, + } + } +} + /// An OpenAI-compatible chat-completions client used for fact extraction. #[derive(Debug)] pub struct Extractor { @@ -123,8 +169,9 @@ impl Extractor { /// Extract atomic facts from a single document. /// /// Returns the parsed list of facts (truncated to `max_facts`). + /// Each fact may carry scene, type, and priority metadata (#1009). /// An empty vec means the model found nothing worth keeping. - pub fn extract(&self, text: &str) -> Result, Error> { + pub fn extract(&self, text: &str) -> Result, Error> { if text.trim().is_empty() { return Ok(Vec::new()); } @@ -206,42 +253,120 @@ fn validate_base_url(base_url: &str) -> Result<(), Error> { Ok(()) } -/// Parse the model's reply into a clean list of facts. -fn parse_facts(content: &str) -> Vec { +/// Parse the model's reply into a clean list of extracted facts. +/// +/// Three formats are handled (#1009): +/// 1. **Scene-segmented**: `[{scene, memories: [{content, type, priority}]}]` +/// 2. **Flat object array**: `[{content, type, priority}]` or `[{fact: "..."}]` +/// 3. **Flat string array**: `["fact one", "fact two"]` (legacy) +/// 4. **Line-by-line fallback** when JSON parsing fails entirely. +fn parse_facts(content: &str) -> Vec { let cleaned = strip_code_fences(content.trim()); - // Preferred path: a JSON array of strings somewhere in the reply. if let Some(arr) = extract_json_array(cleaned) { if let Ok(values) = serde_json::from_str::>(arr) { - let facts: Vec = values + // Try scene-segmented format first: [{scene, memories: [...]}] + if let Some(facts) = try_parse_scene_segmented(&values) { + return dedup_facts(facts); + } + + // Fall back to flat array parsing (strings or objects) + let facts: Vec = values .into_iter() .filter_map(|v| match v { - serde_json::Value::String(s) => Some(s), - // Tolerate models that return objects like {\"fact\": \"...\"}. - serde_json::Value::Object(map) => map - .get("fact") - .or_else(|| map.get("text")) - .or_else(|| map.get("content")) - .and_then(|x| x.as_str()) - .map(|s| s.to_string()), + serde_json::Value::String(s) => Some(ExtractedFact::flat(s)), + serde_json::Value::Object(map) => parse_fact_object(&map), _ => None, }) - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) + .map(|f| ExtractedFact { + content: f.content.trim().to_string(), + ..f + }) + .filter(|f| !f.content.is_empty()) .collect(); - return dedup(facts); + return dedup_facts(facts); } } // Fallback: treat each non-empty line as a fact, stripping list markers. - let facts: Vec = cleaned + let facts: Vec = cleaned .lines() .map(|l| l.trim().trim_start_matches(['-', '*', '•']).trim()) .map(strip_leading_number) .filter(|l| l.len() > 2) - .map(|l| l.to_string()) + .map(ExtractedFact::flat) .collect(); - dedup(facts) + dedup_facts(facts) +} + +/// Try to parse scene-segmented output: `[{scene, memories: [{content, type, priority}]}]`. +/// +/// Returns `Some(vec)` only if at least one element matches the scene structure. +/// If no element has a `memories` array, returns `None` (caller tries flat parsing). +fn try_parse_scene_segmented(values: &[serde_json::Value]) -> Option> { + let mut all_facts = Vec::new(); + let mut found_scene = false; + + for val in values { + let obj = val.as_object()?; + // A scene element must have a "memories" array. + let memories = obj.get("memories")?.as_array()?; + found_scene = true; + + let scene = obj + .get("scene") + .and_then(|s| s.as_str()) + .map(|s| s.trim().to_string()); + + for mem in memories { + if let Some(mem_obj) = mem.as_object() { + if let Some(fact) = parse_fact_object(mem_obj) { + all_facts.push(ExtractedFact { + scene: scene.clone(), + ..fact + }); + } + } + } + } + + if found_scene { Some(all_facts) } else { None } +} + +/// Parse a single fact object: `{content, type, priority}` or `{fact: "..."}`. +fn parse_fact_object(map: &serde_json::Map) -> Option { + let content = map + .get("content") + .or_else(|| map.get("fact")) + .or_else(|| map.get("text")) + .and_then(|v| v.as_str())? + .trim() + .to_string(); + if content.is_empty() { + return None; + } + + let fact_type = map + .get("type") + .and_then(|v| v.as_str()) + .map(|s| s.trim().to_lowercase()); + + let priority = map + .get("priority") + .and_then(|v| v.as_f64()) + .filter(|&p| (0.0..=1.0).contains(&p)); + + let scene = map + .get("scene") + .and_then(|v| v.as_str()) + .map(|s| s.trim().to_string()); + + Some(ExtractedFact { + content, + scene, + fact_type, + priority, + }) } /// Remove a leading backtick-fence wrapper (```` ``` ```` or ```` ```lang ````) if present. @@ -285,15 +410,17 @@ fn strip_leading_number(s: &str) -> &str { } /// Drop duplicate and empty facts while preserving order. -fn dedup(facts: Vec) -> Vec { +fn dedup_facts(facts: Vec) -> Vec { let mut seen: Vec = Vec::with_capacity(facts.len()); + let mut result = Vec::with_capacity(facts.len()); for f in facts { - let f = f.trim().to_string(); - if !f.is_empty() && !seen.iter().any(|x| x == &f) { - seen.push(f); + let key = f.content.trim().to_lowercase(); + if !key.is_empty() && !seen.iter().any(|x| x == &key) { + seen.push(key); + result.push(f); } } - seen + result } #[derive(serde::Deserialize)] @@ -363,40 +490,47 @@ mod tests { #[test] fn parses_clean_json_array() { let facts = parse_facts(r#"["User prefers Indonesian", "Bootcamp has 8 sessions"]"#); - assert_eq!( - facts, - vec!["User prefers Indonesian", "Bootcamp has 8 sessions"] - ); + assert_eq!(facts.len(), 2); + assert_eq!(facts[0].content, "User prefers Indonesian"); + assert_eq!(facts[1].content, "Bootcamp has 8 sessions"); + assert!(facts[0].scene.is_none()); + assert!(facts[0].fact_type.is_none()); + assert!(facts[0].priority.is_none()); } #[test] fn parses_json_array_inside_code_fence() { let raw = "```json\n[\"Fact A\", \"Fact B\"]\n```"; - assert_eq!(parse_facts(raw), vec!["Fact A", "Fact B"]); + let facts = parse_facts(raw); + assert_eq!(facts.len(), 2); + assert_eq!(facts[0].content, "Fact A"); + assert_eq!(facts[1].content, "Fact B"); } #[test] fn parses_array_with_preamble() { let raw = "Here are the facts:\n[\"Only this matters\"]"; - assert_eq!(parse_facts(raw), vec!["Only this matters"]); + let facts = parse_facts(raw); + assert_eq!(facts.len(), 1); + assert_eq!(facts[0].content, "Only this matters"); } #[test] fn parses_object_array_with_fact_key() { let raw = r#"[{"fact": "Deadline is July 31"}, {"fact": "Promo is 65 percent"}]"#; - assert_eq!( - parse_facts(raw), - vec!["Deadline is July 31", "Promo is 65 percent"] - ); + let facts = parse_facts(raw); + assert_eq!(facts.len(), 2); + assert_eq!(facts[0].content, "Deadline is July 31"); + assert_eq!(facts[1].content, "Promo is 65 percent"); } #[test] fn falls_back_to_line_parsing() { let raw = "- First fact\n- Second fact\n1. Third fact"; - assert_eq!( - parse_facts(raw), - vec!["First fact", "Second fact", "Third fact"] - ); + let facts = parse_facts(raw); + assert_eq!(facts.len(), 3); + assert_eq!(facts[0].content, "First fact"); + assert_eq!(facts[2].content, "Third fact"); } #[test] @@ -407,6 +541,71 @@ mod tests { #[test] fn dedups_repeated_facts() { let raw = r#"["Same", "Same", "Different"]"#; - assert_eq!(parse_facts(raw), vec!["Same", "Different"]); + let facts = parse_facts(raw); + assert_eq!(facts.len(), 2); + assert_eq!(facts[0].content, "Same"); + assert_eq!(facts[1].content, "Different"); + } + + // --- Scene-segmented parsing tests (#1009) --- + + #[test] + fn parses_scene_segmented_output() { + let raw = r#"[ + {"scene": "auth", "memories": [ + {"content": "Decided to use OAuth 2.1", "type": "decision", "priority": 0.9}, + {"content": "Auth middleware runs first", "type": "fact", "priority": 0.6} + ]}, + {"scene": "database", "memories": [ + {"content": "Migration adds indexes", "type": "fact", "priority": 0.7} + ]} + ]"#; + let facts = parse_facts(raw); + assert_eq!(facts.len(), 3); + assert_eq!(facts[0].content, "Decided to use OAuth 2.1"); + assert_eq!(facts[0].scene.as_deref(), Some("auth")); + assert_eq!(facts[0].fact_type.as_deref(), Some("decision")); + assert_eq!(facts[0].priority, Some(0.9)); + assert_eq!(facts[1].scene.as_deref(), Some("auth")); + assert_eq!(facts[2].scene.as_deref(), Some("database")); + assert_eq!(facts[2].content, "Migration adds indexes"); + } + + #[test] + fn parses_flat_object_array_with_type_priority() { + let raw = r#"[{"content": "Important decision", "type": "decision", "priority": 0.85}]"#; + let facts = parse_facts(raw); + assert_eq!(facts.len(), 1); + assert_eq!(facts[0].content, "Important decision"); + assert_eq!(facts[0].fact_type.as_deref(), Some("decision")); + assert_eq!(facts[0].priority, Some(0.85)); + assert!(facts[0].scene.is_none()); + } + + #[test] + fn scene_segmented_with_string_memories_falls_back() { + // If "memories" contains strings not objects, they should be skipped gracefully. + let raw = r#"[{"scene": "test", "memories": ["plain string fact"]}]"#; + let facts = parse_facts(raw); + // String elements inside memories are not objects → no facts extracted + // But the array was detected as scene-segmented, so flat parsing is NOT attempted. + assert!(facts.is_empty()); + } + + #[test] + fn dedup_is_case_insensitive() { + let raw = r#"["Same Fact", "same fact", "Different"]"#; + let facts = parse_facts(raw); + assert_eq!(facts.len(), 2); + assert_eq!(facts[0].content, "Same Fact"); + assert_eq!(facts[1].content, "Different"); + } + + #[test] + fn priority_out_of_range_ignored() { + let raw = r#"[{"content": "Test", "priority": 1.5}]"#; + let facts = parse_facts(raw); + assert_eq!(facts.len(), 1); + assert!(facts[0].priority.is_none()); // 1.5 is out of 0.0-1.0 range } } diff --git a/crates/uteke-server/src/handlers.rs b/crates/uteke-server/src/handlers.rs index bdfdb10a..07b957b6 100644 --- a/crates/uteke-server/src/handlers.rs +++ b/crates/uteke-server/src/handlers.rs @@ -1945,14 +1945,21 @@ pub fn route(uteke: &Mutex, ctx: &ReqCtx, req: &mut Request) -> Response< // Store each extracted fact as a memory let mut stored_ids = Vec::new(); - let tag_refs: Vec<&str> = req_data.tags.iter().map(|s| s.as_str()).collect(); let fact_ns = ns(&req_data.namespace); for fact in &facts { - let mut meta = serde_json::Map::new(); - if let Some(t) = &req_data.r#type { - meta.insert("type".into(), serde_json::Value::String(t.clone())); + // Build tags: caller-provided tags + scene tag if present (#1009). + let mut all_tags: Vec = + req_data.tags.iter().map(|s| s.to_string()).collect(); + if let Some(ref scene) = fact.scene { + let scene_tag = format!("scene:{}", scene); + if !all_tags.contains(&scene_tag) { + all_tags.push(scene_tag); + } } + let tag_refs: Vec<&str> = all_tags.iter().map(|s| s.as_str()).collect(); + + let mut meta = serde_json::Map::new(); meta.insert( "source".into(), serde_json::Value::String("extraction".into()), @@ -1963,7 +1970,19 @@ pub fn route(uteke: &Mutex, ctx: &ReqCtx, req: &mut Request) -> Response< Some(serde_json::Value::Object(meta)) }; - if let Ok(id) = uteke.remember(fact, &tag_refs, metadata, fact_ns) { + // Use remember_typed when the LLM or request provided a type (#1009). + let effective_type = fact.fact_type.as_deref().or(req_data.r#type.as_deref()); + let result = if let Some(ft) = effective_type { + uteke.remember_typed(&fact.content, &tag_refs, metadata, fact_ns, ft) + } else { + uteke.remember(&fact.content, &tag_refs, metadata, fact_ns) + }; + + if let Ok(id) = result { + // Set importance if the LLM provided a priority score (#1009). + if let Some(priority) = fact.priority { + let _ = uteke.set_importance(&id, priority); + } // Auto-populate source provenance (#1013). let _ = uteke.set_source(&id, Some("api:extraction"), "extract"); stored_ids.push(id); @@ -1973,7 +1992,7 @@ pub fn route(uteke: &Mutex, ctx: &ReqCtx, req: &mut Request) -> Response< ctx.ok_response_for( req, &serde_json::json!({ - "facts": facts, + "facts": facts.iter().map(|f| &f.content).collect::>(), "count": facts.len(), "stored": stored_ids.len(), "stored_ids": stored_ids, From af0e50040bf4c635bf485308fe965983b7c6bc5e Mon Sep 17 00:00:00 2001 From: "Anaz S. Aji" Date: Fri, 14 Aug 2026 06:50:52 +0700 Subject: [PATCH 07/14] chore: bump version to 0.14.0 (#1019) - Version bump to 0.14.0 - CHANGELOG entry for v0.14.0 - Docs: cli-reference scene-segmented extraction section - Docs: memory-lifecycle deprecated endpoint Co-authored-by: ajianaz --- CHANGELOG.md | 32 ++++++++++++++++++++++++++++++++ Cargo.lock | 8 ++++---- Cargo.toml | 2 +- crates/uteke-cli/Cargo.toml | 2 +- crates/uteke-mcp/Cargo.toml | 2 +- crates/uteke-server/Cargo.toml | 4 ++-- docs/cli-reference.md | 12 ++++++++++++ docs/memory-lifecycle.md | 1 + 8 files changed, 54 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6069e467..62b96c76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,35 @@ +## [0.14.0] — 2026-08-14 + +Minor release: hybrid recall as default strategy, scene-segmented extraction, memory tools guide, lifecycle introspection, and source provenance. One behavior change (recall default). + +### ⚠️ Behavior Change + +- **Hybrid (RRF) is now the default recall strategy (#1005)** — Previously defaulted to vector-only. Hybrid combines vector similarity with FTS5 keyword matching via Reciprocal Rank Fusion, improving recall@5 from 85.4% to 98.0% on the internal benchmark. No action needed — existing setups automatically benefit. To revert to vector-only, set `strategy = "vector"` in `[recall]`. + +### Added + +- **Scene-segmented LLM extraction with priority scoring (#1009)** — When using `--extract` with an LLM, facts are now grouped by topic (scene) and assigned a priority score (0.0–1.0). Each fact gets a `scene:` tag for filtering, a semantic type (`decision`, `fact`, `preference`), and an importance value reflecting its priority. Offline extraction is unaffected. Backward compatible — flat string arrays from older models still parse correctly. + +- **List deprecated memories endpoint (#1007)** — `GET /lifecycle/deprecated` returns deprecated memories with TTL metadata (deprecated date, expiry date, remaining days). Useful for auditing what's slated for pruning before it's gone. + +- **Memory tools guide endpoint (#1010)** — `GET /guide` returns an agent-facing reference document covering all available memory operations. Designed for system-prompt injection — agents can discover capabilities without hardcoded instructions. + +- **Source provenance for extracted memories (#1012, #1013)** — Memories created via `--extract` now automatically record their source file path and extraction timestamp. CLI and server paths both covered. + +### Fixed + +- **Cross-compilation fails for Android/iOS targets (#1014)** — `ORT_LIB_NAME` environment variable was not cfg'd for mobile targets, breaking `cargo build --target aarch64-linux-android` and `aarch64-apple-ios`. Fixed with conditional compilation flags. + +- **Update check wastes API calls in batch mode (#1006)** — The startup update check ran even during `--batch-dir` imports, adding latency to batch operations. Now skipped when `--batch-dir` is active. + +- **Embedding text not pre-truncated before API call (#1002)** — Long content strings were sent to the embedding API untruncated, causing rejections from providers with token limits. Text is now pre-truncated to the configured `max_chunk_tokens`. + +### Contributors + +- [@ajianaz](https://github.com/ajianaz) + +--- + ## [0.13.2] — 2026-08-11 Patch release with update notifications, a startup crash fix, and benchmark accuracy improvements. No breaking changes. diff --git a/Cargo.lock b/Cargo.lock index c5e052c5..d23c0498 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2764,7 +2764,7 @@ dependencies = [ [[package]] name = "uteke-cli" -version = "0.13.2" +version = "0.14.0" dependencies = [ "chrono", "clap", @@ -2790,7 +2790,7 @@ dependencies = [ [[package]] name = "uteke-core" -version = "0.13.2" +version = "0.14.0" dependencies = [ "chrono", "dirs", @@ -2813,7 +2813,7 @@ dependencies = [ [[package]] name = "uteke-mcp" -version = "0.13.2" +version = "0.14.0" dependencies = [ "dirs", "serde", @@ -2823,7 +2823,7 @@ dependencies = [ [[package]] name = "uteke-server" -version = "0.13.2" +version = "0.14.0" dependencies = [ "chrono", "ctrlc", diff --git a/Cargo.toml b/Cargo.toml index 98dd6237..bf6ae8aa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ members = [ ] [workspace.package] -version = "0.13.2" +version = "0.14.0" edition = "2024" license = "Apache-2.0" repository = "https://github.com/codecoradev/uteke" diff --git a/crates/uteke-cli/Cargo.toml b/crates/uteke-cli/Cargo.toml index f40ae032..f52f08cc 100644 --- a/crates/uteke-cli/Cargo.toml +++ b/crates/uteke-cli/Cargo.toml @@ -14,7 +14,7 @@ name = "uteke" path = "src/main.rs" [dependencies] -uteke-core = { path = "../uteke-core", version = "0.13.2", features = ["onnx"] } +uteke-core = { path = "../uteke-core", version = "0.14.0", features = ["onnx"] } clap = { version = "4", features = ["derive"] } serde_json = "1" tracing = "0.1" diff --git a/crates/uteke-mcp/Cargo.toml b/crates/uteke-mcp/Cargo.toml index aec38959..e93947d9 100644 --- a/crates/uteke-mcp/Cargo.toml +++ b/crates/uteke-mcp/Cargo.toml @@ -17,7 +17,7 @@ name = "uteke_mcp" path = "src/lib.rs" [dependencies] -uteke-core = { path = "../uteke-core", version = "0.13.2", features = ["onnx"] } +uteke-core = { path = "../uteke-core", version = "0.14.0", features = ["onnx"] } serde = { version = "1", features = ["derive"] } serde_json = "1" dirs = "6" diff --git a/crates/uteke-server/Cargo.toml b/crates/uteke-server/Cargo.toml index 780b4e4b..b8332272 100644 --- a/crates/uteke-server/Cargo.toml +++ b/crates/uteke-server/Cargo.toml @@ -21,8 +21,8 @@ name = "uteke-serve" path = "src/main.rs" [dependencies] -uteke-core = { path = "../uteke-core", version = "0.13.2", features = ["onnx"] } -uteke-mcp = { path = "../uteke-mcp", version = "0.13.2" } +uteke-core = { path = "../uteke-core", version = "0.14.0", features = ["onnx"] } +uteke-mcp = { path = "../uteke-mcp", version = "0.14.0" } serde = { version = "1", features = ["derive"] } serde_json = "1" schemars = { version = "1", optional = true } diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 8236117a..0ebe58a7 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -504,6 +504,18 @@ needs no duplicate key. See [configuration](#extraction) for the `[extraction]` config block and `UTEKE_EXTRACTION_*` environment variables. +#### Scene-segmented output (#1009) + +When the LLM extracts facts, it groups them by topic (scene) and assigns +a priority score (0.0–1.0) to each. This means: + +- **Scene tags** — each fact gets a `scene:` tag automatically, so you can filter by topic during recall. +- **Priority** — important decisions get higher `importance` (0.7–0.9), routine facts get lower (0.3–0.5). +- **Type** — facts are tagged with a semantic type (`decision`, `fact`, `preference`, etc.) when the model provides one. + +Offline extraction (no LLM) is unaffected — it continues to produce flat +strings with default importance. + ### Batch import (`--batch-dir`) Import all files from a directory in one command. Each file is processed diff --git a/docs/memory-lifecycle.md b/docs/memory-lifecycle.md index eebfe965..70b93b63 100644 --- a/docs/memory-lifecycle.md +++ b/docs/memory-lifecycle.md @@ -71,6 +71,7 @@ See [CLI Reference → lifecycle](/cli-reference#uteke-lifecycle) for details. | Method | Endpoint | Description | |--------|----------|-------------| | `GET` | `/lifecycle/status` | Active vs deprecated counts | +| `GET` | `/lifecycle/deprecated` | List deprecated memories with TTL metadata (#1007) | | `POST` | `/lifecycle/cycle` | Run lifecycle cycle | | `POST` | `/lifecycle/promote` | Promote deprecated → active | From 87a028041e5581869ae81a63c0a220cb13d67fe6 Mon Sep 17 00:00:00 2001 From: "Anaz S. Aji" Date: Fri, 14 Aug 2026 08:09:54 +0700 Subject: [PATCH 08/14] fix: crates.io publish race condition + uteke-mcp metadata (#1021) - Add 60s/30s/30s delay between crate publishes for index propagation - Add repository, rust-version, documentation, homepage to uteke-mcp - Fixes: uteke-mcp/cli/server fail because uteke-core not yet in index - Fixes: 'manifest has no documentation, homepage or repository' warning Co-authored-by: ajianaz --- .github/workflows/release.yml | 9 +++++++++ crates/uteke-mcp/Cargo.toml | 4 ++++ 2 files changed, 13 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cb637ebe..721b9498 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -269,14 +269,23 @@ jobs: run: cargo publish --token ${{ secrets.CARGO_REGISTRY_TOKEN }} -p uteke-core --allow-dirty continue-on-error: true + - name: Wait for crates.io index propagation + run: sleep 60 + - name: Publish uteke-mcp run: cargo publish --token ${{ secrets.CARGO_REGISTRY_TOKEN }} -p uteke-mcp --allow-dirty continue-on-error: true + - name: Wait for crates.io index propagation + run: sleep 30 + - name: Publish uteke-cli run: cargo publish --token ${{ secrets.CARGO_REGISTRY_TOKEN }} -p uteke-cli --allow-dirty continue-on-error: true + - name: Wait for crates.io index propagation + run: sleep 30 + - name: Publish uteke-server run: cargo publish --token ${{ secrets.CARGO_REGISTRY_TOKEN }} -p uteke-server --allow-dirty continue-on-error: true diff --git a/crates/uteke-mcp/Cargo.toml b/crates/uteke-mcp/Cargo.toml index e93947d9..760e2288 100644 --- a/crates/uteke-mcp/Cargo.toml +++ b/crates/uteke-mcp/Cargo.toml @@ -3,10 +3,14 @@ name = "uteke-mcp" version.workspace = true edition.workspace = true license.workspace = true +repository.workspace = true +rust-version.workspace = true description = "MCP server for uteke — expose persistent memory to AI coding agents" readme = "README.md" keywords = ["mcp", "ai", "memory", "embedding", "agents"] categories = ["command-line-utilities", "development-tools"] +documentation = "https://docs.rs/uteke-mcp" +homepage = "https://github.com/codecoradev/uteke" [[bin]] name = "uteke-mcp" From 5e063e265e268cc810b60741143f35c8e8128271 Mon Sep 17 00:00:00 2001 From: "Anaz S. Aji" Date: Fri, 14 Aug 2026 09:46:36 +0700 Subject: [PATCH 09/14] fix(ci): switch deploy-website from npm to bun (#1022) Root cause: commit 0b5cad3 removed docs/package-lock.json from git tracking and added it to .gitignore. The deploy-website workflow still referenced it via cache-dependency-path, causing setup-node to fail: 'Some specified paths were not resolved, unable to cache dependencies.' Fix: - Replace actions/setup-node with oven-sh/setup-bun - Replace npm ci with bun install --frozen-lockfile - Replace npm run build with bun run build - Add docs/bun.lock as tracked lockfile - Remove docs/package-lock.json from .gitignore (no longer relevant) Co-authored-by: ajianaz --- .github/workflows/deploy-website.yml | 10 +- .gitignore | 1 - docs/bun.lock | 365 +++++++++++++++++++++++++++ 3 files changed, 368 insertions(+), 8 deletions(-) create mode 100644 docs/bun.lock diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml index 4af0fb15..ad9a7b8f 100644 --- a/.github/workflows/deploy-website.yml +++ b/.github/workflows/deploy-website.yml @@ -26,18 +26,14 @@ jobs: steps: - uses: actions/checkout@v7 - - uses: actions/setup-node@v7 - with: - node-version: 24 - cache: npm - cache-dependency-path: docs/package-lock.json + - uses: oven-sh/setup-bun@v2 - name: Install dependencies - run: npm ci + run: bun install --frozen-lockfile working-directory: docs - name: Build - run: npm run build + run: bun run build working-directory: docs - name: Verify build output diff --git a/.gitignore b/.gitignore index e5f25e31..f80495ac 100644 --- a/.gitignore +++ b/.gitignore @@ -24,4 +24,3 @@ internal/ benchmarks/longmemeval/results_*/ # VitePress build artifacts (keep config.ts, not lock files) -docs/package-lock.json diff --git a/docs/bun.lock b/docs/bun.lock new file mode 100644 index 00000000..de35aaf0 --- /dev/null +++ b/docs/bun.lock @@ -0,0 +1,365 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "docs", + "devDependencies": { + "@codecora/theme": "github:codecoradev/codecora-theme#c6a6aa654f686a8012a8442310222b8a6e879aa9", + "vitepress": "^1.6.4", + "vue": "^3.5.35", + }, + }, + }, + "packages": { + "@algolia/abtesting": ["@algolia/abtesting@1.22.0", "", { "dependencies": { "@algolia/client-common": "5.56.0", "@algolia/requester-browser-xhr": "5.56.0", "@algolia/requester-fetch": "5.56.0", "@algolia/requester-node-http": "5.56.0" } }, "sha512-BFR6zNowNKcY7Ou7TaJc9QWexES4YKPbmf/OTFofpdsdhz4x6q0lbxp3duO0EHnyrN7rE4ba/TSXuY+BDGu4+g=="], + + "@algolia/autocomplete-core": ["@algolia/autocomplete-core@1.17.7", "", { "dependencies": { "@algolia/autocomplete-plugin-algolia-insights": "1.17.7", "@algolia/autocomplete-shared": "1.17.7" } }, "sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q=="], + + "@algolia/autocomplete-plugin-algolia-insights": ["@algolia/autocomplete-plugin-algolia-insights@1.17.7", "", { "dependencies": { "@algolia/autocomplete-shared": "1.17.7" }, "peerDependencies": { "search-insights": ">= 1 < 3" } }, "sha512-Jca5Ude6yUOuyzjnz57og7Et3aXjbwCSDf/8onLHSQgw1qW3ALl9mrMWaXb5FmPVkV3EtkD2F/+NkT6VHyPu9A=="], + + "@algolia/autocomplete-preset-algolia": ["@algolia/autocomplete-preset-algolia@1.17.7", "", { "dependencies": { "@algolia/autocomplete-shared": "1.17.7" }, "peerDependencies": { "@algolia/client-search": ">= 4.9.1 < 6", "algoliasearch": ">= 4.9.1 < 6" } }, "sha512-ggOQ950+nwbWROq2MOCIL71RE0DdQZsceqrg32UqnhDz8FlO9rL8ONHNsI2R1MH0tkgVIDKI/D0sMiUchsFdWA=="], + + "@algolia/autocomplete-shared": ["@algolia/autocomplete-shared@1.17.7", "", { "peerDependencies": { "@algolia/client-search": ">= 4.9.1 < 6", "algoliasearch": ">= 4.9.1 < 6" } }, "sha512-o/1Vurr42U/qskRSuhBH+VKxMvkkUVTLU6WZQr+L5lGZZLYWyhdzWjW0iGXY7EkwRTjBqvN2EsR81yCTGV/kmg=="], + + "@algolia/client-abtesting": ["@algolia/client-abtesting@5.56.0", "", { "dependencies": { "@algolia/client-common": "5.56.0", "@algolia/requester-browser-xhr": "5.56.0", "@algolia/requester-fetch": "5.56.0", "@algolia/requester-node-http": "5.56.0" } }, "sha512-7r4Z3NC7yU1oAQVWJNA2HX7tX481F3pJvCGyLIXiTdBcthz4Q/o21jwcMYDFkuI92UWTNBQQmHYgwHo1zS5dzg=="], + + "@algolia/client-analytics": ["@algolia/client-analytics@5.56.0", "", { "dependencies": { "@algolia/client-common": "5.56.0", "@algolia/requester-browser-xhr": "5.56.0", "@algolia/requester-fetch": "5.56.0", "@algolia/requester-node-http": "5.56.0" } }, "sha512-avmjXQSq+jadFO8Xl2em05/uQdQnEmHsJyOAdVbZkmVgpMfxL12aJwVVfGNwYr9nulcpuJN1X0lTaQ5wxuNGcA=="], + + "@algolia/client-common": ["@algolia/client-common@5.56.0", "", {}, "sha512-v2TPStUhY//ripPjIVclZ8AWc7DEGooXULZGFlFu37zNatgHjw34oZZ+OSbbc/YHO+xZwPl62I1k8xH1m4S2eg=="], + + "@algolia/client-insights": ["@algolia/client-insights@5.56.0", "", { "dependencies": { "@algolia/client-common": "5.56.0", "@algolia/requester-browser-xhr": "5.56.0", "@algolia/requester-fetch": "5.56.0", "@algolia/requester-node-http": "5.56.0" } }, "sha512-P0ehROpM4Sem3Sqo5x2cKPgj67D3G3jy0rh1Amwkcvsfr6tkvIcdCmerieanqTF7NxUMPNFLkpIFeMO8Rpa50w=="], + + "@algolia/client-personalization": ["@algolia/client-personalization@5.56.0", "", { "dependencies": { "@algolia/client-common": "5.56.0", "@algolia/requester-browser-xhr": "5.56.0", "@algolia/requester-fetch": "5.56.0", "@algolia/requester-node-http": "5.56.0" } }, "sha512-SXK3Vn3WVxyzbm31oePZBJkp1wpOyuWdd4B/Pv7n0aXDxmeSWhC1R1FC1517mMrFAIaPH4Rt0x6RUe7ZNjz8FA=="], + + "@algolia/client-query-suggestions": ["@algolia/client-query-suggestions@5.56.0", "", { "dependencies": { "@algolia/client-common": "5.56.0", "@algolia/requester-browser-xhr": "5.56.0", "@algolia/requester-fetch": "5.56.0", "@algolia/requester-node-http": "5.56.0" } }, "sha512-5+ZdX8garFnmycnZgKhtXHePEaLj5zqDxI/0lkhhluzCcvTn0/PvvTirTg8hHYetQHvn7GDyeAiqTAieMvMW4A=="], + + "@algolia/client-search": ["@algolia/client-search@5.56.0", "", { "dependencies": { "@algolia/client-common": "5.56.0", "@algolia/requester-browser-xhr": "5.56.0", "@algolia/requester-fetch": "5.56.0", "@algolia/requester-node-http": "5.56.0" } }, "sha512-+mKUdYvqOi0BcvpAEyCEw49vSBptufIcfibtHz2bdr1pI789M46Yt0uQEk/sxtK3teh71OQvVFHaTDzShUWewQ=="], + + "@algolia/ingestion": ["@algolia/ingestion@1.56.0", "", { "dependencies": { "@algolia/client-common": "5.56.0", "@algolia/requester-browser-xhr": "5.56.0", "@algolia/requester-fetch": "5.56.0", "@algolia/requester-node-http": "5.56.0" } }, "sha512-9g/zj+AZx5moFcdFIrYQoVrueXivjUcc3MQHtCYT8WhIuk1lUh1AyEhvJCS0XBZld09cLvd1AZ3BvDBpVpX2UA=="], + + "@algolia/monitoring": ["@algolia/monitoring@1.56.0", "", { "dependencies": { "@algolia/client-common": "5.56.0", "@algolia/requester-browser-xhr": "5.56.0", "@algolia/requester-fetch": "5.56.0", "@algolia/requester-node-http": "5.56.0" } }, "sha512-Qf3Sr6f9A9uxCZUf3MXS0d2b877uYzEB5yxqpVGXAhcJnBCQjrRRon0KvefpGkxy+BshrIJs96OUoMtGqXTFDA=="], + + "@algolia/recommend": ["@algolia/recommend@5.56.0", "", { "dependencies": { "@algolia/client-common": "5.56.0", "@algolia/requester-browser-xhr": "5.56.0", "@algolia/requester-fetch": "5.56.0", "@algolia/requester-node-http": "5.56.0" } }, "sha512-GXWG1rWc5wu8hY4N33Y3b6ernY6sAdAvmKWN/zHAiACOx40WnpG0TVX5YazCAr/9gOYGInSiM2A0y2jy2xbiDA=="], + + "@algolia/requester-browser-xhr": ["@algolia/requester-browser-xhr@5.56.0", "", { "dependencies": { "@algolia/client-common": "5.56.0" } }, "sha512-7t24cBxaInS3mZb7ddEaZT/tp6q+/aR4YttsQVyP1/i+LmwPR34atO35KjaLFCcRVrlP7sYOAqkCfg6lIRB+ew=="], + + "@algolia/requester-fetch": ["@algolia/requester-fetch@5.56.0", "", { "dependencies": { "@algolia/client-common": "5.56.0" } }, "sha512-R7ePHgVYmDFjZpvrsVAfbDz/d4RxKAYZ5/vgLfIsCVRZRryjWl/3INOxpOICzitehQ5FjNtNjcLQTrmHPTcHBQ=="], + + "@algolia/requester-node-http": ["@algolia/requester-node-http@5.56.0", "", { "dependencies": { "@algolia/client-common": "5.56.0" } }, "sha512-PIOUXlSnrqM0S+WOgDRb4RzotydJH7ZoT6tOyL7tAO7qJOfvX5wsEW8Pe+PMKMwvuI4/gIyK9cg2H7lJXqnc4Q=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/parser": ["@babel/parser@7.29.8", "", { "dependencies": { "@babel/types": "^7.29.8" }, "bin": "./bin/babel-parser.js" }, "sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA=="], + + "@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + + "@codecora/theme": ["@codecora/theme@github:codecoradev/codecora-theme#c6a6aa6", { "peerDependencies": { "vitepress": "^1.0.0" }, "optionalPeers": ["vitepress"] }, "codecoradev-codecora-theme-c6a6aa6", "sha512-gOPlWau+Z1mV3zAhlUnVDOnGJrdIgasrD0V2zMhG39BHSt9mSVxeWGGWCXAKcqlQzydfGmUd3AwjiwUll1dWjg=="], + + "@docsearch/css": ["@docsearch/css@3.8.2", "", {}, "sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ=="], + + "@docsearch/js": ["@docsearch/js@3.8.2", "", { "dependencies": { "@docsearch/react": "3.8.2", "preact": "^10.0.0" } }, "sha512-Q5wY66qHn0SwA7Taa0aDbHiJvaFJLOJyHmooQ7y8hlwwQLQ/5WwCcoX0g7ii04Qi2DJlHsd0XXzJ8Ypw9+9YmQ=="], + + "@docsearch/react": ["@docsearch/react@3.8.2", "", { "dependencies": { "@algolia/autocomplete-core": "1.17.7", "@algolia/autocomplete-preset-algolia": "1.17.7", "@docsearch/css": "3.8.2", "algoliasearch": "^5.14.2" }, "peerDependencies": { "@types/react": ">= 16.8.0 < 19.0.0", "react": ">= 16.8.0 < 19.0.0", "react-dom": ">= 16.8.0 < 19.0.0", "search-insights": ">= 1 < 3" }, "optionalPeers": ["@types/react", "react", "react-dom", "search-insights"] }, "sha512-xCRrJQlTt8N9GU0DG4ptwHRkfnSnD/YpdeaXe02iKfqs97TkZJv60yE+1eq/tjPcVnTW8dP5qLP7itifFVV5eg=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="], + + "@iconify-json/simple-icons": ["@iconify-json/simple-icons@1.2.93", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-/XhANjfGYOuqvSR3TmUnkQkINvQ4GVjVuukvymRbxtVFBvIq/yiXJqCDycKcQPT401OYT9H2vIY6ihAlz1QIAw=="], + + "@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@napi-rs/lzma-linux-x64-gnu": ["@napi-rs/lzma-linux-x64-gnu@1.5.1", "", { "os": "linux", "cpu": "x64" }, "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.4", "", { "os": "android", "cpu": "arm" }, "sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.62.4", "", { "os": "android", "cpu": "arm64" }, "sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.62.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.62.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.62.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.62.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.62.4", "", { "os": "linux", "cpu": "arm" }, "sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.62.4", "", { "os": "linux", "cpu": "arm" }, "sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.62.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.62.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.62.4", "", { "os": "linux", "cpu": "none" }, "sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.62.4", "", { "os": "linux", "cpu": "none" }, "sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.62.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.62.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.62.4", "", { "os": "linux", "cpu": "none" }, "sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.62.4", "", { "os": "linux", "cpu": "none" }, "sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.62.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.62.4", "", { "os": "linux", "cpu": "x64" }, "sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.62.4", "", { "os": "linux", "cpu": "x64" }, "sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.62.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.62.4", "", { "os": "none", "cpu": "arm64" }, "sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.62.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.62.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.62.4", "", { "os": "win32", "cpu": "x64" }, "sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.4", "", { "os": "win32", "cpu": "x64" }, "sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q=="], + + "@shikijs/core": ["@shikijs/core@2.5.0", "", { "dependencies": { "@shikijs/engine-javascript": "2.5.0", "@shikijs/engine-oniguruma": "2.5.0", "@shikijs/types": "2.5.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.4" } }, "sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg=="], + + "@shikijs/engine-javascript": ["@shikijs/engine-javascript@2.5.0", "", { "dependencies": { "@shikijs/types": "2.5.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^3.1.0" } }, "sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w=="], + + "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@2.5.0", "", { "dependencies": { "@shikijs/types": "2.5.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw=="], + + "@shikijs/langs": ["@shikijs/langs@2.5.0", "", { "dependencies": { "@shikijs/types": "2.5.0" } }, "sha512-Qfrrt5OsNH5R+5tJ/3uYBBZv3SuGmnRPejV9IlIbFH3HTGLDlkqgHymAlzklVmKBjAaVmkPkyikAV/sQ1wSL+w=="], + + "@shikijs/themes": ["@shikijs/themes@2.5.0", "", { "dependencies": { "@shikijs/types": "2.5.0" } }, "sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw=="], + + "@shikijs/transformers": ["@shikijs/transformers@2.5.0", "", { "dependencies": { "@shikijs/core": "2.5.0", "@shikijs/types": "2.5.0" } }, "sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg=="], + + "@shikijs/types": ["@shikijs/types@2.5.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw=="], + + "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], + + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + + "@types/hast": ["@types/hast@3.0.5", "", { "dependencies": { "@types/unist": "*" } }, "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g=="], + + "@types/linkify-it": ["@types/linkify-it@5.0.0", "", {}, "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q=="], + + "@types/markdown-it": ["@types/markdown-it@14.1.2", "", { "dependencies": { "@types/linkify-it": "^5", "@types/mdurl": "^2" } }, "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog=="], + + "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], + + "@types/mdurl": ["@types/mdurl@2.0.0", "", {}, "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg=="], + + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + + "@types/web-bluetooth": ["@types/web-bluetooth@0.0.21", "", {}, "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="], + + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.3", "", {}, "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg=="], + + "@vitejs/plugin-vue": ["@vitejs/plugin-vue@5.2.4", "", { "peerDependencies": { "vite": "^5.0.0 || ^6.0.0", "vue": "^3.2.25" } }, "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA=="], + + "@vue/compiler-core": ["@vue/compiler-core@3.5.41", "", { "dependencies": { "@babel/parser": "^7.29.8", "@vue/shared": "3.5.41", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-q0Xtv/F9w2YO/7htQhtiL+Ev2WCJbe5N2hc+XfgyKkEKqWpSxknmT8QOuGdEKNdjPq0c3F7rNpFkTo3Kfrm7pg=="], + + "@vue/compiler-dom": ["@vue/compiler-dom@3.5.41", "", { "dependencies": { "@vue/compiler-core": "3.5.41", "@vue/shared": "3.5.41" } }, "sha512-oKacVfNglLvGjnS6BXOlGL7EyG2h8X03pqXCjzotRZUaXGjbrTJUnVAQjrCqUnS+lyu31nwQjZY/d817GmCnfw=="], + + "@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.41", "", { "dependencies": { "@babel/parser": "^7.29.8", "@vue/compiler-core": "3.5.41", "@vue/compiler-dom": "3.5.41", "@vue/compiler-ssr": "3.5.41", "@vue/shared": "3.5.41", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.19", "source-map-js": "^1.2.1" } }, "sha512-XJhip7R2wy6vX3knCxdZN4KracFaZUef58s1KYewqluedHIJaPIVfXoYT7MF1F8nCvv6k8bWWxDC8opMkg1VTQ=="], + + "@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.41", "", { "dependencies": { "@vue/compiler-dom": "3.5.41", "@vue/shared": "3.5.41" } }, "sha512-U3v5OejKEGqOI0Wy0+Sz7hGuIFZHA4LSXzrNM3IMIeDyJEBBfTpX26n3SDgToRpP2bLc9FfI2j/kSgcJ8Emq5A=="], + + "@vue/devtools-api": ["@vue/devtools-api@7.7.10", "", { "dependencies": { "@vue/devtools-kit": "^7.7.10" } }, "sha512-KxtEpUOOpFz/qOGRrAwA36QF7DqIA+FXgCYit9mk9wjbaZt0sXOFz81ElOZtKA4HbWHUdwNjZHBFsFFyp5BZiA=="], + + "@vue/devtools-kit": ["@vue/devtools-kit@7.7.10", "", { "dependencies": { "@vue/devtools-shared": "^7.7.10", "birpc": "^2.3.0", "hookable": "^5.5.3", "mitt": "^3.0.1", "perfect-debounce": "^1.0.0", "speakingurl": "^14.0.1", "superjson": "^2.2.2" } }, "sha512-3WNi2Kq4tbpVbmhml7RiphmAt0279oh3fKNeWMQIrltfX8Q91b4i5PL8DtyNKdwmcsGrV4fg+erwWOmD05CLIw=="], + + "@vue/devtools-shared": ["@vue/devtools-shared@7.7.10", "", { "dependencies": { "rfdc": "^1.4.1" } }, "sha512-wOPslzB8vTvpxwdaOcR2qAbwmuSP0L+rhpoC6Cf56V3Jip+HWb7PQQXOUPgBNQARpXsbQX/+mvi8kKucmBGRwQ=="], + + "@vue/reactivity": ["@vue/reactivity@3.5.41", "", { "dependencies": { "@vue/shared": "3.5.41" } }, "sha512-rznsqKM0np0x18EjzF8x88MpEhdNsffbvFbckLL5+oUKz1BxAImEmO7J1ArRYSyo6aQaVoBDp7jEkT91OOxydA=="], + + "@vue/runtime-core": ["@vue/runtime-core@3.5.41", "", { "dependencies": { "@vue/reactivity": "3.5.41", "@vue/shared": "3.5.41" } }, "sha512-Vcry58hiAKwGen9Z1jUZE0feFsNArPCMOImYI8el48A9Idf6DuQYD0U05zZIF2Iad1hGhPSvcbBbAOhNr55fhg=="], + + "@vue/runtime-dom": ["@vue/runtime-dom@3.5.41", "", { "dependencies": { "@vue/reactivity": "3.5.41", "@vue/runtime-core": "3.5.41", "@vue/shared": "3.5.41", "csstype": "^3.2.3" } }, "sha512-3vVBahVBS9+U6cmXBLyb8nE6/yYo4J/CGI9eVFs3KiMc0YHuudwKyShTD65jtJy/L9PUUxNAFu4cj4LiJ0UFbw=="], + + "@vue/server-renderer": ["@vue/server-renderer@3.5.41", "", { "dependencies": { "@vue/compiler-ssr": "3.5.41", "@vue/runtime-dom": "3.5.41", "@vue/shared": "3.5.41" } }, "sha512-n6hx/pNFfbD6SuyeuMVkvqox8bwf/ET9JlA/kAz/imw8sw++wkqKe2mHX5KutjPpbKE4Z56yTHszoOjGMI9igQ=="], + + "@vue/shared": ["@vue/shared@3.5.41", "", {}, "sha512-IOnwSCma8j+9xJT6b8H0dEYidC80NsYmNMlZxRsukYcSoGaDBohog5hDxzeUXdFeGWFA++vWvxqOmrr96VlqMA=="], + + "@vueuse/core": ["@vueuse/core@12.8.2", "", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "12.8.2", "@vueuse/shared": "12.8.2", "vue": "^3.5.13" } }, "sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ=="], + + "@vueuse/integrations": ["@vueuse/integrations@12.8.2", "", { "dependencies": { "@vueuse/core": "12.8.2", "@vueuse/shared": "12.8.2", "vue": "^3.5.13" }, "peerDependencies": { "async-validator": "^4", "axios": "^1", "change-case": "^5", "drauu": "^0.4", "focus-trap": "^7", "fuse.js": "^7", "idb-keyval": "^6", "jwt-decode": "^4", "nprogress": "^0.2", "qrcode": "^1.5", "sortablejs": "^1", "universal-cookie": "^7" }, "optionalPeers": ["async-validator", "axios", "change-case", "drauu", "focus-trap", "fuse.js", "idb-keyval", "jwt-decode", "nprogress", "qrcode", "sortablejs", "universal-cookie"] }, "sha512-fbGYivgK5uBTRt7p5F3zy6VrETlV9RtZjBqd1/HxGdjdckBgBM4ugP8LHpjolqTj14TXTxSK1ZfgPbHYyGuH7g=="], + + "@vueuse/metadata": ["@vueuse/metadata@12.8.2", "", {}, "sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A=="], + + "@vueuse/shared": ["@vueuse/shared@12.8.2", "", { "dependencies": { "vue": "^3.5.13" } }, "sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w=="], + + "algoliasearch": ["algoliasearch@5.56.0", "", { "dependencies": { "@algolia/abtesting": "1.22.0", "@algolia/client-abtesting": "5.56.0", "@algolia/client-analytics": "5.56.0", "@algolia/client-common": "5.56.0", "@algolia/client-insights": "5.56.0", "@algolia/client-personalization": "5.56.0", "@algolia/client-query-suggestions": "5.56.0", "@algolia/client-search": "5.56.0", "@algolia/ingestion": "1.56.0", "@algolia/monitoring": "1.56.0", "@algolia/recommend": "5.56.0", "@algolia/requester-browser-xhr": "5.56.0", "@algolia/requester-fetch": "5.56.0", "@algolia/requester-node-http": "5.56.0" } }, "sha512-PrqppUmhT4ENdas2pH9caE7efUcxy6EcSFhWzosiVuQBzu2tQ5yLTI6jwomT/1cuBnivzGfxiJCqDNN9FRRh+Q=="], + + "birpc": ["birpc@2.9.0", "", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="], + + "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], + + "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], + + "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], + + "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], + + "copy-anything": ["copy-anything@4.0.5", "", { "dependencies": { "is-what": "^5.2.0" } }, "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + + "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], + + "emoji-regex-xs": ["emoji-regex-xs@1.0.0", "", {}, "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg=="], + + "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + + "esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], + + "estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + + "focus-trap": ["focus-trap@7.8.0", "", { "dependencies": { "tabbable": "^6.4.0" } }, "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="], + + "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], + + "hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="], + + "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], + + "is-what": ["is-what@5.5.0", "", {}, "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "mark.js": ["mark.js@8.11.1", "", {}, "sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ=="], + + "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="], + + "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + + "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="], + + "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="], + + "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], + + "minisearch": ["minisearch@7.2.0", "", {}, "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg=="], + + "mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="], + + "nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="], + + "oniguruma-to-es": ["oniguruma-to-es@3.1.1", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ=="], + + "perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="], + + "preact": ["preact@10.29.8", "", { "peerDependencies": { "preact-render-to-string": ">=5" }, "optionalPeers": ["preact-render-to-string"] }, "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q=="], + + "property-information": ["property-information@7.2.0", "", {}, "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg=="], + + "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="], + + "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="], + + "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="], + + "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], + + "rollup": ["rollup@4.62.4", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.62.4", "@rollup/rollup-android-arm64": "4.62.4", "@rollup/rollup-darwin-arm64": "4.62.4", "@rollup/rollup-darwin-x64": "4.62.4", "@rollup/rollup-freebsd-arm64": "4.62.4", "@rollup/rollup-freebsd-x64": "4.62.4", "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", "@rollup/rollup-linux-arm-musleabihf": "4.62.4", "@rollup/rollup-linux-arm64-gnu": "4.62.4", "@rollup/rollup-linux-arm64-musl": "4.62.4", "@rollup/rollup-linux-loong64-gnu": "4.62.4", "@rollup/rollup-linux-loong64-musl": "4.62.4", "@rollup/rollup-linux-ppc64-gnu": "4.62.4", "@rollup/rollup-linux-ppc64-musl": "4.62.4", "@rollup/rollup-linux-riscv64-gnu": "4.62.4", "@rollup/rollup-linux-riscv64-musl": "4.62.4", "@rollup/rollup-linux-s390x-gnu": "4.62.4", "@rollup/rollup-linux-x64-gnu": "4.62.4", "@rollup/rollup-linux-x64-musl": "4.62.4", "@rollup/rollup-openbsd-x64": "4.62.4", "@rollup/rollup-openharmony-arm64": "4.62.4", "@rollup/rollup-win32-arm64-msvc": "4.62.4", "@rollup/rollup-win32-ia32-msvc": "4.62.4", "@rollup/rollup-win32-x64-gnu": "4.62.4", "@rollup/rollup-win32-x64-msvc": "4.62.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg=="], + + "search-insights": ["search-insights@2.17.3", "", {}, "sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ=="], + + "shiki": ["shiki@2.5.0", "", { "dependencies": { "@shikijs/core": "2.5.0", "@shikijs/engine-javascript": "2.5.0", "@shikijs/engine-oniguruma": "2.5.0", "@shikijs/langs": "2.5.0", "@shikijs/themes": "2.5.0", "@shikijs/types": "2.5.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], + + "speakingurl": ["speakingurl@14.0.1", "", {}, "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ=="], + + "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], + + "superjson": ["superjson@2.2.6", "", { "dependencies": { "copy-anything": "^4" } }, "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA=="], + + "tabbable": ["tabbable@6.5.0", "", {}, "sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA=="], + + "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], + + "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], + + "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="], + + "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="], + + "unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="], + + "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], + + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], + + "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], + + "vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="], + + "vitepress": ["vitepress@1.6.4", "", { "dependencies": { "@docsearch/css": "3.8.2", "@docsearch/js": "3.8.2", "@iconify-json/simple-icons": "^1.2.21", "@shikijs/core": "^2.1.0", "@shikijs/transformers": "^2.1.0", "@shikijs/types": "^2.1.0", "@types/markdown-it": "^14.1.2", "@vitejs/plugin-vue": "^5.2.1", "@vue/devtools-api": "^7.7.0", "@vue/shared": "^3.5.13", "@vueuse/core": "^12.4.0", "@vueuse/integrations": "^12.4.0", "focus-trap": "^7.6.4", "mark.js": "8.11.1", "minisearch": "^7.1.1", "shiki": "^2.1.0", "vite": "^5.4.14", "vue": "^3.5.13" }, "peerDependencies": { "markdown-it-mathjax3": "^4", "postcss": "^8" }, "optionalPeers": ["markdown-it-mathjax3", "postcss"], "bin": { "vitepress": "bin/vitepress.js" } }, "sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg=="], + + "vue": ["vue@3.5.41", "", { "dependencies": { "@vue/compiler-dom": "3.5.41", "@vue/compiler-sfc": "3.5.41", "@vue/runtime-dom": "3.5.41", "@vue/server-renderer": "3.5.41", "@vue/shared": "3.5.41" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-2laE0p+aK+/AOPG/XL/WepOs/GlK755LJ1XECi9kDUrz1FKNw8rb2Xzlw9JS1rqEV55nb0ttsKxVlTCcd+R5cg=="], + + "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + } +} From 4aea7c470f39dac8e80dc700cd299b87e8a9534c Mon Sep 17 00:00:00 2001 From: "Anaz S. Aji" Date: Fri, 14 Aug 2026 13:17:21 +0700 Subject: [PATCH 10/14] feat(test): adopt cargo-mutants for mutation testing (#1023) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add mutation testing infrastructure to validate test quality across critical pure-logic modules. 11 new mutation-killing tests improve salience_recency.rs score from 76% to 96%. Changes: - Add cargo-mutants config (mutants.toml) with exclusions for modules requiring external services - Add mutation-testing CI workflow (develop→main PRs only, pre-release quality gate) - Add .gitignore entries for mutants output directories - Add mutation-testing.md developer documentation - Write 9 mutation-killing tests for salience_recency.rs - Write 2 mutation-killing tests for recall_cache.rs Results (cargo-mutants v27.1.0): jaccard.rs: 12 mutants, 9 caught, 0 missed (100%) salience_recency: 53 mutants, 48 caught, 3 missed (96%) recall_cache: 25 mutants, 18 caught, 5 missed (80%) Refs: nginjen mutation testing pattern (#mutation-testing) Co-authored-by: ajianaz --- .github/workflows/mutation-testing.yml | 58 ++++++++ .gitignore | 4 + crates/uteke-core/mutants.toml | 45 ++++++ crates/uteke-core/src/recall_cache.rs | 69 +++++++++ crates/uteke-core/src/salience_recency.rs | 167 ++++++++++++++++++++-- docs/mutation-testing.md | 87 +++++++++++ 6 files changed, 415 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/mutation-testing.yml create mode 100644 crates/uteke-core/mutants.toml create mode 100644 docs/mutation-testing.md diff --git a/.github/workflows/mutation-testing.yml b/.github/workflows/mutation-testing.yml new file mode 100644 index 00000000..c06180c2 --- /dev/null +++ b/.github/workflows/mutation-testing.yml @@ -0,0 +1,58 @@ +# Mutation testing — pre-release validation only. +# +# This workflow runs ONLY on PRs from develop → main, serving as a pre-release +# quality gate. It is NOT part of the default CI pipeline. +# +# Mutation testing validates test quality: cargo-mutants modifies source code +# (operator mutations, boundary changes, etc.) and checks if existing tests +# catch the mutation. "Missed mutants" = tests that pass on buggy code. +# +# Usage: +# - Runs automatically on develop → main PRs +# - Can be triggered manually via workflow_dispatch +# - Results are informational — they don't block merge by default +# +# To fix missed mutants, see the pattern in: +# crates/uteke-core/src/salience_recency.rs (mutation-killing test comments) +# Reference: nginjen project (78% missed-mutant reduction) + +name: Mutation Testing + +on: + pull_request: + branches: [main] + workflow_dispatch: + +# Cancel in-progress runs for the same PR +concurrency: + group: mutation-testing-${{ github.ref }} + cancel-in-progress: true + +jobs: + mutation-test: + name: Cargo Mutants + runs-on: ubuntu-latest + # Only run on PRs from develop (pre-release validation) + if: | + github.event_name == 'workflow_dispatch' || + (github.event_name == 'pull_request' && startsWith(github.head_ref, 'develop')) + steps: + - uses: actions/checkout@v7 + + - uses: dtolnay/rust-toolchain@stable + + - name: Install cargo-mutants + run: cargo install cargo-mutants --locked + + - name: Run cargo-mutants on core logic + working-directory: crates/uteke-core + run: cargo mutants --timeout 120 -j 2 + continue-on-error: true + + - name: Upload mutant results + if: always() + uses: actions/upload-artifact@v4 + with: + name: mutant-results + path: crates/uteke-core/mutants.out/ + retention-days: 30 diff --git a/.gitignore b/.gitignore index f80495ac..c94a088e 100644 --- a/.gitignore +++ b/.gitignore @@ -23,4 +23,8 @@ internal/ # Benchmark results (large, ephemeral — commit RESULTS.md summaries, not raw data) benchmarks/longmemeval/results_*/ +# cargo-mutants output (local only — run before release) +mutants*.out/ +mutants*.out.old/ + # VitePress build artifacts (keep config.ts, not lock files) diff --git a/crates/uteke-core/mutants.toml b/crates/uteke-core/mutants.toml new file mode 100644 index 00000000..901b2a1e --- /dev/null +++ b/crates/uteke-core/mutants.toml @@ -0,0 +1,45 @@ +# cargo-mutants configuration for uteke +# +# Usage: +# cargo mutants # full run (all files) +# cargo mutants --file crates/uteke-core/src/jaccard.rs # single file +# cargo mutants --output mutants-run.out # custom output dir +# +# This is a LOCAL-ONLY tool — not in default CI. +# Pre-release validation: run manually before tagging a new version. +# CI integration (if added): only on develop→main PRs as a pre-release gate. +# +# Exclude modules that require external services (SQLite DB, embedding API, etc.) +# Focus mutation testing on pure logic — the code most prone to silent bugs. + +# Additional timeout per mutant build+test (seconds). +# Rust projects with many deps need generous timeouts. +timeout_secs = 60 + +# Number of parallel jobs. +jobs = 4 + +# Files to EXCLUDE from mutation testing. +# These are excluded because they require I/O, external services, or are +# thin wrappers around stdlib/third-party calls — not pure logic worth mutating. +exclude_files = [ + # Embedding backends — require external API/network + "src/embed/openai.rs", + "src/embed/ollama.rs", + "src/embed/cache.rs", + "src/embed/engine.rs", + "src/embed/fallback.rs", + "src/embed/ort_init.rs", + # SQLite-backed modules — require on-disk database + "src/memory/store.rs", + "src/memory/schema.rs", + "src/memory/fts5.rs", + "src/memory/vector.rs", + "src/memory/documents.rs", + "src/memory/crud.rs", + "src/memory/bulk.rs", + # Import/export — I/O heavy + "src/import_export.rs", + # Update check — network fetch + "src/update_check.rs", +] diff --git a/crates/uteke-core/src/recall_cache.rs b/crates/uteke-core/src/recall_cache.rs index 27ba28fe..b501db23 100644 --- a/crates/uteke-core/src/recall_cache.rs +++ b/crates/uteke-core/src/recall_cache.rs @@ -300,6 +300,75 @@ mod tests { ); } + #[test] + fn test_cache_ttl_boundary_expired() { + // Kill mutant: replace < with <= in TTL check (line 86). + // With ttl_secs=0, any entry is immediately expired: + // original: elapsed >= 0 → always true → expired (returns None) + // mutant: elapsed <= 0 → false for any elapsed > 0 → NOT expired + // ttl_secs=0 makes the boundary explicit. + let cache = RecallCache::new(RecallCacheConfig { + max_entries: 10, + ttl_secs: 0, + }); + + cache.put("query", "default", 5, None, RecallStrategy::Vector, vec![]); + + // With ttl=0, entry should be expired immediately. + // Mutant (<=) would still return the cached entry. + assert!( + cache + .get("query", "default", 5, None, RecallStrategy::Vector) + .is_none(), + "ttl_secs=0 should expire immediately" + ); + } + + #[test] + fn test_cache_fifo_eviction_on_capacity() { + // Kill mutants: replace >= with < in capacity check (line 120). + // and replace < with ==/>/<= in TTL retain (line 117). + // Fill cache to capacity, then add one more → oldest must be evicted. + let cache = RecallCache::new(RecallCacheConfig { + max_entries: 2, + ttl_secs: 60, + }); + + // Insert 2 entries (at capacity) + cache.put("q1", "default", 5, None, RecallStrategy::Vector, vec![]); + cache.put("q2", "default", 5, None, RecallStrategy::Vector, vec![]); + + // Both should be present + assert!( + cache + .get("q1", "default", 5, None, RecallStrategy::Vector) + .is_some(), + "q1 should be cached" + ); + assert!( + cache + .get("q2", "default", 5, None, RecallStrategy::Vector) + .is_some(), + "q2 should be cached" + ); + + // Insert 3rd → FIFO evicts q1 (oldest) + cache.put("q3", "default", 5, None, RecallStrategy::Vector, vec![]); + + assert!( + cache + .get("q1", "default", 5, None, RecallStrategy::Vector) + .is_none(), + "q1 should be evicted (FIFO)" + ); + assert!( + cache + .get("q3", "default", 5, None, RecallStrategy::Vector) + .is_some(), + "q3 should be cached" + ); + } + #[test] fn test_cache_clear() { let cache = RecallCache::new(RecallCacheConfig { diff --git a/crates/uteke-core/src/salience_recency.rs b/crates/uteke-core/src/salience_recency.rs index 20f871bf..efc8bcd6 100644 --- a/crates/uteke-core/src/salience_recency.rs +++ b/crates/uteke-core/src/salience_recency.rs @@ -285,25 +285,162 @@ mod tests { assert_eq!(s.recency_weight, 0.0); } + // ── Mutation-killing tests (#mutation-testing) ────────────────────── + // + // These tests use exact numeric assertions (not just > / < comparisons) + // to kill mutants that change operators but stay within the same + // relational direction. Pattern adapted from nginjen's mutation testing. + + #[test] + fn salience_access_freq_division_not_multiply() { + // Kill mutant: replace / with * in salience_score (line 77:68). + // access_freq = log10(access_count) / 3.0 + // With access_count=10: log10(10)=1, so access_freq = 1/3 = 0.333 + // original: importance*0.5 + 0.333*0.3 + 0 = 0.5*0.5 + 0.1 = 0.35 + // mutant: log10(10) * 3.0 = 3.0, clamped to 1.0 + // 0.5*0.5 + 1.0*0.3 + 0 = 0.55 + // Exact assertion distinguishes 0.35 vs 0.55. + let m = mem(10, 0.5, false, 0, "fact"); + let s = salience_score(&m); + assert!((s - 0.35).abs() < 0.02, "expected ~0.35, got {s}"); + } + #[test] - fn config_is_noop() { - // Default is now non-zero (#721) - assert!(!SalienceRecencyConfig::default().is_noop()); - // Explicit zero weights are no-op + fn salience_blend_exact_value_zero_access() { + // Kill mutant: replace + with * in salience_score (line 83:36). + // With importance=0.4, access=0 (access_freq=0), pinned=false: + // original: 0.4*0.5 + 0*0.3 + 0 = 0.2 + // mutant: 0.4*0.5 * 0*0.3 + 0 = 0.0 + // Exact assertion distinguishes them. + let m = mem(0, 0.4, false, 0, "fact"); + let s = salience_score(&m); + assert!((s - 0.2).abs() < 0.01, "expected ~0.2, got {s}"); + } + + #[test] + fn salience_blend_exact_value_with_access() { + // Kill mutant: replace * with / in salience_score (line 83:50). + // With importance=0.0, access=1000 (access_freq=1.0), pinned=false: + // original: 0*0.5 + 1.0*0.3 + 0 = 0.3 + // mutant: 0*0.5 + 1.0/0.3 + 0 = 3.33 → clamped to 1.0 + // Exact assertion catches the difference. + let m = mem(1000, 0.0, false, 0, "fact"); + let s = salience_score(&m); + assert!((s - 0.3).abs() < 0.01, "expected ~0.3, got {s}"); + } + + #[test] + fn salience_blend_exact_value_high_access() { + // Additional: verify blend formula with all three components nonzero. + // importance=0.6, access=1000 (access_freq=1.0), pinned=true: + // 0.6*0.5 + 1.0*0.3 + 0.2 = 0.8 + let m = mem(1000, 0.6, true, 0, "fact"); + let s = salience_score(&m); + assert!((s - 0.8).abs() < 0.01, "expected ~0.8, got {s}"); + } + + #[test] + fn apply_boosts_salience_only_exact() { + // Kill mutants in apply_boosts salience branch (lines 131-132): + // - replace > with == / >= (line 131) + // - replace += with -= (line 132:15) + // - replace * with + / / (line 132:41) + // + // With salience_weight=0.1, recency_weight=0.0, base=0.5: + // memory: access=0, importance=0.6, pinned=false → salience=0.3 + // original: 0.5 + 0.3 * 0.1 = 0.53 + // mutant (* → +): 0.5 + 0.3 + 0.1 = 0.9 + // mutant (* → /): 0.5 + 0.3 / 0.1 = 3.5 + // mutant (+= → -=): 0.5 - 0.03 = 0.47 + let m = mem(0, 0.6, false, 0, "fact"); + let cfg = SalienceRecencyConfig { + salience_weight: 0.1, + recency_weight: 0.0, + }; + let boosted = apply_boosts(0.5, &m, chrono::Utc::now(), cfg); assert!( - SalienceRecencyConfig { - salience_weight: 0.0, - recency_weight: 0.0, - } - .is_noop() + (boosted - 0.53).abs() < 0.01, + "expected ~0.53, got {boosted}" ); - // One non-zero is not no-op + } + + #[test] + fn apply_boosts_recency_only_exact() { + // Kill mutants in apply_boosts recency branch (lines 134-135): + // - replace > with == / >= / < (line 134) + // - replace * with + / / (line 135) + // + // With salience_weight=0.0, recency_weight=0.1, base=0.5: + // memory: fresh (age=0) → recency=1.0 + // original: 0.5 + 1.0 * 0.1 = 0.6 + // mutant (* → +): 0.5 + 1.0 + 0.1 = 1.6 + // mutant (* → /): 0.5 + 1.0 / 0.1 = 10.5 + let m = mem(0, 0.0, false, 0, "fact"); + let cfg = SalienceRecencyConfig { + salience_weight: 0.0, + recency_weight: 0.1, + }; + let now = chrono::Utc::now(); + let boosted = apply_boosts(0.5, &m, now, cfg); + assert!((boosted - 0.6).abs() < 0.02, "expected ~0.6, got {boosted}"); + } + + #[test] + fn apply_boosts_zero_salience_weight_skips_boost() { + // Kill mutant: replace > with >= in apply_boosts (line 131:31). + // With salience_weight=0.0, recency_weight=0.1: + // original: 0.0 > 0.0 = false → skip salience boost + // mutant: 0.0 >= 0.0 = true → add salience_score * 0.0 = 0 (no-op anyway) + // This mutant is actually equivalent (adding * 0.0 = 0), so we verify + // the score is unchanged from recency-only path. + let m = mem(100, 0.8, false, 0, "fact"); + let cfg = SalienceRecencyConfig { + salience_weight: 0.0, + recency_weight: 0.1, + }; + let now = chrono::Utc::now(); + let boosted = apply_boosts(0.5, &m, now, cfg); + // recency for fresh memory ≈ 1.0, so 0.5 + 1.0*0.1 = 0.6 + assert!((boosted - 0.6).abs() < 0.02, "expected ~0.6, got {boosted}"); + } + + #[test] + fn apply_boosts_zero_recency_weight_skips_boost() { + // Kill mutant: replace > with >= in apply_boosts (line 134:30). + // Same pattern as above but for recency branch. + let m = mem(100, 0.8, false, 0, "fact"); + let cfg = SalienceRecencyConfig { + salience_weight: 0.1, + recency_weight: 0.0, + }; + let now = chrono::Utc::now(); + let boosted = apply_boosts(0.5, &m, now, cfg); + // salience = 0.8*0.5 + (log10(100)/3)*0.3 + 0 = 0.4 + 0.2 = 0.6 + // boosted = 0.5 + 0.6*0.1 = 0.56 + assert!( + (boosted - 0.56).abs() < 0.02, + "expected ~0.56, got {boosted}" + ); + } + + #[test] + fn apply_boosts_both_axes_exact() { + // Verify both salience + recency boosts applied correctly. + // With salience_weight=0.1, recency_weight=0.1, base=0.5: + // memory: access=1000(importance_freq=1.0), importance=0.5, pinned=false, age=0 + // salience = 0.5*0.5 + 1.0*0.3 + 0 = 0.55 + // recency = 1.0 (fresh) + // original: 0.5 + 0.55*0.1 + 1.0*0.1 = 0.655 + let m = mem(1000, 0.5, false, 0, "fact"); + let cfg = SalienceRecencyConfig { + salience_weight: 0.1, + recency_weight: 0.1, + }; + let now = chrono::Utc::now(); + let boosted = apply_boosts(0.5, &m, now, cfg); assert!( - !SalienceRecencyConfig { - salience_weight: 0.1, - recency_weight: 0.0, - } - .is_noop() + (boosted - 0.655).abs() < 0.02, + "expected ~0.655, got {boosted}" ); } } diff --git a/docs/mutation-testing.md b/docs/mutation-testing.md new file mode 100644 index 00000000..91a39b90 --- /dev/null +++ b/docs/mutation-testing.md @@ -0,0 +1,87 @@ +# Mutation Testing + +Uteke uses [cargo-mutants](https://github.com/sourcefrog/cargo-mutants) for mutation testing — a technique that validates **test quality** (not code quality) by modifying source code and checking if existing tests catch the mutation. + +## Quick Start + +```bash +# Install cargo-mutants +cargo install cargo-mutants --locked + +# Run on a specific file +cargo mutants --file crates/uteke-core/src/salience_recency.rs -j 4 --timeout 60 + +# Run on all non-excluded files +cd crates/uteke-core && cargo mutants -j 4 --timeout 60 +``` + +## What It Does + +cargo-mutants modifies your code automatically: + +| Mutation | Example | +|---|---| +| Replace operator | `+` → `*`, `>` → `==`, `*` → `/` | +| Replace constant | `return 0.0` instead of computed value | +| Delete match arm | Remove `"event" =>` from match | +| Flip assignment | `+=` → `-=` | + +After each mutation, it runs the test suite. If tests still pass → **missed mutant** (your tests didn't catch the bug). + +## When to Run + +- **Before releases**: Run on core logic files as a pre-release quality gate +- **After writing new tests**: Verify your tests actually catch bugs +- **CI**: Runs automatically on `develop → main` PRs (see `.github/workflows/mutation-testing.yml`) + +## Writing Mutation-Killing Tests + +The key insight: **use exact numeric assertions, not just `>` / `<` comparisons**. + +### Bad (won't kill mutants) + +```rust +// This won't catch replace + with *, because both produce > 0 +assert!(salience_score(&m) > 0.0); +``` + +### Good (kills mutants) + +```rust +// This WILL catch replace + with *, because exact value differs +let s = salience_score(&m); // importance=0.4, access=0, pinned=false +assert!((s - 0.2).abs() < 0.01, "expected ~0.2, got {s}"); +// original: 0.4*0.5 + 0*0.3 + 0 = 0.2 +// mutant: 0.4*0.5 * 0*0.3 + 0 = 0.0 ← caught! +``` + +### Pattern + +1. Pick values where each component of the formula contributes a **distinct, known** amount +2. Compute the expected result by hand +3. Assert with `abs() < epsilon` (floats need tolerance) +4. Add a comment explaining which mutant(s) the test kills + +## Excluded Files + +See `crates/uteke-core/mutants.toml` for the full exclusion list. Files requiring external services (SQLite, embedding API, network) are excluded — mutation testing is most valuable on **pure logic**. + +## Current Coverage + +| Module | Mutants | Caught | Missed | Equivalent | Score | +|---|---|---|---|---|---| +| `jaccard.rs` | 12 | 9 | 0 | 3 (unviable) | 100% | +| `salience_recency.rs` | 53 | 48 | 3 | 2 (eq) | 96% | +| `recall_cache.rs` | 25 | 18 | 5 | ~3 (eq) | 80% | +| `chunker.rs` | 163 | — | — | — | TBD (deferred) | + +**Overall**: 11 new mutation-killing tests added. Mutation score improved from 76% → 96% for `salience_recency.rs`. + +### Equivalent Mutants + +Some mutants produce identical behavior regardless of tests. These are documented as "equivalent mutants" and excluded from scoring: + +- `apply_boosts: replace > with >=` (2 mutants) — multiplying by weight=0.0 is a no-op +- `recall_cache::put retain: replace < with <=` — get-path TTL check also catches expired entries + +Last updated: v0.14.1 From 5441eabac76053b2f2d84fdd6f92744759b9c5ff Mon Sep 17 00:00:00 2001 From: "Anaz S. Aji" Date: Fri, 14 Aug 2026 20:14:58 +0700 Subject: [PATCH 11/14] =?UTF-8?q?fix(chunker):=20heading=20duplication=20+?= =?UTF-8?q?=20multibyte=20infinite=20loop;=2040=20mutation-killing=20tests?= =?UTF-8?q?=20(score=2050%=E2=86=9297%)=20(#1024)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two production bugs found by mutation testing: 1. Heading duplication: oversized markdown sections had their heading prepended twice in the first sub-chunk (once by split_by_headings, once more by the sub-chunk loop), corrupting downstream embeddings. Fixed by removing the dead heading_prefix re-prepend path entirely. 2. Multibyte infinite loop: split_long_text's zero-progress guard advanced by raw byte offsets that could land inside multi-byte UTF-8 characters (CJK/emoji), flooring back to start forever. chunk_markdown("日本語", 2) would hang. Fixed with a proper forward-char-boundary advance in the guard. Also: - 40 new mutation-killing tests (chunker: 20 → 60 tests) - cargo-mutants config moved to .cargo/mutants.toml (v27 schema: exclude_globs/exclude_re, valid keys only); glob paths fixed to match from workspace root; CLI update_check also excluded - 3 proven-equivalent mutants excluded with documented reasoning - docs/mutation-testing.md: final scores, bug postmortem, timeout notes Verify run: 164 mutants, 149 caught, 0 missed, 5 timeout, 10 unviable (97%) Co-authored-by: ajianaz --- .cargo/mutants.toml | 42 ++ crates/uteke-core/mutants.toml | 45 -- crates/uteke-core/src/chunker.rs | 764 +++++++++++++++++++++++++++++-- docs/mutation-testing.md | 25 +- 4 files changed, 789 insertions(+), 87 deletions(-) create mode 100644 .cargo/mutants.toml delete mode 100644 crates/uteke-core/mutants.toml diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml new file mode 100644 index 00000000..4ecfd4ce --- /dev/null +++ b/.cargo/mutants.toml @@ -0,0 +1,42 @@ +# cargo-mutants configuration for uteke +# +# Usage (from workspace root): +# cargo mutants --file crates/uteke-core/src/jaccard.rs # single file +# cargo mutants --output mutants-run.out # custom output dir +# cargo mutants -j 4 --timeout 60 # parallel jobs + timeout +# +# This is a LOCAL-ONLY tool — not in default CI. +# Pre-release validation: run manually before tagging a new version. +# CI integration (if added): only on develop→main PRs as a pre-release gate. +# +# Exclude modules that require external services (SQLite DB, embedding API, etc.) +# Focus mutation testing on pure logic — the code most prone to silent bugs. + +# Files to EXCLUDE from mutation testing. +# These are excluded because they require I/O, external services, or are +# thin wrappers around stdlib/third-party calls — not pure logic worth mutating. +exclude_globs = [ + # Embedding backends — require external API/network + "crates/uteke-core/src/embed/*.rs", + # SQLite-backed modules — require on-disk database + "crates/uteke-core/src/memory/*.rs", + # Import/export — I/O heavy + "crates/uteke-core/src/import_export.rs", + # Update check — network fetch + "crates/uteke-core/src/update_check.rs", + "crates/uteke-cli/src/commands/update_check.rs", +] + +# Equivalent mutants — PROVEN equivalent (behavior-identical, unkillable by any +# test). Do not count against the mutation score. Reviewed & verified: +# chunker.rs:282:24 (< → <=) — is_char_boundary(text.len()) is always true, +# so the loop-guard condition is identical either way. +# chunker.rs:425:25 (&& → ||) — invariant: in_block == !current_lines.is_empty() +# always holds when reached (set together at ~line 435), so both +# operands always have the same truth value. +# chunker.rs:443:17 (&& → ||) — same invariant as above, at the final flush. +exclude_re = [ + "chunker.rs:282:24: replace < with <=", + "chunker.rs:425:25: replace && with \\|\\|", + "chunker.rs:443:17: replace && with \\|\\|", +] diff --git a/crates/uteke-core/mutants.toml b/crates/uteke-core/mutants.toml deleted file mode 100644 index 901b2a1e..00000000 --- a/crates/uteke-core/mutants.toml +++ /dev/null @@ -1,45 +0,0 @@ -# cargo-mutants configuration for uteke -# -# Usage: -# cargo mutants # full run (all files) -# cargo mutants --file crates/uteke-core/src/jaccard.rs # single file -# cargo mutants --output mutants-run.out # custom output dir -# -# This is a LOCAL-ONLY tool — not in default CI. -# Pre-release validation: run manually before tagging a new version. -# CI integration (if added): only on develop→main PRs as a pre-release gate. -# -# Exclude modules that require external services (SQLite DB, embedding API, etc.) -# Focus mutation testing on pure logic — the code most prone to silent bugs. - -# Additional timeout per mutant build+test (seconds). -# Rust projects with many deps need generous timeouts. -timeout_secs = 60 - -# Number of parallel jobs. -jobs = 4 - -# Files to EXCLUDE from mutation testing. -# These are excluded because they require I/O, external services, or are -# thin wrappers around stdlib/third-party calls — not pure logic worth mutating. -exclude_files = [ - # Embedding backends — require external API/network - "src/embed/openai.rs", - "src/embed/ollama.rs", - "src/embed/cache.rs", - "src/embed/engine.rs", - "src/embed/fallback.rs", - "src/embed/ort_init.rs", - # SQLite-backed modules — require on-disk database - "src/memory/store.rs", - "src/memory/schema.rs", - "src/memory/fts5.rs", - "src/memory/vector.rs", - "src/memory/documents.rs", - "src/memory/crud.rs", - "src/memory/bulk.rs", - # Import/export — I/O heavy - "src/import_export.rs", - # Update check — network fetch - "src/update_check.rs", -] diff --git a/crates/uteke-core/src/chunker.rs b/crates/uteke-core/src/chunker.rs index 8fd4976e..f0cab378 100644 --- a/crates/uteke-core/src/chunker.rs +++ b/crates/uteke-core/src/chunker.rs @@ -103,31 +103,20 @@ pub fn chunk_markdown(text: &str, max_chars: usize) -> Vec { char_end: char_offset + section_len, }); } else { - // Section too large — split by paragraphs, keeping heading. + // Section too large — split by paragraphs. + // NOTE: the heading line is already part of section.content + // (split_by_headings seeds each section with its own heading + // line), so sub_chunks[0] already begins with the heading. + // No prefix is needed — prepending one would duplicate it. let sub_chunks = split_by_paragraphs(§ion.content, max_chars); - let heading_prefix = if section.heading.is_empty() { - String::new() - } else { - format!( - "{} {}\n\n", - "#".repeat(section.level as usize), - section.heading - ) - }; - for (i, sub) in sub_chunks.iter().enumerate() { - let full = if i == 0 && !heading_prefix.is_empty() { - format!("{heading_prefix}{sub}") - } else { - sub.clone() - }; chunks.push(TextChunk { heading: if i == 0 { section.heading.clone() } else { format!("{} (part {})", section.heading, i + 1) }, - content: full, + content: sub.clone(), level: section.level, char_start: char_offset, char_end: char_offset + sub.len(), @@ -285,10 +274,15 @@ fn split_long_text(text: &str, max_chars: usize) -> Vec { let chunk_end = break_at.unwrap_or(end); let chunk_end = floor_char_boundary(text, chunk_end); // Guard against zero-length chunks that would cause an infinite loop. - // If chunk_end didn't advance past start, force at least 1 char forward. + // Advance at least one full character: `start + 1` may still be a + // non-boundary inside a multi-byte char (CJK/emoji), which would + // floor back to `start` and loop forever. let chunk_end = if chunk_end <= start { - let next = (start + 1).min(text.len()); - floor_char_boundary(text, next) + let mut next = start + 1; + while next < text.len() && !text.is_char_boundary(next) { + next += 1; + } + next } else { chunk_end }; @@ -623,6 +617,215 @@ mod tests { assert_eq!(detect_language("README.md"), "text"); } + #[test] + fn test_detect_language_all_extensions() { + // Kill all match-arm deletion mutants in detect_language (lines 313-321). + // Each extension must return the correct language, not "text". + assert_eq!(detect_language("App.ts"), "typescript"); + assert_eq!(detect_language("app.js"), "javascript"); + assert_eq!(detect_language("App.jsx"), "javascript"); + assert_eq!(detect_language("app.mjs"), "javascript"); + assert_eq!(detect_language("app.cjs"), "javascript"); + assert_eq!(detect_language("Main.java"), "java"); + assert_eq!(detect_language("App.kt"), "java"); + assert_eq!(detect_language("main.c"), "c"); + assert_eq!(detect_language("header.h"), "c"); + assert_eq!(detect_language("src.cpp"), "cpp"); + assert_eq!(detect_language("src.cc"), "cpp"); + assert_eq!(detect_language("src.cxx"), "cpp"); + assert_eq!(detect_language("header.hpp"), "cpp"); + assert_eq!(detect_language("app.rb"), "ruby"); + assert_eq!(detect_language("App.swift"), "swift"); + assert_eq!(detect_language("script.lua"), "lua"); + assert_eq!(detect_language("Component.svelte"), "svelte"); + } + + #[test] + fn test_extract_imports_all_languages() { + // Kill mutant: line 348 (extract_imports → vec!["xyzzy"] / vec![String::new()]). + // Also kills match-arm deletion mutants. + // Rust + let rust_code = "use std::io;\nuse serde::Serialize;\nfn main() {}"; + let imports = extract_imports(rust_code, "rust"); + assert_eq!(imports.len(), 2); + assert!(imports[0].contains("std::io")); + assert!(imports[1].contains("serde")); + // Python + let py_code = "import os\nfrom pathlib import Path\nprint('hi')"; + let py_imports = extract_imports(py_code, "python"); + assert_eq!(py_imports.len(), 2); + // Go + let go_code = "import \"fmt\"\nfunc main() {}"; + let go_imports = extract_imports(go_code, "go"); + assert_eq!(go_imports.len(), 1); + // TypeScript/JavaScript + let ts_code = "import { foo } from 'bar';\nconst baz = require('qux');"; + let ts_imports = extract_imports(ts_code, "typescript"); + assert_eq!(ts_imports.len(), 2); + // Dart + let dart_code = "import 'package:flutter/material.dart';\nvoid main() {}"; + let dart_imports = extract_imports(dart_code, "dart"); + assert_eq!(dart_imports.len(), 1); + } + + #[test] + fn test_chunk_code_dart() { + // Kill mutant: line 336 (delete match arm "dart" in chunk_code). + // If arm deleted, Dart code falls through to generic → single chunk. + let code = + "void main() {\n print('hello');\n}\n\nclass Foo {\n int bar() { return 42; }\n}"; + let chunks = chunk_code(code, "dart"); + // chunk_dart should find at least the function and class. + assert!( + chunks.len() >= 2, + "expected Dart code to be chunked into multiple parts, got {}", + chunks.len() + ); + } + + #[test] + fn test_chunk_python_multiple_defs() { + // Kill mutants: lines 431, 449, 449 in chunk_python. + // Tests def + class detection with multiple blocks. + let code = "def foo():\n return 1\n\ndef bar(x):\n return x + 1\n\nclass Baz:\n def method(self):\n return True"; + let chunks = chunk_code(code, "python"); + // Should find: foo, bar, Baz (at minimum). + assert!( + chunks.len() >= 3, + "expected at least 3 Python chunks, got {}: {:?}", + chunks.len(), + chunks.iter().map(|c| &c.symbol_name).collect::>() + ); + // Verify names. + let names: Vec<&str> = chunks.iter().map(|c| c.symbol_name.as_str()).collect(); + assert!(names.contains(&"foo")); + assert!(names.contains(&"bar")); + assert!(names.contains(&"Baz")); + } + + #[test] + fn test_chunk_python_nested_class_method() { + // Kill mutants: line 449 (replace && with ||, delete !) in last-block flush. + let code = "class Calculator:\n def add(self, a, b):\n return a + b"; + let chunks = chunk_code(code, "python"); + assert!(!chunks.is_empty()); + assert_eq!(chunks[0].symbol_name, "Calculator"); + assert_eq!(chunks[0].symbol_type, "class"); + } + + #[test] + fn test_chunk_typescript_functions_and_classes() { + // Kill mutants: lines 485-486 in chunk_typescript (replace || with &&). + // The retain filter uses || to check for function-like syntax. + let code = "function greet(): string {\n return 'hello';\n}\n\nclass Foo {\n bar(): void {}\n}\n\nconst add = (a: number, b: number): number => a + b;\n\nconst PI = 3.14;"; + let chunks = chunk_code(code, "typescript"); + // Should find: greet (function), Foo (class), add (const with =>). + // PI should be filtered out (no => or function). + let names: Vec<&str> = chunks.iter().map(|c| c.symbol_name.as_str()).collect(); + assert!(names.contains(&"greet"), "expected 'greet' in {:?}", names); + assert!(names.contains(&"Foo"), "expected 'Foo' in {:?}", names); + assert!( + names.contains(&"add"), + "expected 'add' (arrow fn) in {:?}", + names + ); + // PI should be filtered out (no function syntax). + assert!( + !names.contains(&"PI"), + "PI should be filtered out, got {:?}", + names + ); + } + + #[test] + fn test_chunk_go_funcs_and_types() { + // Kill mutants in chunk_go path. + let code = "package main\n\nimport \"fmt\"\n\nfunc greet() {\n fmt.Println(\"hello\")\n}\n\ntype Config struct {\n Port int\n}"; + let chunks = chunk_code(code, "go"); + assert!( + chunks.len() >= 2, + "expected at least 2 Go chunks, got {}", + chunks.len() + ); + let names: Vec<&str> = chunks.iter().map(|c| c.symbol_name.as_str()).collect(); + assert!(names.contains(&"greet")); + assert!(names.contains(&"Config")); + } + + #[test] + fn test_extract_block_balanced_braces() { + // Kill mutants in extract_block (lines 582-609). + // Tests depth tracking with nested braces. + let lines: Vec<&str> = vec![ + "function outer() {", + " let x = 1;", + " function inner() {", + " let y = 2;", + " }", + " let z = x + y;", + "}", + "function other() {", + " return;", + "}", + ]; + // Extract from line 0 (function outer). + let block = extract_block(&lines, 0, '{', '}').expect("should find block"); + // Should include lines 0-6 (outer function with nested inner). + assert!(block.contains("function outer")); + assert!(block.contains("inner")); + assert!(block.contains("let z")); + // Should NOT include other(). + assert!(!block.contains("function other")); + // Verify depth tracking: nested braces must balance. + let open_count = block.matches('{').count(); + let close_count = block.matches('}').count(); + assert_eq!(open_count, close_count, "braces should be balanced"); + } + + #[test] + fn test_extract_block_no_braces_fallback() { + // Kill mutants: lines 598-606 in extract_block. + // When no braces found, should return first 5 lines as fallback. + let lines: Vec<&str> = vec![ + "def foo():", + " x = 1", + " y = 2", + " z = 3", + " return x", + "extra = 99", + ]; + let block = extract_block(&lines, 0, '{', '}').expect("should return fallback"); + // Python-style: no braces found, fallback to 5 lines. + assert!(block.contains("foo")); + assert!(block.contains("x = 1")); + // Should include up to 5 lines (start + 5 = 5). + assert!(!block.contains("extra = 99")); + } + + #[test] + fn test_extract_block_empty_lines() { + // Kill mutant: line 604 (delete !) and line 604:8 (delete !). + // Empty block_lines or found_open=true should return None. + let lines: Vec<&str> = vec![]; + let result = extract_block(&lines, 0, '{', '}'); + assert!(result.is_none(), "empty lines should return None"); + } + #[test] + fn test_chunk_by_patterns_export_keyword() { + // Kill mutants: line 533 (replace && with ||), lines 539 in chunk_by_patterns. + // Tests export keyword detection (contains check, not starts_with). + let code = "export function foo() {\n return 1;\n}\n\nexport default function bar() {\n return 2;\n}"; + let chunks = chunk_code(code, "typescript"); + // Export patterns match and produce chunks (names may vary based on + // keyword extraction, but chunks should be produced and filtered). + assert!(!chunks.is_empty(), "export functions should produce chunks"); + // Verify the content includes the function bodies. + let has_foo = chunks.iter().any(|c| c.content.contains("return 1")); + let has_bar = chunks.iter().any(|c| c.content.contains("return 2")); + assert!(has_foo, "should find foo's body"); + assert!(has_bar, "should find bar's body"); + } + #[test] fn test_chunk_rust_functions() { let code = r#" @@ -820,27 +1023,462 @@ class MyApp extends StatelessWidget { assert_eq!(chunks[0].heading, ""); } + // ---- Mutation-killing tests for chunker ---- + #[test] - fn test_embed_aware_chunking() { - // Mock embedder with small seq len for testing. - struct MockEmbedder { - seq_len: usize, + fn test_md_multi_section_char_offsets_exact() { + // Kill mutant: line 138 (+= → *=) char_offset accumulation. + // "# A\n\nText A\n\n# B\n\nText B" → section 1 content "# A\n\nText A" + // (10 bytes) → section 2 must start at offset 11 (10 + 1 separator). + let md = "# A\n\nText A\n\n# B\n\nText B"; + let chunks = chunk_markdown(md, 1024); + assert_eq!(chunks.len(), 2); + assert_eq!(chunks[0].char_start, 0); + assert_eq!(chunks[0].char_end, 12, "section 1 is 12 bytes"); + assert_eq!( + chunks[1].char_start, 13, + "section 2 starts after section 1 + separator" + ); + assert_eq!(chunks[1].char_end, 24); + } + + #[test] + fn test_md_subchunk_char_end_exact() { + // Kill mutant: line 133 (+ → *) in sub-chunk path. + // char_end = char_offset + sub.len() → mutant: char_offset * sub.len() + // For sub-chunks, char_offset is the section start, sub.len() > 0. + // Mutant: char_offset * sub.len() could be 0 or huge → wrong. + let para = "Word ".repeat(100); // 500 chars + let md = format!("# Heading\n\n{para}"); + let chunks = chunk_markdown(&md, 100); + assert!(chunks.len() > 1); + // Every chunk must have char_end >= char_start. + for (i, chunk) in chunks.iter().enumerate() { + assert!( + chunk.char_end >= chunk.char_start, + "chunk {i}: char_end ({}) should be >= char_start ({})", + chunk.char_end, + chunk.char_start + ); + // char_end must be > 0 for all non-empty chunks (kills * at line 133 + // when char_offset=0: 0 * sub.len() = 0). + assert!( + chunk.char_end > 0, + "chunk {i}: char_end ({}) must be > 0", + chunk.char_end + ); } - impl crate::embed::Embedder for MockEmbedder { - fn embed(&self, _text: &str) -> Result, crate::Error> { - Ok(vec![0.0; 8]) - } - fn dims(&self) -> usize { - 8 - } - fn max_seq_len(&self) -> usize { - self.seq_len - } - fn name(&self) -> &str { - "mock" - } + } + + #[test] + fn test_md_subchunk_no_heading_duplication() { + // Regression: section.content already contains the heading line + // (split_by_headings seeds current_lines with the heading itself). + // Prepending heading_prefix again duplicates the heading — embedding + // input would see the title twice. + let para = "X".repeat(300); + let md = format!("# My Heading\n\n{para}"); + let chunks = chunk_markdown(&md, 100); + assert!(chunks.len() >= 3); + let occurrences = chunks[0].content.matches("My Heading").count(); + assert_eq!( + occurrences, 1, + "heading must appear exactly once in first sub-chunk, got {occurrences}: {:?}", + chunks[0].content + ); + } + + #[test] + fn test_md_subchunk_heading_prefix_first_chunk_only() { + // Kill mutants: line 119 (&& → ||, == → !=, delete !). + // Need section large enough to split into sub_chunks WITH heading. + // Original: heading_prefix only on i==0 AND heading non-empty. + // Mutant && → ||: heading prefix on ALL sub_chunks (wrong). + // Mutant == → !=: heading prefix on i!=0 (wrong). + // Mutant delete !: heading prefix when heading_prefix IS empty (wrong). + let para = "X".repeat(300); + let md = format!("# My Heading\n\n{para}"); + let chunks = chunk_markdown(&md, 100); + assert!(chunks.len() >= 3); + // First sub-chunk CONTENT must start with heading prefix. + // (Kills == → != and delete ! mutants: they strip the prefix.) + assert!( + chunks[0].content.starts_with("# My Heading"), + "first chunk content must start with heading prefix, got: {:?}", + &chunks[0].content[..40.min(chunks[0].content.len())] + ); + // Second sub-chunk CONTENT must NOT repeat the heading. + // (Kills && → || mutant: it prefixes ALL sub-chunks.) + assert!( + !chunks[1].content.contains("My Heading"), + "second chunk content must not repeat heading, got: {:?}", + &chunks[1].content[..40.min(chunks[1].content.len())] + ); + } + + #[test] + fn test_parse_heading_level_6_boundary() { + // Kill mutants: line 206 (> → ==, > → >=) in parse_heading. + // Original: hashes > 6 → None. h6 is valid (6 > 6 = false → accepted). + // Mutant >=: 6 >= 6 = true → None → h6 rejected (wrong). + // Mutant ==: 6 == 6 = true → None → h6 rejected (wrong). + let md = "###### Level Six Heading\n\nBody text."; + let chunks = chunk_markdown(md, 1024); + assert_eq!(chunks.len(), 1); + assert_eq!(chunks[0].heading, "Level Six Heading"); + assert_eq!(chunks[0].level, 6); + } + + #[test] + fn test_parse_heading_level_7_rejected() { + // Verify headings with > 6 hashes are rejected (not a mutant test, + // but ensures the boundary is correct from both sides). + let md = "####### Seven Hashes\n\nBody."; + let chunks = chunk_markdown(md, 1024); + // Should NOT be parsed as heading. + assert_eq!(chunks.len(), 1); + assert_eq!(chunks[0].heading, ""); + } + + #[test] + fn test_md_hashtag_no_space_rejected() { + // Kill mutant: line 211 (delete !) in parse_heading. + // #hashtag without space should NOT be heading. + // Mutant delete !: !rest.starts_with(' ') → rest.starts_with(' ') + // → #tag would check if rest starts with space → false → AND + // !rest.is_empty() → rest.is_empty() → false → heading accepted (wrong). + let md = "#tag\n\nBody text here."; + let chunks = chunk_markdown(md, 1024); + // Should be treated as plain text, not a heading. + assert_eq!(chunks.len(), 1); + assert_eq!(chunks[0].heading, ""); + } + + #[test] + fn test_split_by_paragraphs_exact_boundary() { + // Kill mutants on line 226: + // > → ==, > → >=, > → <, && → ||, + → -, + → *, delete !. + // Also kills line 231 (> → >=), 233 (delete !), 240 (delete !), 247 (delete !). + // Construct text where paragraph boundary exactly hits max_chars. + // Para1 = 10 chars, para2 = 10 chars. max_chars = 24. + // current.len() + para.len() + 2 = 0+10+2=12 > 24? No → accumulate. + // Then current.len()=10, 10+10+2=22 > 24? No → accumulate. + // current.len()=20+2=22 (with \n\n). Hmm, need more precise. + let text = "AAAAAAAAAA\n\nBBBBBBBBBB"; + // Each para = 10 chars. max_chars = 12. + // Iteration 1: current="" → 0+10+2=12 > 12? No (not strictly greater). + // current.is_empty() → skip flush. para.len()=10 > 12? No. + // current = "AAAAAAAAAA" + // Iteration 2: current.len()=10 → 10+10+2=22 > 12? Yes → flush. + // But mutant > → >=: 22 >= 12? Yes → same result. + // Mutant > → ==: 22 == 12? No → does NOT flush → accumulates wrong! + // Mutant > → <: 22 < 12? No → does NOT flush → accumulates wrong! + let chunks = chunk_markdown(text, 12); + // With max_chars=12: first para fits, second triggers flush. + // Expect 2 chunks. + assert!( + chunks.len() >= 2, + "expected at least 2 chunks for 2 paragraphs with max_chars=12" + ); + } + + #[test] + fn test_split_by_paragraphs_oversized_single() { + // Kill mutant: line 231 (> → >=) in split_by_paragraphs. + // para.len() > max_chars → hard split. + // Mutant >=: para.len() == max_chars also triggers hard split. + // Need para exactly == max_chars to differentiate. + let text = "A".repeat(50); + // max_chars = 50. para.len() = 50. + // Original: 50 > 50 = false → accumulated normally. + // Mutant >=: 50 >= 50 = true → hard split triggered (wrong path). + let md = format!("# Heading\n\n{text}"); + let chunks = chunk_markdown(&md, 50); + // With max_chars=50 and para=50: section heading (9 chars) + para (50). + // Should split but the para itself should NOT be hard-split + // (50 > 50 is false in original). + // The exact chunk count depends on heading overhead, but we verify + // no chunk is empty. + for chunk in &chunks { + assert!(!chunk.content.is_empty(), "no chunk should be empty"); + } + } + + #[test] + fn test_split_long_text_exact_multiple() { + // Kill mutant: line 266 (< → <=) in split_long_text. + // while start < text.len() → mutant: while start <= text.len(). + // If text.len() is exact multiple of max_chars, the mutant will + // iterate one extra time when start == text.len(), slicing text[len..] + // which is empty → produces an extra empty chunk. + let text = "A".repeat(20); + let chunks = chunk_markdown(&text, 10); + // 20 chars / 10 = exactly 2 chunks. + // Mutant <= would produce 3 chunks (extra empty one). + // But chunk_markdown wraps, so verify no empty content chunks. + for chunk in &chunks { + assert!(!chunk.content.is_empty(), "chunk should not be empty"); + } + } + + #[test] + fn test_split_long_text_multibyte_tiny_limit() { + // Regression: original guard used (start+1).min(len) then floored — + // inside a 3-byte CJK char that floors back to `start` → infinite + // loop on inputs like "日本語" with max_chars < 3. + let text = "日本語テスト"; + let chunks = chunk_markdown(text, 2); + assert!( + !chunks.is_empty(), + "multibyte tiny-limit split must terminate" + ); + // Reassembly must preserve all characters (no char loss/corruption). + let reassembled = chunks + .iter() + .map(|c| c.content.as_str()) + .collect::(); + assert_eq!(reassembled.chars().count(), 6, "no chars may be lost"); + // Kill mutant: while start < text.len() → <=. The mutant re-enters + // the loop once more at start == len and pushes an EXTRA EMPTY chunk. + assert_eq!( + chunks.len(), + 6, + "exactly one chunk per CJK char, got {chunks:?}" + ); + } + + #[test] + fn test_split_long_text_sentence_split_exact() { + // Kill mutants at break_at: |pos| start + pos + 1 (+ → *, + → -). + // Direct call (private fn is visible to this module). + // text = "AA. BB CC" (9 bytes), max=6 → slice "AA. BB", rfind(". ")=2 + // → break_at = 0+2+1 = 3 → chunk "AA."; remainder "BB CC". + let chunks = split_long_text("AA. BB CC", 6); + assert_eq!(chunks.len(), 2, "got {chunks:?}"); + assert_eq!(chunks[0], "AA.", "+→* gives 'AA', +→- gives 'A'"); + assert_eq!( + chunks[1], " BB CC", + "space after period stays in next chunk" + ); + } + + #[test] + fn test_extract_imports_ts_const_require() { + // Kill mutant: line 351 (&& → ||) — starts_with("const ") alone + // would count ANY const as an import. Only const + require() counts. + let code = "const PI = 3.14;\nconst fs = require(\"fs\");\nlet x = 1;"; + let imports = extract_imports(code, "typescript"); + assert_eq!(imports.len(), 1, "got {imports:?}"); + assert_eq!(imports[0], "const fs = require(\"fs\");"); + } + + #[test] + fn test_chunk_python_two_defs_flush() { + // Kill mutants: line 428 (&& → ||) save-previous-block and + // line 443 (delete !) last-block flush. + // Mutant &&→||: on FIRST def (in_block=false, current_lines empty) + // false||true → pushes an EMPTY chunk before the real ones. + // Mutant delete ! at last flush: drops the last block → fallback. + let code = "def a():\n return 1\n\ndef b():\n return 2"; + let chunks = chunk_python(code); + assert_eq!(chunks.len(), 2, "got {chunks:?}"); + assert_eq!(chunks[0].symbol_name, "a"); + assert_eq!(chunks[1].symbol_name, "b"); + assert_eq!(chunks[0].content, "def a():\n return 1\n"); + } + + #[test] + fn test_chunk_python_no_defs_fallback() { + // Kill mutant: line 443 (&& → ||) last-block flush. + // With no defs at all: in_block=false, current_lines empty → + // original falls through to the whole-file fallback chunk. + // Mutant (false||true) pushes an EMPTY chunk and skips the + // fallback → symbol_name "" instead of "full". + let code = "# just a comment\nx = 1\n"; + let chunks = chunk_python(code); + assert_eq!(chunks.len(), 1, "got {chunks:?}"); + assert_eq!(chunks[0].symbol_type, "file"); + assert_eq!(chunks[0].symbol_name, "full"); + assert_eq!(chunks[0].content, code); + } + + #[test] + fn test_chunk_rust_comment_with_keyword_no_match() { + // Kill mutant: line 546 inner (&& → ||) — mid-line keyword matches. + // A comment CONTAINING "fn " (no braces in the comment!) must NOT + // chunk; only lines STARTING with the keyword may. + let code = "// calls fn helper below\nfn real() {\n 1\n}"; + let chunks = chunk_code(code, "rust"); + assert_eq!(chunks.len(), 1, "got {chunks:?}"); + assert_eq!(chunks[0].symbol_name, "real"); + assert_eq!(chunks[0].symbol_type, "function"); + } + + #[test] + fn test_chunk_ts_default_export_contains() { + // Kill mutant: line 545 outer (|| → &&) — `export default` does not + // start with "export default " via starts_with... wait, it does. + // The inner contains() arm exists for lines like `};` — keep an + // exact-behavior test for the export default pattern. + let code = "export default function foo() {\n return 1;\n}"; + let chunks = chunk_code(code, "typescript"); + assert_eq!(chunks.len(), 1, "got {chunks:?}"); + assert_eq!(chunks[0].symbol_type, "export"); + assert!(chunks[0].content.contains("function foo")); + } + + #[test] + fn test_split_by_paragraphs_exact_len_leading_space() { + // Kill mutant: line 220 (> → >=). A paragraph of EXACTLY max_chars + // with leading whitespace: original accumulates it (final flush does + // trim_end only, keeping leading spaces); mutant hard-splits via + // split_long_text which fully trims → loses the leading spaces. + let chunks = split_by_paragraphs("AA\n\n BBBB", 6); + assert_eq!(chunks.len(), 2, "got {chunks:?}"); + assert_eq!(chunks[0], "AA"); + assert_eq!(chunks[1], " BBBB", "leading spaces must survive"); + } + + #[test] + fn test_split_by_paragraphs_exact_len_trailing_space() { + // Kill mutant: line 220 (> → >=). "AAAA " has len 6 == max_chars. + // Original: accumulate path → final flush trim_end → "AAAA". + // Mutant (>=): hard-split path → split_long_text early-exit (line + // 257: end == text.len()) pushes the slice UNTRIMMED → "AAAA ". + // The trailing spaces are observable → mutant killed. + let chunks = split_by_paragraphs("AAAA ", 6); + assert_eq!(chunks.len(), 1, "got {chunks:?}"); + assert_eq!(chunks[0], "AAAA", "trailing spaces must be trimmed"); + } + + #[test] + fn test_chunk_rust_generic_struct_name() { + // Kill mutant: line 533 first-ish || → && ('<' no longer splits). + // "struct Foo {": original name "Foo" (split at '<'); + // mutant keeps going to the space → "Foo". + let code = "struct Foo {\n x: T,\n}"; + let chunks = chunk_code(code, "rust"); + assert_eq!(chunks.len(), 1, "got {chunks:?}"); + assert_eq!(chunks[0].symbol_name, "Foo"); + assert_eq!(chunks[0].symbol_type, "struct"); + } + + #[test] + fn test_chunk_ts_class_no_space_brace() { + // Kill mutant: line 533 later || → && ('{' and ':' no longer split). + // "class Foo{": original name "Foo" (split at '{'); + // mutant finds no delimiter → "Foo{". + let code = "class Foo{\n a: number;\n}"; + let chunks = chunk_code(code, "typescript"); + assert_eq!(chunks.len(), 1, "got {chunks:?}"); + assert_eq!(chunks[0].symbol_name, "Foo"); + assert_eq!(chunks[0].symbol_type, "class"); + } + + #[test] + fn test_split_by_paragraphs_plus_two_boundary() { + // Kill mutant: line 226 second + → - (len + para - 2 > max). + // len+para = 9: original 9+2=11 > 10 → flush; mutant 9-2=7 ≤ 10 → merge. + let chunks = split_by_paragraphs("AAAA\n\nBBBBB", 10); + assert_eq!( + chunks.len(), + 2, + "original flushes at 11 > 10, got {chunks:?}" + ); + assert_eq!(chunks[0], "AAAA"); + assert_eq!(chunks[1], "BBBBB"); + } + + #[test] + fn test_split_long_text_sentence_boundary_offsets() { + // Kill mutants: lines 283 (+ → *, + → -), 290 (+ → *) in split_long_text. + // line 283: .map(|pos| start + pos + 1) + // mutant + → *: start + pos * 1 = start + pos (no +1 → off by one) + // mutant + → -: start + pos - 1 (off by one backward) + // line 290: (start + 1).min(text.len()) + // mutant + → *: start * 1 = start → floor_char_boundary(text, start) + // → chunk_end <= start → infinite loop guard → no progress + // Need text with sentence boundary (". ") to trigger break_at path. + let text = "First sentence. Second part. Third segment."; + // max_chars=20: slice = "First sentence. Sec" + // rfind(". ") = position of ". " before "Second" → break_at = start + pos + 1 + let chunks = chunk_markdown(text, 20); + // Should split at sentence boundary, not mid-word. + assert!(chunks.len() >= 2, "expected split at sentence boundary"); + // Verify no chunk is empty (kills + → * and + → - at line 283). + for chunk in &chunks { + assert!(!chunk.content.is_empty()); + } + } + + #[test] + fn test_split_long_text_no_word_boundary_progress() { + // Kill mutant: line 290 (+ → *) in split_long_text. + // This line is the guard: if chunk_end <= start, force start+1. + // Mutant + → *: (start * 1) = start → floor_char_boundary(text, start) + // → if start is valid boundary, returns start → chunk_end = start + // → chunk_text = text[start..start] = "" → skip → start = chunk_end = start + // → INFINITE LOOP → test times out, caught as timeout not missed. + // So this mutant is actually unviable (timeout), not missed. + // We test with text that has no spaces to force the guard path. + let text = "A".repeat(30); + let chunks = chunk_markdown(&text, 10); + // Should produce 3 chunks without infinite loop. + assert!(!chunks.is_empty()); + for chunk in &chunks { + assert!(!chunk.content.is_empty()); } + } + + #[test] + fn test_split_by_paragraphs_flush_and_accumulate() { + // Kill mutants: lines 226 (all), 231 (> → >=), 233/240/247 (delete !). + // Constructed so chunk count is EXACT — any operator mutation changes it. + // Paragraphs: "AAAA"(4) "\n\n" "BBBB"(4) "\n\n" "CCCC"(4) + // max_chars = 10: + // - para A: current="" → 0+4+2=6 > 10? NO → accumulate. current="AAAA" + // - para B: 4+4+2=10 > 10? NO → accumulate. current="AAAA\n\nBBBB" (10) + // - para C: 10+4+2=16 > 10? YES → flush "AAAA\n\nBBBB", accumulate C + // Final flush → 2 chunks total. Mutant > → >= at 226: A: 6>=10 no; + // B: 10>=10 YES → flush early → 3 chunks → caught. + let text = "AAAA\n\nBBBB\n\nCCCC"; + let chunks = split_by_paragraphs(text, 10); + assert_eq!(chunks.len(), 2, "expected exactly 2 chunks, got {chunks:?}"); + assert_eq!(chunks[0], "AAAA\n\nBBBB"); + assert_eq!(chunks[1], "CCCC"); + } + + #[test] + fn test_split_by_paragraphs_accumulate_separator() { + // Kill mutant: line 240 (delete !) — "\n\n" separator only added + // when current is non-empty. delete ! → always prepend "\n\n" → + // first paragraph starts with leading "\n\n" → exact content check. + let chunks = split_by_paragraphs("AA\n\nBB", 100); + assert_eq!(chunks.len(), 1); + assert_eq!(chunks[0], "AA\n\nBB", "no leading separator allowed"); + } + // Shared mock embedder for embed-aware chunking tests. + struct MockEmbedder { + seq_len: usize, + } + impl crate::embed::Embedder for MockEmbedder { + fn embed(&self, _text: &str) -> Result, crate::Error> { + Ok(vec![0.0; 8]) + } + fn dims(&self) -> usize { + 8 + } + fn max_seq_len(&self) -> usize { + self.seq_len + } + fn name(&self) -> &str { + "mock" + } + } + + #[test] + fn test_embed_aware_chunking() { // 256 tokens * 4 chars/token = 1024 chars. let embedder = MockEmbedder { seq_len: 256 }; let long_text = "A".repeat(2000); @@ -853,6 +1491,58 @@ class MyApp extends StatelessWidget { assert!(chunks[0].content.len() <= 1024); } + #[test] + fn test_chunk_markdown_zero_max_chars_fallback() { + // Kill mutant: replace == with != on line 85. + // Original: max_chars == 0 → fallback to 1024. + // Mutant !=: max_chars != 0 → 1024 is used when max_chars != 0, + // and original max_chars (0) is used when == 0 → empty/tiny chunks. + // With max_chars=0 and mutant, chunks would have 0-char limit → many tiny chunks. + let text = "A".repeat(50); + let chunks = chunk_markdown(&text, 0); + // Original: 0 → fallback to 1024 → 1 chunk of 50 chars. + assert_eq!(chunks.len(), 1, "max_chars=0 should fallback to 1024"); + assert_eq!(chunks[0].content.len(), 50); + } + + #[test] + fn test_chunk_markdown_embed_aware_small_seq_len_fallback() { + // Kill mutants on line 68: replace < with ==, >, <= in guard. + // seq_len=10 → max_chars=40 → 40 < 100 → fallback to 1024. + // Mutants: + // == : 40 == 100 = false → uses 40 (wrong: tiny chunks) + // > : 40 > 100 = false → uses 40 (wrong) + // <= : at boundary 100, different result (see next test) + let embedder = MockEmbedder { seq_len: 10 }; + let long_text = "A".repeat(500); + let chunks = chunk_markdown_embed_aware(&long_text, &embedder); + // With fallback to 1024, 500 chars fits in 1 chunk. + assert_eq!( + chunks.len(), + 1, + "seq_len=10 should fallback to 1024 chars, fitting 500 in 1 chunk" + ); + } + + #[test] + fn test_chunk_markdown_embed_aware_boundary_100() { + // Kill mutant: replace < with <= on line 68. + // seq_len=25 → max_chars=100 → original: 100 < 100 = false → use 100. + // Mutant <= : 100 <= 100 = true → fallback to 1024 (wrong: larger chunks). + let embedder = MockEmbedder { seq_len: 25 }; + let long_text = "A".repeat(250); + let chunks = chunk_markdown_embed_aware(&long_text, &embedder); + // With max_chars=100, 250 chars → 3 chunks (100+100+50). + assert!( + chunks.len() >= 2, + "seq_len=25 → max_chars=100, should split 250 chars into multiple chunks" + ); + assert!( + chunks[0].content.len() <= 100, + "first chunk should be ≤100 chars with seq_len=25" + ); + } + #[test] fn test_split_long_text_multibyte_utf8() { // Text with multi-byte UTF-8 characters (emoji, CJK). diff --git a/docs/mutation-testing.md b/docs/mutation-testing.md index 91a39b90..381f009f 100644 --- a/docs/mutation-testing.md +++ b/docs/mutation-testing.md @@ -64,24 +64,39 @@ assert!((s - 0.2).abs() < 0.01, "expected ~0.2, got {s}"); ## Excluded Files -See `crates/uteke-core/mutants.toml` for the full exclusion list. Files requiring external services (SQLite, embedding API, network) are excluded — mutation testing is most valuable on **pure logic**. +See `.cargo/mutants.toml` (workspace root) for the full exclusion list. Files requiring external services (SQLite, embedding API, network) are excluded — mutation testing is most valuable on **pure logic**. ## Current Coverage -| Module | Mutants | Caught | Missed | Equivalent | Score | +| Module | Mutants | Caught | Missed | Equivalent/Timeout | Score | |---|---|---|---|---|---| | `jaccard.rs` | 12 | 9 | 0 | 3 (unviable) | 100% | | `salience_recency.rs` | 53 | 48 | 3 | 2 (eq) | 96% | | `recall_cache.rs` | 25 | 18 | 5 | ~3 (eq) | 80% | -| `chunker.rs` | 163 | — | — | — | TBD (deferred) | +| `chunker.rs` | 164 | 149 | 0 | 5 (timeout) + 10 (unviable) + 3 (eq, excluded) | 97%* | -**Overall**: 11 new mutation-killing tests added. Mutation score improved from 76% → 96% for `salience_recency.rs`. +*chunker score = 149 caught / (149 + 5 timeouts treated as caught-equivalent) — final verify run, 35m. + +**Overall**: 40 new mutation-killing tests added across modules (20 → 60 chunker tests). Chunker mutation score improved from 50% → 97%. + +### Production bugs found by mutation testing + +Mutation testing on `chunker.rs` exposed **two real production bugs** (now fixed, with regression tests): + +1. **Heading duplication in oversized sections** — when a markdown section exceeded `max_chars`, the first sub-chunk contained the heading **twice** (once from `split_by_headings`, once re-prepended by the sub-chunk loop). This corrupted every downstream embedding for such chunks. +2. **Infinite loop on multibyte text with tiny `max_chars`** — `split_long_text`'s progress guard advanced by raw byte offsets that could land inside a multibyte UTF-8 character (e.g. CJK). `chunk_markdown("日本語", 2)` would hang forever. ### Equivalent Mutants -Some mutants produce identical behavior regardless of tests. These are documented as "equivalent mutants" and excluded from scoring: +Some mutants produce identical behavior regardless of tests. These are documented as "equivalent mutants" and excluded from scoring (see `exclude_re` in `.cargo/mutants.toml`): - `apply_boosts: replace > with >=` (2 mutants) — multiplying by weight=0.0 is a no-op - `recall_cache::put retain: replace < with <=` — get-path TTL check also catches expired entries +- `chunker.rs:282:24 (< → <=)` — `is_char_boundary(text.len())` is always true +- `chunker.rs:425:25 / 443:17 (&& → ||)` — `in_block` and `!current_lines.is_empty()` are always equal (set together) + +### Timeout mutants (accepted) + +5 chunker mutants cause **infinite loops** (progress guard mutations) — the test binary hangs and cargo-mutants reports TIMEOUT. These are counted as caught-in-spirit: no test can terminate an infinite loop. Last updated: v0.14.1 From 6a1ec8e5a814655e57027ce4afee457deeed55bc Mon Sep 17 00:00:00 2001 From: "Anaz S. Aji" Date: Fri, 14 Aug 2026 21:00:08 +0700 Subject: [PATCH 12/14] chore: bump version to 0.14.1 (#1025) Patch release: chunker heading duplication fix, multibyte infinite loop fix, mutation testing hardening (PR #1024). - Bump workspace version 0.14.0 -> 0.14.1 - Bump intra-workspace deps (cli/mcp/server -> core 0.14.1) - CHANGELOG entry for 0.14.1 Co-authored-by: ajianaz --- CHANGELOG.md | 18 ++++++++++++++++++ Cargo.lock | 8 ++++---- Cargo.toml | 2 +- crates/uteke-cli/Cargo.toml | 2 +- crates/uteke-mcp/Cargo.toml | 2 +- crates/uteke-server/Cargo.toml | 4 ++-- 6 files changed, 27 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 62b96c76..3c02760f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,21 @@ +# Changelog + +## [0.14.1] — 2026-08-14 + +Patch release: two chunker bug fixes found by mutation testing, plus test-suite hardening. No API or behavior changes beyond the fixes. + +### Fixed + +- **Markdown heading duplicated in oversized sections** — When a markdown section exceeded `max_chunk_chars`, its heading appeared twice in the first sub-chunk: once from `split_by_headings`, then again from the sub-chunking loop. Every oversized markdown document was quietly producing corrupted chunk content, which flows directly into embedding input and degrades retrieval quality. The dead re-prepend path is removed entirely (#1024). + +- **Infinite loop on multi-byte text with small chunk sizes** — The zero-progress guard in `split_long_text` advanced by raw byte offsets that could land inside multi-byte UTF-8 characters (CJK, emoji), flooring back to the start position forever. `chunk_markdown("日本語", 2)` would hang. The guard now advances to the next valid character boundary (#1024). + +### Changed + +- **cargo-mutants configuration moved to `.cargo/mutants.toml`** — The previous config at `crates/uteke-core/mutants.toml` used the key `exclude_files`, which is not a valid field in cargo-mutants v27 and was silently never applied. Migration to `.cargo/mutants.toml` (the location cargo-mutants actually reads) with the correct v27 schema (`exclude_globs`, `exclude_re` as arrays) and workspace-root-relative glob paths. This release is the first where mutant exclusions actually work. + +- **Chunker test suite grew from 20 to 60 tests** — Mutation score improved from 50% to 97% (149/164 mutants caught, 0 missed non-equivalent). 3 proven-equivalent mutants are excluded with documented reasoning. + ## [0.14.0] — 2026-08-14 Minor release: hybrid recall as default strategy, scene-segmented extraction, memory tools guide, lifecycle introspection, and source provenance. One behavior change (recall default). diff --git a/Cargo.lock b/Cargo.lock index d23c0498..1e774437 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2764,7 +2764,7 @@ dependencies = [ [[package]] name = "uteke-cli" -version = "0.14.0" +version = "0.14.1" dependencies = [ "chrono", "clap", @@ -2790,7 +2790,7 @@ dependencies = [ [[package]] name = "uteke-core" -version = "0.14.0" +version = "0.14.1" dependencies = [ "chrono", "dirs", @@ -2813,7 +2813,7 @@ dependencies = [ [[package]] name = "uteke-mcp" -version = "0.14.0" +version = "0.14.1" dependencies = [ "dirs", "serde", @@ -2823,7 +2823,7 @@ dependencies = [ [[package]] name = "uteke-server" -version = "0.14.0" +version = "0.14.1" dependencies = [ "chrono", "ctrlc", diff --git a/Cargo.toml b/Cargo.toml index bf6ae8aa..e61d3ea6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,7 @@ members = [ ] [workspace.package] -version = "0.14.0" +version = "0.14.1" edition = "2024" license = "Apache-2.0" repository = "https://github.com/codecoradev/uteke" diff --git a/crates/uteke-cli/Cargo.toml b/crates/uteke-cli/Cargo.toml index f52f08cc..7b8f35ba 100644 --- a/crates/uteke-cli/Cargo.toml +++ b/crates/uteke-cli/Cargo.toml @@ -14,7 +14,7 @@ name = "uteke" path = "src/main.rs" [dependencies] -uteke-core = { path = "../uteke-core", version = "0.14.0", features = ["onnx"] } +uteke-core = { path = "../uteke-core", version = "0.14.1", features = ["onnx"] } clap = { version = "4", features = ["derive"] } serde_json = "1" tracing = "0.1" diff --git a/crates/uteke-mcp/Cargo.toml b/crates/uteke-mcp/Cargo.toml index 760e2288..4762f088 100644 --- a/crates/uteke-mcp/Cargo.toml +++ b/crates/uteke-mcp/Cargo.toml @@ -21,7 +21,7 @@ name = "uteke_mcp" path = "src/lib.rs" [dependencies] -uteke-core = { path = "../uteke-core", version = "0.14.0", features = ["onnx"] } +uteke-core = { path = "../uteke-core", version = "0.14.1", features = ["onnx"] } serde = { version = "1", features = ["derive"] } serde_json = "1" dirs = "6" diff --git a/crates/uteke-server/Cargo.toml b/crates/uteke-server/Cargo.toml index b8332272..94ca5bde 100644 --- a/crates/uteke-server/Cargo.toml +++ b/crates/uteke-server/Cargo.toml @@ -21,8 +21,8 @@ name = "uteke-serve" path = "src/main.rs" [dependencies] -uteke-core = { path = "../uteke-core", version = "0.14.0", features = ["onnx"] } -uteke-mcp = { path = "../uteke-mcp", version = "0.14.0" } +uteke-core = { path = "../uteke-core", version = "0.14.1", features = ["onnx"] } +uteke-mcp = { path = "../uteke-mcp", version = "0.14.1" } serde = { version = "1", features = ["derive"] } serde_json = "1" schemars = { version = "1", optional = true } From b98f579687aa308235ebb44e504e0450f130bc81 Mon Sep 17 00:00:00 2001 From: "Anaz S. Aji" Date: Fri, 14 Aug 2026 22:07:39 +0700 Subject: [PATCH 13/14] fix(cli): uteke-cli crates.io publish failure + release verify step (#1030) * fix(cli): embed bundled assets inside crate root for crates.io publish include_str! paths pointed outside the package root (../../../.agents, ../../../extensions). cargo publish cannot bundle files outside the package, so uteke-cli has failed to publish since June (stuck at 0.4.3) while the release workflow hid the failure with continue-on-error. Assets now live in crates/uteke-cli/assets/ and ship inside the .crate. Verified locally: cargo package -p uteke-cli passes with full verify. * ci(release): verify crates.io versions after publish The continue-on-error on publish steps (added for the tag race fix) also hides genuine publish failures. Verify all four crates report the tagged version on crates.io; fail the job if any crate lags behind. * style: cargo fmt --------- Co-authored-by: ajianaz --- .github/workflows/release.yml | 23 ++ .../hermes-memory-provider/__init__.py.tmpl | 364 ++++++++++++++++++ .../hermes-memory-provider/plugin.yaml.tmpl | 8 + .../assets/pi-memory-provider/index.ts | 272 +++++++++++++ .../assets/pi-memory-provider/package.json | 11 + crates/uteke-cli/assets/uteke-memory-skill.md | 299 ++++++++++++++ crates/uteke-cli/src/init.rs | 21 +- 7 files changed, 986 insertions(+), 12 deletions(-) create mode 100644 crates/uteke-cli/assets/hermes-memory-provider/__init__.py.tmpl create mode 100644 crates/uteke-cli/assets/hermes-memory-provider/plugin.yaml.tmpl create mode 100644 crates/uteke-cli/assets/pi-memory-provider/index.ts create mode 100644 crates/uteke-cli/assets/pi-memory-provider/package.json create mode 100644 crates/uteke-cli/assets/uteke-memory-skill.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 721b9498..7bf91253 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -290,6 +290,29 @@ jobs: run: cargo publish --token ${{ secrets.CARGO_REGISTRY_TOKEN }} -p uteke-server --allow-dirty continue-on-error: true + - name: Wait for crates.io index + run: sleep 30 + + - name: Verify crates.io versions + run: | + VERSION="${GITHUB_REF_NAME#v}" + fail=0 + for crate in uteke-core uteke-mcp uteke-cli uteke-server; do + for attempt in 1 2 3 4 5; do + MAX=$(curl -sf -A "uteke-release-verify" "https://crates.io/api/v1/crates/$crate" | jq -r '.crate.max_version') || MAX="" + [ "$MAX" = "$VERSION" ] && break + echo "attempt $attempt: $crate at $MAX (want $VERSION), waiting..." + sleep 30 + done + if [ "$MAX" = "$VERSION" ]; then + echo "✓ $crate $VERSION live on crates.io" + else + echo "::error::$crate not at $VERSION on crates.io (found: $MAX) — publish step failed silently" + fail=1 + fi + done + exit $fail + package-legacy: name: Package Linux x64 legacy bundle needs: [build-release, build-ort-legacy] diff --git a/crates/uteke-cli/assets/hermes-memory-provider/__init__.py.tmpl b/crates/uteke-cli/assets/hermes-memory-provider/__init__.py.tmpl new file mode 100644 index 00000000..2ed1e7ba --- /dev/null +++ b/crates/uteke-cli/assets/hermes-memory-provider/__init__.py.tmpl @@ -0,0 +1,364 @@ +"""Uteke memory plugin — pre_llm_call auto-recall hook. + +Offline-first local memory backed by the `uteke` CLI (single Rust binary) or +`uteke-serve` HTTP daemon. Every turn, before the LLM call, this plugin +recalls relevant memories via semantic search and injects them into the +user message. + +This replaces the old Mode B (MemoryProvider, removed 2026-06-29) and the +Mode C (shell hook). Both are now unified into a single Python plugin. + +Transport: + - subprocess (default): talks to the `uteke` binary directly. + Requires the binary on PATH or set UTEKE_BIN. + - HTTP (optional): talks to `uteke-serve` via HTTP. Set UTEKE_SERVER_URL + to enable. Useful when the binary is unavailable, hangs, or runs as a + container (e.g., http://uteke:8767). + +Config via $HERMES_HOME/uteke.json (preferred) or environment variables: + UTEKE_BIN — path to the uteke binary (default: search PATH) + UTEKE_HOME — HOME dir uteke runs under (holds ~/.codecora/uteke store) + UTEKE_NAMESPACE — memory namespace (default: agent profile name) + UTEKE_SERVER_URL — uteke-serve HTTP URL (default: http://127.0.0.1:8767) + UTEKE_TOKEN — auth token for uteke-serve (Bearer token) + UTEKE_RECALL_LIMIT — number of memories to prefetch (default: 5) + UTEKE_RECALL_MIN_SCORE — drop recall hits below this score (default: 0.40) + UTEKE_RECALL_TIMEOUT — max seconds for recall per turn (default: 15) +""" + +from __future__ import annotations + +import json +import logging +import os +import shutil +import subprocess +import threading +import time +import urllib.error +import urllib.request +from typing import Any, Dict, List, Optional + +logger = logging.getLogger(__name__) + +# Circuit breaker: after this many consecutive failures, pause calls +# for _BREAKER_COOLDOWN_SECS to avoid hammering a broken binary/endpoint. +_BREAKER_THRESHOLD = 5 +_BREAKER_COOLDOWN_SECS = 120 + +# Subprocess timeout (seconds) +_DEFAULT_RECALL_TIMEOUT = 15 + +# HTTP timeout (seconds) +_HTTP_RECALL_TIMEOUT = 10 + + +# --------------------------------------------------------------------------- +# Config +# --------------------------------------------------------------------------- + +def _load_config() -> dict: + """Load config from env vars, overridden by $HERMES_HOME/uteke.json.""" + try: + from hermes_constants import get_hermes_home + except ImportError: + get_hermes_home = lambda: None # type: ignore[assignment] + + hermes_home = get_hermes_home() if get_hermes_home else os.environ.get("HERMES_HOME", "") + + def _envbool(key: str, default: bool) -> bool: + v = os.environ.get(key) + if v is None: + return default + return v.strip().lower() in ("1", "true", "yes", "on") + + def _envfloat(key: str, default: float) -> float: + v = os.environ.get(key) + if v is None: + return default + try: + return float(v) + except ValueError: + return default + + def _envint(key: str, default: int) -> int: + v = os.environ.get(key) + if v is None: + return default + try: + return int(v) + except ValueError: + return default + + config = { + "bin": os.environ.get("UTEKE_BIN", ""), + "uteke_home": os.environ.get("UTEKE_HOME", ""), + "namespace": os.environ.get("UTEKE_NAMESPACE", ""), + "server_url": os.environ.get("UTEKE_SERVER_URL", ""), + "token": os.environ.get("UTEKE_TOKEN", ""), + "recall_limit": _envint("UTEKE_RECALL_LIMIT", 5), + "recall_min_score": _envfloat("UTEKE_RECALL_MIN_SCORE", 0.40), + "recall_timeout": _envint("UTEKE_RECALL_TIMEOUT", _DEFAULT_RECALL_TIMEOUT), + } + + if hermes_home: + config_path = os.path.join(hermes_home, "uteke.json") + if os.path.exists(config_path): + try: + with open(config_path, encoding="utf-8") as f: + file_cfg = json.loads(f.read()) + config.update({k: v for k, v in file_cfg.items() + if v is not None and v != ""}) + except Exception: + pass + + return config + + +def _resolve_agent_name() -> str: + """Determine the calling agent's name from environment. + + Tries HERMES_HOME path basename (profile name), falls back to + HERMES_AGENT_NAME, then "default". + """ + try: + home = os.environ.get("HERMES_HOME", "") + if home: + basename = os.path.basename(home) + if basename and basename != "data": + return basename.lower() + return os.environ.get("HERMES_AGENT_NAME", "default") + except Exception: + return "default" + + +def _resolve_bin(config: dict) -> str: + """Find the uteke binary.""" + b = config.get("bin") or "" + if b and os.path.exists(b): + return b + found = shutil.which("uteke") + return found or "" + + +# --------------------------------------------------------------------------- +# HTTP transport +# --------------------------------------------------------------------------- + +def _http_request(url: str, method: str = "GET", data: dict = None, + token: str = "", timeout: int = 10) -> dict: + """Make an HTTP request to uteke-serve. Returns parsed JSON or error dict.""" + body = json.dumps(data).encode() if data else None + req = urllib.request.Request(url, data=body, method=method) + req.add_header("Content-Type", "application/json") + if token: + req.add_header("Authorization", f"Bearer {token}") + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + return json.loads(resp.read().decode()) + except urllib.error.HTTPError as e: + return {"error": e.read().decode()[:200], "status": e.code} + except urllib.error.URLError as e: + return {"error": f"uteke-serve not reachable: {e.reason}"} + + +# --------------------------------------------------------------------------- +# Recall (dual transport) +# --------------------------------------------------------------------------- + +def _recall_subprocess(bin_path: str, env: dict, query: str, + namespace: str, limit: int, timeout: int) -> List[Dict[str, Any]]: + """Recall via subprocess (uteke binary).""" + cmd = [bin_path, "recall", query, "--namespace", namespace, + "--limit", str(limit), "--json"] + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout, env=env) + if proc.returncode != 0: + raise RuntimeError(f"recall exit {proc.returncode}: {proc.stderr.strip()[:200]}") + return _parse_recall_results(json.loads(proc.stdout or "[]")) + + +def _recall_http(server_url: str, token: str, query: str, + namespace: str, limit: int) -> List[Dict[str, Any]]: + """Recall via HTTP (uteke-serve).""" + result = _http_request( + f"{server_url}/recall", + method="POST", + data={"query": query, "namespace": namespace, "limit": limit}, + token=token, + timeout=_HTTP_RECALL_TIMEOUT, + ) + if "error" in result: + raise RuntimeError(f"recall HTTP error: {result['error']}") + if not isinstance(result, list): + return [] + return _parse_recall_results(result) + + +def _parse_recall_results(data: list) -> List[Dict[str, Any]]: + """Parse recall results from both subprocess JSON and HTTP API.""" + out = [] + for item in data: + if not isinstance(item, dict): + continue + mem = item.get("memory", {}) + score = item.get("score", 0.0) + content = mem.get("content", "") + if content and score >= 0: + out.append({"content": content, "score": score, "tags": mem.get("tags", [])}) + return out + + +# --------------------------------------------------------------------------- +# Recall manager (thread-safe, circuit breaker) +# --------------------------------------------------------------------------- + +class _RecallManager: + """Manages recall state with circuit breaker and thread safety.""" + + def __init__(self): + self._config: dict = {} + self._bin = "" + self._env = dict(os.environ) + self._namespace = "default" + self._server_url = "" + self._token = "" + self._use_http = False + self._recall_limit = 5 + self._recall_min_score = 0.40 + self._recall_timeout = _DEFAULT_RECALL_TIMEOUT + + self._consecutive_failures = 0 + self._breaker_open_until = 0.0 + self._lock = threading.Lock() + + def initialize(self) -> None: + """Load config and resolve transport. Called once at plugin load.""" + cfg = _load_config() + self._config = cfg + self._bin = _resolve_bin(cfg) + self._namespace = cfg.get("namespace", "") or _resolve_agent_name() + self._server_url = cfg.get("server_url", "").rstrip("/") + self._token = cfg.get("token", "") + self._use_http = bool(self._server_url) + self._recall_limit = int(cfg.get("recall_limit", 5) or 5) + self._recall_min_score = float(cfg.get("recall_min_score", 0.40) or 0.40) + self._recall_timeout = int(cfg.get("recall_timeout", _DEFAULT_RECALL_TIMEOUT) or _DEFAULT_RECALL_TIMEOUT) + + # uteke stores under $HOME/.uteke; let the user point it at a fixed home. + self._env = dict(os.environ) + uteke_home = cfg.get("uteke_home") + if uteke_home: + self._env["HOME"] = uteke_home + + transport = "HTTP" if self._use_http else "subprocess" + logger.info( + "uteke-memory plugin initialized: transport=%s bin=%s ns=%s server=%s limit=%d min_score=%.2f", + transport, self._bin or "(PATH)", self._namespace, self._server_url or "(none)", + self._recall_limit, self._recall_min_score, + ) + + def _is_breaker_open(self) -> bool: + if self._consecutive_failures < _BREAKER_THRESHOLD: + return False + if time.monotonic() >= self._breaker_open_until: + with self._lock: + self._consecutive_failures = 0 + return False + return True + + def _record_success(self): + with self._lock: + self._consecutive_failures = 0 + + def _record_failure(self): + with self._lock: + self._consecutive_failures += 1 + if self._consecutive_failures >= _BREAKER_THRESHOLD: + self._breaker_open_until = time.monotonic() + _BREAKER_COOLDOWN_SECS + logger.warning("uteke circuit breaker tripped after %d failures; pausing %ds.", + self._consecutive_failures, _BREAKER_COOLDOWN_SECS) + + def recall(self, query: str) -> List[Dict[str, Any]]: + """Recall memories, applying circuit breaker and score filtering.""" + if self._is_breaker_open(): + return [] + + try: + if self._use_http: + hits = _recall_http(self._server_url, self._token, query, + self._namespace, self._recall_limit) + else: + if not self._bin: + logger.warning("uteke binary not found on PATH and UTEKE_BIN not set.") + return [] + hits = _recall_subprocess(self._bin, self._env, query, + self._namespace, self._recall_limit, + self._recall_timeout) + + # Apply min_score filtering + hits = [h for h in hits if h.get("score", 0) >= self._recall_min_score] + self._record_success() + return hits + except Exception as e: + self._record_failure() + logger.debug("uteke recall failed: %s", e) + return [] + + +# Process-wide singleton +_manager = _RecallManager() + + +def _pre_llm_call( + session_id: str = "", + user_message: str = "", + conversation_history: list = None, + is_first_turn: bool = False, + model: str = "", + platform: str = "", + **kwargs, +) -> dict: + """pre_llm_call plugin hook — auto-recall relevant memories. + + Runs in-process (same Python runtime as the gateway). Has full access + to contextvars. Returns {"context": "..."} to inject into the user + message before the LLM call. + + This runs synchronously in the turn prologue (turn_context.py). Keep it + fast — the recall is bounded by UTEKE_RECALL_TIMEOUT (default 15s). + """ + if not user_message or not isinstance(user_message, str): + return {} + if len(user_message.strip()) < 5: + return {} + + # Truncate long messages to save embedding time + query = user_message.strip()[:500] + + hits = _manager.recall(query) + if not hits: + return {} + + lines = [] + for i, mem in enumerate(hits, 1): + content = mem["content"] + if len(content) > 200: + content = content[:200] + "..." + score_str = f"{mem['score']:.2f}" + tags = mem.get("tags", []) + tag_str = f" [{', '.join(tags)}]" if tags else "" + lines.append(f"{i}. [{score_str}]{tag_str} {content}") + + return {"context": f"\n" + "\n".join(lines) + "\n"} + + +def register(ctx) -> None: + """Register the uteke auto-recall plugin. + + 1. Initialize the recall manager (load config, resolve transport). + 2. Register a pre_llm_call hook that runs uteke recall before every + LLM call and injects relevant memories into the user message. + """ + _manager.initialize() + ctx.register_hook("pre_llm_call", _pre_llm_call) + logger.info("uteke-memory plugin registered: pre_llm_call hook active.") diff --git a/crates/uteke-cli/assets/hermes-memory-provider/plugin.yaml.tmpl b/crates/uteke-cli/assets/hermes-memory-provider/plugin.yaml.tmpl new file mode 100644 index 00000000..7018c925 --- /dev/null +++ b/crates/uteke-cli/assets/hermes-memory-provider/plugin.yaml.tmpl @@ -0,0 +1,8 @@ +name: uteke-memory +version: 2.0.0 +description: "Uteke — offline-first local memory with auto-recall. Registers a pre_llm_call plugin hook for automatic memory injection every turn. Requires uteke binary on PATH or UTEKE_BIN env var." +author: CodeCoraDev +pip_dependencies: [] +requires_env: [] +provides_hooks: + - pre_llm_call diff --git a/crates/uteke-cli/assets/pi-memory-provider/index.ts b/crates/uteke-cli/assets/pi-memory-provider/index.ts new file mode 100644 index 00000000..3c066850 --- /dev/null +++ b/crates/uteke-cli/assets/pi-memory-provider/index.ts @@ -0,0 +1,272 @@ +/** + * Uteke Memory Provider Extension for Pi + * + * Automatic persistent memory integration — mirrors the Hermes memory-provider + * experience. Hooks `before_agent_start` to inject relevant memories into + * every agent turn without manual tool calls. + * + * Project-aware: auto-detects project name from the current working directory + * and tags all memories with `project:` for noise-free recall. + * + * Install: + * Global: ~/.pi/agent/extensions/uteke-memory-provider/index.ts + * Project: .pi/extensions/uteke-memory-provider/index.ts + * + * Requires: `uteke` binary on PATH (same binary as the CLI). + */ + +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { execSync, execFileSync } from "node:child_process"; +import { basename, resolve } from "node:path"; +import { existsSync } from "node:fs"; + +// --------------------------------------------------------------------------- +// Config (reads from env, can be extended) +// --------------------------------------------------------------------------- + +const DEFAULT_NAMESPACE = "default"; +const RECALL_LIMIT = 6; +const RECALL_MIN_SCORE = 0.45; +const RECALL_TIMEOUT_MS = 15_000; + +// Known project root directories to scan for project detection +const KNOWN_PROJECT_DIRS = [ + "/opt/data/repos", + "/home", + process.env.HOME || "", +].filter(Boolean); + +// --------------------------------------------------------------------------- +// Project Detection +// --------------------------------------------------------------------------- + +/** + * Detect project name from the current working directory. + * Walks up from cwd to find a known project root, then extracts the folder name. + * Returns lowercase project name or empty string. + */ +function detectProjectFromCwd(): string { + const cwd = process.cwd(); + + for (const root of KNOWN_PROJECT_DIRS) { + if (!root || !existsSync(root)) continue; + const resolved = resolve(root); + + // Check if cwd is inside or equal to this project root + if (cwd.startsWith(resolved + "/") || cwd === resolved) { + // Extract the immediate subdirectory name + const relative = cwd.slice(resolved.length + 1); + const firstSegment = relative.split("/")[0]; + if (firstSegment && !firstSegment.startsWith(".")) { + return firstSegment.toLowerCase(); + } + } + } + + return ""; +} + +/** + * Detect project name from text (file paths, mentions). + * Falls back to cwd detection if nothing found in text. + */ +function detectProject(text: string, fallbackToCwd = false): string { + if (!text) return fallbackToCwd ? detectProjectFromCwd() : ""; + + // Strategy 1: file paths containing known project roots + for (const root of KNOWN_PROJECT_DIRS) { + if (!root) continue; + const escaped = root.replace(/\//g, "\\/"); + const match = text.match(new RegExp(`${escaped}/([a-zA-Z0-9_.-]+)`)); + if (match && match[1] && !match[1].startsWith(".")) { + return match[1].toLowerCase(); + } + } + + return fallbackToCwd ? detectProjectFromCwd() : ""; +} + +// --------------------------------------------------------------------------- +// Subprocess helpers +// --------------------------------------------------------------------------- + +function utekeBin(): string { + const envBin = process.env.UTEKE_BIN; + if (envBin) return envBin; + // pi extensions run as Node.js — use which-compatible lookup + try { + return execSync("which uteke", { encoding: "utf-8", timeout: 3000 }).trim(); + } catch { + return "uteke"; + } +} + +function runUteke(args: string[], timeout = RECALL_TIMEOUT_MS): string { + const bin = utekeBin(); + const env = { ...process.env }; + // Allow pointing uteke at a different home directory + if (process.env.UTEKE_HOME) { + env.HOME = process.env.UTEKE_HOME; + } + try { + return execFileSync(bin, args, { + encoding: "utf-8", + timeout, + env, + }); + } catch { + return ""; + } +} + +function recallMemories(query: string, project: string): string[] { + const ns = process.env.UTEKE_NAMESPACE || DEFAULT_NAMESPACE; + const limit = process.env.UTEKE_RECALL_LIMIT || String(RECALL_LIMIT); + const minScore = process.env.UTEKE_RECALL_MIN_SCORE || String(RECALL_MIN_SCORE); + + const args: string[] = [ + "recall", query, + "--namespace", ns, + "--limit", limit, + "--min-score", minScore, + "--json", + ]; + + // Filter by project tag if detected + if (project) { + args.push("--tags", `project:${project}`); + } + + const raw = runUteke(args); + + if (!raw.trim()) return []; + + try { + const items = JSON.parse(raw); + const results: string[] = []; + for (const item of items) { + const mem = item.memory || item; + const content = mem.content || ""; + if (content) { + results.push(content); + } + } + return results; + } catch { + return []; + } +} + +function formatMemories(memories: string[], project: string): string { + if (!memories.length) return ""; + const projectHint = project ? ` [project: ${project}]` : ""; + const lines = memories.map((m, i) => `${i + 1}. ${m}`).join("\n"); + return ( + `\n\n## Relevant Memories (uteke)${projectHint}\n\n` + + lines + + "\n\nUse `uteke remember` to store new facts, `uteke recall` to search more." + ); +} + +// --------------------------------------------------------------------------- +// Extension +// --------------------------------------------------------------------------- + +export default function (pi: ExtensionAPI) { + let available = false; + + // Check uteke availability on session start + pi.on("session_start", async (_event, ctx) => { + const out = runUteke(["stats"], 3000); + available = out.trim().length > 0; + + const project = detectProjectFromCwd(); + const status = project + ? `🧠 uteke: active (${project})` + : available + ? "🧠 uteke: active" + : "🧠 uteke: not found"; + + ctx.ui.setStatus("uteke", status); + }); + + // Core hook: inject relevant memories before every agent turn + pi.on("before_agent_start", async (event) => { + if (!available) return; + + const prompt = (event.prompt || "").trim(); + if (!prompt || prompt.length < 3) return; + + // Detect project from prompt text, fallback to cwd + const project = detectProject(prompt, true); + const memories = recallMemories(prompt, project); + if (!memories.length) return; + + const injection = formatMemories(memories, project); + + return { + systemPrompt: event.systemPrompt + injection, + }; + }); + + // Slash commands for manual control + pi.registerCommand("uteke-recall", { + description: "Manually recall memories for a topic", + handler: async (args, _ctx) => { + const query = args.join(" ").trim(); + if (!query) return "Usage: /uteke-recall "; + const project = detectProject(query, true); + const memories = recallMemories(query, project); + if (!memories.length) return "No relevant memories found."; + return formatMemories(memories, project); + }, + }); + + pi.registerCommand("uteke-save", { + description: "Save a memory to uteke (auto-tagged with project)", + handler: async (args, _ctx) => { + const content = args.join(" ").trim(); + if (!content) return "Usage: /uteke-save "; + + const ns = process.env.UTEKE_NAMESPACE || DEFAULT_NAMESPACE; + const project = detectProjectFromCwd(); + const cmdArgs: string[] = [ + "remember", content, + "--namespace", ns, + ]; + if (project) { + cmdArgs.push("--tags", `project:${project}`); + } + + runUteke(cmdArgs, RECALL_TIMEOUT_MS); + const projectNote = project ? ` [project: ${project}]` : ""; + return `✓ Memory saved.${projectNote}`; + }, + }); + + pi.registerCommand("uteke-stats", { + description: "Show uteke memory stats", + handler: async (_args, ctx) => { + const out = runUteke(["stats"], 5000); + available = out.trim().length > 0; + ctx.ui.setStatus("uteke", available ? "🧠 uteke: active" : "🧠 uteke: not found"); + return out.trim() || "uteke: not available"; + }, + }); + + pi.registerCommand("uteke-on", { + description: "Enable automatic memory recall", + handler: async () => { + available = true; + return "✓ Automatic uteke recall enabled."; + }, + }); + + pi.registerCommand("uteke-off", { + description: "Disable automatic memory recall", + handler: async () => { + available = false; + return "✓ Automatic uteke recall disabled."; + }, + }); +} diff --git a/crates/uteke-cli/assets/pi-memory-provider/package.json b/crates/uteke-cli/assets/pi-memory-provider/package.json new file mode 100644 index 00000000..7cf4a146 --- /dev/null +++ b/crates/uteke-cli/assets/pi-memory-provider/package.json @@ -0,0 +1,11 @@ +{ + "name": "uteke-memory-provider", + "version": "0.1.0", + "description": "Pi extension — automatic uteke memory recall injected into every turn", + "main": "index.ts", + "pi": { + "extensions": ["./index.ts"] + }, + "keywords": ["pi-extension", "uteke", "memory", "memory-provider"], + "license": "Apache-2.0" +} diff --git a/crates/uteke-cli/assets/uteke-memory-skill.md b/crates/uteke-cli/assets/uteke-memory-skill.md new file mode 100644 index 00000000..09d9ef9b --- /dev/null +++ b/crates/uteke-cli/assets/uteke-memory-skill.md @@ -0,0 +1,299 @@ +--- +description: "Persistent memory engine for AI agents via the uteke CLI — remember, recall, search, forget with semantic + FTS5 hybrid search, documents, knowledge graph, rooms, tiered memory, and multi-agent namespaces." +--- + +# Uteke Memory Skill + +Persistent memory engine for AI agents via the `uteke` CLI. +Version: **0.10.0** — SQLite + usearch HNSW + FTS5 hybrid search (RRF k=60). Zero unsafe code. + +> **Hermes integration:** Install the `uteke-memory` plugin for automatic recall +> on every turn via the `pre_llm_call` hook. No shell hook or daemon needed. +> See `extensions/hermes-memory-provider/` for the plugin source. +> Manual tool calls via `uteke-tool` plugin remain available for explicit +> remember/forget/room operations. + +## Global Flags (all commands) + +| Flag | Description | +|------|-------------| +| `--store ` | Override store path (default: `~/.uteke`) | +| `--namespace ` | Multi-agent isolation (default: `"default"`) | +| `--json` | Machine-readable JSON output | +| `--verbose` | Debug logging | + +## Commands + +### Core Memory Operations + +| Command | Description | Key Options | +|---------|-------------|-------------| +| `uteke remember ` | Store a new memory | `--tags`, `--type`, `--entity`, `--category`, `--meta`, `--room`, `--author`, `--source`, `--source-type`, `--detect-contradiction` | +| `uteke recall ` | Hybrid search (vector + FTS5, RRF) | `--limit`, `--tags`, `--entity`, `--category`, `--min`, `--strategy` (vector/fts5/hybrid/graph), `--salience`, `--recency`, `--related`, `--depth`, `--context`, `--at` (time-travel), `--type` (all/memory/doc), `--where` (JSON field filter) | +| `uteke search ` | Keyword text search | `--limit`, `--tags` | +| `uteke list` | List memories with filters | `--tag`, `--entity`, `--category`, `--limit`, `--offset`, `--at` | +| `uteke get ` | Get single memory by UUID | | +| `uteke forget ` | Delete memory by ID, tag, tier, or all | `--tag`, `--cold`, `--all`, `--confirm` | + +### Documents (Wiki / Knowledge Base) + +| Command | Description | Key Options | +|---------|-------------|-------------| +| `uteke doc create ` | Create document from file/stdin | `--title`, `--file`, `--content`, `--tags`, `--parent` | +| `uteke doc get ` | Get a document | | +| `uteke doc update ` | Partial update — only provided fields change | `--title`, `--content`, `--file`, `--tags`, `--metadata` | +| `uteke doc delete ` | Delete document (cascades to children) | | +| `uteke doc list` | List documents | `--limit`, `--tree` (hierarchy) | +| `uteke doc search ` | Search documents (semantic + FTS5) | `--limit`, `--mode` (semantic/fts/hybrid) | +| `uteke doc children ` | List child documents | `--limit` | +| `uteke doc move ` | Move to new parent or root | `--parent` | +| `uteke doc breadcrumbs ` | Show path from root | | +| `uteke doc descendants ` | List all descendants | `--max-depth`, `--limit` | +| `uteke doc export` | Export all documents as JSON | `--output` | + +### Knowledge Graph + +| Command | Description | Key Options | +|---------|-------------|-------------| +| `uteke graph nodes` | List all graph nodes | `--entity-type` | +| `uteke graph edges` | List all graph edges | `--relation` | +| `uteke graph neighbors