robo-cortex 0.4.2__tar.gz → 0.4.3__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.
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/CHANGELOG.md +10 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/PKG-INFO +4 -3
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/README.md +3 -2
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/__init__.py +1 -1
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/cli/commands/evidence.py +8 -11
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/cli/commands/link.py +6 -2
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/cli/commands/status.py +3 -2
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/core/errors.py +4 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/core/evidence.py +40 -1
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/core/memory.py +28 -13
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/core/store.py +38 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/test_cli_scope_b.py +56 -9
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/test_evidence.py +47 -2
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/test_mcp_contracts.py +25 -8
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/test_scope_b.py +69 -14
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/test_smoke.py +1 -1
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/.gitignore +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/LICENSE +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/pyproject.toml +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/schema.sql +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/cli/__init__.py +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/cli/__main__.py +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/cli/_common.py +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/cli/_output.py +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/cli/app.py +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/cli/commands/__init__.py +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/cli/commands/affected.py +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/cli/commands/completion.py +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/cli/commands/hooks.py +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/cli/commands/init.py +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/cli/commands/list_cmd.py +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/cli/commands/mcp.py +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/cli/commands/record.py +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/cli/commands/retrieve.py +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/cli/commands/search.py +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/cli/commands/show.py +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/cli/commands/transfer.py +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/core/__init__.py +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/core/assumptions.py +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/core/db.py +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/core/git.py +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/core/hooks.py +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/core/init.py +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/core/invalidate.py +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/core/lifecycle.py +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/core/retrieve.py +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/core/semantic.py +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/core/text.py +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/core/transfer.py +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/gitea.py +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/mcp_server.py +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/migrations/0001_init.sql +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/migrations/0002_add_usage_tracking.sql +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/migrations/0003_add_meta_table.sql +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/sdk.py +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/__init__.py +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/conftest.py +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/fixtures.py +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/test_add_path.py +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/test_affected.py +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/test_assumptions.py +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/test_cli_affected.py +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/test_cli_init.py +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/test_cli_lifecycle.py +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/test_cli_record.py +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/test_cli_retrieve.py +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/test_completion.py +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/test_curation.py +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/test_db.py +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/test_gitea.py +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/test_hooks.py +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/test_init.py +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/test_invalidate.py +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/test_lifecycle.py +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/test_mcp_stdio.py +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/test_memory.py +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/test_output.py +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/test_record_batch.py +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/test_retrieve.py +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/test_reverify.py +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/test_sdk.py +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/test_search.py +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/test_semantic.py +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/test_transfer.py +0 -0
- {robo_cortex-0.4.2 → robo_cortex-0.4.3}/tests/test_update_path.py +0 -0
|
@@ -4,6 +4,16 @@ All notable changes to this project are documented here. Format loosely follows
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.4.3] — 2026-07-31
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **Silent wrong-store mutation on id collision.** Repo-local and global stores are independent autoincrement sequences, so a repo memory and a global memory can share the same numeric id. `roco status`/`link`/`evidence verify` (and the SDK/MCP equivalents), when called without `--scope`, used to silently pick whichever store hit first (local, by convention) — so e.g. `roco status 10 activate` could flip the wrong, unrelated memory to `active` with no error and no indication anything was off. `find_memory_store`/the new `find_evidence_store` now detect the collision and raise a clear `id N exists in both repo and global stores -- pass --scope repo or --scope global to disambiguate` error instead of guessing. `status`/`link` also now echo which store they acted on (`Memory 10 (global) -> active`) so a resolved-but-unintended target is visible immediately. Found live during normal usage.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **Global store id partitioning.** New global memories/evidence/links now start at id 1,000,000,000+ (bumped in place, no renumbering of existing rows) so future ids can no longer collide with any repo-local store's ids — the same bug class above, closed structurally going forward. Ids assigned before this change can still collide with existing repo-local ids; the collision-detection fix above is what catches those.
|
|
16
|
+
|
|
7
17
|
## [0.4.2] — 2026-07-26
|
|
8
18
|
|
|
9
19
|
### Changed
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: robo-cortex
|
|
3
|
-
Version: 0.4.
|
|
3
|
+
Version: 0.4.3
|
|
4
4
|
Summary: Long-term memory system for LLM coding agents working inside a Git repository
|
|
5
5
|
Project-URL: Homepage, https://robotel.top
|
|
6
6
|
Project-URL: Repository, https://github.com/robotel-limited/robo-cortex
|
|
@@ -36,12 +36,13 @@ Description-Content-Type: text/markdown
|
|
|
36
36
|
[](https://opensource.org/licenses/MIT)
|
|
37
37
|
[](https://smithery.ai/servers/robotel-top/robo-cortex)
|
|
38
38
|
[](https://glama.ai/mcp/servers/robotel-limited/robo-cortex)
|
|
39
|
+
[](https://doi.org/10.5281/zenodo.21610869)
|
|
39
40
|
|
|
40
41
|
[](https://glama.ai/mcp/servers/robotel-limited/robo-cortex)
|
|
41
42
|
|
|
42
43
|
**Git-aware knowledge integrity for AI coding agents.**
|
|
43
44
|
|
|
44
|
-
Your AI agent debugs a bug, learns the lesson, and forgets it by next week — or worse, keeps citing a "lesson" that the code has since made false. robo-cortex is a local knowledge base built for that specific failure mode: memories are anchored to git blob hashes, so when the code they're about changes, the memory is automatically flagged for review instead of being silently trusted or silently lost.
|
|
45
|
+
Your AI agent debugs a bug, learns the lesson, and forgets it by next week — or worse, keeps citing a "lesson" that the code has since made false. robo-cortex is a local knowledge base built for that specific failure mode: memories are anchored to git blob hashes, so when the code they're about changes, the memory is automatically flagged for review instead of being silently trusted or silently lost. In other words: **drift detection and stale-memory detection for AI agent memory**, done deterministically from git history — not an LLM guess, not an embedding-similarity heuristic.
|
|
45
46
|
|
|
46
47
|
---
|
|
47
48
|
|
|
@@ -49,7 +50,7 @@ Your AI agent debugs a bug, learns the lesson, and forgets it by next week — o
|
|
|
49
50
|
|
|
50
51
|
Most agent-memory tools are embeddings + a vector database + a cloud dependency. robo-cortex is deliberately none of that. Four things it does that a generic key-value memory store doesn't:
|
|
51
52
|
|
|
52
|
-
1. **Git-aware staleness detection.** Every memory can link to specific file paths, captured by git blob hash at record time. When the linked code changes, the memory is automatically flagged `needs_review` on the next `retrieve`/`search`/`affected` call — and a committed revert automatically heals it back. The healing requires three conditions: (a) the revert must be **committed** (git blob hash must match HEAD again), (b) the memory must have been flagged by the system (not manually via `change_status`), and (c) you must run a lazy trigger (`retrieve`/`search`/`affected`) after the revert to scan for changes.
|
|
53
|
+
1. **Git-aware staleness (drift) detection.** Every memory can link to specific file paths, captured by git blob hash at record time. When the linked code changes, the memory is automatically flagged `needs_review` on the next `retrieve`/`search`/`affected` call — and a committed revert automatically heals it back. The healing requires three conditions: (a) the revert must be **committed** (git blob hash must match HEAD again), (b) the memory must have been flagged by the system (not manually via `change_status`), and (c) you must run a lazy trigger (`retrieve`/`search`/`affected`) after the revert to scan for changes. Few memory tools tie themselves to your actual code's history this deterministically — no embedding similarity, no LLM judgment call, just a git blob hash matching or not.
|
|
53
54
|
2. **Evidence, not vibes.** Memories can carry attached evidence (test output, a commit, a re-runnable command) with a mechanically computed strength — not a judgment call. A `provisional` memory only becomes `active` once real evidence backs it, or a human explicitly promotes it.
|
|
54
55
|
3. **Zero dependencies, fully local.** Pure stdlib + SQLite (FTS5 for search). No embeddings, no API keys, no network calls unless you explicitly configure Gitea evidence verification. `.cortex/memory.db` is just a file — inspect it with `sqlite3` any time.
|
|
55
56
|
4. **A real lifecycle with an audit trail.** Every status change (`active` → `superseded` → `archived`, etc.) requires a `--reason`, permanently recorded. Nothing disappears silently; wrong lessons are marked `invalidated`, not deleted.
|
|
@@ -7,12 +7,13 @@
|
|
|
7
7
|
[](https://opensource.org/licenses/MIT)
|
|
8
8
|
[](https://smithery.ai/servers/robotel-top/robo-cortex)
|
|
9
9
|
[](https://glama.ai/mcp/servers/robotel-limited/robo-cortex)
|
|
10
|
+
[](https://doi.org/10.5281/zenodo.21610869)
|
|
10
11
|
|
|
11
12
|
[](https://glama.ai/mcp/servers/robotel-limited/robo-cortex)
|
|
12
13
|
|
|
13
14
|
**Git-aware knowledge integrity for AI coding agents.**
|
|
14
15
|
|
|
15
|
-
Your AI agent debugs a bug, learns the lesson, and forgets it by next week — or worse, keeps citing a "lesson" that the code has since made false. robo-cortex is a local knowledge base built for that specific failure mode: memories are anchored to git blob hashes, so when the code they're about changes, the memory is automatically flagged for review instead of being silently trusted or silently lost.
|
|
16
|
+
Your AI agent debugs a bug, learns the lesson, and forgets it by next week — or worse, keeps citing a "lesson" that the code has since made false. robo-cortex is a local knowledge base built for that specific failure mode: memories are anchored to git blob hashes, so when the code they're about changes, the memory is automatically flagged for review instead of being silently trusted or silently lost. In other words: **drift detection and stale-memory detection for AI agent memory**, done deterministically from git history — not an LLM guess, not an embedding-similarity heuristic.
|
|
16
17
|
|
|
17
18
|
---
|
|
18
19
|
|
|
@@ -20,7 +21,7 @@ Your AI agent debugs a bug, learns the lesson, and forgets it by next week — o
|
|
|
20
21
|
|
|
21
22
|
Most agent-memory tools are embeddings + a vector database + a cloud dependency. robo-cortex is deliberately none of that. Four things it does that a generic key-value memory store doesn't:
|
|
22
23
|
|
|
23
|
-
1. **Git-aware staleness detection.** Every memory can link to specific file paths, captured by git blob hash at record time. When the linked code changes, the memory is automatically flagged `needs_review` on the next `retrieve`/`search`/`affected` call — and a committed revert automatically heals it back. The healing requires three conditions: (a) the revert must be **committed** (git blob hash must match HEAD again), (b) the memory must have been flagged by the system (not manually via `change_status`), and (c) you must run a lazy trigger (`retrieve`/`search`/`affected`) after the revert to scan for changes.
|
|
24
|
+
1. **Git-aware staleness (drift) detection.** Every memory can link to specific file paths, captured by git blob hash at record time. When the linked code changes, the memory is automatically flagged `needs_review` on the next `retrieve`/`search`/`affected` call — and a committed revert automatically heals it back. The healing requires three conditions: (a) the revert must be **committed** (git blob hash must match HEAD again), (b) the memory must have been flagged by the system (not manually via `change_status`), and (c) you must run a lazy trigger (`retrieve`/`search`/`affected`) after the revert to scan for changes. Few memory tools tie themselves to your actual code's history this deterministically — no embedding similarity, no LLM judgment call, just a git blob hash matching or not.
|
|
24
25
|
2. **Evidence, not vibes.** Memories can carry attached evidence (test output, a commit, a re-runnable command) with a mechanically computed strength — not a judgment call. A `provisional` memory only becomes `active` once real evidence backs it, or a human explicitly promotes it.
|
|
25
26
|
3. **Zero dependencies, fully local.** Pure stdlib + SQLite (FTS5 for search). No embeddings, no API keys, no network calls unless you explicitly configure Gitea evidence verification. `.cortex/memory.db` is just a file — inspect it with `sqlite3` any time.
|
|
26
27
|
4. **A real lifecycle with an audit trail.** Every status change (`active` → `superseded` → `archived`, etc.) requires a `--reason`, permanently recorded. Nothing disappears silently; wrong lessons are marked `invalidated`, not deleted.
|
|
@@ -3,8 +3,12 @@
|
|
|
3
3
|
import json
|
|
4
4
|
import sys
|
|
5
5
|
|
|
6
|
-
from robo_cortex.core.
|
|
7
|
-
|
|
6
|
+
from robo_cortex.core.evidence import (
|
|
7
|
+
EVIDENCE_KINDS,
|
|
8
|
+
attach_evidence,
|
|
9
|
+
find_evidence_store,
|
|
10
|
+
verify_evidence,
|
|
11
|
+
)
|
|
8
12
|
from robo_cortex.core.memory import find_memory_store
|
|
9
13
|
from robo_cortex.cli._common import _get_cmd_name, _store, _global_store, cli_command
|
|
10
14
|
|
|
@@ -45,15 +49,8 @@ def run_verify(args) -> int:
|
|
|
45
49
|
# lives in: it's this invocation's ambient repo context, used to
|
|
46
50
|
# resolve the Gitea owner/repo via the local git remote
|
|
47
51
|
# (Stage 10) for gitea_pr/gitea_issue evidence specifically.
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
elif args.scope == "global":
|
|
51
|
-
result = verify_evidence(global_conn, args.evidence_id, repo_root)
|
|
52
|
-
else:
|
|
53
|
-
try:
|
|
54
|
-
result = verify_evidence(conn, args.evidence_id, repo_root)
|
|
55
|
-
except NotFoundError:
|
|
56
|
-
result = verify_evidence(global_conn, args.evidence_id, repo_root)
|
|
52
|
+
store = find_evidence_store(conn, global_conn, args.evidence_id, scope=args.scope)
|
|
53
|
+
result = verify_evidence(store, args.evidence_id, repo_root)
|
|
57
54
|
|
|
58
55
|
print(json.dumps(result) if args.json else json.dumps(result, indent=2))
|
|
59
56
|
return 0
|
|
@@ -21,12 +21,16 @@ def run(args) -> int:
|
|
|
21
21
|
file=sys.stderr,
|
|
22
22
|
)
|
|
23
23
|
return 1
|
|
24
|
+
resolved_scope = "global" if store1 is global_conn else "repo"
|
|
24
25
|
result = create_link(store1, args.id1, args.id2, link_type)
|
|
25
26
|
|
|
26
27
|
if args.json:
|
|
27
|
-
print(json.dumps(result))
|
|
28
|
+
print(json.dumps({**result, "scope": resolved_scope}))
|
|
28
29
|
else:
|
|
29
|
-
print(
|
|
30
|
+
print(
|
|
31
|
+
f"Linked {result['from_id']} --{result['link_type']}--> "
|
|
32
|
+
f"{result['to_id']} ({resolved_scope})"
|
|
33
|
+
)
|
|
30
34
|
return 0
|
|
31
35
|
|
|
32
36
|
|
|
@@ -39,15 +39,16 @@ def run(args) -> int:
|
|
|
39
39
|
|
|
40
40
|
new_status = STATUS_ACTIONS[args.action]
|
|
41
41
|
store = find_memory_store(conn, global_conn, args.id, scope=args.scope)
|
|
42
|
+
resolved_scope = "global" if store is global_conn else "repo"
|
|
42
43
|
result = change_status(
|
|
43
44
|
store, repo_root, args.id, new_status, args.reason,
|
|
44
45
|
supersedes_link_to=args.supersedes,
|
|
45
46
|
)
|
|
46
47
|
|
|
47
48
|
if args.json:
|
|
48
|
-
print(json.dumps(result))
|
|
49
|
+
print(json.dumps({**result, "scope": resolved_scope}))
|
|
49
50
|
else:
|
|
50
|
-
print(f"Memory {result['id']} -> {result['status']}")
|
|
51
|
+
print(f"Memory {result['id']} ({resolved_scope}) -> {result['status']}")
|
|
51
52
|
return 0
|
|
52
53
|
|
|
53
54
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
from datetime import datetime, timezone
|
|
2
2
|
|
|
3
|
-
from .errors import NotFoundError, ValidationError
|
|
3
|
+
from .errors import AmbiguousIdError, NotFoundError, ValidationError
|
|
4
4
|
from .memory import get_memory, reverify
|
|
5
5
|
|
|
6
6
|
EVIDENCE_KINDS = {
|
|
@@ -107,6 +107,45 @@ def _mark_unverifiable(conn, evidence_id: int, kind: str, ref: str | None, reaso
|
|
|
107
107
|
}
|
|
108
108
|
|
|
109
109
|
|
|
110
|
+
def find_evidence_store(local_conn, global_conn, evidence_id: int, scope: str | None = None):
|
|
111
|
+
"""Same shape as `memory.find_memory_store`, for evidence rows: evidence
|
|
112
|
+
ids are also a per-store autoincrement sequence, so a bare id can
|
|
113
|
+
collide between the repo and global stores just like memory ids do.
|
|
114
|
+
`scope=None` checks both and raises AmbiguousIdError on a double hit
|
|
115
|
+
instead of silently verifying whichever store's row happened to match
|
|
116
|
+
first -- the same failure mode reported for memory ids in
|
|
117
|
+
prompt-bug-roco.md, just not routed through find_memory_store because
|
|
118
|
+
evidence rows aren't looked up by `get_memory`.
|
|
119
|
+
"""
|
|
120
|
+
def _has_evidence(conn) -> bool:
|
|
121
|
+
return conn.execute(
|
|
122
|
+
"SELECT 1 FROM evidence WHERE id = ?", (evidence_id,)
|
|
123
|
+
).fetchone() is not None
|
|
124
|
+
|
|
125
|
+
if scope == "repo":
|
|
126
|
+
if not _has_evidence(local_conn):
|
|
127
|
+
raise NotFoundError(f"no evidence with id {evidence_id}")
|
|
128
|
+
return local_conn
|
|
129
|
+
if scope == "global":
|
|
130
|
+
if global_conn is None or not _has_evidence(global_conn):
|
|
131
|
+
raise NotFoundError(f"no evidence with id {evidence_id}")
|
|
132
|
+
return global_conn
|
|
133
|
+
|
|
134
|
+
local_hit = _has_evidence(local_conn)
|
|
135
|
+
global_hit = global_conn is not None and _has_evidence(global_conn)
|
|
136
|
+
|
|
137
|
+
if local_hit and global_hit:
|
|
138
|
+
raise AmbiguousIdError(
|
|
139
|
+
f"evidence id {evidence_id} exists in both repo and global stores -- "
|
|
140
|
+
"pass --scope repo or --scope global to disambiguate"
|
|
141
|
+
)
|
|
142
|
+
if local_hit:
|
|
143
|
+
return local_conn
|
|
144
|
+
if global_hit:
|
|
145
|
+
return global_conn
|
|
146
|
+
raise NotFoundError(f"no evidence with id {evidence_id}")
|
|
147
|
+
|
|
148
|
+
|
|
110
149
|
def verify_evidence(conn, evidence_id: int, repo_root=None) -> dict:
|
|
111
150
|
"""The single explicit re-verification entry point (ARCHITECTURE.md §3,
|
|
112
151
|
§9) -- the only place a network call to Gitea is ever allowed, and only
|
|
@@ -3,7 +3,7 @@ import sqlite3
|
|
|
3
3
|
from pathlib import Path
|
|
4
4
|
|
|
5
5
|
from . import db as dbmod
|
|
6
|
-
from .errors import NotFoundError, ValidationError
|
|
6
|
+
from .errors import AmbiguousIdError, NotFoundError, ValidationError
|
|
7
7
|
from .git import blob_hash_at_head
|
|
8
8
|
from .text import fts_query_string, tokenize_without_stopwords
|
|
9
9
|
|
|
@@ -289,12 +289,15 @@ def find_memory_store(local_conn, global_conn, memory_id: int, scope: str | None
|
|
|
289
289
|
proven live: a local id 1 makes global id 1 unreachable by id from that
|
|
290
290
|
repo without a way to say which store you mean.
|
|
291
291
|
|
|
292
|
-
`scope=None` (default):
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
292
|
+
`scope=None` (default): look in both stores. Exactly one hit resolves
|
|
293
|
+
unambiguously. Both hit -> AmbiguousIdError instead of silently picking
|
|
294
|
+
one -- an earlier version silently preferred local here, which is what
|
|
295
|
+
let `roco status 10 activate` flip an unrelated repo-scoped memory
|
|
296
|
+
instead of the intended global one, live, with no error and no
|
|
297
|
+
indication anything had gone wrong (see prompt-bug-roco.md). Neither
|
|
298
|
+
hit -> NotFoundError. `scope='repo'` / `scope='global'`: look only in
|
|
299
|
+
that store, no fallback -- the explicit disambiguator for when the
|
|
300
|
+
caller already knows which memory they mean.
|
|
298
301
|
|
|
299
302
|
Used by every ID-taking command (`show`, `status`, `evidence
|
|
300
303
|
add/verify`, `link`) so a scope='global' memory recorded once is still
|
|
@@ -309,17 +312,29 @@ def find_memory_store(local_conn, global_conn, memory_id: int, scope: str | None
|
|
|
309
312
|
get_memory(global_conn, memory_id)
|
|
310
313
|
return global_conn
|
|
311
314
|
|
|
312
|
-
|
|
313
|
-
|
|
315
|
+
local_hit = _has_memory(local_conn, memory_id)
|
|
316
|
+
global_hit = global_conn is not None and _has_memory(global_conn, memory_id)
|
|
317
|
+
|
|
318
|
+
if local_hit and global_hit:
|
|
319
|
+
raise AmbiguousIdError(
|
|
320
|
+
f"id {memory_id} exists in both repo and global stores -- "
|
|
321
|
+
"pass --scope repo or --scope global to disambiguate"
|
|
322
|
+
)
|
|
323
|
+
if local_hit:
|
|
314
324
|
return local_conn
|
|
315
|
-
|
|
316
|
-
pass
|
|
317
|
-
if global_conn is not None:
|
|
318
|
-
get_memory(global_conn, memory_id) # raises NotFoundError if absent there too
|
|
325
|
+
if global_hit:
|
|
319
326
|
return global_conn
|
|
320
327
|
raise NotFoundError(f"no memory with id {memory_id}")
|
|
321
328
|
|
|
322
329
|
|
|
330
|
+
def _has_memory(conn, memory_id: int) -> bool:
|
|
331
|
+
try:
|
|
332
|
+
get_memory(conn, memory_id)
|
|
333
|
+
return True
|
|
334
|
+
except NotFoundError:
|
|
335
|
+
return False
|
|
336
|
+
|
|
337
|
+
|
|
323
338
|
def reverify(conn, repo_root: Path, memory_id: int) -> None:
|
|
324
339
|
"""Re-verification: recapture the current HEAD blob hash of every
|
|
325
340
|
linked path and bump `last_verified_at` to now.
|
|
@@ -32,6 +32,21 @@ def open_store(repo_arg: str | None) -> tuple[Path, sqlite3.Connection]:
|
|
|
32
32
|
GLOBAL_DIR = Path.home() / ".cortex"
|
|
33
33
|
GLOBAL_DB_FILENAME = "global.db"
|
|
34
34
|
|
|
35
|
+
# Repo-local stores and the global store share one schema/migration chain,
|
|
36
|
+
# so each starts its own autoincrement id sequence at 1 -- meaning a repo
|
|
37
|
+
# memory #10 and a global memory #10 can (and did, live) coexist and refer
|
|
38
|
+
# to unrelated rows, with nothing to disambiguate a bare id between them
|
|
39
|
+
# (see prompt-bug-roco.md). Reserving this range for the global store makes
|
|
40
|
+
# new collisions structurally impossible: no realistic single-user repo
|
|
41
|
+
# will ever record a billion memories. This only pushes future ids in the
|
|
42
|
+
# global store's autoincrement tables above the floor -- it never touches
|
|
43
|
+
# already-assigned ids (that would renumber existing memory/evidence rows
|
|
44
|
+
# out from under their own FK references and any external reference to
|
|
45
|
+
# those ids), so ids assigned before this fix shipped can still collide;
|
|
46
|
+
# `find_memory_store`'s AmbiguousIdError is what catches those.
|
|
47
|
+
GLOBAL_ID_FLOOR = 1_000_000_000
|
|
48
|
+
_GLOBAL_AUTOINCREMENT_TABLES = ("memory", "evidence", "memory_link", "cold_storage")
|
|
49
|
+
|
|
35
50
|
# Override for tests (and any user who wants a non-default location): a full
|
|
36
51
|
# path to the global db file. Without this, every test process would read
|
|
37
52
|
# and write the *real* ~/.cortex/global.db on whatever machine runs the
|
|
@@ -69,4 +84,27 @@ def open_global_store() -> sqlite3.Connection | None:
|
|
|
69
84
|
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
70
85
|
conn = connect(db_path)
|
|
71
86
|
migrate(conn)
|
|
87
|
+
_ensure_global_id_floor(conn)
|
|
72
88
|
return conn
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _ensure_global_id_floor(conn: sqlite3.Connection) -> None:
|
|
92
|
+
"""Bump each autoincrement table's next id up to GLOBAL_ID_FLOOR if it
|
|
93
|
+
isn't already past it. Idempotent (only ever raises seq, never lowers
|
|
94
|
+
it) and a no-op once the floor has been crossed, so it's cheap to call
|
|
95
|
+
on every open rather than needing a one-time migration step.
|
|
96
|
+
"""
|
|
97
|
+
for table in _GLOBAL_AUTOINCREMENT_TABLES:
|
|
98
|
+
row = conn.execute(
|
|
99
|
+
"SELECT seq FROM sqlite_sequence WHERE name = ?", (table,)
|
|
100
|
+
).fetchone()
|
|
101
|
+
if row is None:
|
|
102
|
+
conn.execute(
|
|
103
|
+
"INSERT INTO sqlite_sequence (name, seq) VALUES (?, ?)",
|
|
104
|
+
(table, GLOBAL_ID_FLOOR),
|
|
105
|
+
)
|
|
106
|
+
elif row[0] < GLOBAL_ID_FLOOR:
|
|
107
|
+
conn.execute(
|
|
108
|
+
"UPDATE sqlite_sequence SET seq = ? WHERE name = ?",
|
|
109
|
+
(GLOBAL_ID_FLOOR, table),
|
|
110
|
+
)
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import json
|
|
2
|
+
import os
|
|
3
|
+
import sqlite3
|
|
2
4
|
import subprocess
|
|
3
5
|
import sys
|
|
4
6
|
|
|
@@ -104,12 +106,14 @@ def test_cli_explain_human_readable_output_on_global_item_does_not_crash(tmp_pat
|
|
|
104
106
|
assert _run_cli("init", "--repo", str(repo_a)).returncode == 0
|
|
105
107
|
assert _run_cli("init", "--repo", str(repo_b)).returncode == 0
|
|
106
108
|
|
|
107
|
-
|
|
109
|
+
record_result = _run_cli(
|
|
108
110
|
"record", "--repo", str(repo_a),
|
|
109
111
|
"--type", "lesson", "--scope", "global", "--confidence", "high",
|
|
110
112
|
"--statement", "Prefer SQLite over Postgres for local-first single-user tools.",
|
|
111
113
|
"--assumptions", "single-user, local-first",
|
|
112
|
-
|
|
114
|
+
"--json",
|
|
115
|
+
)
|
|
116
|
+
assert record_result.returncode == 0
|
|
113
117
|
|
|
114
118
|
retrieve_result = _run_cli(
|
|
115
119
|
"retrieve", "--repo", str(repo_b),
|
|
@@ -120,7 +124,7 @@ def test_cli_explain_human_readable_output_on_global_item_does_not_crash(tmp_pat
|
|
|
120
124
|
assert "assumptions_gate=" in retrieve_result.stdout
|
|
121
125
|
assert "passed=True" in retrieve_result.stdout
|
|
122
126
|
|
|
123
|
-
memory_id =
|
|
127
|
+
memory_id = json.loads(record_result.stdout)["id"]
|
|
124
128
|
show_result = _run_cli(
|
|
125
129
|
"show", str(memory_id), "--repo", str(repo_b),
|
|
126
130
|
"--explain-against", "database choice for a single-user local-first tool",
|
|
@@ -129,9 +133,11 @@ def test_cli_explain_human_readable_output_on_global_item_does_not_crash(tmp_pat
|
|
|
129
133
|
assert "assumptions_gate=" in show_result.stdout
|
|
130
134
|
|
|
131
135
|
|
|
132
|
-
def
|
|
133
|
-
"""
|
|
134
|
-
|
|
136
|
+
def test_cli_new_global_ids_no_longer_collide_with_repo_ids(tmp_path):
|
|
137
|
+
"""Structural half of the id-collision fix (prompt-bug-roco.md option
|
|
138
|
+
3): the global store's ids are now partitioned into a high range, so a
|
|
139
|
+
freshly recorded global memory no longer collides with a repo-local id
|
|
140
|
+
-- `show` resolves either one correctly with no --scope needed."""
|
|
135
141
|
repo_a = build_fixture_repo_a(tmp_path / "a")
|
|
136
142
|
assert _run_cli("init", "--repo", str(repo_a)).returncode == 0
|
|
137
143
|
|
|
@@ -148,11 +154,52 @@ def test_cli_scope_disambiguates_a_colliding_id(tmp_path):
|
|
|
148
154
|
)
|
|
149
155
|
local_id = json.loads(local_record.stdout)["id"]
|
|
150
156
|
global_id = json.loads(global_record.stdout)["id"]
|
|
157
|
+
assert local_id != global_id
|
|
158
|
+
|
|
159
|
+
local_show = _run_cli("show", str(local_id), "--repo", str(repo_a), "--json")
|
|
160
|
+
global_show = _run_cli("show", str(global_id), "--repo", str(repo_a), "--json")
|
|
161
|
+
assert json.loads(local_show.stdout)["statement"] == "a local fact"
|
|
162
|
+
assert json.loads(global_show.stdout)["statement"] == "a global lesson"
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def test_cli_scope_disambiguates_a_legacy_colliding_id(tmp_path):
|
|
166
|
+
"""Proven live (prompt-bug-roco.md): before the id-floor fix, a local
|
|
167
|
+
id could shadow a global id of the same number for show/status/evidence
|
|
168
|
+
with no way to say which store is meant. New installs no longer produce
|
|
169
|
+
this (ids are partitioned apart, re-enforced on every open -- resetting
|
|
170
|
+
the sequence between CLI invocations wouldn't stick), but rows already
|
|
171
|
+
assigned a low id before the floor existed still can collide forever
|
|
172
|
+
(the floor never renumbers existing rows). Simulated here by inserting
|
|
173
|
+
a global row directly with an explicit low id, bypassing the sequence
|
|
174
|
+
the same way pre-fix on-disk data would sit. Bare `show` must now
|
|
175
|
+
refuse instead of silently shadowing; --scope still reaches each one."""
|
|
176
|
+
repo_a = build_fixture_repo_a(tmp_path / "a")
|
|
177
|
+
assert _run_cli("init", "--repo", str(repo_a)).returncode == 0
|
|
178
|
+
assert _run_cli("init", "--repo", str(repo_a), "--global").returncode == 0
|
|
179
|
+
|
|
180
|
+
local_record = _run_cli(
|
|
181
|
+
"record", "--repo", str(repo_a),
|
|
182
|
+
"--type", "fact", "--scope", "repo", "--confidence", "low",
|
|
183
|
+
"--statement", "a local fact", "--json",
|
|
184
|
+
)
|
|
185
|
+
local_id = json.loads(local_record.stdout)["id"]
|
|
186
|
+
|
|
187
|
+
global_db_path = os.environ["ROBO_CORTEX_GLOBAL_DB"]
|
|
188
|
+
global_conn = sqlite3.connect(global_db_path)
|
|
189
|
+
global_conn.execute(
|
|
190
|
+
"INSERT INTO memory (id, type, scope, statement, assumptions, confidence) "
|
|
191
|
+
"VALUES (?, 'lesson', 'global', 'a global lesson', 'single-user', 'high')",
|
|
192
|
+
(local_id,),
|
|
193
|
+
)
|
|
194
|
+
global_conn.commit()
|
|
195
|
+
global_conn.close()
|
|
196
|
+
global_id = local_id
|
|
151
197
|
assert local_id == global_id # the collision this test is about
|
|
152
198
|
|
|
153
|
-
# without disambiguation, the
|
|
154
|
-
default_show = _run_cli("show", str(local_id), "--repo", str(repo_a)
|
|
155
|
-
assert
|
|
199
|
+
# without disambiguation, the ambiguous id is refused, not silently guessed
|
|
200
|
+
default_show = _run_cli("show", str(local_id), "--repo", str(repo_a))
|
|
201
|
+
assert default_show.returncode != 0
|
|
202
|
+
assert "exists in both repo and global stores" in default_show.stderr
|
|
156
203
|
|
|
157
204
|
# --scope reaches each one unambiguously
|
|
158
205
|
repo_show = _run_cli("show", str(local_id), "--repo", str(repo_a), "--scope", "repo", "--json")
|
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import pytest
|
|
2
2
|
|
|
3
3
|
from robo_cortex.core.db import connect, migrate
|
|
4
|
-
from robo_cortex.core.errors import NotFoundError, ValidationError
|
|
5
|
-
from robo_cortex.core.evidence import
|
|
4
|
+
from robo_cortex.core.errors import AmbiguousIdError, NotFoundError, ValidationError
|
|
5
|
+
from robo_cortex.core.evidence import (
|
|
6
|
+
attach_evidence,
|
|
7
|
+
evidence_strength,
|
|
8
|
+
find_evidence_store,
|
|
9
|
+
verify_evidence,
|
|
10
|
+
)
|
|
6
11
|
from robo_cortex.core.memory import get_memory, record_memory
|
|
12
|
+
from robo_cortex.core.store import open_global_store
|
|
7
13
|
|
|
8
14
|
from .fixtures import build_fixture_repo_a
|
|
9
15
|
|
|
@@ -131,3 +137,42 @@ def test_verify_evidence_rejects_unknown_id(tmp_path):
|
|
|
131
137
|
_repo, conn = _store(tmp_path)
|
|
132
138
|
with pytest.raises(NotFoundError):
|
|
133
139
|
verify_evidence(conn, 9999)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_find_evidence_store_raises_ambiguous_on_id_collision(tmp_path):
|
|
143
|
+
"""Evidence ids are a per-store autoincrement sequence too -- same
|
|
144
|
+
shape as the memory-id collision (prompt-bug-roco.md), just not routed
|
|
145
|
+
through find_memory_store since evidence rows aren't memories. The
|
|
146
|
+
store.py id-floor fix stops *new* collisions, but ids assigned before
|
|
147
|
+
that floor existed can still collide; simulated here by resetting the
|
|
148
|
+
global store's evidence sequence back down, the way pre-fix on-disk
|
|
149
|
+
data would look."""
|
|
150
|
+
repo, local_conn = _store(tmp_path)
|
|
151
|
+
global_conn = open_global_store()
|
|
152
|
+
|
|
153
|
+
local_result = record_memory(local_conn, repo, type="fact", scope="repo", statement="x", confidence="low")
|
|
154
|
+
local_evidence = attach_evidence(
|
|
155
|
+
local_conn, repo, local_result["id"], kind="free_text", description="local evidence",
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
global_conn.execute("UPDATE sqlite_sequence SET seq = 0 WHERE name = 'evidence'")
|
|
159
|
+
global_result = record_memory(
|
|
160
|
+
global_conn, repo, type="lesson", scope="global", confidence="high",
|
|
161
|
+
statement="global lesson", assumptions="single-user",
|
|
162
|
+
)
|
|
163
|
+
global_evidence = attach_evidence(
|
|
164
|
+
global_conn, repo, global_result["id"], kind="free_text", description="global evidence",
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
assert local_evidence["evidence_id"] == global_evidence["evidence_id"] # the collision this test is about
|
|
168
|
+
|
|
169
|
+
with pytest.raises(AmbiguousIdError, match="exists in both repo and global stores"):
|
|
170
|
+
find_evidence_store(local_conn, global_conn, local_evidence["evidence_id"])
|
|
171
|
+
|
|
172
|
+
# explicit scope reaches each one unambiguously
|
|
173
|
+
assert find_evidence_store(
|
|
174
|
+
local_conn, global_conn, local_evidence["evidence_id"], scope="repo"
|
|
175
|
+
) is local_conn
|
|
176
|
+
assert find_evidence_store(
|
|
177
|
+
local_conn, global_conn, global_evidence["evidence_id"], scope="global"
|
|
178
|
+
) is global_conn
|
|
@@ -6,6 +6,7 @@ JSON-RPC, same as the stdout-purity tests.
|
|
|
6
6
|
|
|
7
7
|
import json
|
|
8
8
|
import os
|
|
9
|
+
import sqlite3
|
|
9
10
|
import subprocess
|
|
10
11
|
import sys
|
|
11
12
|
|
|
@@ -303,10 +304,14 @@ def test_verify_evidence_gitea_backed_degrades_gracefully(tmp_path):
|
|
|
303
304
|
assert result["reason"] == "gitea_not_configured"
|
|
304
305
|
|
|
305
306
|
|
|
306
|
-
def
|
|
307
|
+
def test_scope_disambiguator_reaches_a_legacy_colliding_memory(tmp_path):
|
|
307
308
|
"""The second pre-Stage-9 fix, verified all the way through the MCP
|
|
308
|
-
layer
|
|
309
|
-
|
|
309
|
+
layer. A fresh global id no longer collides with a repo-local one at all
|
|
310
|
+
(store.py's id-floor partitioning) -- forced here by resetting the
|
|
311
|
+
global sequence back down, the way pre-fix on-disk data would look, so
|
|
312
|
+
this still exercises the collision path. Bare get_memory on the
|
|
313
|
+
ambiguous id must now fail instead of silently shadowing to local;
|
|
314
|
+
scope='repo'/'global' still reach each one."""
|
|
310
315
|
repo = build_fixture_repo_a(tmp_path)
|
|
311
316
|
_init(repo)
|
|
312
317
|
|
|
@@ -314,18 +319,30 @@ def test_scope_disambiguator_reaches_shadowed_global_memory(tmp_path):
|
|
|
314
319
|
local_result = await _call(session, "record_memory", {
|
|
315
320
|
"type": "fact", "scope": "repo", "confidence": "low", "statement": "a local fact",
|
|
316
321
|
})
|
|
322
|
+
|
|
323
|
+
global_db_path = os.environ["ROBO_CORTEX_GLOBAL_DB"]
|
|
324
|
+
global_conn = sqlite3.connect(global_db_path)
|
|
325
|
+
global_conn.execute(
|
|
326
|
+
"UPDATE sqlite_sequence SET seq = ? WHERE name = 'memory'",
|
|
327
|
+
(local_result["id"] - 1,),
|
|
328
|
+
)
|
|
329
|
+
global_conn.commit()
|
|
330
|
+
global_conn.close()
|
|
331
|
+
|
|
317
332
|
global_result = await _call(session, "record_memory", {
|
|
318
333
|
"type": "lesson", "scope": "global", "confidence": "high",
|
|
319
334
|
"statement": "a global lesson", "assumptions": "single-user",
|
|
320
335
|
})
|
|
321
|
-
assert local_result["id"] == global_result["id"]
|
|
336
|
+
assert local_result["id"] == global_result["id"] # the collision this test is about
|
|
322
337
|
|
|
323
|
-
|
|
338
|
+
default_error = await _call_expect_error(
|
|
339
|
+
session, "get_memory", {"memory_id": local_result["id"]}
|
|
340
|
+
)
|
|
324
341
|
repo_fetch = await _call(session, "get_memory", {"memory_id": local_result["id"], "scope": "repo"})
|
|
325
342
|
global_fetch = await _call(session, "get_memory", {"memory_id": global_result["id"], "scope": "global"})
|
|
326
|
-
return
|
|
343
|
+
return default_error, repo_fetch, global_fetch
|
|
327
344
|
|
|
328
|
-
|
|
329
|
-
assert
|
|
345
|
+
default_error, repo_fetch, global_fetch = run_scenario(repo, scenario)
|
|
346
|
+
assert "exists in both repo and global stores" in default_error
|
|
330
347
|
assert repo_fetch["statement"] == "a local fact"
|
|
331
348
|
assert global_fetch["statement"] == "a global lesson"
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import pytest
|
|
6
6
|
|
|
7
7
|
from robo_cortex.core.db import connect, migrate
|
|
8
|
-
from robo_cortex.core.errors import NotFoundError, ValidationError
|
|
8
|
+
from robo_cortex.core.errors import AmbiguousIdError, NotFoundError, ValidationError
|
|
9
9
|
from robo_cortex.core.memory import find_memory_store, get_memory, list_memories, record_memory
|
|
10
10
|
from robo_cortex.core.retrieve import retrieve_context, search_memory
|
|
11
11
|
from robo_cortex.core.store import open_global_store
|
|
@@ -212,45 +212,55 @@ def test_find_memory_store_falls_back_to_global_when_absent_locally(tmp_path):
|
|
|
212
212
|
find_memory_store(local_conn_a, global_conn, 999999)
|
|
213
213
|
|
|
214
214
|
|
|
215
|
-
def
|
|
216
|
-
"""Both stores are independent autoincrement sequences
|
|
217
|
-
|
|
218
|
-
|
|
215
|
+
def test_find_memory_store_raises_ambiguous_on_id_collision(tmp_path):
|
|
216
|
+
"""Both stores are independent autoincrement sequences, so the same id
|
|
217
|
+
can legitimately refer to two different memories -- proven live
|
|
218
|
+
(prompt-bug-roco.md): silently preferring local here let `roco status
|
|
219
|
+
10 activate` flip an unrelated repo memory instead of the intended
|
|
220
|
+
global one, with no error and no indication anything was wrong.
|
|
221
|
+
scope=None must now refuse instead of guessing."""
|
|
219
222
|
repo_a, local_conn_a = _repo_a(tmp_path)
|
|
220
223
|
global_conn = open_global_store()
|
|
221
224
|
|
|
222
225
|
local_result = record_memory(
|
|
223
226
|
local_conn_a, repo_a, type="fact", scope="repo", confidence="low", statement="local fact",
|
|
224
227
|
)
|
|
228
|
+
# the id-floor fix (store.py) stops *new* global ids from colliding with
|
|
229
|
+
# repo-local ones -- force the pre-fix scenario (ids assigned before the
|
|
230
|
+
# floor existed) by resetting the global sequence back down, since that
|
|
231
|
+
# residual case is exactly what AmbiguousIdError still needs to catch.
|
|
232
|
+
global_conn.execute("UPDATE sqlite_sequence SET seq = 0 WHERE name = 'memory'")
|
|
225
233
|
global_result = record_memory(
|
|
226
234
|
global_conn, repo_a, type="lesson", scope="global", confidence="high",
|
|
227
235
|
statement="global lesson", assumptions="single-user",
|
|
228
236
|
)
|
|
229
237
|
assert local_result["id"] == global_result["id"] # the collision this test is about
|
|
230
238
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
assert get_memory(resolved, local_result["id"])["statement"] == "local fact"
|
|
239
|
+
with pytest.raises(AmbiguousIdError, match="exists in both repo and global stores"):
|
|
240
|
+
find_memory_store(local_conn_a, global_conn, local_result["id"])
|
|
234
241
|
|
|
235
242
|
|
|
236
|
-
def
|
|
237
|
-
"""Proven live: without a disambiguator, a colliding
|
|
238
|
-
|
|
239
|
-
|
|
243
|
+
def test_find_memory_store_explicit_scope_reaches_either_side_of_a_collision(tmp_path):
|
|
244
|
+
"""Proven live: without a disambiguator, a colliding id is ambiguous
|
|
245
|
+
between stores. --scope='repo'/'global' (surfaced in the CLI and, per
|
|
246
|
+
Gate B follow-up, as an MCP tool input) reaches each one unambiguously."""
|
|
240
247
|
repo_a, local_conn_a = _repo_a(tmp_path)
|
|
241
248
|
global_conn = open_global_store()
|
|
242
249
|
|
|
243
250
|
local_result = record_memory(
|
|
244
251
|
local_conn_a, repo_a, type="fact", scope="repo", confidence="low", statement="local fact",
|
|
245
252
|
)
|
|
253
|
+
# force a pre-id-floor-style collision -- see comment above
|
|
254
|
+
global_conn.execute("UPDATE sqlite_sequence SET seq = 0 WHERE name = 'memory'")
|
|
246
255
|
global_result = record_memory(
|
|
247
256
|
global_conn, repo_a, type="lesson", scope="global", confidence="high",
|
|
248
257
|
statement="global lesson", assumptions="single-user",
|
|
249
258
|
)
|
|
250
259
|
assert local_result["id"] == global_result["id"]
|
|
251
260
|
|
|
252
|
-
# default (no scope)
|
|
253
|
-
|
|
261
|
+
# default (no scope) is ambiguous and must be refused, not silently guessed
|
|
262
|
+
with pytest.raises(AmbiguousIdError):
|
|
263
|
+
find_memory_store(local_conn_a, global_conn, local_result["id"])
|
|
254
264
|
|
|
255
265
|
# explicit scope reaches each one unambiguously
|
|
256
266
|
local_store = find_memory_store(local_conn_a, global_conn, local_result["id"], scope="repo")
|
|
@@ -269,3 +279,48 @@ def test_find_memory_store_explicit_scope_raises_not_found_if_wrong(tmp_path):
|
|
|
269
279
|
|
|
270
280
|
with pytest.raises(NotFoundError):
|
|
271
281
|
find_memory_store(local_conn_a, global_conn, local_result["id"], scope="global")
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def test_global_store_ids_are_partitioned_above_repo_ids(tmp_path):
|
|
285
|
+
"""Structural half of the id-collision fix (prompt-bug-roco.md option
|
|
286
|
+
3): new global memories start far above any realistic repo-local id, so
|
|
287
|
+
future recordings can no longer collide by construction -- unlike the
|
|
288
|
+
AmbiguousIdError guard, which only catches a collision after it exists."""
|
|
289
|
+
from robo_cortex.core.store import GLOBAL_ID_FLOOR
|
|
290
|
+
|
|
291
|
+
repo_a, local_conn_a = _repo_a(tmp_path)
|
|
292
|
+
global_conn = open_global_store()
|
|
293
|
+
|
|
294
|
+
local_result = record_memory(
|
|
295
|
+
local_conn_a, repo_a, type="fact", scope="repo", confidence="low", statement="local fact",
|
|
296
|
+
)
|
|
297
|
+
global_result = record_memory(
|
|
298
|
+
global_conn, repo_a, type="lesson", scope="global", confidence="high",
|
|
299
|
+
statement="global lesson", assumptions="single-user",
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
assert local_result["id"] < GLOBAL_ID_FLOOR
|
|
303
|
+
assert global_result["id"] >= GLOBAL_ID_FLOOR
|
|
304
|
+
assert local_result["id"] != global_result["id"]
|
|
305
|
+
|
|
306
|
+
# unambiguous by construction now -- no --scope needed
|
|
307
|
+
resolved = find_memory_store(local_conn_a, global_conn, global_result["id"])
|
|
308
|
+
assert resolved is global_conn
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def test_global_store_id_floor_does_not_renumber_existing_rows(tmp_path):
|
|
312
|
+
"""The floor only pushes the *next* id up; it must never touch ids
|
|
313
|
+
already assigned (that would break memory_link/evidence FK references,
|
|
314
|
+
and any external reference to a memory by id)."""
|
|
315
|
+
repo_a, local_conn_a = _repo_a(tmp_path)
|
|
316
|
+
global_conn = open_global_store()
|
|
317
|
+
|
|
318
|
+
first = record_memory(
|
|
319
|
+
global_conn, repo_a, type="lesson", scope="global", confidence="high",
|
|
320
|
+
statement="first global lesson", assumptions="single-user",
|
|
321
|
+
)
|
|
322
|
+
global_conn.close()
|
|
323
|
+
|
|
324
|
+
# simulate a second process/CLI invocation re-opening the same store
|
|
325
|
+
reopened = open_global_store()
|
|
326
|
+
assert get_memory(reopened, first["id"])["statement"] == "first global lesson"
|
|
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
|
{robo_cortex-0.4.2 → robo_cortex-0.4.3}/src/robo_cortex/migrations/0002_add_usage_tracking.sql
RENAMED
|
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
|