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.
Files changed (129) hide show
  1. {loop_memory-0.4.3 → loop_memory-0.4.4}/PKG-INFO +7 -1
  2. {loop_memory-0.4.3 → loop_memory-0.4.4}/README.md +6 -0
  3. loop_memory-0.4.4/loop_memory/cli/commands/rules.py +245 -0
  4. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/cli/main.py +5 -0
  5. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/storage/sqlite_store.py +12 -0
  6. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory.egg-info/PKG-INFO +7 -1
  7. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory.egg-info/SOURCES.txt +3 -0
  8. {loop_memory-0.4.3 → loop_memory-0.4.4}/pyproject.toml +1 -1
  9. loop_memory-0.4.4/tests/test_cli_rules.py +161 -0
  10. loop_memory-0.4.4/tests/test_short_query.py +143 -0
  11. {loop_memory-0.4.3 → loop_memory-0.4.4}/LICENSE +0 -0
  12. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/__init__.py +0 -0
  13. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/backends/__init__.py +0 -0
  14. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/backends/embedding.py +0 -0
  15. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/backends/sentence_embedder.py +0 -0
  16. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/backends/vector_store.py +0 -0
  17. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/cli/__init__.py +0 -0
  18. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/cli/_common.py +0 -0
  19. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/cli/commands/__init__.py +0 -0
  20. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/cli/commands/cognitive.py +0 -0
  21. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/cli/commands/diag.py +0 -0
  22. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/cli/commands/graph.py +0 -0
  23. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/cli/commands/hooks.py +0 -0
  24. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/cli/commands/read.py +0 -0
  25. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/cli/commands/serve.py +0 -0
  26. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/cli/commands/write.py +0 -0
  27. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/engine/__init__.py +0 -0
  28. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/engine/loop.py +0 -0
  29. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/engine/reflect.py +0 -0
  30. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/examples/__init__.py +0 -0
  31. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/examples/demo.py +0 -0
  32. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/export/__init__.py +0 -0
  33. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/export/memory_md.py +0 -0
  34. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/graph/__init__.py +0 -0
  35. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/graph/build.py +0 -0
  36. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/graph/extract.py +0 -0
  37. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/ingest/__init__.py +0 -0
  38. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/ingest/loader.py +0 -0
  39. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/ingest/pipeline.py +0 -0
  40. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/jobs/__init__.py +0 -0
  41. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/jobs/cognitive.py +0 -0
  42. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/jobs/compact.py +0 -0
  43. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/jobs/consolidate.py +0 -0
  44. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/jobs/contradiction.py +0 -0
  45. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/jobs/evolution.py +0 -0
  46. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/jobs/graph.py +0 -0
  47. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/jobs/llm_compact_pass.py +0 -0
  48. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/jobs/llm_consolidate.py +0 -0
  49. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/jobs/scheduler.py +0 -0
  50. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/llm/__init__.py +0 -0
  51. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/llm/base.py +0 -0
  52. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/llm/openai_adapter.py +0 -0
  53. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/llm/providers.py +0 -0
  54. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/mcp/__init__.py +0 -0
  55. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/memory/__init__.py +0 -0
  56. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/memory/types.py +0 -0
  57. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/privacy/__init__.py +0 -0
  58. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/privacy/private.py +0 -0
  59. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/privacy/redact.py +0 -0
  60. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/py.typed +0 -0
  61. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/sdk.py +0 -0
  62. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/sdk_extensions.py +0 -0
  63. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/security/__init__.py +0 -0
  64. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/security/secrets.py +0 -0
  65. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/serve/__init__.py +0 -0
  66. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/serve/app.py +0 -0
  67. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/serve/handlers.py +0 -0
  68. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/serve/routes/_shared.py +0 -0
  69. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/serve/routes/admin.py +0 -0
  70. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/serve/routes/cognitive.py +0 -0
  71. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/serve/routes/export.py +0 -0
  72. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/serve/routes/graph.py +0 -0
  73. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/serve/routes/insights.py +0 -0
  74. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/serve/routes/memories.py +0 -0
  75. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/serve/routes/sessions.py +0 -0
  76. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/serve/routes/system.py +0 -0
  77. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/serve/routes/wiki.py +0 -0
  78. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/serve/static/__init__.py +0 -0
  79. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/serve/static/index.html +0 -0
  80. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/serve/watcher.py +0 -0
  81. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/storage/__init__.py +0 -0
  82. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/storage/retrieval.py +0 -0
  83. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/wiki/__init__.py +0 -0
  84. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/wiki/backfill.py +0 -0
  85. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/wiki/classifier.py +0 -0
  86. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/wiki/prompts.py +0 -0
  87. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory/wiki/scope.py +0 -0
  88. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory.egg-info/dependency_links.txt +0 -0
  89. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory.egg-info/entry_points.txt +0 -0
  90. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory.egg-info/requires.txt +0 -0
  91. {loop_memory-0.4.3 → loop_memory-0.4.4}/loop_memory.egg-info/top_level.txt +0 -0
  92. {loop_memory-0.4.3 → loop_memory-0.4.4}/setup.cfg +0 -0
  93. {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_admin_ingest_route.py +0 -0
  94. {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_agent_memory_api.py +0 -0
  95. {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_agent_memory_sdk.py +0 -0
  96. {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_auth_token_rotate.py +0 -0
  97. {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_cli_v7.py +0 -0
  98. {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_cli_version.py +0 -0
  99. {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_contradictions.py +0 -0
  100. {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_evolution.py +0 -0
  101. {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_evolution_quality.py +0 -0
  102. {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_expanduser.py +0 -0
  103. {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_export_ask.py +0 -0
  104. {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_graph.py +0 -0
  105. {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_ingest.py +0 -0
  106. {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_llm_consolidator.py +0 -0
  107. {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_llm_fingerprint_not_persisted.py +0 -0
  108. {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_llm_providers.py +0 -0
  109. {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_llm_test_endpoint.py +0 -0
  110. {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_loop.py +0 -0
  111. {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_mcp.py +0 -0
  112. {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_memories_pagination.py +0 -0
  113. {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_openclaw_loader.py +0 -0
  114. {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_reflection.py +0 -0
  115. {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_repo_framing.py +0 -0
  116. {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_score_api.py +0 -0
  117. {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_scoring_v2.py +0 -0
  118. {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_secrets.py +0 -0
  119. {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_serve_app.py +0 -0
  120. {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_serve_handlers.py +0 -0
  121. {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_session_order.py +0 -0
  122. {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_store.py +0 -0
  123. {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_summarization.py +0 -0
  124. {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_universal_memory.py +0 -0
  125. {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_vector_store.py +0 -0
  126. {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_watcher.py +0 -0
  127. {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_wiki_classifier.py +0 -0
  128. {loop_memory-0.4.3 → loop_memory-0.4.4}/tests/test_wiki_export_escape.py +0 -0
  129. {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
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
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.3"
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