omega-code 0.4.0__py3-none-any.whl
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.
- omega/__init__.py +0 -0
- omega/__main__.py +589 -0
- omega/artifacts.py +151 -0
- omega/checkpoint.py +246 -0
- omega/compact.py +106 -0
- omega/config.py +285 -0
- omega/eval/__init__.py +3 -0
- omega/eval/cli.py +127 -0
- omega/eval/examples/plan-version-flag.yaml +11 -0
- omega/eval/examples/relative-age-negative-delta.yaml +14 -0
- omega/eval/examples/version-flag.yaml +10 -0
- omega/eval/manifest.py +129 -0
- omega/eval/prices.py +29 -0
- omega/eval/report.py +135 -0
- omega/eval/runner.py +199 -0
- omega/eval/tasks.py +97 -0
- omega/events.py +145 -0
- omega/export.py +80 -0
- omega/gitlog.py +229 -0
- omega/hooks.py +63 -0
- omega/instructions.py +103 -0
- omega/integrations.py +284 -0
- omega/keys.py +173 -0
- omega/llm.py +442 -0
- omega/loop.py +510 -0
- omega/mcp.py +490 -0
- omega/memory/__init__.py +5 -0
- omega/memory/consolidate.py +103 -0
- omega/memory/curate.py +69 -0
- omega/memory/store.py +321 -0
- omega/memory/tools.py +175 -0
- omega/migrate.py +40 -0
- omega/onboarding.py +242 -0
- omega/permissions.py +137 -0
- omega/secrets.py +173 -0
- omega/server/__init__.py +7 -0
- omega/server/__main__.py +18 -0
- omega/server/app.py +71 -0
- omega/server/auth.py +73 -0
- omega/server/manager.py +287 -0
- omega/server/models.py +123 -0
- omega/server/tasks_api.py +311 -0
- omega/server/terminals.py +245 -0
- omega/server/worker.py +186 -0
- omega/session.py +209 -0
- omega/setup.html +281 -0
- omega/setup_server.py +452 -0
- omega/skills.py +158 -0
- omega/subagent.py +98 -0
- omega/tasks.py +195 -0
- omega/tools.py +590 -0
- omega/trace.py +156 -0
- omega/trajectory.py +146 -0
- omega/ui/__init__.py +0 -0
- omega/ui/composer.py +140 -0
- omega/ui/format.py +708 -0
- omega/ui/plain.py +141 -0
- omega/ui/tui/__init__.py +9 -0
- omega/ui/tui/app.py +958 -0
- omega/ui/tui/history.py +50 -0
- omega/ui/tui/modals.py +292 -0
- omega/ui/tui/onboarding.py +367 -0
- omega/ui/tui/prefs.py +25 -0
- omega/ui/tui/sidebar.py +510 -0
- omega/ui/tui/status.py +115 -0
- omega/ui/tui/theme.py +91 -0
- omega/ui/tui/transcript.py +783 -0
- omega/verify.py +133 -0
- omega_code-0.4.0.dist-info/METADATA +479 -0
- omega_code-0.4.0.dist-info/RECORD +73 -0
- omega_code-0.4.0.dist-info/WHEEL +4 -0
- omega_code-0.4.0.dist-info/entry_points.txt +2 -0
- omega_code-0.4.0.dist-info/licenses/LICENSE +21 -0
omega/memory/store.py
ADDED
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
import secrets
|
|
4
|
+
import sqlite3
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
Node = dict[str, Any]
|
|
11
|
+
|
|
12
|
+
GLOBAL_DIR = Path.home() / ".omega" / "memory"
|
|
13
|
+
|
|
14
|
+
SCOPES = {"global", "project"}
|
|
15
|
+
TYPES = {"fact", "preference", "decision", "entity", "file_note", "open_question"}
|
|
16
|
+
VOLATILITIES = {"stable", "medium", "volatile"}
|
|
17
|
+
SENSITIVITIES = {"normal", "personal", "sensitive"}
|
|
18
|
+
RELATIONS = {"relates_to", "supersedes", "depends_on", "part_of", "mentions", "contradicts"}
|
|
19
|
+
|
|
20
|
+
SCHEMA = """
|
|
21
|
+
CREATE TABLE IF NOT EXISTS nodes (
|
|
22
|
+
id TEXT PRIMARY KEY,
|
|
23
|
+
type TEXT NOT NULL,
|
|
24
|
+
title TEXT NOT NULL,
|
|
25
|
+
body TEXT NOT NULL,
|
|
26
|
+
confidence REAL DEFAULT 0.8,
|
|
27
|
+
volatility TEXT DEFAULT 'stable',
|
|
28
|
+
sensitivity TEXT DEFAULT 'normal',
|
|
29
|
+
importance REAL DEFAULT 0.5,
|
|
30
|
+
superseded_by TEXT REFERENCES nodes(id),
|
|
31
|
+
source_session_id TEXT,
|
|
32
|
+
created REAL, updated REAL, last_accessed REAL, access_count INTEGER DEFAULT 0
|
|
33
|
+
);
|
|
34
|
+
CREATE TABLE IF NOT EXISTS edges (
|
|
35
|
+
src TEXT REFERENCES nodes(id), dst TEXT REFERENCES nodes(id),
|
|
36
|
+
relation TEXT,
|
|
37
|
+
created REAL,
|
|
38
|
+
PRIMARY KEY (src, dst, relation)
|
|
39
|
+
);
|
|
40
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS nodes_fts USING fts5(
|
|
41
|
+
title, body, content='nodes', content_rowid='rowid');
|
|
42
|
+
CREATE TRIGGER IF NOT EXISTS nodes_ai AFTER INSERT ON nodes BEGIN
|
|
43
|
+
INSERT INTO nodes_fts(rowid, title, body) VALUES (new.rowid, new.title, new.body);
|
|
44
|
+
END;
|
|
45
|
+
CREATE TRIGGER IF NOT EXISTS nodes_ad AFTER DELETE ON nodes BEGIN
|
|
46
|
+
INSERT INTO nodes_fts(nodes_fts, rowid, title, body) VALUES ('delete', old.rowid, old.title, old.body);
|
|
47
|
+
END;
|
|
48
|
+
CREATE TRIGGER IF NOT EXISTS nodes_au AFTER UPDATE ON nodes BEGIN
|
|
49
|
+
INSERT INTO nodes_fts(nodes_fts, rowid, title, body) VALUES ('delete', old.rowid, old.title, old.body);
|
|
50
|
+
INSERT INTO nodes_fts(rowid, title, body) VALUES (new.rowid, new.title, new.body);
|
|
51
|
+
END;
|
|
52
|
+
CREATE TABLE IF NOT EXISTS meta (
|
|
53
|
+
last_consolidated REAL, nodes_since_consolidation INTEGER
|
|
54
|
+
);
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def project_dir(cwd: str | None = None) -> Path:
|
|
59
|
+
return Path(cwd or os.getcwd()) / ".omega"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _db_path(scope: str, cwd: str | None = None) -> Path:
|
|
63
|
+
if scope not in SCOPES:
|
|
64
|
+
raise ValueError(f"invalid scope {scope!r}; expected one of {sorted(SCOPES)}")
|
|
65
|
+
if scope == "global":
|
|
66
|
+
return GLOBAL_DIR / "memory.db"
|
|
67
|
+
return project_dir(cwd) / "memory.db"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def db_exists(scope: str, cwd: str | None = None) -> bool:
|
|
71
|
+
return _db_path(scope, cwd).exists()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _bootstrap_project(cwd: str | None = None) -> None:
|
|
75
|
+
root = Path(cwd or os.getcwd())
|
|
76
|
+
project_dir(cwd).mkdir(parents=True, exist_ok=True)
|
|
77
|
+
# Never touch .gitignore outside an actual repo -- a bare cwd or $HOME
|
|
78
|
+
# isn't ours to modify.
|
|
79
|
+
if not (root / ".git").is_dir():
|
|
80
|
+
return
|
|
81
|
+
gitignore = root / ".gitignore"
|
|
82
|
+
line = ".omega/"
|
|
83
|
+
existing = gitignore.read_text() if gitignore.exists() else ""
|
|
84
|
+
if line in existing.splitlines():
|
|
85
|
+
return
|
|
86
|
+
with gitignore.open("a") as f:
|
|
87
|
+
if existing and not existing.endswith("\n"):
|
|
88
|
+
f.write("\n")
|
|
89
|
+
f.write(line + "\n")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def connect(scope: str, cwd: str | None = None) -> sqlite3.Connection:
|
|
93
|
+
path = _db_path(scope, cwd)
|
|
94
|
+
if scope == "project" and not path.exists():
|
|
95
|
+
_bootstrap_project(cwd)
|
|
96
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
97
|
+
conn = sqlite3.connect(path)
|
|
98
|
+
conn.row_factory = sqlite3.Row
|
|
99
|
+
conn.execute("PRAGMA journal_mode=WAL")
|
|
100
|
+
conn.executescript(SCHEMA)
|
|
101
|
+
if conn.execute("SELECT COUNT(*) FROM meta").fetchone()[0] == 0:
|
|
102
|
+
conn.execute("INSERT INTO meta (last_consolidated, nodes_since_consolidation) "
|
|
103
|
+
"VALUES (0, 0)")
|
|
104
|
+
conn.commit()
|
|
105
|
+
return conn
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _validate(**kwargs: str | None) -> None:
|
|
109
|
+
checks = {"type": TYPES, "volatility": VOLATILITIES, "sensitivity": SENSITIVITIES,
|
|
110
|
+
"relation": RELATIONS}
|
|
111
|
+
for field, value in kwargs.items():
|
|
112
|
+
if value is not None and value not in checks[field]:
|
|
113
|
+
raise ValueError(f"invalid {field} {value!r}; expected one of {sorted(checks[field])}")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def write_node(scope: str, type: str, title: str, body: str, confidence: float = 0.8,
|
|
117
|
+
volatility: str = "stable", sensitivity: str = "normal", importance: float = 0.5,
|
|
118
|
+
source_session_id: str | None = None, cwd: str | None = None) -> str:
|
|
119
|
+
_validate(type=type, volatility=volatility, sensitivity=sensitivity)
|
|
120
|
+
conn = connect(scope, cwd)
|
|
121
|
+
try:
|
|
122
|
+
node_id = secrets.token_hex(4)
|
|
123
|
+
now = time.time()
|
|
124
|
+
conn.execute(
|
|
125
|
+
"INSERT INTO nodes (id, type, title, body, confidence, volatility, sensitivity, "
|
|
126
|
+
"importance, source_session_id, created, updated, last_accessed, access_count) "
|
|
127
|
+
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,0)",
|
|
128
|
+
(node_id, type, title, body, confidence, volatility, sensitivity, importance,
|
|
129
|
+
source_session_id, now, now, now))
|
|
130
|
+
conn.commit()
|
|
131
|
+
return node_id
|
|
132
|
+
finally:
|
|
133
|
+
conn.close()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def add_edge(scope: str, src: str, dst: str, relation: str, cwd: str | None = None) -> None:
|
|
137
|
+
_validate(relation=relation)
|
|
138
|
+
conn = connect(scope, cwd)
|
|
139
|
+
try:
|
|
140
|
+
conn.execute("INSERT OR IGNORE INTO edges (src, dst, relation, created) VALUES (?,?,?,?)",
|
|
141
|
+
(src, dst, relation, time.time()))
|
|
142
|
+
conn.commit()
|
|
143
|
+
finally:
|
|
144
|
+
conn.close()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def get(scope: str, id_or_title: str, cwd: str | None = None) -> Node | None:
|
|
148
|
+
conn = connect(scope, cwd)
|
|
149
|
+
try:
|
|
150
|
+
row = conn.execute("SELECT * FROM nodes WHERE id = ?", (id_or_title,)).fetchone()
|
|
151
|
+
if row is None:
|
|
152
|
+
row = conn.execute(
|
|
153
|
+
"SELECT * FROM nodes WHERE lower(title) = lower(?) ORDER BY updated DESC LIMIT 1",
|
|
154
|
+
(id_or_title,)).fetchone()
|
|
155
|
+
return dict(row) if row else None
|
|
156
|
+
finally:
|
|
157
|
+
conn.close()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _fts_query_variants(query: str) -> Iterator[str]:
|
|
161
|
+
yield query
|
|
162
|
+
terms = re.findall(r"\w+", query)
|
|
163
|
+
if terms:
|
|
164
|
+
yield " ".join(f'"{t}"' for t in terms)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def search(scope: str, query: str, type: str | None = None, limit: int = 8,
|
|
168
|
+
include_superseded: bool = False, cwd: str | None = None) -> list[Node]:
|
|
169
|
+
_validate(type=type)
|
|
170
|
+
conn = connect(scope, cwd)
|
|
171
|
+
try:
|
|
172
|
+
sql = ("SELECT n.*, bm25(nodes_fts) AS rank FROM nodes_fts "
|
|
173
|
+
"JOIN nodes n ON n.rowid = nodes_fts.rowid WHERE nodes_fts MATCH ?")
|
|
174
|
+
extra: list[str] = []
|
|
175
|
+
if type is not None:
|
|
176
|
+
sql += " AND n.type = ?"
|
|
177
|
+
extra.append(type)
|
|
178
|
+
if not include_superseded:
|
|
179
|
+
sql += " AND n.superseded_by IS NULL"
|
|
180
|
+
sql += " ORDER BY rank LIMIT ?"
|
|
181
|
+
|
|
182
|
+
rows = None
|
|
183
|
+
for variant in _fts_query_variants(query):
|
|
184
|
+
try:
|
|
185
|
+
# bm25: smaller (more negative) is a better match, so plain
|
|
186
|
+
# ascending order (the default) already ranks best-first.
|
|
187
|
+
rows = conn.execute(sql, (variant, *extra, limit)).fetchall()
|
|
188
|
+
break
|
|
189
|
+
except sqlite3.OperationalError:
|
|
190
|
+
continue
|
|
191
|
+
return [dict(r) for r in rows] if rows is not None else []
|
|
192
|
+
finally:
|
|
193
|
+
conn.close()
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def neighbors(scope: str, id: str, depth: int = 1, cwd: str | None = None) -> list[Node]:
|
|
197
|
+
depth = min(max(depth, 0), 2)
|
|
198
|
+
conn = connect(scope, cwd)
|
|
199
|
+
try:
|
|
200
|
+
visited = {id}
|
|
201
|
+
frontier = [id]
|
|
202
|
+
results: list[Node] = []
|
|
203
|
+
seen: set[str] = set()
|
|
204
|
+
for _ in range(depth):
|
|
205
|
+
next_frontier = []
|
|
206
|
+
for nid in frontier:
|
|
207
|
+
rows = conn.execute(
|
|
208
|
+
"SELECT dst AS other, relation, 'out' AS direction FROM edges WHERE src = ? "
|
|
209
|
+
"UNION ALL "
|
|
210
|
+
"SELECT src AS other, relation, 'in' AS direction FROM edges WHERE dst = ?",
|
|
211
|
+
(nid, nid)).fetchall()
|
|
212
|
+
for r in rows:
|
|
213
|
+
other = r["other"]
|
|
214
|
+
if other in visited:
|
|
215
|
+
continue
|
|
216
|
+
node = conn.execute("SELECT * FROM nodes WHERE id = ?", (other,)).fetchone()
|
|
217
|
+
if node is None:
|
|
218
|
+
continue
|
|
219
|
+
visited.add(other)
|
|
220
|
+
next_frontier.append(other)
|
|
221
|
+
if other not in seen:
|
|
222
|
+
seen.add(other)
|
|
223
|
+
d = dict(node)
|
|
224
|
+
d["relation"], d["direction"] = r["relation"], r["direction"]
|
|
225
|
+
results.append(d)
|
|
226
|
+
frontier = next_frontier
|
|
227
|
+
return results
|
|
228
|
+
finally:
|
|
229
|
+
conn.close()
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def touch(scope: str, id: str, cwd: str | None = None) -> None:
|
|
233
|
+
conn = connect(scope, cwd)
|
|
234
|
+
try:
|
|
235
|
+
conn.execute("UPDATE nodes SET last_accessed = ?, access_count = access_count + 1 "
|
|
236
|
+
"WHERE id = ?", (time.time(), id))
|
|
237
|
+
conn.commit()
|
|
238
|
+
finally:
|
|
239
|
+
conn.close()
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def mark_superseded(scope: str, old_id: str, new_id: str, cwd: str | None = None) -> None:
|
|
243
|
+
conn = connect(scope, cwd)
|
|
244
|
+
try:
|
|
245
|
+
conn.execute("UPDATE nodes SET superseded_by = ?, updated = ? WHERE id = ?",
|
|
246
|
+
(new_id, time.time(), old_id))
|
|
247
|
+
conn.commit()
|
|
248
|
+
finally:
|
|
249
|
+
conn.close()
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def retag(scope: str, id: str, cwd: str | None = None, **fields: str | float | None) -> None:
|
|
253
|
+
fields = {k: v for k, v in fields.items() if k in ("volatility", "importance")
|
|
254
|
+
and v is not None}
|
|
255
|
+
if not fields:
|
|
256
|
+
return
|
|
257
|
+
if "volatility" in fields:
|
|
258
|
+
_validate(volatility=str(fields["volatility"]))
|
|
259
|
+
conn = connect(scope, cwd)
|
|
260
|
+
try:
|
|
261
|
+
set_clause = ", ".join(f"{k} = ?" for k in fields)
|
|
262
|
+
conn.execute(f"UPDATE nodes SET {set_clause}, updated = ? WHERE id = ?",
|
|
263
|
+
(*fields.values(), time.time(), id))
|
|
264
|
+
conn.commit()
|
|
265
|
+
finally:
|
|
266
|
+
conn.close()
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def recent(scope: str, since: float | None = None, limit: int = 50,
|
|
270
|
+
cwd: str | None = None) -> list[Node]:
|
|
271
|
+
conn = connect(scope, cwd)
|
|
272
|
+
try:
|
|
273
|
+
sql = "SELECT * FROM nodes WHERE superseded_by IS NULL"
|
|
274
|
+
params: list[float] = []
|
|
275
|
+
if since is not None:
|
|
276
|
+
sql += " AND created >= ?"
|
|
277
|
+
params.append(since)
|
|
278
|
+
sql += " ORDER BY created DESC LIMIT ?"
|
|
279
|
+
params.append(limit)
|
|
280
|
+
return [dict(r) for r in conn.execute(sql, params).fetchall()]
|
|
281
|
+
finally:
|
|
282
|
+
conn.close()
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def all_nodes(scope: str, include_superseded: bool = False, cwd: str | None = None) -> list[Node]:
|
|
286
|
+
conn = connect(scope, cwd)
|
|
287
|
+
try:
|
|
288
|
+
sql = "SELECT * FROM nodes"
|
|
289
|
+
if not include_superseded:
|
|
290
|
+
sql += " WHERE superseded_by IS NULL"
|
|
291
|
+
return [dict(r) for r in conn.execute(sql).fetchall()]
|
|
292
|
+
finally:
|
|
293
|
+
conn.close()
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def bump_since_consolidation(scope: str, cwd: str | None = None) -> None:
|
|
297
|
+
conn = connect(scope, cwd)
|
|
298
|
+
try:
|
|
299
|
+
conn.execute("UPDATE meta SET nodes_since_consolidation = nodes_since_consolidation + 1")
|
|
300
|
+
conn.commit()
|
|
301
|
+
finally:
|
|
302
|
+
conn.close()
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def reset_consolidation(scope: str, cwd: str | None = None) -> None:
|
|
306
|
+
conn = connect(scope, cwd)
|
|
307
|
+
try:
|
|
308
|
+
conn.execute("UPDATE meta SET nodes_since_consolidation = 0, last_consolidated = ?",
|
|
309
|
+
(time.time(),))
|
|
310
|
+
conn.commit()
|
|
311
|
+
finally:
|
|
312
|
+
conn.close()
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def since_consolidation(scope: str, cwd: str | None = None) -> int:
|
|
316
|
+
conn = connect(scope, cwd)
|
|
317
|
+
try:
|
|
318
|
+
row = conn.execute("SELECT nodes_since_consolidation FROM meta").fetchone()
|
|
319
|
+
return row[0] if row else 0
|
|
320
|
+
finally:
|
|
321
|
+
conn.close()
|
omega/memory/tools.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import re
|
|
2
|
+
|
|
3
|
+
from .. import tools
|
|
4
|
+
from . import store
|
|
5
|
+
from .store import Node
|
|
6
|
+
|
|
7
|
+
S = {"type": "string"}
|
|
8
|
+
I = {"type": "integer"}
|
|
9
|
+
F = {"type": "number"}
|
|
10
|
+
|
|
11
|
+
# Safety net, same shape as permissions.FORBIDDEN_PATTERNS: mislabeling a
|
|
12
|
+
# node's sensitivity must not be the only thing standing between a leaked
|
|
13
|
+
# secret and the preamble every future turn gets injected with.
|
|
14
|
+
SENSITIVE_PATTERNS = [
|
|
15
|
+
(re.compile(r"\bsk-[A-Za-z0-9]{10,}"), "looks like an API key (sk-...)"),
|
|
16
|
+
(re.compile(r"\bghp_[A-Za-z0-9]{10,}"), "looks like a GitHub token (ghp_...)"),
|
|
17
|
+
(re.compile(r"\bxox[bp]-[A-Za-z0-9-]{10,}"), "looks like a Slack token (xox[bp]-...)"),
|
|
18
|
+
(re.compile(r"\bAKIA[A-Z0-9]{12,}"), "looks like an AWS access key (AKIA...)"),
|
|
19
|
+
(re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY"), "contains a private key block"),
|
|
20
|
+
(re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}"), "contains an email address"),
|
|
21
|
+
(re.compile(r"\b(?:\+?\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b"),
|
|
22
|
+
"contains a phone number"),
|
|
23
|
+
(re.compile(r"\b(password|passwd|secret)\s*[:=]", re.I), "contains a password/secret assignment"),
|
|
24
|
+
# No `/` in the class and letters+digits both required: file paths and
|
|
25
|
+
# long snake_case identifiers are the normal content of a coding agent's
|
|
26
|
+
# memory and must not be mistaken for secrets.
|
|
27
|
+
(re.compile(r"(?=[A-Za-z0-9+=_-]*\d)(?=[A-Za-z0-9+=_-]*[A-Za-z])[A-Za-z0-9+=_-]{32,}"),
|
|
28
|
+
"contains a long token-like blob"),
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _scan_sensitive(*texts: str) -> list[str]:
|
|
33
|
+
blob = "\n".join(texts)
|
|
34
|
+
reasons = []
|
|
35
|
+
for pattern, reason in SENSITIVE_PATTERNS:
|
|
36
|
+
if pattern.search(blob) and reason not in reasons:
|
|
37
|
+
reasons.append(reason)
|
|
38
|
+
return reasons
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _resolve(id_or_title: str) -> tuple[str, Node] | tuple[None, None]:
|
|
42
|
+
"""Look up a node across both scopes, project first. Never triggers the
|
|
43
|
+
project db's .gitignore bootstrap by probing a db that doesn't exist yet."""
|
|
44
|
+
for scope in ("project", "global"):
|
|
45
|
+
if scope == "project" and not store.db_exists("project"):
|
|
46
|
+
continue
|
|
47
|
+
node = store.get(scope, id_or_title)
|
|
48
|
+
if node:
|
|
49
|
+
return scope, node
|
|
50
|
+
return None, None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@tools.tool(
|
|
54
|
+
"remember",
|
|
55
|
+
"Save a durable fact about the user or their projects to long-term memory "
|
|
56
|
+
"so it survives into FUTURE sessions. The current conversation is already "
|
|
57
|
+
"remembered without this. type: fact|preference|decision|entity|file_note|"
|
|
58
|
+
"open_question. scope: project (default) or global (true across projects). "
|
|
59
|
+
"volatility: stable|medium|volatile -- volatile facts (current bug, mood, "
|
|
60
|
+
"this week's focus) are never auto-injected, only recallable. sensitivity: "
|
|
61
|
+
"normal|personal|sensitive -- personal facts are rarely injected, sensitive "
|
|
62
|
+
"never. confidence and importance are 0-1.",
|
|
63
|
+
{"title": S, "body": S, "type": S, "scope": S, "confidence": F,
|
|
64
|
+
"volatility": S, "sensitivity": S, "importance": F,
|
|
65
|
+
"relates_to": {"type": "array", "items": S}},
|
|
66
|
+
["title", "body"], mutates=True)
|
|
67
|
+
def _remember(title: str, body: str, type: str = "fact", scope: str = "project",
|
|
68
|
+
confidence: float = 0.8, volatility: str = "stable", sensitivity: str = "normal",
|
|
69
|
+
importance: float = 0.5, relates_to: list[str] | None = None) -> str:
|
|
70
|
+
reasons = _scan_sensitive(title, body)
|
|
71
|
+
if reasons:
|
|
72
|
+
sensitivity = "sensitive"
|
|
73
|
+
try:
|
|
74
|
+
node_id = store.write_node(scope, type, title, body, confidence=confidence,
|
|
75
|
+
volatility=volatility, sensitivity=sensitivity,
|
|
76
|
+
importance=importance, source_session_id=tools.SESSION_ID)
|
|
77
|
+
except ValueError as e:
|
|
78
|
+
return f"error: {e}"
|
|
79
|
+
|
|
80
|
+
unresolved: list[str] = []
|
|
81
|
+
for ref in relates_to or []:
|
|
82
|
+
target = store.get(scope, ref)
|
|
83
|
+
if target is None:
|
|
84
|
+
unresolved.append(ref)
|
|
85
|
+
else:
|
|
86
|
+
store.add_edge(scope, node_id, target["id"], "relates_to")
|
|
87
|
+
store.bump_since_consolidation(scope)
|
|
88
|
+
|
|
89
|
+
msg = f"remembered [{node_id}] {title!r} ({scope})"
|
|
90
|
+
if reasons:
|
|
91
|
+
msg += f"; sensitivity forced to 'sensitive': {'; '.join(reasons)}"
|
|
92
|
+
if unresolved:
|
|
93
|
+
msg += f"; could not resolve relates_to: {', '.join(unresolved)}"
|
|
94
|
+
return msg
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@tools.tool(
|
|
98
|
+
"recall",
|
|
99
|
+
"Search LONG-TERM memory notes saved across sessions with the `remember` "
|
|
100
|
+
"tool. This is NOT the conversation: everything said in this session, "
|
|
101
|
+
"including a resumed one, is already in your context above. Do not call "
|
|
102
|
+
"this to answer questions about what was just discussed.",
|
|
103
|
+
{"query": S, "scope": S, "type": S, "depth": I}, ["query"])
|
|
104
|
+
def _recall(query: str, scope: str = "both", type: str | None = None, depth: int = 1) -> str:
|
|
105
|
+
if scope not in ("both", "project", "global"):
|
|
106
|
+
return f"error: invalid scope {scope!r}; expected 'both', 'project' or 'global'"
|
|
107
|
+
scopes = ["project", "global"] if scope == "both" else [scope]
|
|
108
|
+
|
|
109
|
+
blocks: list[str] = []
|
|
110
|
+
for sc in scopes:
|
|
111
|
+
if sc == "project" and not store.db_exists("project"):
|
|
112
|
+
continue
|
|
113
|
+
try:
|
|
114
|
+
hits = store.search(sc, query, type=type, limit=8)
|
|
115
|
+
except ValueError as e:
|
|
116
|
+
return f"error: {e}"
|
|
117
|
+
for node in hits:
|
|
118
|
+
store.touch(sc, node["id"])
|
|
119
|
+
lines = [f"[{node['id']}] {node['type']} · {sc} · conf {node['confidence']} · "
|
|
120
|
+
f"{node['volatility']} · importance {node['importance']}",
|
|
121
|
+
node["title"], node["body"]]
|
|
122
|
+
for nb in store.neighbors(sc, node["id"], depth=depth):
|
|
123
|
+
lines.append(f" → {nb['relation']} ({nb['direction']}): "
|
|
124
|
+
f"[{nb['id']}] {nb['title']}")
|
|
125
|
+
blocks.append("\n".join(lines))
|
|
126
|
+
|
|
127
|
+
if not blocks:
|
|
128
|
+
return "(no matching memories)"
|
|
129
|
+
return tools.truncate("\n\n".join(blocks))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@tools.tool(
|
|
133
|
+
"supersede",
|
|
134
|
+
"Replace an outdated memory node with a corrected one. The old node stops "
|
|
135
|
+
"appearing in recall/preamble by default but stays queryable by id.",
|
|
136
|
+
{"old": S, "new_body": S, "confidence": F}, ["old", "new_body"], mutates=True)
|
|
137
|
+
def _supersede(old: str, new_body: str, confidence: float | None = None) -> str:
|
|
138
|
+
scope, node = _resolve(old)
|
|
139
|
+
if node is None or scope is None:
|
|
140
|
+
return f"error: no memory node found matching {old!r}"
|
|
141
|
+
new_id = store.write_node(
|
|
142
|
+
scope, node["type"], node["title"], new_body,
|
|
143
|
+
confidence=confidence if confidence is not None else node["confidence"],
|
|
144
|
+
volatility=node["volatility"], sensitivity=node["sensitivity"],
|
|
145
|
+
importance=node["importance"], source_session_id=tools.SESSION_ID)
|
|
146
|
+
store.add_edge(scope, new_id, node["id"], "supersedes")
|
|
147
|
+
store.mark_superseded(scope, node["id"], new_id)
|
|
148
|
+
store.bump_since_consolidation(scope)
|
|
149
|
+
return f"superseded [{node['id']}] with [{new_id}] ({scope})"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@tools.tool(
|
|
153
|
+
"link",
|
|
154
|
+
"Add an explicit relation between two existing memory nodes (contradicts, "
|
|
155
|
+
"depends_on, part_of, mentions, relates_to).",
|
|
156
|
+
{"a": S, "b": S, "relation": S}, ["a", "b", "relation"], mutates=True)
|
|
157
|
+
def _link(a: str, b: str, relation: str) -> str:
|
|
158
|
+
scope_a, node_a = _resolve(a)
|
|
159
|
+
if node_a is None or scope_a is None:
|
|
160
|
+
return f"error: no memory node found matching {a!r}"
|
|
161
|
+
# Edges live inside a single sqlite db, so both ends must be in the same
|
|
162
|
+
# scope -- look b up in a's scope directly rather than resolving it
|
|
163
|
+
# independently, which could silently pick the wrong-scope node.
|
|
164
|
+
node_b = store.get(scope_a, b)
|
|
165
|
+
if node_b is None:
|
|
166
|
+
scope_b, other = _resolve(b)
|
|
167
|
+
if other is None:
|
|
168
|
+
return f"error: no memory node found matching {b!r}"
|
|
169
|
+
return (f"error: {a!r} is in scope {scope_a!r} but {b!r} is in scope "
|
|
170
|
+
f"{scope_b!r}; link requires both nodes in the same scope")
|
|
171
|
+
try:
|
|
172
|
+
store.add_edge(scope_a, node_a["id"], node_b["id"], relation)
|
|
173
|
+
except ValueError as e:
|
|
174
|
+
return f"error: {e}"
|
|
175
|
+
return f"linked [{node_a['id']}] --{relation}--> [{node_b['id']}] ({scope_a})"
|
omega/migrate.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""One-time compatibility copy from the pre-rename `.rig` layout to `.omega`.
|
|
2
|
+
|
|
3
|
+
Runs from `__main__.cli()` before anything else touches config, permissions,
|
|
4
|
+
or sessions, so every other module can assume `~/.omega` (and `<cwd>/.omega`)
|
|
5
|
+
already hold whatever `.rig` had -- without ever deleting the old directory,
|
|
6
|
+
in case something still points at it."""
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
_PRESERVE_MODE = ("config.json", "permissions.json")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _copy(old: Path, new: Path) -> bool:
|
|
15
|
+
if new.exists() or not old.is_dir():
|
|
16
|
+
return False
|
|
17
|
+
shutil.copytree(old, new)
|
|
18
|
+
for name in _PRESERVE_MODE:
|
|
19
|
+
dst = new / name
|
|
20
|
+
src = old / name
|
|
21
|
+
if dst.exists() and src.exists():
|
|
22
|
+
dst.chmod(src.stat().st_mode & 0o777)
|
|
23
|
+
return True
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def migrate_home(home: Path | None = None) -> bool:
|
|
27
|
+
home = home or Path.home()
|
|
28
|
+
return _copy(home / ".rig", home / ".omega")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def migrate_project(cwd: str | None = None) -> bool:
|
|
32
|
+
root = Path(cwd or os.getcwd())
|
|
33
|
+
return _copy(root / ".rig", root / ".omega")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def run(cwd: str | None = None, home: Path | None = None) -> None:
|
|
37
|
+
if migrate_home(home):
|
|
38
|
+
from rich.console import Console
|
|
39
|
+
Console().print("[dim]migrated ~/.rig → ~/.omega (the old directory was left in place)[/dim]")
|
|
40
|
+
migrate_project(cwd)
|