omnemo 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.
- omnemo-0.1.0/LICENSE +21 -0
- omnemo-0.1.0/PKG-INFO +104 -0
- omnemo-0.1.0/README.md +88 -0
- omnemo-0.1.0/omnemo/__init__.py +3 -0
- omnemo-0.1.0/omnemo/cli.py +175 -0
- omnemo-0.1.0/omnemo/config.py +208 -0
- omnemo-0.1.0/omnemo/embedder.py +83 -0
- omnemo-0.1.0/omnemo/omarchy/__init__.py +1 -0
- omnemo-0.1.0/omnemo/omarchy/assets/plugin/BarWidget.qml +78 -0
- omnemo-0.1.0/omnemo/omarchy/assets/plugin/manifest.json +20 -0
- omnemo-0.1.0/omnemo/omarchy/assets/skill/SKILL.md +69 -0
- omnemo-0.1.0/omnemo/omarchy/assets/systemd/omnemo-warm.service +11 -0
- omnemo-0.1.0/omnemo/omarchy/glove.py +147 -0
- omnemo-0.1.0/omnemo/omarchy/harnesses.py +369 -0
- omnemo-0.1.0/omnemo/server.py +98 -0
- omnemo-0.1.0/omnemo/store.py +286 -0
- omnemo-0.1.0/omnemo.egg-info/PKG-INFO +104 -0
- omnemo-0.1.0/omnemo.egg-info/SOURCES.txt +27 -0
- omnemo-0.1.0/omnemo.egg-info/dependency_links.txt +1 -0
- omnemo-0.1.0/omnemo.egg-info/entry_points.txt +2 -0
- omnemo-0.1.0/omnemo.egg-info/requires.txt +5 -0
- omnemo-0.1.0/omnemo.egg-info/top_level.txt +1 -0
- omnemo-0.1.0/pyproject.toml +31 -0
- omnemo-0.1.0/setup.cfg +4 -0
- omnemo-0.1.0/tests/test_config.py +88 -0
- omnemo-0.1.0/tests/test_glove.py +84 -0
- omnemo-0.1.0/tests/test_harnesses.py +287 -0
- omnemo-0.1.0/tests/test_server.py +38 -0
- omnemo-0.1.0/tests/test_store.py +241 -0
omnemo-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Guy Hutchins
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
omnemo-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: omnemo
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Memory-only MCP server: save, recall, search, forget.
|
|
5
|
+
Author: Guy Hutchins
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/GuyMannDude/omnemo
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: mcp>=2
|
|
12
|
+
Requires-Dist: fastembed>=0.8
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: pytest; extra == "dev"
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# Omnemo — memory for Omarchy
|
|
18
|
+
|
|
19
|
+
**One shared memory for every agent harness on the box.**
|
|
20
|
+
|
|
21
|
+
Omarchy made coding agents system citizens. Omnemo gives them the one thing
|
|
22
|
+
they still wake up without: memory. Claude Code remembers what Codex did
|
|
23
|
+
yesterday. Your agent knows this app crashed before — and what fixed it.
|
|
24
|
+
|
|
25
|
+
Built just for [Omarchy](https://omarchy.org). Not a fork, not a patch — a
|
|
26
|
+
glove-fit layer made entirely from Omarchy's own extension points (skills,
|
|
27
|
+
user plugins, hooks), so it survives every `omarchy update`.
|
|
28
|
+
|
|
29
|
+
## What you get
|
|
30
|
+
|
|
31
|
+
- **Shared recall across all harnesses** — one memory store, spoken over MCP,
|
|
32
|
+
registered with every agent CLI Omarchy ships. Save in one, recall in any.
|
|
33
|
+
- **Human-like memory, not a fixed database** — recall blends similarity,
|
|
34
|
+
recency, and importance; memories decay at different rates by kind, the way
|
|
35
|
+
yours do.
|
|
36
|
+
- **A memory skill** dropped into `~/.agents/skills/` — every harness learns
|
|
37
|
+
*when* to remember and *when* to recall, automatically.
|
|
38
|
+
- **Crashes with a past** — Omarchy hands core dumps to your agent; Omnemo
|
|
39
|
+
lets the agent answer "this crashed before, here's what fixed it."
|
|
40
|
+
- **Memory in the bar** — an Omarchy shell plugin showing what your machine
|
|
41
|
+
remembered today.
|
|
42
|
+
|
|
43
|
+
## Install
|
|
44
|
+
|
|
45
|
+
> Fastest path: open your agent (`omarchy agent`) and say
|
|
46
|
+
> **"Install Omnemo — follow INSTALL.md."** It will do the rest.
|
|
47
|
+
|
|
48
|
+
Manual instructions: [`INSTALL.md`](./INSTALL.md).
|
|
49
|
+
|
|
50
|
+
## Prove it
|
|
51
|
+
|
|
52
|
+
Memory is durable the instant a save returns — a crashing harness loses
|
|
53
|
+
nothing. Don't take our word for it:
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
python3 examples/crash_demo.py
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
It opens a real MCP session against `omnemo serve`, saves a memory, SIGKILLs
|
|
60
|
+
the server the moment the save is acknowledged (no shutdown, no flush), then
|
|
61
|
+
recalls the memory from a fresh process.
|
|
62
|
+
|
|
63
|
+
### Cross-harness recall, live
|
|
64
|
+
|
|
65
|
+
The core promise — save in one harness, recall in any — is a two-command
|
|
66
|
+
demo on a box with two authenticated harnesses:
|
|
67
|
+
|
|
68
|
+
```sh
|
|
69
|
+
# Ask Claude Code (Anthropic) to remember something
|
|
70
|
+
claude -p 'Use the omnemo save tool to store this memory: the electronics
|
|
71
|
+
repair shop favorite song was "Everything'\''s Broken" by Bob Dylan' \
|
|
72
|
+
--allowedTools "mcp__omnemo__save"
|
|
73
|
+
|
|
74
|
+
# Ask Codex (OpenAI) — a different vendor's harness, fresh process
|
|
75
|
+
codex exec 'Using only the omnemo MCP recall tool: what was the favorite
|
|
76
|
+
song at the electronics repair shop?'
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Run live on the reference Omarchy 4.0.1 box:
|
|
80
|
+
|
|
81
|
+
```
|
|
82
|
+
mcp: omnemo/recall started
|
|
83
|
+
mcp: omnemo/recall (completed)
|
|
84
|
+
codex
|
|
85
|
+
"Everything's Broken" by Bob Dylan.
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
One memory, written by one vendor's agent, recalled by its competitor.
|
|
89
|
+
The memory belongs to the machine, not the harness.
|
|
90
|
+
|
|
91
|
+
## Status
|
|
92
|
+
|
|
93
|
+
**v0.1 glove, proven on a clean Omarchy 4.0.1 box.** Fresh install to green
|
|
94
|
+
in minutes: MCP registered with every shipping harness (verified read-back,
|
|
95
|
+
not just name-matched), skill linked, bar widget live — and everything
|
|
96
|
+
survives `omarchy update`, harness upgrades, theme switches, and a shell
|
|
97
|
+
restart with zero re-setup. See [`SPEC.md`](./SPEC.md) for the design.
|
|
98
|
+
|
|
99
|
+
## Lineage
|
|
100
|
+
|
|
101
|
+
Omnemo is the memory core of [Mnemo Cortex](https://github.com/GuyMannDude/mnemo-cortex)
|
|
102
|
+
— "memory that works more like a human than the others" — rebuilt clean and
|
|
103
|
+
cut down to just memory, then fitted to Omarchy natively. MIT licensed, like
|
|
104
|
+
Omarchy itself.
|
omnemo-0.1.0/README.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# Omnemo — memory for Omarchy
|
|
2
|
+
|
|
3
|
+
**One shared memory for every agent harness on the box.**
|
|
4
|
+
|
|
5
|
+
Omarchy made coding agents system citizens. Omnemo gives them the one thing
|
|
6
|
+
they still wake up without: memory. Claude Code remembers what Codex did
|
|
7
|
+
yesterday. Your agent knows this app crashed before — and what fixed it.
|
|
8
|
+
|
|
9
|
+
Built just for [Omarchy](https://omarchy.org). Not a fork, not a patch — a
|
|
10
|
+
glove-fit layer made entirely from Omarchy's own extension points (skills,
|
|
11
|
+
user plugins, hooks), so it survives every `omarchy update`.
|
|
12
|
+
|
|
13
|
+
## What you get
|
|
14
|
+
|
|
15
|
+
- **Shared recall across all harnesses** — one memory store, spoken over MCP,
|
|
16
|
+
registered with every agent CLI Omarchy ships. Save in one, recall in any.
|
|
17
|
+
- **Human-like memory, not a fixed database** — recall blends similarity,
|
|
18
|
+
recency, and importance; memories decay at different rates by kind, the way
|
|
19
|
+
yours do.
|
|
20
|
+
- **A memory skill** dropped into `~/.agents/skills/` — every harness learns
|
|
21
|
+
*when* to remember and *when* to recall, automatically.
|
|
22
|
+
- **Crashes with a past** — Omarchy hands core dumps to your agent; Omnemo
|
|
23
|
+
lets the agent answer "this crashed before, here's what fixed it."
|
|
24
|
+
- **Memory in the bar** — an Omarchy shell plugin showing what your machine
|
|
25
|
+
remembered today.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
> Fastest path: open your agent (`omarchy agent`) and say
|
|
30
|
+
> **"Install Omnemo — follow INSTALL.md."** It will do the rest.
|
|
31
|
+
|
|
32
|
+
Manual instructions: [`INSTALL.md`](./INSTALL.md).
|
|
33
|
+
|
|
34
|
+
## Prove it
|
|
35
|
+
|
|
36
|
+
Memory is durable the instant a save returns — a crashing harness loses
|
|
37
|
+
nothing. Don't take our word for it:
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
python3 examples/crash_demo.py
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
It opens a real MCP session against `omnemo serve`, saves a memory, SIGKILLs
|
|
44
|
+
the server the moment the save is acknowledged (no shutdown, no flush), then
|
|
45
|
+
recalls the memory from a fresh process.
|
|
46
|
+
|
|
47
|
+
### Cross-harness recall, live
|
|
48
|
+
|
|
49
|
+
The core promise — save in one harness, recall in any — is a two-command
|
|
50
|
+
demo on a box with two authenticated harnesses:
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
# Ask Claude Code (Anthropic) to remember something
|
|
54
|
+
claude -p 'Use the omnemo save tool to store this memory: the electronics
|
|
55
|
+
repair shop favorite song was "Everything'\''s Broken" by Bob Dylan' \
|
|
56
|
+
--allowedTools "mcp__omnemo__save"
|
|
57
|
+
|
|
58
|
+
# Ask Codex (OpenAI) — a different vendor's harness, fresh process
|
|
59
|
+
codex exec 'Using only the omnemo MCP recall tool: what was the favorite
|
|
60
|
+
song at the electronics repair shop?'
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Run live on the reference Omarchy 4.0.1 box:
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
mcp: omnemo/recall started
|
|
67
|
+
mcp: omnemo/recall (completed)
|
|
68
|
+
codex
|
|
69
|
+
"Everything's Broken" by Bob Dylan.
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
One memory, written by one vendor's agent, recalled by its competitor.
|
|
73
|
+
The memory belongs to the machine, not the harness.
|
|
74
|
+
|
|
75
|
+
## Status
|
|
76
|
+
|
|
77
|
+
**v0.1 glove, proven on a clean Omarchy 4.0.1 box.** Fresh install to green
|
|
78
|
+
in minutes: MCP registered with every shipping harness (verified read-back,
|
|
79
|
+
not just name-matched), skill linked, bar widget live — and everything
|
|
80
|
+
survives `omarchy update`, harness upgrades, theme switches, and a shell
|
|
81
|
+
restart with zero re-setup. See [`SPEC.md`](./SPEC.md) for the design.
|
|
82
|
+
|
|
83
|
+
## Lineage
|
|
84
|
+
|
|
85
|
+
Omnemo is the memory core of [Mnemo Cortex](https://github.com/GuyMannDude/mnemo-cortex)
|
|
86
|
+
— "memory that works more like a human than the others" — rebuilt clean and
|
|
87
|
+
cut down to just memory, then fitted to Omarchy natively. MIT licensed, like
|
|
88
|
+
Omarchy itself.
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""CLI: `omnemo serve` runs the MCP server; save/recall/search/forget are
|
|
2
|
+
direct verbs for testing; `omnemo stats` is the stats-only digest source.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import sys
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .config import ConfigError, load_config, store_path
|
|
13
|
+
from .embedder import make_embedder
|
|
14
|
+
from .store import CorruptStoreError, EmbedderMismatchError, Store
|
|
15
|
+
|
|
16
|
+
# Anything the store/config layer reports as a clean, named error.
|
|
17
|
+
_KNOWN_ERRORS = (ConfigError, CorruptStoreError, EmbedderMismatchError)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _open_store() -> Store:
|
|
21
|
+
config = load_config()
|
|
22
|
+
embedder = make_embedder(config.embedder)
|
|
23
|
+
return Store(store_path(), embedder, config)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _fmt_time(ts: float | None) -> str:
|
|
27
|
+
if ts is None:
|
|
28
|
+
return "never"
|
|
29
|
+
return datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def main(argv: list[str] | None = None) -> int:
|
|
33
|
+
parser = argparse.ArgumentParser(
|
|
34
|
+
prog="omnemo", description="Memory-only MCP server."
|
|
35
|
+
)
|
|
36
|
+
parser.add_argument("--version", action="version", version=__version__)
|
|
37
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
38
|
+
|
|
39
|
+
sub.add_parser("serve", help="run the MCP server on stdio")
|
|
40
|
+
|
|
41
|
+
p_save = sub.add_parser("save", help="save one memory")
|
|
42
|
+
p_save.add_argument("text")
|
|
43
|
+
p_save.add_argument("--category", "-c", default=None)
|
|
44
|
+
|
|
45
|
+
p_recall = sub.add_parser("recall", help="recall memories by meaning")
|
|
46
|
+
p_recall.add_argument("query")
|
|
47
|
+
p_recall.add_argument("--limit", "-n", type=int, default=None)
|
|
48
|
+
|
|
49
|
+
p_search = sub.add_parser("search", help="search memories by substring")
|
|
50
|
+
p_search.add_argument("query")
|
|
51
|
+
p_search.add_argument("--limit", "-n", type=int, default=None)
|
|
52
|
+
|
|
53
|
+
p_forget = sub.add_parser("forget", help="permanently delete a memory by id")
|
|
54
|
+
p_forget.add_argument("id", type=int)
|
|
55
|
+
|
|
56
|
+
p_stats = sub.add_parser(
|
|
57
|
+
"stats", help="memory count, learned today, last recall"
|
|
58
|
+
)
|
|
59
|
+
p_stats.add_argument("--json", action="store_true", dest="as_json")
|
|
60
|
+
|
|
61
|
+
sub.add_parser("warm", help="pre-load the embedder (boot warm-up)")
|
|
62
|
+
|
|
63
|
+
sub.add_parser(
|
|
64
|
+
"setup",
|
|
65
|
+
help="install the Omarchy glove: skill, bar plugin, warm-up, connect",
|
|
66
|
+
)
|
|
67
|
+
sub.add_parser("connect", help="register the MCP server with every harness")
|
|
68
|
+
p_disc = sub.add_parser("disconnect", help="unregister from every harness")
|
|
69
|
+
p_disc.add_argument(
|
|
70
|
+
"--all-pieces", action="store_true",
|
|
71
|
+
help="also remove skill links, bar plugin, and warm-up unit",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
args = parser.parse_args(argv)
|
|
75
|
+
|
|
76
|
+
if args.command in ("setup", "connect", "disconnect"):
|
|
77
|
+
from .omarchy import glove
|
|
78
|
+
from .omarchy.harnesses import connect_all, disconnect_all
|
|
79
|
+
|
|
80
|
+
if args.command == "setup":
|
|
81
|
+
lines, results = glove.setup()
|
|
82
|
+
elif args.command == "connect":
|
|
83
|
+
lines, results = [], connect_all()
|
|
84
|
+
elif getattr(args, "all_pieces", False):
|
|
85
|
+
lines, results = glove.teardown()
|
|
86
|
+
else:
|
|
87
|
+
lines, results = [], disconnect_all()
|
|
88
|
+
for line in lines:
|
|
89
|
+
print(line)
|
|
90
|
+
failed = False
|
|
91
|
+
for r in results:
|
|
92
|
+
mark = "ok " if r.ok else "FAIL"
|
|
93
|
+
detail = f" — {r.detail}" if r.detail else ""
|
|
94
|
+
print(f"[{mark}] {r.harness}: {r.action}{detail}")
|
|
95
|
+
failed = failed or not r.ok
|
|
96
|
+
return 1 if failed else 0
|
|
97
|
+
|
|
98
|
+
if args.command == "warm":
|
|
99
|
+
try:
|
|
100
|
+
config = load_config()
|
|
101
|
+
embedder = make_embedder(config.embedder)
|
|
102
|
+
embedder.embed(["omnemo warm-up"])
|
|
103
|
+
except _KNOWN_ERRORS as e:
|
|
104
|
+
print(f"error: {e}", file=sys.stderr)
|
|
105
|
+
return 1
|
|
106
|
+
print("embedder warm")
|
|
107
|
+
return 0
|
|
108
|
+
|
|
109
|
+
if args.command == "serve":
|
|
110
|
+
from .server import serve
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
serve()
|
|
114
|
+
except _KNOWN_ERRORS as e:
|
|
115
|
+
print(f"error: {e}", file=sys.stderr)
|
|
116
|
+
return 1
|
|
117
|
+
return 0
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
store = _open_store()
|
|
121
|
+
except _KNOWN_ERRORS as e:
|
|
122
|
+
print(f"error: {e}", file=sys.stderr)
|
|
123
|
+
return 1
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
if args.command == "save":
|
|
127
|
+
try:
|
|
128
|
+
memory = store.save(args.text, args.category)
|
|
129
|
+
except ValueError as e:
|
|
130
|
+
print(f"error: {e}", file=sys.stderr)
|
|
131
|
+
return 1
|
|
132
|
+
print(f"saved #{memory.id} [{memory.category}]")
|
|
133
|
+
|
|
134
|
+
elif args.command == "recall":
|
|
135
|
+
results = store.recall(args.query, args.limit)
|
|
136
|
+
if not results:
|
|
137
|
+
print("no memories recalled")
|
|
138
|
+
for r in results:
|
|
139
|
+
print(
|
|
140
|
+
f"#{r.memory.id} [{r.memory.category}] "
|
|
141
|
+
f"score={r.score:.3f} sim={r.similarity:.3f} {r.memory.text}"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
elif args.command == "search":
|
|
145
|
+
memories = store.search(args.query, args.limit)
|
|
146
|
+
if not memories:
|
|
147
|
+
print("no matches")
|
|
148
|
+
for m in memories:
|
|
149
|
+
print(f"#{m.id} [{m.category}] {m.text}")
|
|
150
|
+
|
|
151
|
+
elif args.command == "forget":
|
|
152
|
+
if store.forget(args.id):
|
|
153
|
+
print(f"forgot #{args.id}")
|
|
154
|
+
else:
|
|
155
|
+
print(f"error: no memory #{args.id}", file=sys.stderr)
|
|
156
|
+
return 1
|
|
157
|
+
|
|
158
|
+
elif args.command == "stats":
|
|
159
|
+
s = store.stats()
|
|
160
|
+
if args.as_json:
|
|
161
|
+
import json
|
|
162
|
+
|
|
163
|
+
print(json.dumps(s))
|
|
164
|
+
else:
|
|
165
|
+
print(f"memories: {s['memory_count']}")
|
|
166
|
+
print(f"learned today: {s['learned_today']}")
|
|
167
|
+
print(f"last recall: {_fmt_time(s['last_recall_at'])}")
|
|
168
|
+
finally:
|
|
169
|
+
store.close()
|
|
170
|
+
|
|
171
|
+
return 0
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
if __name__ == "__main__":
|
|
175
|
+
sys.exit(main())
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Configuration: exposed defaults, optionally overridden by a TOML file.
|
|
2
|
+
|
|
3
|
+
Every ranking weight, threshold, and category parameter lives here — never
|
|
4
|
+
hard-coded at the point of use — so recall can be tuned on real data
|
|
5
|
+
without touching code.
|
|
6
|
+
|
|
7
|
+
Config file: $XDG_CONFIG_HOME/omnemo/config.toml (default ~/.config/omnemo/).
|
|
8
|
+
Missing file or missing keys fall back to the shipped defaults below.
|
|
9
|
+
Invalid values fail at load time with a ConfigError naming the key —
|
|
10
|
+
hand-edited TOML is the supported tuning surface, so it must never crash
|
|
11
|
+
a verb with a raw traceback later.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import os
|
|
17
|
+
import tomllib
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ConfigError(Exception):
|
|
23
|
+
"""config.toml contains an invalid value."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def config_path() -> Path:
|
|
27
|
+
base = os.environ.get("XDG_CONFIG_HOME") or str(Path.home() / ".config")
|
|
28
|
+
return Path(base) / "omnemo" / "config.toml"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def data_dir() -> Path:
|
|
32
|
+
base = os.environ.get("XDG_DATA_HOME") or str(Path.home() / ".local" / "share")
|
|
33
|
+
return Path(base) / "omnemo"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def store_path() -> Path:
|
|
37
|
+
return data_dir() / "store.db"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class Category:
|
|
42
|
+
"""Per-category ranking parameters.
|
|
43
|
+
|
|
44
|
+
importance: additive weight in the composite recall score (0..1).
|
|
45
|
+
half_life_multiplier: scales the base recency half-life — memories in
|
|
46
|
+
a category with multiplier 2.0 fade half as fast as the base rate.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
importance: float
|
|
50
|
+
half_life_multiplier: float
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
DEFAULT_CATEGORIES: dict[str, Category] = {
|
|
54
|
+
"fact": Category(importance=0.6, half_life_multiplier=4.0),
|
|
55
|
+
"decision": Category(importance=1.0, half_life_multiplier=6.0),
|
|
56
|
+
"preference": Category(importance=0.8, half_life_multiplier=8.0),
|
|
57
|
+
"incident": Category(importance=0.9, half_life_multiplier=2.0),
|
|
58
|
+
"transient": Category(importance=0.2, half_life_multiplier=0.25),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class Config:
|
|
64
|
+
# Embedder spec: "fastembed:<model-name>".
|
|
65
|
+
embedder: str = "fastembed:BAAI/bge-small-en-v1.5"
|
|
66
|
+
|
|
67
|
+
# Recall ranking — composite score:
|
|
68
|
+
# score = weight_similarity * cosine_similarity
|
|
69
|
+
# + weight_recency * 0.5 ** (age_days / (base_half_life_days * category.half_life_multiplier))
|
|
70
|
+
# + weight_importance * category.importance
|
|
71
|
+
# + weight_recall_count * min(recall_count, recall_count_cap) / recall_count_cap
|
|
72
|
+
# Candidates below min_similarity are dropped before scoring.
|
|
73
|
+
min_similarity: float = 0.35
|
|
74
|
+
weight_similarity: float = 1.0
|
|
75
|
+
weight_recency: float = 0.25
|
|
76
|
+
weight_importance: float = 0.15
|
|
77
|
+
weight_recall_count: float = 0.10
|
|
78
|
+
base_half_life_days: float = 30.0
|
|
79
|
+
recall_count_cap: int = 10
|
|
80
|
+
|
|
81
|
+
default_category: str = "fact"
|
|
82
|
+
recall_limit: int = 5
|
|
83
|
+
search_limit: int = 20
|
|
84
|
+
|
|
85
|
+
# Parameters used for memories whose saved category is no longer in
|
|
86
|
+
# the config (removed or renamed): recall degrades, never crashes.
|
|
87
|
+
fallback_importance: float = 0.5
|
|
88
|
+
fallback_half_life_multiplier: float = 1.0
|
|
89
|
+
|
|
90
|
+
categories: dict[str, Category] = field(
|
|
91
|
+
default_factory=lambda: dict(DEFAULT_CATEGORIES)
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
_SCALAR_TYPES: dict[str, type] = {
|
|
96
|
+
"embedder": str,
|
|
97
|
+
"min_similarity": float,
|
|
98
|
+
"weight_similarity": float,
|
|
99
|
+
"weight_recency": float,
|
|
100
|
+
"weight_importance": float,
|
|
101
|
+
"weight_recall_count": float,
|
|
102
|
+
"base_half_life_days": float,
|
|
103
|
+
"recall_count_cap": int,
|
|
104
|
+
"default_category": str,
|
|
105
|
+
"recall_limit": int,
|
|
106
|
+
"search_limit": int,
|
|
107
|
+
"fallback_importance": float,
|
|
108
|
+
"fallback_half_life_multiplier": float,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _scalar(key: str, value: object) -> object:
|
|
113
|
+
expected = _SCALAR_TYPES[key]
|
|
114
|
+
is_bool = isinstance(value, bool)
|
|
115
|
+
if expected is float and isinstance(value, (int, float)) and not is_bool:
|
|
116
|
+
return float(value)
|
|
117
|
+
if expected is int and isinstance(value, int) and not is_bool:
|
|
118
|
+
return value
|
|
119
|
+
if expected is str and isinstance(value, str):
|
|
120
|
+
return value
|
|
121
|
+
kind = {float: "number", int: "integer", str: "string"}[expected]
|
|
122
|
+
raise ConfigError(f"{key} must be a {kind} (got {value!r})")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _number(name: str, value: object) -> float:
|
|
126
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
127
|
+
return float(value)
|
|
128
|
+
raise ConfigError(f"{name} must be a number (got {value!r})")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def load_config(path: Path | None = None) -> Config:
|
|
132
|
+
"""Load config from TOML, merging over the shipped defaults.
|
|
133
|
+
|
|
134
|
+
Raises ConfigError with the offending key named for any invalid value.
|
|
135
|
+
"""
|
|
136
|
+
path = path if path is not None else config_path()
|
|
137
|
+
if not path.exists():
|
|
138
|
+
return Config()
|
|
139
|
+
|
|
140
|
+
with open(path, "rb") as f:
|
|
141
|
+
raw = tomllib.load(f)
|
|
142
|
+
|
|
143
|
+
kwargs: dict = {k: _scalar(k, raw[k]) for k in _SCALAR_TYPES if k in raw}
|
|
144
|
+
|
|
145
|
+
defaults = Config()
|
|
146
|
+
# Base for categories new to this file that omit a field.
|
|
147
|
+
new_base = Category(
|
|
148
|
+
importance=kwargs.get("fallback_importance", defaults.fallback_importance),
|
|
149
|
+
half_life_multiplier=kwargs.get(
|
|
150
|
+
"fallback_half_life_multiplier", defaults.fallback_half_life_multiplier
|
|
151
|
+
),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
raw_categories = raw.get("categories", {})
|
|
155
|
+
if not isinstance(raw_categories, dict):
|
|
156
|
+
raise ConfigError(f"categories must be a table (got {raw_categories!r})")
|
|
157
|
+
categories = dict(DEFAULT_CATEGORIES)
|
|
158
|
+
for name, params in raw_categories.items():
|
|
159
|
+
if not isinstance(params, dict):
|
|
160
|
+
raise ConfigError(f"categories.{name} must be a table (got {params!r})")
|
|
161
|
+
base = categories.get(name, new_base)
|
|
162
|
+
categories[name] = Category(
|
|
163
|
+
importance=_number(
|
|
164
|
+
f"categories.{name}.importance",
|
|
165
|
+
params.get("importance", base.importance),
|
|
166
|
+
),
|
|
167
|
+
half_life_multiplier=_number(
|
|
168
|
+
f"categories.{name}.half_life_multiplier",
|
|
169
|
+
params.get("half_life_multiplier", base.half_life_multiplier),
|
|
170
|
+
),
|
|
171
|
+
)
|
|
172
|
+
kwargs["categories"] = categories
|
|
173
|
+
|
|
174
|
+
config = Config(**kwargs)
|
|
175
|
+
_validate(config)
|
|
176
|
+
return config
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _validate(config: Config) -> None:
|
|
180
|
+
if config.base_half_life_days <= 0:
|
|
181
|
+
raise ConfigError(
|
|
182
|
+
f"base_half_life_days must be positive (got {config.base_half_life_days})"
|
|
183
|
+
)
|
|
184
|
+
if config.fallback_half_life_multiplier <= 0:
|
|
185
|
+
raise ConfigError(
|
|
186
|
+
"fallback_half_life_multiplier must be positive "
|
|
187
|
+
f"(got {config.fallback_half_life_multiplier})"
|
|
188
|
+
)
|
|
189
|
+
for name, cat in config.categories.items():
|
|
190
|
+
if cat.half_life_multiplier <= 0:
|
|
191
|
+
raise ConfigError(
|
|
192
|
+
f"categories.{name}.half_life_multiplier must be positive "
|
|
193
|
+
f"(got {cat.half_life_multiplier})"
|
|
194
|
+
)
|
|
195
|
+
if config.recall_count_cap < 1:
|
|
196
|
+
raise ConfigError(
|
|
197
|
+
f"recall_count_cap must be >= 1 (got {config.recall_count_cap})"
|
|
198
|
+
)
|
|
199
|
+
if config.recall_limit < 1:
|
|
200
|
+
raise ConfigError(f"recall_limit must be >= 1 (got {config.recall_limit})")
|
|
201
|
+
if config.search_limit < 1:
|
|
202
|
+
raise ConfigError(f"search_limit must be >= 1 (got {config.search_limit})")
|
|
203
|
+
if config.default_category not in config.categories:
|
|
204
|
+
known = ", ".join(sorted(config.categories))
|
|
205
|
+
raise ConfigError(
|
|
206
|
+
f"default_category {config.default_category!r} is not a "
|
|
207
|
+
f"configured category (known: {known})"
|
|
208
|
+
)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Embedders: local only, one per store.
|
|
2
|
+
|
|
3
|
+
The store records which embedder produced its vectors and refuses to open
|
|
4
|
+
under a different one — mixing embedders fragments the vector space and
|
|
5
|
+
silently breaks recall.
|
|
6
|
+
|
|
7
|
+
No provider API keys are ever read from the environment; embedding never
|
|
8
|
+
leaves the machine (fastembed's one-time model download excepted).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Protocol
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Embedder(Protocol):
|
|
17
|
+
"""A text embedder. `name` identifies it in store metadata; `dim` is the
|
|
18
|
+
vector length. Both must be stable for the life of a store."""
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def name(self) -> str: ...
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def dim(self) -> int: ...
|
|
25
|
+
|
|
26
|
+
def warm_up(self) -> None:
|
|
27
|
+
"""Load the model and run one embedding so first real use is fast."""
|
|
28
|
+
...
|
|
29
|
+
|
|
30
|
+
def embed(self, texts: list[str]) -> list[list[float]]: ...
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class FastEmbedEmbedder:
|
|
34
|
+
"""Local ONNX embedder via the fastembed package.
|
|
35
|
+
|
|
36
|
+
The model is downloaded once to fastembed's cache on first use; after
|
|
37
|
+
that everything runs offline.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, model_name: str = "BAAI/bge-small-en-v1.5") -> None:
|
|
41
|
+
self._model_name = model_name
|
|
42
|
+
self._model = None
|
|
43
|
+
self._dim: int | None = None
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def name(self) -> str:
|
|
47
|
+
return f"fastembed:{self._model_name}"
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def dim(self) -> int:
|
|
51
|
+
if self._dim is None:
|
|
52
|
+
# Known models: read the dimension from fastembed's registry so
|
|
53
|
+
# opening a store (which checks it) never forces a model load.
|
|
54
|
+
from fastembed import TextEmbedding
|
|
55
|
+
|
|
56
|
+
for entry in TextEmbedding.list_supported_models():
|
|
57
|
+
if entry["model"] == self._model_name:
|
|
58
|
+
self._dim = int(entry["dim"])
|
|
59
|
+
break
|
|
60
|
+
else:
|
|
61
|
+
self.warm_up()
|
|
62
|
+
assert self._dim is not None
|
|
63
|
+
return self._dim
|
|
64
|
+
|
|
65
|
+
def warm_up(self) -> None:
|
|
66
|
+
if self._model is None:
|
|
67
|
+
from fastembed import TextEmbedding # deferred: heavy import
|
|
68
|
+
|
|
69
|
+
self._model = TextEmbedding(model_name=self._model_name)
|
|
70
|
+
self._dim = len(next(iter(self._model.embed(["warm up"]))))
|
|
71
|
+
|
|
72
|
+
def embed(self, texts: list[str]) -> list[list[float]]:
|
|
73
|
+
self.warm_up()
|
|
74
|
+
assert self._model is not None
|
|
75
|
+
return [[float(x) for x in vec] for vec in self._model.embed(texts)]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def make_embedder(spec: str) -> Embedder:
|
|
79
|
+
"""Build an embedder from a config spec string:
|
|
80
|
+
"fastembed:<model>" — local ONNX model (the default)."""
|
|
81
|
+
if spec.startswith("fastembed:"):
|
|
82
|
+
return FastEmbedEmbedder(spec.removeprefix("fastembed:"))
|
|
83
|
+
raise ValueError(f"unknown embedder spec: {spec!r}")
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The Omarchy glove: harness registration, skill links, bar plugin, warm-up."""
|