loop-memory 0.4.3__tar.gz → 0.4.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {loop_memory-0.4.3 → loop_memory-0.4.4}/PKG-INFO +7 -1
- {loop_memory-0.4.3 → loop_memory-0.4.4}/README.md +6 -0
- loop_memory-0.4.4/loop_memory/cli/commands/rules.py +245 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/cli/main.py +5 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/storage/sqlite_store.py +12 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory.egg-info/PKG-INFO +7 -1
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory.egg-info/SOURCES.txt +3 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/pyproject.toml +1 -1
- loop_memory-0.4.4/tests/test_cli_rules.py +161 -0
- loop_memory-0.4.4/tests/test_short_query.py +143 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/LICENSE +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/__init__.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/backends/__init__.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/backends/embedding.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/backends/sentence_embedder.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/backends/vector_store.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/cli/__init__.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/cli/_common.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/cli/commands/__init__.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/cli/commands/cognitive.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/cli/commands/diag.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/cli/commands/graph.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/cli/commands/hooks.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/cli/commands/read.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/cli/commands/serve.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/cli/commands/write.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/engine/__init__.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/engine/loop.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/engine/reflect.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/examples/__init__.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/examples/demo.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/export/__init__.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/export/memory_md.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/graph/__init__.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/graph/build.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/graph/extract.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/ingest/__init__.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/ingest/loader.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/ingest/pipeline.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/jobs/__init__.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/jobs/cognitive.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/jobs/compact.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/jobs/consolidate.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/jobs/contradiction.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/jobs/evolution.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/jobs/graph.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/jobs/llm_compact_pass.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/jobs/llm_consolidate.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/jobs/scheduler.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/llm/__init__.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/llm/base.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/llm/openai_adapter.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/llm/providers.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/mcp/__init__.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/memory/__init__.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/memory/types.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/privacy/__init__.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/privacy/private.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/privacy/redact.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/py.typed +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/sdk.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/sdk_extensions.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/security/__init__.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/security/secrets.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/serve/__init__.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/serve/app.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/serve/handlers.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/serve/routes/_shared.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/serve/routes/admin.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/serve/routes/cognitive.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/serve/routes/export.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/serve/routes/graph.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/serve/routes/insights.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/serve/routes/memories.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/serve/routes/sessions.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/serve/routes/system.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/serve/routes/wiki.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/serve/static/__init__.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/serve/static/index.html +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/serve/watcher.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/storage/__init__.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/storage/retrieval.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/wiki/__init__.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/wiki/backfill.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/wiki/classifier.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/wiki/prompts.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/wiki/scope.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory.egg-info/dependency_links.txt +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory.egg-info/entry_points.txt +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory.egg-info/requires.txt +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory.egg-info/top_level.txt +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/setup.cfg +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_admin_ingest_route.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_agent_memory_api.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_agent_memory_sdk.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_auth_token_rotate.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_cli_v7.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_cli_version.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_contradictions.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_evolution.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_evolution_quality.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_expanduser.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_export_ask.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_graph.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_ingest.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_llm_consolidator.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_llm_fingerprint_not_persisted.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_llm_providers.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_llm_test_endpoint.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_loop.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_mcp.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_memories_pagination.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_openclaw_loader.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_reflection.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_repo_framing.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_score_api.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_scoring_v2.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_secrets.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_serve_app.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_serve_handlers.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_session_order.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_store.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_summarization.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_universal_memory.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_vector_store.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_watcher.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_wiki_classifier.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_wiki_export_escape.py +0 -0
- {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_wiki_prompts.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: loop-memory
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.4
|
|
4
4
|
Summary: A general-purpose, local memory system for every AI agent you run. Loop Memory auto-captures conversations from Codex / Claude / Hermes / OpenClaw, scores them by importance × recency × usage × feedback, distils them into a curated wiki, and serves everything from a single web UI.
|
|
5
5
|
Author: Loop Memory contributors <loop-memory@users.noreply.github.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -432,6 +432,7 @@ automatically:
|
|
|
432
432
|
| Command | What it does |
|
|
433
433
|
| -------------------------------- | ---------------------------------------------------------------------------- |
|
|
434
434
|
| `loop-memory install-hooks` | Auto-detect `~/.codex`, `~/.claude`, `~/.hermes` and write MCP + SessionStart hook configs in place. Idempotent — re-run any time. |
|
|
435
|
+
| `loop-memory rules --write` | Append the three-phase memory-discipline block (task start / mid-task / wrap-up) into the agent's rule file (`AGENTS.md` for codex / hermes / openclaw, `CLAUDE.md` for claude). **Never overwrites user content.** |
|
|
435
436
|
| `loop-memory inject [query]` | Print a `# Long-term memory context` markdown block (distilled wiki + recent relevant memories) for a SessionStart hook. |
|
|
436
437
|
| `loop-memory mcp` | Run the **stdio MCP server** with memory, graph, and cognitive tools (`recall`, `remember`, `forget`, `feedback`, `remember_edge`, `subgraph`, `cognitive_sleep`, `audit`, and wiki tools). |
|
|
437
438
|
|
|
@@ -448,6 +449,11 @@ loop-memory install-hooks # writes ~/.codex/config.toml + ~/.claude/{mcp.j
|
|
|
448
449
|
Manual smoke-test without restarting the client:
|
|
449
450
|
|
|
450
451
|
```bash
|
|
452
|
+
# one-shot: install the memory-discipline block into the agent's
|
|
453
|
+
# rule file so the client calls `recall` on every task start.
|
|
454
|
+
loop-memory rules --agent codex --write # writes ./AGENTS.md (append, never overwrite)
|
|
455
|
+
loop-memory rules --agent claude --write # writes ./CLAUDE.md
|
|
456
|
+
|
|
451
457
|
loop-memory inject # dumps the warm-start block to stdout
|
|
452
458
|
printf '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}\n{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"wiki_summary"}}\n' \
|
|
453
459
|
| loop-memory mcp # round-trips JSON-RPC over stdio
|
|
@@ -388,6 +388,7 @@ automatically:
|
|
|
388
388
|
| Command | What it does |
|
|
389
389
|
| -------------------------------- | ---------------------------------------------------------------------------- |
|
|
390
390
|
| `loop-memory install-hooks` | Auto-detect `~/.codex`, `~/.claude`, `~/.hermes` and write MCP + SessionStart hook configs in place. Idempotent — re-run any time. |
|
|
391
|
+
| `loop-memory rules --write` | Append the three-phase memory-discipline block (task start / mid-task / wrap-up) into the agent's rule file (`AGENTS.md` for codex / hermes / openclaw, `CLAUDE.md` for claude). **Never overwrites user content.** |
|
|
391
392
|
| `loop-memory inject [query]` | Print a `# Long-term memory context` markdown block (distilled wiki + recent relevant memories) for a SessionStart hook. |
|
|
392
393
|
| `loop-memory mcp` | Run the **stdio MCP server** with memory, graph, and cognitive tools (`recall`, `remember`, `forget`, `feedback`, `remember_edge`, `subgraph`, `cognitive_sleep`, `audit`, and wiki tools). |
|
|
393
394
|
|
|
@@ -404,6 +405,11 @@ loop-memory install-hooks # writes ~/.codex/config.toml + ~/.claude/{mcp.j
|
|
|
404
405
|
Manual smoke-test without restarting the client:
|
|
405
406
|
|
|
406
407
|
```bash
|
|
408
|
+
# one-shot: install the memory-discipline block into the agent's
|
|
409
|
+
# rule file so the client calls `recall` on every task start.
|
|
410
|
+
loop-memory rules --agent codex --write # writes ./AGENTS.md (append, never overwrite)
|
|
411
|
+
loop-memory rules --agent claude --write # writes ./CLAUDE.md
|
|
412
|
+
|
|
407
413
|
loop-memory inject # dumps the warm-start block to stdout
|
|
408
414
|
printf '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}\n{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"wiki_summary"}}\n' \
|
|
409
415
|
| loop-memory mcp # round-trips JSON-RPC over stdio
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""rules: install a memory-discipline block into the agent's rule file.
|
|
2
|
+
|
|
3
|
+
Audit 2026-08-23: pattern adopted from
|
|
4
|
+
``2672243194/agentbrain`` v0.4.3 (``agentbrain rules --agent … --write``,
|
|
5
|
+
which appends a three-phase workflow block into CLAUDE.md /
|
|
6
|
+
AGENTS.md / Cursor / Trae rule files). Loop Memory's port wires
|
|
7
|
+
the same discipline into the four shipped agent targets:
|
|
8
|
+
|
|
9
|
+
* ``codex`` -> ``AGENTS.md`` (Codex CLI convention)
|
|
10
|
+
* ``claude`` -> ``CLAUDE.md`` (Claude Code convention)
|
|
11
|
+
* ``hermes`` -> ``AGENTS.md`` (Hermes CLI also reads AGENTS.md)
|
|
12
|
+
* ``openclaw`` -> ``AGENTS.md`` (OpenClaw reads AGENTS.md)
|
|
13
|
+
* ``generic`` -> ``AGENTS.md`` (any other AGENTS.md-aware client)
|
|
14
|
+
|
|
15
|
+
Safety properties:
|
|
16
|
+
|
|
17
|
+
* Existing rule files are **never overwritten** — the block is
|
|
18
|
+
appended after the user's content. Their rules stay byte-identical.
|
|
19
|
+
* **Idempotent** — a marker comment detects a prior install and
|
|
20
|
+
skips the write.
|
|
21
|
+
* The CLI exits 0 even when the target file does not exist (it
|
|
22
|
+
creates an empty one) and when no ``--agent`` is given (it just
|
|
23
|
+
prints the generic block to stdout).
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import sys
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Marker line used to detect a prior install. The leading space +
|
|
33
|
+
# trailing space keep the marker from accidentally colliding with a
|
|
34
|
+
# user's prose like "loop-memory is great". Keep this string in sync
|
|
35
|
+
# with the appended block below.
|
|
36
|
+
MARKER = "<!-- loop-memory:rules:installed -->"
|
|
37
|
+
|
|
38
|
+
# Agent-name -> relative path under cwd. Unknown agents fall back
|
|
39
|
+
# to ``generic`` (AGENTS.md).
|
|
40
|
+
AGENT_TARGETS: dict[str, str] = {
|
|
41
|
+
"codex": "AGENTS.md",
|
|
42
|
+
"claude": "CLAUDE.md",
|
|
43
|
+
"hermes": "AGENTS.md",
|
|
44
|
+
"openclaw": "AGENTS.md",
|
|
45
|
+
"generic": "AGENTS.md",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _generic_block() -> str:
|
|
50
|
+
"""The discipline block written to every agent's rule file."""
|
|
51
|
+
return (
|
|
52
|
+
f"\n{MARKER}\n"
|
|
53
|
+
"# Memory discipline (auto-installed by `loop-memory rules`).\n"
|
|
54
|
+
"# Re-run the same command to refresh in place; manual edits\n"
|
|
55
|
+
"# between the marker lines will be preserved on the next refresh.\n"
|
|
56
|
+
"\n"
|
|
57
|
+
"## At task start\n"
|
|
58
|
+
"\n"
|
|
59
|
+
"Before you read or write anything in this project, query Loop\n"
|
|
60
|
+
"Memory first so you can act on prior work instead of rebuilding\n"
|
|
61
|
+
"it. Use the project's existing memory tooling — for example\n"
|
|
62
|
+
"`loop-memory recall \"<task topic>\"` (shell), the `recall` MCP\n"
|
|
63
|
+
"tool, or the `inject` SessionStart hook if one is wired up.\n"
|
|
64
|
+
"Read the top hits before you start typing.\n"
|
|
65
|
+
"\n"
|
|
66
|
+
"## Mid-task\n"
|
|
67
|
+
"\n"
|
|
68
|
+
"Re-query whenever you switch subtask, hit an unexpected error,\n"
|
|
69
|
+
"or follow up a topic the initial query did not cover. Plain\n"
|
|
70
|
+
"continuation of the same line of thought does not need a\n"
|
|
71
|
+
"re-query — that is what saves tokens.\n"
|
|
72
|
+
"\n"
|
|
73
|
+
"## Wrap-up\n"
|
|
74
|
+
"\n"
|
|
75
|
+
"Each distinct reusable lesson — a non-obvious gotcha, a\n"
|
|
76
|
+
"convention you wish you'd known, a fix to a bug class —\n"
|
|
77
|
+
"should be written back via `loop-memory ingest` / the `add`\n"
|
|
78
|
+
"MCP tool, with user confirmation so nothing private lands in\n"
|
|
79
|
+
"the vault. Never paste secrets, tokens, or local paths into\n"
|
|
80
|
+
"a memory; use `${ENV:VAR_NAME}` placeholders or omit them.\n"
|
|
81
|
+
f"\n{MARKER} (end)\n"
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _render_block(agent: str) -> str:
|
|
86
|
+
"""Return the discipline block tailored to ``agent``."""
|
|
87
|
+
base = _generic_block()
|
|
88
|
+
if agent in {"claude", "codex"}:
|
|
89
|
+
return base
|
|
90
|
+
# Hermes / OpenClaw: same text but the explicit tool name in the
|
|
91
|
+
# hook line differs because the MCP tool prefix varies. The block
|
|
92
|
+
# already names the tool generically; we add one agent-specific
|
|
93
|
+
# clarifying line at the bottom for these two.
|
|
94
|
+
if agent == "hermes":
|
|
95
|
+
extra = (
|
|
96
|
+
"\n## Hermes-specific\n"
|
|
97
|
+
"\n"
|
|
98
|
+
"Hermes exposes Loop Memory through MCP too. The same\n"
|
|
99
|
+
"`recall` / `add` tool names apply; if you do not see\n"
|
|
100
|
+
"them in the MCP tool list, run\n"
|
|
101
|
+
"`loop-memory install-hooks` once and restart Hermes.\n"
|
|
102
|
+
)
|
|
103
|
+
return base.replace(f"\n{MARKER} (end)\n", extra + f"\n{MARKER} (end)\n")
|
|
104
|
+
if agent == "openclaw":
|
|
105
|
+
extra = (
|
|
106
|
+
"\n## OpenClaw-specific\n"
|
|
107
|
+
"\n"
|
|
108
|
+
"OpenClaw sessions are auto-ingested when\n"
|
|
109
|
+
"`loop-memory hook --source openclaw --watch <sessions-dir>`\n"
|
|
110
|
+
"is running. If the recall hits are empty, check that\n"
|
|
111
|
+
"the watcher is alive (`loop-memory doctor`) and that\n"
|
|
112
|
+
"the workspace log directory exists.\n"
|
|
113
|
+
)
|
|
114
|
+
return base.replace(f"\n{MARKER} (end)\n", extra + f"\n{MARKER} (end)\n")
|
|
115
|
+
return base
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _resolve_target(agent: str, cwd: Path) -> Path:
|
|
119
|
+
"""Map an agent name to its rule-file path under ``cwd``."""
|
|
120
|
+
rel = AGENT_TARGETS.get(agent)
|
|
121
|
+
if rel is None:
|
|
122
|
+
# Unknown agent — keep the same path as ``generic`` so the
|
|
123
|
+
# user still gets a working AGENTS.md and can rename it.
|
|
124
|
+
rel = AGENT_TARGETS["generic"]
|
|
125
|
+
return cwd / rel
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _install(cwd: Path, agent: str, *, force: bool) -> tuple[str, str]:
|
|
129
|
+
"""Install the block; return (status, message).
|
|
130
|
+
|
|
131
|
+
``status`` is one of: ``installed``, ``already-installed``,
|
|
132
|
+
``appended``, ``dry-run``.
|
|
133
|
+
"""
|
|
134
|
+
target = _resolve_target(agent, cwd)
|
|
135
|
+
existing = ""
|
|
136
|
+
if target.exists():
|
|
137
|
+
try:
|
|
138
|
+
existing = target.read_text(encoding="utf-8")
|
|
139
|
+
except Exception as e:
|
|
140
|
+
return "error", f"could not read {target}: {e}"
|
|
141
|
+
if MARKER in existing and not force:
|
|
142
|
+
return "already-installed", str(target)
|
|
143
|
+
# ``run_rules`` does the actual write; this helper just classifies
|
|
144
|
+
# the request so callers can unit-test the marker-detection path.
|
|
145
|
+
return "installed", str(target)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def run_rules(args: list) -> int:
|
|
149
|
+
"""``loop-memory rules [--agent NAME] [--write] [--force] [--cwd PATH]``.
|
|
150
|
+
|
|
151
|
+
Behaviour:
|
|
152
|
+
* no args -> print the generic block to stdout
|
|
153
|
+
* ``--agent NAME`` -> print the agent-tailored block
|
|
154
|
+
* ``--write`` -> install (append) into the agent's
|
|
155
|
+
rule file under cwd (or ``--cwd``)
|
|
156
|
+
* ``--force`` -> overwrite a prior install in place
|
|
157
|
+
(still never touches user content
|
|
158
|
+
outside the marker lines)
|
|
159
|
+
"""
|
|
160
|
+
agent = "generic"
|
|
161
|
+
write = False
|
|
162
|
+
force = False
|
|
163
|
+
cwd = Path.cwd()
|
|
164
|
+
it = iter(args)
|
|
165
|
+
for tok in it:
|
|
166
|
+
if tok == "--agent":
|
|
167
|
+
try:
|
|
168
|
+
agent = next(it)
|
|
169
|
+
except StopIteration:
|
|
170
|
+
print("--agent requires a name", file=sys.stderr)
|
|
171
|
+
return 2
|
|
172
|
+
elif tok == "--write":
|
|
173
|
+
write = True
|
|
174
|
+
elif tok == "--force":
|
|
175
|
+
force = True
|
|
176
|
+
elif tok == "--cwd":
|
|
177
|
+
try:
|
|
178
|
+
cwd = Path(next(it)).expanduser()
|
|
179
|
+
except StopIteration:
|
|
180
|
+
print("--cwd requires a path", file=sys.stderr)
|
|
181
|
+
return 2
|
|
182
|
+
elif tok in {"-h", "--help"}:
|
|
183
|
+
print(__doc__ or "loop-memory rules [--agent NAME] [--write] [--force]")
|
|
184
|
+
return 0
|
|
185
|
+
else:
|
|
186
|
+
print(f"unknown argument: {tok}", file=sys.stderr)
|
|
187
|
+
return 2
|
|
188
|
+
|
|
189
|
+
if not write:
|
|
190
|
+
sys.stdout.write(_render_block(agent))
|
|
191
|
+
return 0
|
|
192
|
+
|
|
193
|
+
target = _resolve_target(agent, cwd)
|
|
194
|
+
existing = ""
|
|
195
|
+
if target.exists():
|
|
196
|
+
try:
|
|
197
|
+
existing = target.read_text(encoding="utf-8")
|
|
198
|
+
except Exception as e:
|
|
199
|
+
print(f"could not read {target}: {e}", file=sys.stderr)
|
|
200
|
+
return 1
|
|
201
|
+
|
|
202
|
+
block = _render_block(agent)
|
|
203
|
+
if MARKER in existing and not force:
|
|
204
|
+
print(f"[loop-memory] rules: already installed in {target}")
|
|
205
|
+
return 0
|
|
206
|
+
if MARKER in existing and force:
|
|
207
|
+
# Replace the existing marker span in place: keep everything
|
|
208
|
+
# before the start marker and everything after the close
|
|
209
|
+
# marker, splice a fresh block between them. User content
|
|
210
|
+
# outside the marker lines is preserved byte-for-byte.
|
|
211
|
+
start_idx = existing.index(MARKER)
|
|
212
|
+
close_idx = existing.find(MARKER + " (end)", start_idx)
|
|
213
|
+
if close_idx < 0:
|
|
214
|
+
# Partial / corrupted install — fall back to append.
|
|
215
|
+
close_idx = len(existing)
|
|
216
|
+
else:
|
|
217
|
+
close_idx = existing.index("\n", close_idx)
|
|
218
|
+
new_content = existing[:start_idx].rstrip("\n") + "\n" + block + existing[close_idx:]
|
|
219
|
+
action = "refreshed"
|
|
220
|
+
else:
|
|
221
|
+
sep = ""
|
|
222
|
+
if existing and not existing.endswith("\n"):
|
|
223
|
+
sep = "\n"
|
|
224
|
+
new_content = existing + sep + block
|
|
225
|
+
action = "appended" if existing else "created"
|
|
226
|
+
|
|
227
|
+
try:
|
|
228
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
229
|
+
target.write_text(new_content, encoding="utf-8")
|
|
230
|
+
except Exception as e:
|
|
231
|
+
print(f"[loop-memory] rules: write failed: {e}", file=sys.stderr)
|
|
232
|
+
return 1
|
|
233
|
+
print(f"[loop-memory] rules: {action} {target}")
|
|
234
|
+
return 0
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
__all__ = [
|
|
238
|
+
"AGENT_TARGETS",
|
|
239
|
+
"MARKER",
|
|
240
|
+
"_generic_block",
|
|
241
|
+
"_install",
|
|
242
|
+
"_render_block",
|
|
243
|
+
"_resolve_target",
|
|
244
|
+
"run_rules",
|
|
245
|
+
]
|
|
@@ -17,6 +17,8 @@ Usage:
|
|
|
17
17
|
loop-memory consolidate-now # ask the running server to trigger a pass right now
|
|
18
18
|
loop-memory export # legacy markdown export (no positional path)
|
|
19
19
|
loop-memory digest [--out PATH] # compact knowledge digest for AGENTS.md (≤ max-chars bytes)
|
|
20
|
+
loop-memory rules [--agent codex|claude|hermes|openclaw] [--write] [--force]
|
|
21
|
+
# install the three-phase memory discipline block into the agent's rule file
|
|
20
22
|
loop-memory ask "what about…" # print a paste-ready context block for any LLM client
|
|
21
23
|
loop-memory cognitive-sleep [--apply] # dry-run / apply cognitive sweep (v7)
|
|
22
24
|
loop-memory audit [--kind X] [--action Y] # read the cognitive audit trail
|
|
@@ -38,6 +40,7 @@ from .commands import diag as diag_cmd
|
|
|
38
40
|
from .commands import graph as graph_cmd
|
|
39
41
|
from .commands import hooks as hooks_cmd
|
|
40
42
|
from .commands import read as read_cmd
|
|
43
|
+
from .commands import rules as rules_cmd
|
|
41
44
|
from .commands import serve as serve_cmd
|
|
42
45
|
from .commands import write as write_cmd
|
|
43
46
|
|
|
@@ -113,6 +116,7 @@ COMMANDS = {
|
|
|
113
116
|
"subgraph": cognitive_cmd.run_subgraph,
|
|
114
117
|
"graph-rebuild": cognitive_cmd.run_graph_rebuild,
|
|
115
118
|
"wiki-reclassify-legacy": cognitive_cmd.run_wiki_reclassify_legacy,
|
|
119
|
+
"rules": rules_cmd.run_rules,
|
|
116
120
|
"version": _run_version,
|
|
117
121
|
}
|
|
118
122
|
|
|
@@ -152,6 +156,7 @@ COMMAND_HELP: dict[str, str] = {
|
|
|
152
156
|
"subgraph": "loop-memory subgraph <query> # print a small subgraph.",
|
|
153
157
|
"version": "loop-memory version # print the installed distribution version.",
|
|
154
158
|
"wiki-reclassify-legacy": "loop-memory wiki-reclassify-legacy # back-fill scope + scope_filter for H4+H5 pages.",
|
|
159
|
+
"rules": "loop-memory rules [--agent codex|claude|hermes|openclaw] [--write] [--force]\n Print, or append into the agent's rule file, the three-phase memory discipline (task start / mid-task / wrap-up). Never overwrites user content.",
|
|
155
160
|
}
|
|
156
161
|
|
|
157
162
|
|
|
@@ -1280,6 +1280,13 @@ class MemoryStore:
|
|
|
1280
1280
|
"LIMIT ?"
|
|
1281
1281
|
)
|
|
1282
1282
|
rows = c.execute(sql, (*params, *tag_params, limit * 3)).fetchall()
|
|
1283
|
+
# Short-query mode: when a query has only 1-2 tokens the
|
|
1284
|
+
# lexical overlap is too narrow to rank by itself, so we
|
|
1285
|
+
# also weight ``recall_count`` -- a memory the user has
|
|
1286
|
+
# surfaced before is more likely to be the one they want
|
|
1287
|
+
# this time too. Capped at +30 % so it nudges rather than
|
|
1288
|
+
# dominates the importance + score multipliers.
|
|
1289
|
+
short_query = len(tokens) <= 2
|
|
1283
1290
|
for r in rows:
|
|
1284
1291
|
tags = []
|
|
1285
1292
|
try:
|
|
@@ -1293,6 +1300,11 @@ class MemoryStore:
|
|
|
1293
1300
|
score = body_hits + 2 * tag_hits
|
|
1294
1301
|
score *= 0.5 + float(r["importance"] or 0) * 0.8
|
|
1295
1302
|
score *= 0.7 + float(r["score"] or 0) * 0.6
|
|
1303
|
+
if short_query:
|
|
1304
|
+
recall_count = int(r["recall_count"] or 0)
|
|
1305
|
+
# log1p saturates so a memory with thousands of
|
|
1306
|
+
# recalls does not crowd out everything else.
|
|
1307
|
+
score *= 1.0 + min(0.3, recall_count * 0.03)
|
|
1296
1308
|
out["memories"].append({
|
|
1297
1309
|
"id": r["id"],
|
|
1298
1310
|
"kind": "memory",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: loop-memory
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.4
|
|
4
4
|
Summary: A general-purpose, local memory system for every AI agent you run. Loop Memory auto-captures conversations from Codex / Claude / Hermes / OpenClaw, scores them by importance × recency × usage × feedback, distils them into a curated wiki, and serves everything from a single web UI.
|
|
5
5
|
Author: Loop Memory contributors <loop-memory@users.noreply.github.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -432,6 +432,7 @@ automatically:
|
|
|
432
432
|
| Command | What it does |
|
|
433
433
|
| -------------------------------- | ---------------------------------------------------------------------------- |
|
|
434
434
|
| `loop-memory install-hooks` | Auto-detect `~/.codex`, `~/.claude`, `~/.hermes` and write MCP + SessionStart hook configs in place. Idempotent — re-run any time. |
|
|
435
|
+
| `loop-memory rules --write` | Append the three-phase memory-discipline block (task start / mid-task / wrap-up) into the agent's rule file (`AGENTS.md` for codex / hermes / openclaw, `CLAUDE.md` for claude). **Never overwrites user content.** |
|
|
435
436
|
| `loop-memory inject [query]` | Print a `# Long-term memory context` markdown block (distilled wiki + recent relevant memories) for a SessionStart hook. |
|
|
436
437
|
| `loop-memory mcp` | Run the **stdio MCP server** with memory, graph, and cognitive tools (`recall`, `remember`, `forget`, `feedback`, `remember_edge`, `subgraph`, `cognitive_sleep`, `audit`, and wiki tools). |
|
|
437
438
|
|
|
@@ -448,6 +449,11 @@ loop-memory install-hooks # writes ~/.codex/config.toml + ~/.claude/{mcp.j
|
|
|
448
449
|
Manual smoke-test without restarting the client:
|
|
449
450
|
|
|
450
451
|
```bash
|
|
452
|
+
# one-shot: install the memory-discipline block into the agent's
|
|
453
|
+
# rule file so the client calls `recall` on every task start.
|
|
454
|
+
loop-memory rules --agent codex --write # writes ./AGENTS.md (append, never overwrite)
|
|
455
|
+
loop-memory rules --agent claude --write # writes ./CLAUDE.md
|
|
456
|
+
|
|
451
457
|
loop-memory inject # dumps the warm-start block to stdout
|
|
452
458
|
printf '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}\n{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"wiki_summary"}}\n' \
|
|
453
459
|
| loop-memory mcp # round-trips JSON-RPC over stdio
|
|
@@ -24,6 +24,7 @@ loop_memory/cli/commands/diag.py
|
|
|
24
24
|
loop_memory/cli/commands/graph.py
|
|
25
25
|
loop_memory/cli/commands/hooks.py
|
|
26
26
|
loop_memory/cli/commands/read.py
|
|
27
|
+
loop_memory/cli/commands/rules.py
|
|
27
28
|
loop_memory/cli/commands/serve.py
|
|
28
29
|
loop_memory/cli/commands/write.py
|
|
29
30
|
loop_memory/engine/__init__.py
|
|
@@ -89,6 +90,7 @@ tests/test_admin_ingest_route.py
|
|
|
89
90
|
tests/test_agent_memory_api.py
|
|
90
91
|
tests/test_agent_memory_sdk.py
|
|
91
92
|
tests/test_auth_token_rotate.py
|
|
93
|
+
tests/test_cli_rules.py
|
|
92
94
|
tests/test_cli_v7.py
|
|
93
95
|
tests/test_cli_version.py
|
|
94
96
|
tests/test_contradictions.py
|
|
@@ -114,6 +116,7 @@ tests/test_secrets.py
|
|
|
114
116
|
tests/test_serve_app.py
|
|
115
117
|
tests/test_serve_handlers.py
|
|
116
118
|
tests/test_session_order.py
|
|
119
|
+
tests/test_short_query.py
|
|
117
120
|
tests/test_store.py
|
|
118
121
|
tests/test_summarization.py
|
|
119
122
|
tests/test_universal_memory.py
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "loop-memory"
|
|
7
|
-
version = "0.4.
|
|
7
|
+
version = "0.4.4"
|
|
8
8
|
description = "A general-purpose, local memory system for every AI agent you run. Loop Memory auto-captures conversations from Codex / Claude / Hermes / OpenClaw, scores them by importance × recency × usage × feedback, distils them into a curated wiki, and serves everything from a single web UI."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Tests for the rules installer (Audit 2026-08-23)."""
|
|
2
|
+
import os
|
|
3
|
+
import tempfile
|
|
4
|
+
import unittest
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from loop_memory.cli.main import main, COMMAND_HELP
|
|
8
|
+
from loop_memory.cli.commands.rules import (
|
|
9
|
+
AGENT_TARGETS,
|
|
10
|
+
MARKER,
|
|
11
|
+
_install,
|
|
12
|
+
_render_block,
|
|
13
|
+
_resolve_target,
|
|
14
|
+
run_rules,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class RulesInstallerTests(unittest.TestCase):
|
|
19
|
+
def setUp(self) -> None:
|
|
20
|
+
self.tmp = tempfile.TemporaryDirectory(prefix="loop_rules_")
|
|
21
|
+
self.root = Path(self.tmp.name)
|
|
22
|
+
|
|
23
|
+
def tearDown(self) -> None:
|
|
24
|
+
self.tmp.cleanup()
|
|
25
|
+
|
|
26
|
+
def test_renders_generic_block_with_marker(self) -> None:
|
|
27
|
+
block = _render_block("generic")
|
|
28
|
+
self.assertIn(MARKER, block)
|
|
29
|
+
self.assertIn("Memory discipline", block)
|
|
30
|
+
self.assertIn("task start", block)
|
|
31
|
+
self.assertIn("Mid-task", block)
|
|
32
|
+
self.assertIn("Wrap-up", block)
|
|
33
|
+
|
|
34
|
+
def test_renders_agent_specific_block(self) -> None:
|
|
35
|
+
for agent in ("codex", "claude", "hermes", "openclaw"):
|
|
36
|
+
block = _render_block(agent)
|
|
37
|
+
self.assertIn(MARKER, block, agent)
|
|
38
|
+
|
|
39
|
+
def test_hermes_block_has_hermes_section(self) -> None:
|
|
40
|
+
block = _render_block("hermes")
|
|
41
|
+
self.assertIn("Hermes-specific", block)
|
|
42
|
+
|
|
43
|
+
def test_openclaw_block_has_openclaw_section(self) -> None:
|
|
44
|
+
block = _render_block("openclaw")
|
|
45
|
+
self.assertIn("OpenClaw-specific", block)
|
|
46
|
+
|
|
47
|
+
def test_resolve_target_known_agents(self) -> None:
|
|
48
|
+
self.assertEqual(_resolve_target("codex", self.root), self.root / "AGENTS.md")
|
|
49
|
+
self.assertEqual(_resolve_target("claude", self.root), self.root / "CLAUDE.md")
|
|
50
|
+
self.assertEqual(_resolve_target("hermes", self.root), self.root / "AGENTS.md")
|
|
51
|
+
self.assertEqual(_resolve_target("openclaw", self.root), self.root / "AGENTS.md")
|
|
52
|
+
|
|
53
|
+
def test_resolve_target_unknown_falls_back_to_agents_md(self) -> None:
|
|
54
|
+
self.assertEqual(_resolve_target("claude-cli", self.root),
|
|
55
|
+
self.root / "AGENTS.md")
|
|
56
|
+
self.assertEqual(_resolve_target("anything", self.root),
|
|
57
|
+
self.root / "AGENTS.md")
|
|
58
|
+
|
|
59
|
+
def test_install_creates_new_file(self) -> None:
|
|
60
|
+
status, target = _install(self.root, "codex", force=False)
|
|
61
|
+
self.assertEqual(status, "installed")
|
|
62
|
+
# Then via run_rules we should actually write.
|
|
63
|
+
self.assertEqual(run_rules(["--agent", "codex", "--write", "--cwd",
|
|
64
|
+
str(self.root)]), 0)
|
|
65
|
+
target = self.root / "AGENTS.md"
|
|
66
|
+
self.assertTrue(target.exists())
|
|
67
|
+
content = target.read_text(encoding="utf-8")
|
|
68
|
+
self.assertIn(MARKER, content)
|
|
69
|
+
self.assertIn("Memory discipline", content)
|
|
70
|
+
|
|
71
|
+
def test_install_appends_without_overwriting_user_content(self) -> None:
|
|
72
|
+
target = self.root / "AGENTS.md"
|
|
73
|
+
target.write_text(
|
|
74
|
+
"# User's own rule\n\nDo not touch this.\n",
|
|
75
|
+
encoding="utf-8",
|
|
76
|
+
)
|
|
77
|
+
self.assertEqual(run_rules(["--agent", "codex", "--write",
|
|
78
|
+
"--cwd", str(self.root)]), 0)
|
|
79
|
+
content = target.read_text(encoding="utf-8")
|
|
80
|
+
self.assertIn("Do not touch this.", content)
|
|
81
|
+
self.assertIn(MARKER, content)
|
|
82
|
+
# User content must appear before our appended block.
|
|
83
|
+
self.assertLess(content.index("Do not touch this."),
|
|
84
|
+
content.index(MARKER))
|
|
85
|
+
|
|
86
|
+
def test_install_is_idempotent(self) -> None:
|
|
87
|
+
self.assertEqual(run_rules(["--agent", "codex", "--write",
|
|
88
|
+
"--cwd", str(self.root)]), 0)
|
|
89
|
+
first = (self.root / "AGENTS.md").read_text(encoding="utf-8")
|
|
90
|
+
# Second run: same content, no duplicate block.
|
|
91
|
+
self.assertEqual(run_rules(["--agent", "codex", "--write",
|
|
92
|
+
"--cwd", str(self.root)]), 0)
|
|
93
|
+
second = (self.root / "AGENTS.md").read_text(encoding="utf-8")
|
|
94
|
+
self.assertEqual(first, second)
|
|
95
|
+
# The block contains the marker twice (open + close comment).
|
|
96
|
+
# Re-running is a no-op so the count must stay at 2.
|
|
97
|
+
self.assertEqual(first.count(MARKER), 2)
|
|
98
|
+
self.assertEqual(second.count(MARKER), 2)
|
|
99
|
+
self.assertEqual(first, second)
|
|
100
|
+
|
|
101
|
+
def test_force_overwrites_marker_span(self) -> None:
|
|
102
|
+
# Seed a user rule above the discipline block.
|
|
103
|
+
(self.root / "AGENTS.md").write_text(
|
|
104
|
+
"# user-rule\n\nkeep me\n",
|
|
105
|
+
encoding="utf-8",
|
|
106
|
+
)
|
|
107
|
+
self.assertEqual(run_rules(["--agent", "codex", "--write",
|
|
108
|
+
"--cwd", str(self.root)]), 0)
|
|
109
|
+
first = (self.root / "AGENTS.md").read_text(encoding="utf-8")
|
|
110
|
+
# --force refreshes the discipline block in place; user content
|
|
111
|
+
# above the start marker must stay byte-identical.
|
|
112
|
+
self.assertEqual(run_rules(["--agent", "codex", "--write",
|
|
113
|
+
"--force", "--cwd", str(self.root)]), 0)
|
|
114
|
+
second = (self.root / "AGENTS.md").read_text(encoding="utf-8")
|
|
115
|
+
# User content is preserved.
|
|
116
|
+
self.assertIn("keep me", second)
|
|
117
|
+
# Marker count stays at 2 (one open + one close).
|
|
118
|
+
self.assertEqual(first.count(MARKER), 2)
|
|
119
|
+
self.assertEqual(second.count(MARKER), 2)
|
|
120
|
+
|
|
121
|
+
def test_claude_targets_claudemd(self) -> None:
|
|
122
|
+
self.assertEqual(run_rules(["--agent", "claude", "--write",
|
|
123
|
+
"--cwd", str(self.root)]), 0)
|
|
124
|
+
target = self.root / "CLAUDE.md"
|
|
125
|
+
self.assertTrue(target.exists())
|
|
126
|
+
self.assertNotEqual(target, self.root / "AGENTS.md")
|
|
127
|
+
self.assertIn(MARKER, target.read_text(encoding="utf-8"))
|
|
128
|
+
|
|
129
|
+
def test_print_mode_prints_to_stdout_no_write(self) -> None:
|
|
130
|
+
import io
|
|
131
|
+
from unittest import mock
|
|
132
|
+
buf = io.StringIO()
|
|
133
|
+
with mock.patch("sys.stdout", buf):
|
|
134
|
+
self.assertEqual(run_rules(["--agent", "generic"]), 0)
|
|
135
|
+
out = buf.getvalue()
|
|
136
|
+
self.assertIn(MARKER, out)
|
|
137
|
+
# Print mode must NOT create any file.
|
|
138
|
+
self.assertEqual(list(self.root.iterdir()), [])
|
|
139
|
+
|
|
140
|
+
def test_command_help_is_present(self) -> None:
|
|
141
|
+
self.assertIn("rules", COMMAND_HELP)
|
|
142
|
+
self.assertIn("--write", COMMAND_HELP["rules"])
|
|
143
|
+
self.assertIn("--agent", COMMAND_HELP["rules"])
|
|
144
|
+
|
|
145
|
+
def test_help_via_dispatcher_exits_zero(self) -> None:
|
|
146
|
+
# Same pattern as ``test_cli_v7`` — exercises the dispatcher
|
|
147
|
+
# path for the new subcommand.
|
|
148
|
+
self.assertEqual(main(["rules", "--help"]), 0)
|
|
149
|
+
|
|
150
|
+
def test_unknown_argument_returns_2(self) -> None:
|
|
151
|
+
self.assertEqual(run_rules(["--bogus"]), 2)
|
|
152
|
+
|
|
153
|
+
def test_agent_targets_map_matches_shipped_agents(self) -> None:
|
|
154
|
+
# The shipped-agents mapping must cover everything the README
|
|
155
|
+
# advertises (4 + generic).
|
|
156
|
+
for agent in ("codex", "claude", "hermes", "openclaw", "generic"):
|
|
157
|
+
self.assertIn(agent, AGENT_TARGETS)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
if __name__ == "__main__":
|
|
161
|
+
unittest.main()
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Tests for the recall() short-query tightening (Audit 2026-08-23)."""
|
|
2
|
+
import tempfile
|
|
3
|
+
import unittest
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from loop_memory.storage.sqlite_store import MemoryStore
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _new_store() -> MemoryStore:
|
|
10
|
+
tmp = Path(tempfile.mkdtemp(prefix="loop_shortq_"))
|
|
11
|
+
return MemoryStore(tmp / "short.db")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RecallShortQueryTests(unittest.TestCase):
|
|
15
|
+
"""Audit 2026-08-23: Cognee v1.5.2 short-query ranking.
|
|
16
|
+
|
|
17
|
+
When a query has 1-2 tokens, ``recall()`` boosts
|
|
18
|
+
``recall_count`` so a memory the user has already surfaced wins
|
|
19
|
+
over a memory that only matches the substring but was never
|
|
20
|
+
actually useful. These tests pin both the boost and the cap so a
|
|
21
|
+
regression toward silent-noise OR toward runaway crowd-out both
|
|
22
|
+
fail CI.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def setUp(self) -> None:
|
|
26
|
+
self.store = _new_store()
|
|
27
|
+
|
|
28
|
+
def _seed(self):
|
|
29
|
+
# Two memories that match the same short query. The recalled
|
|
30
|
+
# one (b1) should outrank the never-recalled one (b2) once
|
|
31
|
+
# recall() sees the short-query boost.
|
|
32
|
+
a1 = self.store.upsert_memory(
|
|
33
|
+
kind="fact", text="database is postgres",
|
|
34
|
+
importance=0.5, agent_id="bot", user_id="u1",
|
|
35
|
+
)
|
|
36
|
+
a2 = self.store.upsert_memory(
|
|
37
|
+
kind="fact", text="uses postgres for orders",
|
|
38
|
+
importance=0.5, agent_id="bot", user_id="u1",
|
|
39
|
+
)
|
|
40
|
+
# Bump the second one several times so recall_count > 0.
|
|
41
|
+
for _ in range(5):
|
|
42
|
+
self.store.bump_recalls([a2.id])
|
|
43
|
+
return a1.id, a2.id
|
|
44
|
+
|
|
45
|
+
def test_short_query_recall_boost_prefers_recalled_memory(self) -> None:
|
|
46
|
+
_never, recalled = self._seed()
|
|
47
|
+
r = self.store.recall("postgres")
|
|
48
|
+
# Top hit is the recalled memory, not the never-recalled one.
|
|
49
|
+
ids = [m["id"] for m in r["memories"]]
|
|
50
|
+
self.assertIn(recalled, ids)
|
|
51
|
+
self.assertEqual(ids[0], recalled)
|
|
52
|
+
|
|
53
|
+
def test_short_query_boost_is_capped(self) -> None:
|
|
54
|
+
"""A runaway recall_count must not crowd out everything else."""
|
|
55
|
+
# Seed a memory with 1000 recalls vs a sibling with 0.
|
|
56
|
+
a_lo = self.store.upsert_memory(
|
|
57
|
+
kind="fact", text="database is postgres",
|
|
58
|
+
importance=0.5, agent_id="bot",
|
|
59
|
+
)
|
|
60
|
+
a_hi = self.store.upsert_memory(
|
|
61
|
+
kind="fact", text="database is postgres too",
|
|
62
|
+
importance=0.5, agent_id="bot",
|
|
63
|
+
)
|
|
64
|
+
for _ in range(1000):
|
|
65
|
+
self.store.bump_recalls([a_hi.id])
|
|
66
|
+
r = self.store.recall("postgres")
|
|
67
|
+
scores = {m["id"]: m["score"] for m in r["memories"]}
|
|
68
|
+
# The cap is +30% so the high-recall memory wins, but the
|
|
69
|
+
# low-recall sibling must NOT be zeroed-out.
|
|
70
|
+
if a_hi.id in scores and a_lo.id in scores:
|
|
71
|
+
self.assertGreater(scores[a_hi.id], scores[a_lo.id])
|
|
72
|
+
# Ratio is bounded by 1.0 + cap = 1.30 (modulo rounding).
|
|
73
|
+
ratio = scores[a_hi.id] / max(scores[a_lo.id], 0.001)
|
|
74
|
+
self.assertLess(ratio, 1.5, f"boost overrun: {ratio:.3f}")
|
|
75
|
+
|
|
76
|
+
def test_short_query_boost_disabled_for_long_query(self) -> None:
|
|
77
|
+
"""3+ tokens must NOT apply the recall_count boost.
|
|
78
|
+
|
|
79
|
+
Long queries have enough lexical overlap to rank by hits, so
|
|
80
|
+
adding recall_count on top would over-weight popular
|
|
81
|
+
memories and hide fresh-but-relevant ones.
|
|
82
|
+
"""
|
|
83
|
+
# Seed two memories; only one has been recalled.
|
|
84
|
+
a_fresh = self.store.upsert_memory(
|
|
85
|
+
kind="fact",
|
|
86
|
+
text="Postgres orders table is used by service",
|
|
87
|
+
importance=0.5, agent_id="bot",
|
|
88
|
+
)
|
|
89
|
+
a_pop = self.store.upsert_memory(
|
|
90
|
+
kind="fact",
|
|
91
|
+
text="Postgres cache table is used by service",
|
|
92
|
+
importance=0.5, agent_id="bot",
|
|
93
|
+
)
|
|
94
|
+
for _ in range(10):
|
|
95
|
+
self.store.bump_recalls([a_pop.id])
|
|
96
|
+
# Three tokens -> NOT short.
|
|
97
|
+
r = self.store.recall("Postgres orders table")
|
|
98
|
+
# Both should be returned; ordering is by lexical signal
|
|
99
|
+
# (body_hits + importance + score), not by recall_count.
|
|
100
|
+
ids = [m["id"] for m in r["memories"]]
|
|
101
|
+
self.assertIn(a_fresh.id, ids)
|
|
102
|
+
self.assertIn(a_pop.id, ids)
|
|
103
|
+
|
|
104
|
+
def test_short_query_two_token_boundary(self) -> None:
|
|
105
|
+
"""Two tokens IS short (len(tokens) <= 2)."""
|
|
106
|
+
store = _new_store()
|
|
107
|
+
_ = store.upsert_memory(
|
|
108
|
+
kind="fact", text="team uses postgres",
|
|
109
|
+
importance=0.5, agent_id="bot",
|
|
110
|
+
)
|
|
111
|
+
a2 = store.upsert_memory(
|
|
112
|
+
kind="fact", text="team uses postgres for orders",
|
|
113
|
+
importance=0.5, agent_id="bot",
|
|
114
|
+
)
|
|
115
|
+
for _ in range(5):
|
|
116
|
+
store.bump_recalls([a2.id])
|
|
117
|
+
r = store.recall("team postgres")
|
|
118
|
+
ids = [m["id"] for m in r["memories"]]
|
|
119
|
+
# 2 tokens -> short mode -> recalled memory wins.
|
|
120
|
+
self.assertEqual(ids[0], a2.id)
|
|
121
|
+
|
|
122
|
+
def test_short_query_zero_recall_count_no_change(self) -> None:
|
|
123
|
+
"""Zero recall_count must NOT change the score (no boost, no penalty)."""
|
|
124
|
+
store = _new_store()
|
|
125
|
+
store.upsert_memory(
|
|
126
|
+
kind="fact", text="team uses postgres",
|
|
127
|
+
importance=0.5, agent_id="bot",
|
|
128
|
+
)
|
|
129
|
+
store.upsert_memory(
|
|
130
|
+
kind="fact", text="team uses postgres for orders",
|
|
131
|
+
importance=0.5, agent_id="bot",
|
|
132
|
+
)
|
|
133
|
+
r = store.recall("team postgres")
|
|
134
|
+
# Without bumps, both have recall_count=0; the boost should
|
|
135
|
+
# not flip the ranking purely on that field.
|
|
136
|
+
ids = [m["id"] for m in r["memories"]]
|
|
137
|
+
# The two-token query returns both; either order is fine since
|
|
138
|
+
# recall_count=0 means the multiplier is 1.0 for both.
|
|
139
|
+
self.assertEqual(len(ids), 2)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
if __name__ == "__main__":
|
|
143
|
+
unittest.main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|