awm 0.1.0__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.
- awm-0.1.0/LICENSE +202 -0
- awm-0.1.0/PKG-INFO +58 -0
- awm-0.1.0/README.md +44 -0
- awm-0.1.0/awm/__init__.py +56 -0
- awm-0.1.0/awm/cli.py +190 -0
- awm-0.1.0/awm/scope.py +182 -0
- awm-0.1.0/awm/store.py +174 -0
- awm-0.1.0/awm.egg-info/PKG-INFO +58 -0
- awm-0.1.0/awm.egg-info/SOURCES.txt +13 -0
- awm-0.1.0/awm.egg-info/dependency_links.txt +1 -0
- awm-0.1.0/awm.egg-info/entry_points.txt +2 -0
- awm-0.1.0/awm.egg-info/requires.txt +3 -0
- awm-0.1.0/awm.egg-info/top_level.txt +1 -0
- awm-0.1.0/pyproject.toml +32 -0
- awm-0.1.0/setup.cfg +4 -0
awm-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
|
|
2
|
+
Apache License
|
|
3
|
+
Version 2.0, January 2004
|
|
4
|
+
http://www.apache.org/licenses/
|
|
5
|
+
|
|
6
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
7
|
+
|
|
8
|
+
1. Definitions.
|
|
9
|
+
|
|
10
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
11
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
12
|
+
|
|
13
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
14
|
+
the copyright owner that is granting the License.
|
|
15
|
+
|
|
16
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
17
|
+
other entities that control, are controlled by, or are under common
|
|
18
|
+
control with that entity. For the purposes of this definition,
|
|
19
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
20
|
+
direction or management of such entity, whether by contract or
|
|
21
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
22
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
23
|
+
|
|
24
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
25
|
+
exercising permissions granted by this License.
|
|
26
|
+
|
|
27
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
28
|
+
including but not limited to software source code, documentation
|
|
29
|
+
source, and configuration files.
|
|
30
|
+
|
|
31
|
+
"Object" form shall mean any form resulting from mechanical
|
|
32
|
+
transformation or translation of a Source form, including but
|
|
33
|
+
not limited to compiled object code, generated documentation,
|
|
34
|
+
and conversions to other media types.
|
|
35
|
+
|
|
36
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
37
|
+
Object form, made available under the License, as indicated by a
|
|
38
|
+
copyright notice that is included in or attached to the work
|
|
39
|
+
(an example is provided in the Appendix below).
|
|
40
|
+
|
|
41
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
42
|
+
form, that is based on (or derived from) the Work and for which the
|
|
43
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
44
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
45
|
+
of this License, Derivative Works shall not include works that remain
|
|
46
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
47
|
+
the Work and Derivative Works thereof.
|
|
48
|
+
|
|
49
|
+
"Contribution" shall mean any work of authorship, including
|
|
50
|
+
the original version of the Work and any modifications or additions
|
|
51
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
52
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
53
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
54
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
55
|
+
means any form of electronic, verbal, or written communication sent
|
|
56
|
+
to the Licensor or its representatives, including but not limited to
|
|
57
|
+
communication on electronic mailing lists, source code control systems,
|
|
58
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
59
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
60
|
+
excluding communication that is conspicuously marked or otherwise
|
|
61
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
62
|
+
|
|
63
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
64
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
65
|
+
subsequently incorporated within the Work.
|
|
66
|
+
|
|
67
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
68
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
69
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
70
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
71
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
72
|
+
Work and such Derivative Works in Source or Object form.
|
|
73
|
+
|
|
74
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
75
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
76
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
77
|
+
(except as stated in this section) patent license to make, have made,
|
|
78
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
79
|
+
where such license applies only to those patent claims licensable
|
|
80
|
+
by such Contributor that are necessarily infringed by their
|
|
81
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
82
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
83
|
+
institute patent litigation against any entity (including a
|
|
84
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
85
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
86
|
+
or contributory patent infringement, then any patent licenses
|
|
87
|
+
granted to You under this License for that Work shall terminate
|
|
88
|
+
as of the date such litigation is filed.
|
|
89
|
+
|
|
90
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
91
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
92
|
+
modifications, and in Source or Object form, provided that You
|
|
93
|
+
meet the following conditions:
|
|
94
|
+
|
|
95
|
+
(a) You must give any other recipients of the Work or
|
|
96
|
+
Derivative Works a copy of this License; and
|
|
97
|
+
|
|
98
|
+
(b) You must cause any modified files to carry prominent notices
|
|
99
|
+
stating that You changed the files; and
|
|
100
|
+
|
|
101
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
102
|
+
that You distribute, all copyright, patent, trademark, and
|
|
103
|
+
attribution notices from the Source form of the Work,
|
|
104
|
+
excluding those notices that do not pertain to any part of
|
|
105
|
+
the Derivative Works; and
|
|
106
|
+
|
|
107
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
108
|
+
distribution, then any Derivative Works that You distribute must
|
|
109
|
+
include a readable copy of the attribution notices contained
|
|
110
|
+
within such NOTICE file, excluding those notices that do not
|
|
111
|
+
pertain to any part of the Derivative Works, in at least one
|
|
112
|
+
of the following places: within a NOTICE text file distributed
|
|
113
|
+
as part of the Derivative Works; within the Source form or
|
|
114
|
+
documentation, if provided along with the Derivative Works; or,
|
|
115
|
+
within a display generated by the Derivative Works, if and
|
|
116
|
+
wherever such third-party notices normally appear. The contents
|
|
117
|
+
of the NOTICE file are for informational purposes only and
|
|
118
|
+
do not modify the License. You may add Your own attribution
|
|
119
|
+
notices within Derivative Works that You distribute, alongside
|
|
120
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
121
|
+
that such additional attribution notices cannot be construed
|
|
122
|
+
as modifying the License.
|
|
123
|
+
|
|
124
|
+
You may add Your own copyright statement to Your modifications and
|
|
125
|
+
may provide additional or different license terms and conditions
|
|
126
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
127
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
128
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
129
|
+
the conditions stated in this License.
|
|
130
|
+
|
|
131
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
132
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
133
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
134
|
+
this License, without any additional terms or conditions.
|
|
135
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
136
|
+
the terms of any separate license agreement you may have executed
|
|
137
|
+
with Licensor regarding such Contributions.
|
|
138
|
+
|
|
139
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
140
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
141
|
+
except as required for reasonable and customary use in describing the
|
|
142
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
143
|
+
|
|
144
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
145
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
146
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
147
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
148
|
+
implied, including, without limitation, any warranties or conditions
|
|
149
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
150
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
151
|
+
appropriateness of using or redistributing the Work and assume any
|
|
152
|
+
risks associated with Your exercise of permissions under this License.
|
|
153
|
+
|
|
154
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
155
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
156
|
+
unless required by applicable law (such as deliberate and grossly
|
|
157
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
158
|
+
liable to You for damages, including any direct, indirect, special,
|
|
159
|
+
incidental, or consequential damages of any character arising as a
|
|
160
|
+
result of this License or out of the use or inability to use the
|
|
161
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
162
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
163
|
+
other commercial damages or losses), even if such Contributor
|
|
164
|
+
has been advised of the possibility of such damages.
|
|
165
|
+
|
|
166
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
167
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
168
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
169
|
+
or other liability obligations and/or rights consistent with this
|
|
170
|
+
License. However, in accepting such obligations, You may act only
|
|
171
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
172
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
173
|
+
defend, and hold each Contributor harmless for any liability
|
|
174
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
175
|
+
of your accepting any such warranty or additional liability.
|
|
176
|
+
|
|
177
|
+
END OF TERMS AND CONDITIONS
|
|
178
|
+
|
|
179
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
180
|
+
|
|
181
|
+
To apply the Apache License to your work, attach the following
|
|
182
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
183
|
+
replaced with your own identifying information. (Don't include
|
|
184
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
185
|
+
comment syntax for the file format. We also recommend that a
|
|
186
|
+
file or class name and description of purpose be included on the
|
|
187
|
+
same "printed page" as the copyright notice for easier
|
|
188
|
+
identification within third-party archives.
|
|
189
|
+
|
|
190
|
+
Copyright [yyyy] [name of copyright owner]
|
|
191
|
+
|
|
192
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
193
|
+
you may not use this file except in compliance with the License.
|
|
194
|
+
You may obtain a copy of the License at
|
|
195
|
+
|
|
196
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
197
|
+
|
|
198
|
+
Unless required by applicable law or agreed to in writing, software
|
|
199
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
200
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
201
|
+
See the License for the specific language governing permissions and
|
|
202
|
+
limitations under the License.
|
awm-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: awm
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A portable, scoped agent memory — hierarchical tenant:user:project scopes where a write lands at exactly one scope, a read includes ancestors by distance, and siblings never leak.
|
|
5
|
+
License: Apache-2.0
|
|
6
|
+
Project-URL: Homepage, https://github.com/Aitherium/awm
|
|
7
|
+
Project-URL: Repository, https://github.com/Aitherium/awm.git
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
13
|
+
Dynamic: license-file
|
|
14
|
+
|
|
15
|
+
# awm
|
|
16
|
+
|
|
17
|
+
A portable, scoped agent memory. SQLite, no service, no network.
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
platform:*:* everyone
|
|
21
|
+
{tenant}:*:* one org
|
|
22
|
+
{tenant}:{user}:* one person
|
|
23
|
+
{tenant}:{user}:{project} one piece of work
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install awm
|
|
28
|
+
|
|
29
|
+
awm remember --scope acme:alice:orchestrator --key recipe --value "rank 16, lr 2e-5"
|
|
30
|
+
awm recall --scope acme:alice:orchestrator
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Two rules, deliberately asymmetric
|
|
34
|
+
|
|
35
|
+
**A write lands at exactly one scope.** Writing "somewhere in this subtree" is
|
|
36
|
+
how a memory becomes visible to a scope its author never considered.
|
|
37
|
+
|
|
38
|
+
**A read includes ancestors, weighted by distance.** A project query surfaces
|
|
39
|
+
the user's preferences and the platform's conventions — that is the point of a
|
|
40
|
+
hierarchy — but a platform fact must not outrank a project fact just because it
|
|
41
|
+
was written first.
|
|
42
|
+
|
|
43
|
+
## One property that is security, not tidiness
|
|
44
|
+
|
|
45
|
+
**Siblings never see each other.** `acme:alice:*` and `acme:bob:*` share an
|
|
46
|
+
ancestor and nothing else. The scope check is segment-wise, never a string
|
|
47
|
+
prefix, because `"acmecorp:...".startswith("acme")` is `True` — that is one
|
|
48
|
+
customer's memory entering another's context, silently, with the answer still
|
|
49
|
+
looking like an answer. SQL narrows by an exact computed set, never a `LIKE`.
|
|
50
|
+
|
|
51
|
+
`platform` is a reserved sentinel tenant meaning "everyone", not an
|
|
52
|
+
organisation. Treating it as a literal tenant name made the root of the
|
|
53
|
+
hierarchy invisible from every scope — every recall still returned results, just
|
|
54
|
+
never the platform's. The self-test carries that case.
|
|
55
|
+
|
|
56
|
+
## Licence
|
|
57
|
+
|
|
58
|
+
Apache-2.0.
|
awm-0.1.0/README.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# awm
|
|
2
|
+
|
|
3
|
+
A portable, scoped agent memory. SQLite, no service, no network.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
platform:*:* everyone
|
|
7
|
+
{tenant}:*:* one org
|
|
8
|
+
{tenant}:{user}:* one person
|
|
9
|
+
{tenant}:{user}:{project} one piece of work
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
pip install awm
|
|
14
|
+
|
|
15
|
+
awm remember --scope acme:alice:orchestrator --key recipe --value "rank 16, lr 2e-5"
|
|
16
|
+
awm recall --scope acme:alice:orchestrator
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Two rules, deliberately asymmetric
|
|
20
|
+
|
|
21
|
+
**A write lands at exactly one scope.** Writing "somewhere in this subtree" is
|
|
22
|
+
how a memory becomes visible to a scope its author never considered.
|
|
23
|
+
|
|
24
|
+
**A read includes ancestors, weighted by distance.** A project query surfaces
|
|
25
|
+
the user's preferences and the platform's conventions — that is the point of a
|
|
26
|
+
hierarchy — but a platform fact must not outrank a project fact just because it
|
|
27
|
+
was written first.
|
|
28
|
+
|
|
29
|
+
## One property that is security, not tidiness
|
|
30
|
+
|
|
31
|
+
**Siblings never see each other.** `acme:alice:*` and `acme:bob:*` share an
|
|
32
|
+
ancestor and nothing else. The scope check is segment-wise, never a string
|
|
33
|
+
prefix, because `"acmecorp:...".startswith("acme")` is `True` — that is one
|
|
34
|
+
customer's memory entering another's context, silently, with the answer still
|
|
35
|
+
looking like an answer. SQL narrows by an exact computed set, never a `LIKE`.
|
|
36
|
+
|
|
37
|
+
`platform` is a reserved sentinel tenant meaning "everyone", not an
|
|
38
|
+
organisation. Treating it as a literal tenant name made the root of the
|
|
39
|
+
hierarchy invisible from every scope — every recall still returned results, just
|
|
40
|
+
never the platform's. The self-test carries that case.
|
|
41
|
+
|
|
42
|
+
## Licence
|
|
43
|
+
|
|
44
|
+
Apache-2.0.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""awm — a portable, scoped agent memory. SQLite, no service, no network.
|
|
2
|
+
|
|
3
|
+
Extracted from AitherOS's ScopedMemory. The service plumbing does not
|
|
4
|
+
generalise; the semantics do:
|
|
5
|
+
|
|
6
|
+
platform:*:* everyone
|
|
7
|
+
{tenant}:*:* one org
|
|
8
|
+
{tenant}:{user}:* one person
|
|
9
|
+
{tenant}:{user}:{project} one piece of work
|
|
10
|
+
|
|
11
|
+
import awm
|
|
12
|
+
st = awm.MemoryStore(Path("memory.db"))
|
|
13
|
+
st.remember(awm.Scope("acme", "alice", "orchestrator"), "recipe",
|
|
14
|
+
"rank 16, lr 2e-5")
|
|
15
|
+
st.recall(awm.Scope("acme", "alice", "orchestrator"))
|
|
16
|
+
|
|
17
|
+
Two rules, deliberately asymmetric:
|
|
18
|
+
|
|
19
|
+
- **A write lands at exactly one scope.** Writing "somewhere in this subtree" is
|
|
20
|
+
how a memory becomes visible to a scope its author never considered.
|
|
21
|
+
- **A read includes ancestors, weighted by distance.** A project query should
|
|
22
|
+
surface the user's preferences and the platform's conventions — but a
|
|
23
|
+
platform fact must not outrank a project fact just because it was older.
|
|
24
|
+
|
|
25
|
+
And one property that is security, not tidiness: **siblings never see each
|
|
26
|
+
other.** The scope check is segment-wise, never a string prefix, because
|
|
27
|
+
`"acmecorp:...".startswith("acme")` is True — that is one customer's memory
|
|
28
|
+
entering another's context, silently, with the answer still looking like an
|
|
29
|
+
answer. SQL narrows by an exact computed set, never by a LIKE.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
from .scope import (
|
|
35
|
+
ANCESTOR_DECAY,
|
|
36
|
+
PLATFORM,
|
|
37
|
+
WILDCARD,
|
|
38
|
+
Scope,
|
|
39
|
+
ScopeError,
|
|
40
|
+
visible_scopes,
|
|
41
|
+
)
|
|
42
|
+
from .store import SCHEMA_VERSION, Memory, MemoryStore
|
|
43
|
+
|
|
44
|
+
__version__ = "0.1.0"
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
"ANCESTOR_DECAY",
|
|
48
|
+
"PLATFORM",
|
|
49
|
+
"SCHEMA_VERSION",
|
|
50
|
+
"Memory",
|
|
51
|
+
"MemoryStore",
|
|
52
|
+
"Scope",
|
|
53
|
+
"ScopeError",
|
|
54
|
+
"WILDCARD",
|
|
55
|
+
"visible_scopes",
|
|
56
|
+
]
|
awm-0.1.0/awm/cli.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""`awm` — a portable, scoped agent memory.
|
|
2
|
+
|
|
3
|
+
awm remember --scope acme:alice:proj --key style --value "prefers tables"
|
|
4
|
+
awm recall --scope acme:alice:proj [--query tables]
|
|
5
|
+
awm forget --scope acme:alice:proj --key style
|
|
6
|
+
awm --self-test
|
|
7
|
+
|
|
8
|
+
Scopes are `tenant:user:project`, `*` meaning "not narrowed here". A write lands
|
|
9
|
+
at exactly one scope; a read sees that scope and its ancestors, weighted by
|
|
10
|
+
distance, and never a sibling.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import json
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from .scope import Scope, ScopeError
|
|
21
|
+
from .store import MemoryStore
|
|
22
|
+
|
|
23
|
+
DEFAULT_DB = Path.home() / ".aither" / "awm" / "memory.db"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _store(a) -> MemoryStore:
|
|
27
|
+
return MemoryStore(Path(a.db) if a.db else DEFAULT_DB)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _cmd_remember(a) -> int:
|
|
31
|
+
with _store(a) as st:
|
|
32
|
+
m = st.remember(Scope.parse(a.scope), a.key, a.value, kind=a.kind)
|
|
33
|
+
print(f"remembered {m.key} at {m.scope}")
|
|
34
|
+
return 0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _cmd_recall(a) -> int:
|
|
38
|
+
with _store(a) as st:
|
|
39
|
+
rows = st.recall(Scope.parse(a.scope), query=a.query, limit=a.limit,
|
|
40
|
+
kind=a.kind)
|
|
41
|
+
if a.json:
|
|
42
|
+
print(json.dumps([r.to_dict() for r in rows], indent=2))
|
|
43
|
+
return 0
|
|
44
|
+
if not rows:
|
|
45
|
+
print("no memories visible from this scope")
|
|
46
|
+
return 0
|
|
47
|
+
for r in rows:
|
|
48
|
+
print(f"[{r.weight:.2f}] {r.scope:28} {r.key:20} {r.value[:60]}")
|
|
49
|
+
return 0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _cmd_forget(a) -> int:
|
|
53
|
+
with _store(a) as st:
|
|
54
|
+
gone = st.forget(Scope.parse(a.scope), a.key)
|
|
55
|
+
print("forgotten" if gone else "no such memory at that exact scope")
|
|
56
|
+
return 0 if gone else 1
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def self_test() -> int:
|
|
60
|
+
import tempfile
|
|
61
|
+
ok = True
|
|
62
|
+
|
|
63
|
+
def chk(label, got, want):
|
|
64
|
+
nonlocal ok
|
|
65
|
+
good = got == want
|
|
66
|
+
ok = ok and good
|
|
67
|
+
print(f" {'PASS' if good else 'FAIL'} {label} -> {got!r} (want {want!r})")
|
|
68
|
+
|
|
69
|
+
with tempfile.TemporaryDirectory() as td:
|
|
70
|
+
db = Path(td) / "m.db"
|
|
71
|
+
st = MemoryStore(db)
|
|
72
|
+
|
|
73
|
+
plat = Scope("platform")
|
|
74
|
+
acme = Scope("acme")
|
|
75
|
+
alice = Scope("acme", "alice")
|
|
76
|
+
proj = Scope("acme", "alice", "orchestrator")
|
|
77
|
+
bob = Scope("acme", "bob")
|
|
78
|
+
globex = Scope("globex")
|
|
79
|
+
# The prefix trap, as a real tenant.
|
|
80
|
+
acmecorp = Scope("acmecorp")
|
|
81
|
+
|
|
82
|
+
st.remember(plat, "convention", "anchor the keep-set")
|
|
83
|
+
st.remember(acme, "policy", "no cloud inference")
|
|
84
|
+
st.remember(alice, "style", "prefers tables")
|
|
85
|
+
st.remember(proj, "recipe", "rank 16, lr 2e-5")
|
|
86
|
+
st.remember(bob, "style", "prefers prose")
|
|
87
|
+
st.remember(globex, "policy", "cloud is fine")
|
|
88
|
+
st.remember(acmecorp, "policy", "different company entirely")
|
|
89
|
+
|
|
90
|
+
seen = {m.key for m in st.recall(proj)}
|
|
91
|
+
chk("a project read sees its own memory", "recipe" in seen, True)
|
|
92
|
+
chk(" and its user's", "style" in seen, True)
|
|
93
|
+
chk(" and its tenant's", "policy" in seen, True)
|
|
94
|
+
chk(" and the platform's", "convention" in seen, True)
|
|
95
|
+
|
|
96
|
+
# THE SECURITY PROPERTY. Siblings share an ancestor and nothing else.
|
|
97
|
+
vals = {m.value for m in st.recall(proj)}
|
|
98
|
+
chk("a sibling USER's memory is invisible",
|
|
99
|
+
"prefers prose" in vals, False)
|
|
100
|
+
chk("another TENANT's memory is invisible",
|
|
101
|
+
"cloud is fine" in vals, False)
|
|
102
|
+
# The prefix trap: `"acmecorp:...".startswith("acme")` is True, and a
|
|
103
|
+
# LIKE-based query leaks here silently.
|
|
104
|
+
chk("a tenant whose name PREFIXES ours is invisible",
|
|
105
|
+
"different company entirely" in vals, False)
|
|
106
|
+
|
|
107
|
+
# Nearer scopes must outrank further ones, or a hierarchy is just a bag.
|
|
108
|
+
rows = st.recall(proj)
|
|
109
|
+
chk("the nearest scope ranks first", rows[0].key, "recipe")
|
|
110
|
+
chk(" and the platform fact ranks last", rows[-1].key, "convention")
|
|
111
|
+
chk(" weights decay with distance", rows[0].weight > rows[-1].weight, True)
|
|
112
|
+
|
|
113
|
+
# A read from a WIDER scope must not see narrower memories: alice's
|
|
114
|
+
# style is not the tenant's.
|
|
115
|
+
tenant_vals = {m.value for m in st.recall(acme)}
|
|
116
|
+
chk("a tenant read does NOT see a user's memory",
|
|
117
|
+
"prefers tables" in tenant_vals, False)
|
|
118
|
+
chk(" but does see its own", "no cloud inference" in tenant_vals, True)
|
|
119
|
+
|
|
120
|
+
# forget is exact, never cascading.
|
|
121
|
+
chk("forget at the wrong scope does nothing", st.forget(acme, "style"), False)
|
|
122
|
+
chk(" the memory survives", any(m.key == "style" for m in st.recall(alice)), True)
|
|
123
|
+
chk("forget at the exact scope works", st.forget(alice, "style"), True)
|
|
124
|
+
|
|
125
|
+
# Malformed scopes refuse rather than normalise.
|
|
126
|
+
for bad in ("acme", "a:b:c:d", "", "acme::proj"):
|
|
127
|
+
try:
|
|
128
|
+
Scope.parse(bad)
|
|
129
|
+
chk(f"refuses malformed scope {bad!r}", "no raise", "raise")
|
|
130
|
+
except ScopeError:
|
|
131
|
+
chk(f"refuses malformed scope {bad!r}", "raise", "raise")
|
|
132
|
+
try:
|
|
133
|
+
Scope("acme", "*", "secret")
|
|
134
|
+
chk("refuses a project under a wildcard user", "no raise", "raise")
|
|
135
|
+
except ScopeError:
|
|
136
|
+
chk("refuses a project under a wildcard user", "raise", "raise")
|
|
137
|
+
try:
|
|
138
|
+
Scope("ac:me")
|
|
139
|
+
chk("refuses a separator inside a segment", "no raise", "raise")
|
|
140
|
+
except ScopeError:
|
|
141
|
+
chk("refuses a separator inside a segment", "raise", "raise")
|
|
142
|
+
|
|
143
|
+
st.close()
|
|
144
|
+
|
|
145
|
+
print("\nself-test:", "PASS" if ok else "FAIL")
|
|
146
|
+
return 0 if ok else 1
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def main(argv=None) -> int:
|
|
150
|
+
ap = argparse.ArgumentParser(prog="awm", description=__doc__,
|
|
151
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
152
|
+
ap.add_argument("--self-test", action="store_true")
|
|
153
|
+
ap.add_argument("--db")
|
|
154
|
+
sub = ap.add_subparsers(dest="cmd")
|
|
155
|
+
|
|
156
|
+
r = sub.add_parser("remember")
|
|
157
|
+
r.add_argument("--scope", required=True)
|
|
158
|
+
r.add_argument("--key", required=True)
|
|
159
|
+
r.add_argument("--value", required=True)
|
|
160
|
+
r.add_argument("--kind", default="fact")
|
|
161
|
+
r.set_defaults(fn=_cmd_remember)
|
|
162
|
+
|
|
163
|
+
c = sub.add_parser("recall")
|
|
164
|
+
c.add_argument("--scope", required=True)
|
|
165
|
+
c.add_argument("--query")
|
|
166
|
+
c.add_argument("--kind")
|
|
167
|
+
c.add_argument("--limit", type=int, default=20)
|
|
168
|
+
c.add_argument("--json", action="store_true")
|
|
169
|
+
c.set_defaults(fn=_cmd_recall)
|
|
170
|
+
|
|
171
|
+
f = sub.add_parser("forget")
|
|
172
|
+
f.add_argument("--scope", required=True)
|
|
173
|
+
f.add_argument("--key", required=True)
|
|
174
|
+
f.set_defaults(fn=_cmd_forget)
|
|
175
|
+
|
|
176
|
+
a = ap.parse_args(argv)
|
|
177
|
+
if a.self_test:
|
|
178
|
+
return self_test()
|
|
179
|
+
if not getattr(a, "fn", None):
|
|
180
|
+
ap.print_help()
|
|
181
|
+
return 2
|
|
182
|
+
try:
|
|
183
|
+
return a.fn(a)
|
|
184
|
+
except ScopeError as exc:
|
|
185
|
+
print(f"REFUSED: {exc}", file=sys.stderr)
|
|
186
|
+
return 2
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
if __name__ == "__main__":
|
|
190
|
+
sys.exit(main())
|
awm-0.1.0/awm/scope.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Hierarchical memory scopes: what an agent may see, and what it may not.
|
|
2
|
+
|
|
3
|
+
Extracted from AitherOS's ScopedMemory, which wraps a memory service and a
|
|
4
|
+
knowledge graph with project-aware scoping. The service plumbing does not
|
|
5
|
+
generalise; the SEMANTICS do, and they are the whole value:
|
|
6
|
+
|
|
7
|
+
platform:*:* everyone
|
|
8
|
+
{tenant}:*:* one org
|
|
9
|
+
{tenant}:{user}:* one person
|
|
10
|
+
{tenant}:{user}:{project} one piece of work
|
|
11
|
+
|
|
12
|
+
Two rules, and they are deliberately asymmetric:
|
|
13
|
+
|
|
14
|
+
- **A write lands at EXACTLY ONE scope.** Writing "somewhere in this subtree" is
|
|
15
|
+
how a memory ends up visible to a scope its author never considered.
|
|
16
|
+
- **A read includes ANCESTORS, with diminishing weight.** Asking at project
|
|
17
|
+
level should surface the user's preferences and the platform's conventions —
|
|
18
|
+
that is the point of a hierarchy — but a platform fact must never outrank a
|
|
19
|
+
project fact simply because it was written first.
|
|
20
|
+
|
|
21
|
+
WHAT MUST NEVER HAPPEN, AND WHY IT IS A SECURITY PROPERTY RATHER THAN A BUG
|
|
22
|
+
|
|
23
|
+
Siblings must not see each other. `acme:alice:*` and `acme:bob:*` share an
|
|
24
|
+
ancestor and are otherwise unrelated; `acme:*:*` and `globex:*:*` share only the
|
|
25
|
+
root. A retrieval that walks the tree loosely — a `LIKE 'acme:%'` here, a
|
|
26
|
+
prefix compare there — leaks one customer's memory into another's context, and
|
|
27
|
+
it leaks SILENTLY: the answer still looks like an answer.
|
|
28
|
+
|
|
29
|
+
Prefix matching is the specific trap and it is not hypothetical: `acme` is a
|
|
30
|
+
prefix of `acmecorp`, so `scope.startswith(ancestor)` returns True for two
|
|
31
|
+
unrelated tenants. Scopes here are compared SEGMENT-WISE, never as strings, and
|
|
32
|
+
the self-test carries that exact pair as a case.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
from dataclasses import dataclass
|
|
38
|
+
from typing import List, Tuple
|
|
39
|
+
|
|
40
|
+
SEP = ":"
|
|
41
|
+
WILDCARD = "*"
|
|
42
|
+
PLATFORM = "platform"
|
|
43
|
+
|
|
44
|
+
#: How much an ancestor's memories are discounted per level of distance. A
|
|
45
|
+
#: project fact and a platform fact are both relevant; the nearer one should
|
|
46
|
+
#: win. Applied multiplicatively, so two levels up is 0.25 rather than 0.
|
|
47
|
+
ANCESTOR_DECAY = 0.5
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ScopeError(ValueError):
|
|
51
|
+
"""A malformed or unsafe scope. Raised rather than normalised.
|
|
52
|
+
|
|
53
|
+
"Fixing" a bad scope quietly is how a memory lands somewhere its author did
|
|
54
|
+
not intend — and the write succeeds, so nothing looks wrong.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class Scope:
|
|
60
|
+
"""`tenant:user:project`, where `*` means "not narrowed at this level"."""
|
|
61
|
+
|
|
62
|
+
tenant: str
|
|
63
|
+
user: str = WILDCARD
|
|
64
|
+
project: str = WILDCARD
|
|
65
|
+
|
|
66
|
+
def __post_init__(self) -> None:
|
|
67
|
+
for name, part in (("tenant", self.tenant), ("user", self.user),
|
|
68
|
+
("project", self.project)):
|
|
69
|
+
if not part:
|
|
70
|
+
raise ScopeError(f"{name} must not be empty (use {WILDCARD!r})")
|
|
71
|
+
if SEP in part:
|
|
72
|
+
raise ScopeError(
|
|
73
|
+
f"{name}={part!r} contains {SEP!r}, the scope separator — "
|
|
74
|
+
f"that would silently re-partition the scope")
|
|
75
|
+
# A narrower level under a wildcard is meaningless and, worse,
|
|
76
|
+
# ambiguous: `acme:*:secret` reads as "that project for every user",
|
|
77
|
+
# which no ancestor walk can express consistently.
|
|
78
|
+
if self.user == WILDCARD and self.project != WILDCARD:
|
|
79
|
+
raise ScopeError(
|
|
80
|
+
f"project={self.project!r} under a wildcard user is ambiguous — "
|
|
81
|
+
f"name the user, or widen the project to {WILDCARD!r}")
|
|
82
|
+
|
|
83
|
+
def __str__(self) -> str:
|
|
84
|
+
return SEP.join((self.tenant, self.user, self.project))
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def parts(self) -> Tuple[str, str, str]:
|
|
88
|
+
return (self.tenant, self.user, self.project)
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def parse(cls, text: str) -> "Scope":
|
|
92
|
+
if not isinstance(text, str) or not text.strip():
|
|
93
|
+
raise ScopeError("scope must be a non-empty string")
|
|
94
|
+
bits = text.strip().split(SEP)
|
|
95
|
+
if len(bits) != 3:
|
|
96
|
+
raise ScopeError(
|
|
97
|
+
f"scope {text!r} must have exactly three segments "
|
|
98
|
+
f"(tenant{SEP}user{SEP}project); got {len(bits)}")
|
|
99
|
+
return cls(*bits)
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def is_platform(self) -> bool:
|
|
103
|
+
"""`platform` is a SENTINEL tenant meaning "everyone", not a tenant.
|
|
104
|
+
|
|
105
|
+
Reserved: a real organisation cannot be called `platform` here. The
|
|
106
|
+
alternative — resolving the ambiguity at read time — means a tenant
|
|
107
|
+
could name itself into the root of the hierarchy and see every other
|
|
108
|
+
tenant's memories.
|
|
109
|
+
"""
|
|
110
|
+
return self.tenant == PLATFORM
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def depth(self) -> int:
|
|
114
|
+
"""How specific this scope is: 0 platform-wide … 3 one project.
|
|
115
|
+
|
|
116
|
+
The platform scope is depth 0, not 1. It names no organisation; it is
|
|
117
|
+
the root every other scope descends from, and counting its sentinel
|
|
118
|
+
tenant as a narrowing put it at the same distance as a real tenant —
|
|
119
|
+
which is how it came to be invisible from everywhere.
|
|
120
|
+
"""
|
|
121
|
+
if self.is_platform:
|
|
122
|
+
return 0
|
|
123
|
+
return sum(1 for p in self.parts if p != WILDCARD)
|
|
124
|
+
|
|
125
|
+
def ancestors(self) -> List["Scope"]:
|
|
126
|
+
"""Every scope a read at this scope may ALSO see, nearest first.
|
|
127
|
+
|
|
128
|
+
Never includes siblings. Widening happens strictly right-to-left, which
|
|
129
|
+
is what keeps `acme:alice:*` from ever reaching `acme:bob:*`.
|
|
130
|
+
"""
|
|
131
|
+
out: List[Scope] = []
|
|
132
|
+
tenant, user, project = self.parts
|
|
133
|
+
if project != WILDCARD:
|
|
134
|
+
out.append(Scope(tenant, user, WILDCARD))
|
|
135
|
+
if user != WILDCARD:
|
|
136
|
+
out.append(Scope(tenant, WILDCARD, WILDCARD))
|
|
137
|
+
if tenant != PLATFORM:
|
|
138
|
+
out.append(Scope(PLATFORM, WILDCARD, WILDCARD))
|
|
139
|
+
return out
|
|
140
|
+
|
|
141
|
+
def covers(self, other: "Scope") -> bool:
|
|
142
|
+
"""True when a read at `other` may see memories written at `self`.
|
|
143
|
+
|
|
144
|
+
Segment-wise, never a string prefix. `Scope("acme").covers(...)` must
|
|
145
|
+
not match tenant `acmecorp`, and `"acmecorp:...".startswith("acme")` is
|
|
146
|
+
True — which is precisely how one customer's memory reaches another's
|
|
147
|
+
context, silently, with the answer still looking like an answer.
|
|
148
|
+
"""
|
|
149
|
+
if not isinstance(other, Scope):
|
|
150
|
+
raise ScopeError(f"expected a Scope, got {type(other).__name__}")
|
|
151
|
+
# The platform scope is the root of the hierarchy and covers every
|
|
152
|
+
# scope. Comparing its sentinel tenant literally made `platform:*:*`
|
|
153
|
+
# cover NOTHING — so platform-wide conventions reached no agent, and
|
|
154
|
+
# the failure was a silence: every recall still returned results, just
|
|
155
|
+
# never that one.
|
|
156
|
+
if self.is_platform:
|
|
157
|
+
return True
|
|
158
|
+
for mine, theirs in zip(self.parts, other.parts):
|
|
159
|
+
if mine == WILDCARD:
|
|
160
|
+
continue
|
|
161
|
+
if mine != theirs:
|
|
162
|
+
return False
|
|
163
|
+
return True
|
|
164
|
+
|
|
165
|
+
def weight_for(self, query: "Scope") -> float:
|
|
166
|
+
"""Relevance multiplier for a memory at `self` read from `query`.
|
|
167
|
+
|
|
168
|
+
1.0 at the query's own scope, decaying per level of distance. Returns
|
|
169
|
+
0.0 when this scope is not visible from `query` at all — a caller that
|
|
170
|
+
forgets to filter still gets a zero rather than a leak.
|
|
171
|
+
"""
|
|
172
|
+
if not self.covers(query):
|
|
173
|
+
return 0.0
|
|
174
|
+
distance = query.depth - self.depth
|
|
175
|
+
if distance < 0:
|
|
176
|
+
return 0.0
|
|
177
|
+
return ANCESTOR_DECAY ** distance
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def visible_scopes(query: Scope) -> List[Scope]:
|
|
181
|
+
"""The query scope plus its ancestors, nearest first."""
|
|
182
|
+
return [query, *query.ancestors()]
|
awm-0.1.0/awm/store.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""A portable, scoped agent memory. SQLite, no service, no network.
|
|
2
|
+
|
|
3
|
+
`remember` writes at exactly one scope. `recall` reads that scope and its
|
|
4
|
+
ancestors, weighted by distance, and nothing else.
|
|
5
|
+
|
|
6
|
+
WHY SQLITE AND WHY THE FILTERING IS IN PYTHON
|
|
7
|
+
|
|
8
|
+
The scope check is the security boundary, and it is done in `Scope.covers` —
|
|
9
|
+
segment-wise — rather than in SQL. A `LIKE 'acme:%'` is one keystroke from
|
|
10
|
+
matching `acmecorp:...`, and the failure is silent: the query returns rows, the
|
|
11
|
+
agent answers, and one customer's memory has entered another's context. So SQL
|
|
12
|
+
narrows by an exact set of scope strings computed in Python, and never by a
|
|
13
|
+
pattern.
|
|
14
|
+
|
|
15
|
+
That set is small by construction — a scope has at most three ancestors — so
|
|
16
|
+
"filter in Python" costs an `IN (?,?,?,?)` and buys a boundary that can be
|
|
17
|
+
read, tested and reasoned about in one function.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import sqlite3
|
|
24
|
+
import time
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any, Dict, List, Optional
|
|
28
|
+
|
|
29
|
+
from .scope import Scope, ScopeError, visible_scopes
|
|
30
|
+
|
|
31
|
+
SCHEMA_VERSION = 1
|
|
32
|
+
|
|
33
|
+
_SCHEMA = """
|
|
34
|
+
CREATE TABLE IF NOT EXISTS memories (
|
|
35
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
36
|
+
scope TEXT NOT NULL,
|
|
37
|
+
key TEXT NOT NULL,
|
|
38
|
+
value TEXT NOT NULL,
|
|
39
|
+
kind TEXT NOT NULL DEFAULT 'fact',
|
|
40
|
+
created REAL NOT NULL,
|
|
41
|
+
updated REAL NOT NULL,
|
|
42
|
+
hits INTEGER NOT NULL DEFAULT 0,
|
|
43
|
+
meta TEXT NOT NULL DEFAULT '{}',
|
|
44
|
+
UNIQUE(scope, key)
|
|
45
|
+
);
|
|
46
|
+
CREATE INDEX IF NOT EXISTS idx_scope ON memories(scope);
|
|
47
|
+
CREATE TABLE IF NOT EXISTS schema_meta (version INTEGER NOT NULL);
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class Memory:
|
|
53
|
+
scope: str
|
|
54
|
+
key: str
|
|
55
|
+
value: str
|
|
56
|
+
kind: str
|
|
57
|
+
created: float
|
|
58
|
+
updated: float
|
|
59
|
+
hits: int
|
|
60
|
+
meta: Dict[str, Any]
|
|
61
|
+
weight: float = 1.0
|
|
62
|
+
|
|
63
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
64
|
+
d = {"scope": self.scope, "key": self.key, "value": self.value,
|
|
65
|
+
"kind": self.kind, "created": self.created,
|
|
66
|
+
"updated": self.updated, "hits": self.hits, "meta": self.meta}
|
|
67
|
+
d["weight"] = self.weight
|
|
68
|
+
return d
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class MemoryStore:
|
|
72
|
+
"""Scoped memory backed by one SQLite file."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, path: Path):
|
|
75
|
+
self.path = Path(path)
|
|
76
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
self._db = sqlite3.connect(str(self.path))
|
|
78
|
+
self._db.row_factory = sqlite3.Row
|
|
79
|
+
self._db.executescript(_SCHEMA)
|
|
80
|
+
row = self._db.execute("SELECT version FROM schema_meta").fetchone()
|
|
81
|
+
if row is None:
|
|
82
|
+
self._db.execute("INSERT INTO schema_meta(version) VALUES (?)",
|
|
83
|
+
(SCHEMA_VERSION,))
|
|
84
|
+
self._db.commit()
|
|
85
|
+
elif row["version"] != SCHEMA_VERSION:
|
|
86
|
+
raise ScopeError(
|
|
87
|
+
f"{self.path} is schema version {row['version']}, this is "
|
|
88
|
+
f"{SCHEMA_VERSION}. Refusing to read it — a misread row here is "
|
|
89
|
+
f"a memory attributed to the wrong scope")
|
|
90
|
+
|
|
91
|
+
def close(self) -> None:
|
|
92
|
+
self._db.close()
|
|
93
|
+
|
|
94
|
+
def __enter__(self) -> "MemoryStore":
|
|
95
|
+
return self
|
|
96
|
+
|
|
97
|
+
def __exit__(self, *exc) -> None:
|
|
98
|
+
self.close()
|
|
99
|
+
|
|
100
|
+
def remember(self, scope: Scope, key: str, value: str, *,
|
|
101
|
+
kind: str = "fact", meta: Optional[Dict[str, Any]] = None) -> Memory:
|
|
102
|
+
"""Write at EXACTLY this scope. Upserts on (scope, key)."""
|
|
103
|
+
if not isinstance(scope, Scope):
|
|
104
|
+
raise ScopeError(f"expected a Scope, got {type(scope).__name__}")
|
|
105
|
+
if not key or not isinstance(key, str):
|
|
106
|
+
raise ScopeError("key must be a non-empty string")
|
|
107
|
+
now = time.time()
|
|
108
|
+
s = str(scope)
|
|
109
|
+
payload = json.dumps(meta or {}, sort_keys=True)
|
|
110
|
+
self._db.execute(
|
|
111
|
+
"INSERT INTO memories(scope,key,value,kind,created,updated,meta) "
|
|
112
|
+
"VALUES (?,?,?,?,?,?,?) "
|
|
113
|
+
"ON CONFLICT(scope,key) DO UPDATE SET value=excluded.value, "
|
|
114
|
+
"kind=excluded.kind, updated=excluded.updated, meta=excluded.meta",
|
|
115
|
+
(s, key, value, kind, now, now, payload))
|
|
116
|
+
self._db.commit()
|
|
117
|
+
return Memory(scope=s, key=key, value=value, kind=kind, created=now,
|
|
118
|
+
updated=now, hits=0, meta=dict(meta or {}))
|
|
119
|
+
|
|
120
|
+
def recall(self, scope: Scope, *, query: Optional[str] = None,
|
|
121
|
+
limit: int = 20, kind: Optional[str] = None) -> List[Memory]:
|
|
122
|
+
"""Read this scope and its ancestors, nearest-weighted. Nothing else."""
|
|
123
|
+
if not isinstance(scope, Scope):
|
|
124
|
+
raise ScopeError(f"expected a Scope, got {type(scope).__name__}")
|
|
125
|
+
wanted = visible_scopes(scope)
|
|
126
|
+
names = [str(s) for s in wanted]
|
|
127
|
+
# An exact IN over a computed set. Never a LIKE: `LIKE 'acme:%'` also
|
|
128
|
+
# matches `acmecorp:...`, and the leak is silent.
|
|
129
|
+
placeholders = ",".join("?" * len(names))
|
|
130
|
+
sql = f"SELECT * FROM memories WHERE scope IN ({placeholders})"
|
|
131
|
+
args: List[Any] = list(names)
|
|
132
|
+
if kind:
|
|
133
|
+
sql += " AND kind = ?"
|
|
134
|
+
args.append(kind)
|
|
135
|
+
rows = self._db.execute(sql, args).fetchall()
|
|
136
|
+
|
|
137
|
+
out: List[Memory] = []
|
|
138
|
+
for r in rows:
|
|
139
|
+
src = Scope.parse(r["scope"])
|
|
140
|
+
w = src.weight_for(scope)
|
|
141
|
+
if w <= 0.0:
|
|
142
|
+
# Belt and braces. The IN clause should make this unreachable;
|
|
143
|
+
# if it ever is reachable, dropping the row is the safe answer
|
|
144
|
+
# and a leak is not.
|
|
145
|
+
continue
|
|
146
|
+
if query and query.lower() not in (r["value"] or "").lower() \
|
|
147
|
+
and query.lower() not in (r["key"] or "").lower():
|
|
148
|
+
continue
|
|
149
|
+
out.append(Memory(scope=r["scope"], key=r["key"], value=r["value"],
|
|
150
|
+
kind=r["kind"], created=r["created"],
|
|
151
|
+
updated=r["updated"], hits=r["hits"],
|
|
152
|
+
meta=json.loads(r["meta"] or "{}"), weight=w))
|
|
153
|
+
# Nearest scope first, then most recently updated. A platform fact must
|
|
154
|
+
# not outrank a project fact just because it was written first.
|
|
155
|
+
out.sort(key=lambda m: (-m.weight, -m.updated))
|
|
156
|
+
return out[:limit]
|
|
157
|
+
|
|
158
|
+
def forget(self, scope: Scope, key: str) -> bool:
|
|
159
|
+
"""Delete one memory at EXACTLY this scope. Never cascades.
|
|
160
|
+
|
|
161
|
+
A delete that walked descendants would let a tenant-level forget silently
|
|
162
|
+
remove a project's memories — destructive, invisible, and impossible to
|
|
163
|
+
undo from here.
|
|
164
|
+
"""
|
|
165
|
+
cur = self._db.execute("DELETE FROM memories WHERE scope=? AND key=?",
|
|
166
|
+
(str(scope), key))
|
|
167
|
+
self._db.commit()
|
|
168
|
+
return cur.rowcount > 0
|
|
169
|
+
|
|
170
|
+
def count(self, scope: Optional[Scope] = None) -> int:
|
|
171
|
+
if scope is None:
|
|
172
|
+
return self._db.execute("SELECT COUNT(*) c FROM memories").fetchone()["c"]
|
|
173
|
+
return self._db.execute("SELECT COUNT(*) c FROM memories WHERE scope=?",
|
|
174
|
+
(str(scope),)).fetchone()["c"]
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: awm
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A portable, scoped agent memory — hierarchical tenant:user:project scopes where a write lands at exactly one scope, a read includes ancestors by distance, and siblings never leak.
|
|
5
|
+
License: Apache-2.0
|
|
6
|
+
Project-URL: Homepage, https://github.com/Aitherium/awm
|
|
7
|
+
Project-URL: Repository, https://github.com/Aitherium/awm.git
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
13
|
+
Dynamic: license-file
|
|
14
|
+
|
|
15
|
+
# awm
|
|
16
|
+
|
|
17
|
+
A portable, scoped agent memory. SQLite, no service, no network.
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
platform:*:* everyone
|
|
21
|
+
{tenant}:*:* one org
|
|
22
|
+
{tenant}:{user}:* one person
|
|
23
|
+
{tenant}:{user}:{project} one piece of work
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
pip install awm
|
|
28
|
+
|
|
29
|
+
awm remember --scope acme:alice:orchestrator --key recipe --value "rank 16, lr 2e-5"
|
|
30
|
+
awm recall --scope acme:alice:orchestrator
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Two rules, deliberately asymmetric
|
|
34
|
+
|
|
35
|
+
**A write lands at exactly one scope.** Writing "somewhere in this subtree" is
|
|
36
|
+
how a memory becomes visible to a scope its author never considered.
|
|
37
|
+
|
|
38
|
+
**A read includes ancestors, weighted by distance.** A project query surfaces
|
|
39
|
+
the user's preferences and the platform's conventions — that is the point of a
|
|
40
|
+
hierarchy — but a platform fact must not outrank a project fact just because it
|
|
41
|
+
was written first.
|
|
42
|
+
|
|
43
|
+
## One property that is security, not tidiness
|
|
44
|
+
|
|
45
|
+
**Siblings never see each other.** `acme:alice:*` and `acme:bob:*` share an
|
|
46
|
+
ancestor and nothing else. The scope check is segment-wise, never a string
|
|
47
|
+
prefix, because `"acmecorp:...".startswith("acme")` is `True` — that is one
|
|
48
|
+
customer's memory entering another's context, silently, with the answer still
|
|
49
|
+
looking like an answer. SQL narrows by an exact computed set, never a `LIKE`.
|
|
50
|
+
|
|
51
|
+
`platform` is a reserved sentinel tenant meaning "everyone", not an
|
|
52
|
+
organisation. Treating it as a literal tenant name made the root of the
|
|
53
|
+
hierarchy invisible from every scope — every recall still returned results, just
|
|
54
|
+
never the platform's. The self-test carries that case.
|
|
55
|
+
|
|
56
|
+
## Licence
|
|
57
|
+
|
|
58
|
+
Apache-2.0.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
awm/__init__.py
|
|
5
|
+
awm/cli.py
|
|
6
|
+
awm/scope.py
|
|
7
|
+
awm/store.py
|
|
8
|
+
awm.egg-info/PKG-INFO
|
|
9
|
+
awm.egg-info/SOURCES.txt
|
|
10
|
+
awm.egg-info/dependency_links.txt
|
|
11
|
+
awm.egg-info/entry_points.txt
|
|
12
|
+
awm.egg-info/requires.txt
|
|
13
|
+
awm.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
awm
|
awm-0.1.0/pyproject.toml
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "awm"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A portable, scoped agent memory — hierarchical tenant:user:project scopes where a write lands at exactly one scope, a read includes ancestors by distance, and siblings never leak."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "Apache-2.0" }
|
|
12
|
+
# stdlib only (sqlite3, json). An agent's memory is the last thing that should
|
|
13
|
+
# need a network or a service to be usable off-fleet.
|
|
14
|
+
dependencies = []
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
dev = ["pytest>=7.0"]
|
|
18
|
+
|
|
19
|
+
[project.scripts]
|
|
20
|
+
awm = "awm.cli:main"
|
|
21
|
+
|
|
22
|
+
[project.urls]
|
|
23
|
+
Homepage = "https://github.com/Aitherium/awm"
|
|
24
|
+
Repository = "https://github.com/Aitherium/awm.git"
|
|
25
|
+
|
|
26
|
+
[tool.setuptools]
|
|
27
|
+
packages = ["awm"]
|
|
28
|
+
license-files = ["LICENSE"]
|
|
29
|
+
|
|
30
|
+
[tool.ruff]
|
|
31
|
+
line-length = 100
|
|
32
|
+
target-version = "py310"
|
awm-0.1.0/setup.cfg
ADDED