omind 10.3.4__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.
Files changed (97) hide show
  1. omind/__init__.py +5 -0
  2. omind/__main__.py +8 -0
  3. omind/_omi_enforce.py +139 -0
  4. omind/access.py +226 -0
  5. omind/adapters.py +364 -0
  6. omind/agents.py +3895 -0
  7. omind/ai_usage.py +790 -0
  8. omind/audit.py +553 -0
  9. omind/backup.py +576 -0
  10. omind/bench.py +717 -0
  11. omind/checkpoint.py +367 -0
  12. omind/cli.py +2015 -0
  13. omind/clock.py +64 -0
  14. omind/compliance.py +393 -0
  15. omind/consolidate.py +277 -0
  16. omind/corpus.py +88 -0
  17. omind/deadline.py +145 -0
  18. omind/embed.py +209 -0
  19. omind/entities.py +479 -0
  20. omind/filelock.py +206 -0
  21. omind/fleet-sudo.sh +37 -0
  22. omind/git-fresh-base.sh +87 -0
  23. omind/graph.py +391 -0
  24. omind/guard.py +6098 -0
  25. omind/harness.py +808 -0
  26. omind/help_system.py +65 -0
  27. omind/hooks.py +1037 -0
  28. omind/journal.py +364 -0
  29. omind/learn.py +166 -0
  30. omind/lint.py +337 -0
  31. omind/loopguard.py +244 -0
  32. omind/maintain.py +192 -0
  33. omind/merge.py +461 -0
  34. omind/mesh.py +1209 -0
  35. omind/model_http.py +89 -0
  36. omind/namehints.py +729 -0
  37. omind/needle.py +344 -0
  38. omind/notes.py +80 -0
  39. omind/okf.py +152 -0
  40. omind/omi-gate-reset.sh +52 -0
  41. omind/omi-guard-hermes.sh +98 -0
  42. omind/omi-guard.dsh.js +390 -0
  43. omind/omi-guard.opencode.js +56 -0
  44. omind/omi-guard.sh +239 -0
  45. omind/paths.py +137 -0
  46. omind/policy.py +955 -0
  47. omind/proc.py +88 -0
  48. omind/provision.py +2764 -0
  49. omind/quickstart.py +530 -0
  50. omind/recall.py +166 -0
  51. omind/retrieve.py +500 -0
  52. omind/rules.py +1131 -0
  53. omind/scope_guard.py +73 -0
  54. omind/searchindex.py +2064 -0
  55. omind/secret-output-guard.sh +138 -0
  56. omind/seeds.py +405 -0
  57. omind/server.py +743 -0
  58. omind/skills/omind/SKILL.md +37 -0
  59. omind/skills/omind/agents/openai.yaml +4 -0
  60. omind/store.py +1835 -0
  61. omind/timeline.py +323 -0
  62. omind/transfer.py +347 -0
  63. omind/txn.py +429 -0
  64. omind/unwritten.py +129 -0
  65. omind/update.py +728 -0
  66. omind/verify.py +779 -0
  67. omind/web/__init__.py +3 -0
  68. omind/web/app.py +255 -0
  69. omind/web/static/app.css +841 -0
  70. omind/web/static/app.js +1062 -0
  71. omind/web/static/graph.js +336 -0
  72. omind/web/static/index.html +91 -0
  73. omind/web/static/vendor/fonts/bricolagegrotesque-1c7e673e77.woff2 +0 -0
  74. omind/web/static/vendor/fonts/bricolagegrotesque-40c2cb34af.woff2 +0 -0
  75. omind/web/static/vendor/fonts/bricolagegrotesque-fdb6b064da.woff2 +0 -0
  76. omind/web/static/vendor/fonts/jetbrainsmono-028edbf560.woff2 +0 -0
  77. omind/web/static/vendor/fonts/jetbrainsmono-0a7197f9e2.woff2 +0 -0
  78. omind/web/static/vendor/fonts/jetbrainsmono-acf87d8659.woff2 +0 -0
  79. omind/web/static/vendor/fonts/jetbrainsmono-c8b23d7bdf.woff2 +0 -0
  80. omind/web/static/vendor/fonts/jetbrainsmono-d2761cb6ca.woff2 +0 -0
  81. omind/web/static/vendor/fonts/jetbrainsmono-db1e206703.woff2 +0 -0
  82. omind/web/static/vendor/fonts/newsreader-1c520e8540.woff2 +0 -0
  83. omind/web/static/vendor/fonts/newsreader-2b5209ccd5.woff2 +0 -0
  84. omind/web/static/vendor/fonts/newsreader-34eec9781a.woff2 +0 -0
  85. omind/web/static/vendor/fonts/newsreader-72c495d39d.woff2 +0 -0
  86. omind/web/static/vendor/fonts/newsreader-a6a2a4bf8d.woff2 +0 -0
  87. omind/web/static/vendor/fonts/newsreader-f24c321523.woff2 +0 -0
  88. omind/web/static/vendor/fonts.css +251 -0
  89. omind/web/static/vendor/marked.min.js +69 -0
  90. omind/web/static/vendor/purify.min.js +3 -0
  91. omind/web/static/vendor/tailwind.css +2 -0
  92. omind/writecontext.py +463 -0
  93. omind-10.3.4.dist-info/METADATA +794 -0
  94. omind-10.3.4.dist-info/RECORD +97 -0
  95. omind-10.3.4.dist-info/WHEEL +4 -0
  96. omind-10.3.4.dist-info/entry_points.txt +3 -0
  97. omind-10.3.4.dist-info/licenses/LICENSE +201 -0
omind/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2026 Aaron K. Clark
3
+ """omind — OMI/Obsidian memory tooling for AI agents."""
4
+
5
+ __version__ = "10.3.4"
omind/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2026 Aaron K. Clark
3
+ """Enable `python -m omind`."""
4
+
5
+ from omind.cli import main
6
+
7
+ if __name__ == "__main__":
8
+ raise SystemExit(main())
omind/_omi_enforce.py ADDED
@@ -0,0 +1,139 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ omi-enforce.py — PostToolUse hook: prevent built-in Claude memory from persisting.
4
+
5
+ For each .md file found in ~/.claude/projects/*/memory/:
6
+ 1. Parse name + description from YAML frontmatter
7
+ 2. Check if a matching note already exists in the OMI vault (by filename)
8
+ 3. If NOT found → migrate via `omind note` first
9
+ 4. Delete the Claude memory file either way
10
+ """
11
+ # Lazy annotations so the builtin-generic hints (dict[str, str]) don't need
12
+ # evaluation at import — this ships as a hook run under the host's system
13
+ # python3, whose version `requires-python` does not govern.
14
+ from __future__ import annotations
15
+
16
+ import contextlib
17
+ import glob
18
+ import pathlib
19
+ import re
20
+ import subprocess
21
+
22
+ HOME = pathlib.Path.home()
23
+ OMIND = HOME / ".local/bin/omind"
24
+ VAULT = HOME / "Documents/Obsidian Vault"
25
+ OMI_DIR = VAULT / "OMI"
26
+ CLAUDE_PROJECTS = HOME / ".claude/projects"
27
+
28
+
29
+ def parse_frontmatter(text: str) -> tuple[dict[str, str], str]:
30
+ """Return (fields_dict, body_text). Handles simple key: value and nested metadata.type."""
31
+ if not text.startswith("---"):
32
+ return {}, text
33
+ parts = text.split("---", 2)
34
+ if len(parts) < 3:
35
+ return {}, text
36
+ fm_raw, body = parts[1], parts[2].strip()
37
+ fields: dict[str, str] = {}
38
+ in_metadata = False
39
+ for line in fm_raw.splitlines():
40
+ if line.strip() == "metadata:":
41
+ in_metadata = True
42
+ continue
43
+ if in_metadata:
44
+ m = re.match(r"\s+(\w+):\s*(.*)", line)
45
+ if m:
46
+ fields[m.group(1)] = m.group(2).strip()
47
+ continue
48
+ else:
49
+ in_metadata = False
50
+ if ":" in line and not line.startswith(" "):
51
+ k, _, v = line.partition(":")
52
+ fields[k.strip()] = v.strip()
53
+ return fields, body
54
+
55
+
56
+ def slug_to_title(slug: str) -> str:
57
+ return " ".join(w.capitalize() for w in re.split(r"[-_]", slug) if w)
58
+
59
+
60
+ def omi_exists(title: str) -> bool:
61
+ """True only if the OMI vault already has a note with this EXACT filename.
62
+
63
+ A fuzzy "≥2 of 3 slug words appear in some filename" match used to declare a
64
+ memory already-covered and DELETE it without migrating its content — so
65
+ ``docker-compose-tips`` was destroyed because a "Docker Compose Setup" note
66
+ existed. Exact match only: anything else is migrated before deletion.
67
+ """
68
+ if not OMI_DIR.exists():
69
+ return False
70
+ safe_title = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "", title).strip()
71
+ return bool(safe_title) and (OMI_DIR / f"{safe_title}.md").exists()
72
+
73
+
74
+ def migrate(title: str, summary: str, body: str, mem_type: str) -> bool:
75
+ """Create a note in OMI via the omind CLI. Returns True on success."""
76
+ if not OMIND.exists():
77
+ return False
78
+ tags = f"claude-memory,{mem_type}" if mem_type else "claude-memory"
79
+ cmd = [
80
+ str(OMIND), "note",
81
+ "--vault", str(VAULT),
82
+ "--folder", "OMI",
83
+ "--title", title,
84
+ "--summary", summary or title,
85
+ "--tags", tags,
86
+ ]
87
+ try:
88
+ # Timeout so a hung `omind note` (vault lock held by mesh sync, gpg
89
+ # pinentry, NFS stall) can't hang this PostToolUse hook — and therefore
90
+ # every agent turn — indefinitely.
91
+ result = subprocess.run(
92
+ cmd, input=body, text=True, capture_output=True, timeout=30
93
+ )
94
+ except (subprocess.SubprocessError, OSError):
95
+ return False
96
+ return result.returncode == 0
97
+
98
+
99
+ def _safe_unlink(path: pathlib.Path) -> None:
100
+ """Delete a migrated memory file; a permission/read-only-FS error must not
101
+ crash a hook that fires on every tool call."""
102
+ with contextlib.suppress(OSError):
103
+ path.unlink(missing_ok=True)
104
+
105
+
106
+ def main() -> None:
107
+ pattern = str(CLAUDE_PROJECTS / "*/memory/*.md")
108
+ for filepath in glob.glob(pattern):
109
+ path = pathlib.Path(filepath)
110
+
111
+ # Always nuke stale MEMORY.md index files (a generated pointer, no content)
112
+ if path.name == "MEMORY.md":
113
+ _safe_unlink(path)
114
+ continue
115
+
116
+ try:
117
+ content = path.read_text(encoding="utf-8", errors="replace")
118
+ except OSError:
119
+ continue
120
+
121
+ fm, body = parse_frontmatter(content)
122
+ slug = fm.get("name", "").strip()
123
+ description = fm.get("description", "").strip()
124
+ mem_type = fm.get("type", "").strip()
125
+
126
+ # A file with no `name:` slug still holds memory content — derive a title
127
+ # from its filename and MIGRATE it before deleting (never unlink blind).
128
+ title = slug_to_title(slug) if slug else slug_to_title(path.stem)
129
+
130
+ if omi_exists(title):
131
+ _safe_unlink(path) # content already in OMI under this exact title
132
+ elif migrate(title, description, body, mem_type):
133
+ _safe_unlink(path)
134
+ # If migration fails (or omind is unavailable), LEAVE the file — the whole
135
+ # point is to never lose memory content.
136
+
137
+
138
+ if __name__ == "__main__":
139
+ main()
omind/access.py ADDED
@@ -0,0 +1,226 @@
1
+ # SPDX-License-Identifier: Apache-2.0
2
+ # Copyright 2026 Aaron K. Clark
3
+ """Machine-local access statistics for dynamic core-memory selection.
4
+
5
+ Two independent consumers share the one ``access`` table (2026-08-27 roundtable,
6
+ item #2): the #173 tiered-core scorer (:func:`core_members`) and the usefulness
7
+ feedback leg (:func:`usefulness_weights`). They *share the table and keep their
8
+ own scorer* — the two effects are never blended, or a note would be counted
9
+ twice.
10
+
11
+ The split that keeps them honest lives in the schema:
12
+
13
+ * ``count`` / ``last_access`` — every read, no dedupe. Feeds #173 unchanged; the
14
+ usefulness leg never looks at these.
15
+ * ``organic_count`` / ``organic_last`` — only *organic* reads (an agent
16
+ explicitly calling read-note/recall-note), deduped once per session. This is
17
+ the clean usefulness signal. Capsule/hook/turn-preflight reads are excluded
18
+ (their position basis is not evidence a human found the note useful — the
19
+ Cortana/Wintermute concern the panel adopted as mandatory).
20
+ * ``filtered_count`` — reads the usefulness leg deliberately dropped
21
+ (non-organic, or an organic re-read already seen this session). Kept only so
22
+ ``search --explain`` can show *counted and filtered* reads side by side.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import contextlib
28
+ import math
29
+ import sqlite3
30
+ import time
31
+ from pathlib import Path
32
+
33
+ from omind import paths
34
+
35
+ _MAX_AGE_DAYS = 90
36
+
37
+ #: Usefulness decay (item #2). Decay-only, no boost: a term that only subtracts
38
+ #: cannot self-reinforce, so this can only ever *demote* an organically-read
39
+ #: note that has since gone stale — never push a popular one up. The floor and
40
+ #: the 30-day onset are HAL9000's dissent, adopted: a note read recently (or
41
+ #: never read at all — new/never-surfaced) is untouched, and even a long-stale
42
+ #: note bottoms out at the floor rather than being buried.
43
+ _USEFUL_ONSET_DAYS = 30.0
44
+ _USEFUL_FLOOR = 0.7
45
+
46
+
47
+ def _ensure_schema(db: sqlite3.Connection) -> None:
48
+ db.execute(
49
+ "CREATE TABLE IF NOT EXISTS access ("
50
+ "filename TEXT PRIMARY KEY, count INTEGER NOT NULL, last_access REAL NOT NULL,"
51
+ " organic_count INTEGER NOT NULL DEFAULT 0,"
52
+ " organic_last REAL NOT NULL DEFAULT 0,"
53
+ " filtered_count INTEGER NOT NULL DEFAULT 0)"
54
+ )
55
+ # Migrate installs created before the usefulness columns existed. A duplicate
56
+ # column raises OperationalError, which is the "already migrated" signal.
57
+ for column, decl in (
58
+ ("organic_count", "INTEGER NOT NULL DEFAULT 0"),
59
+ ("organic_last", "REAL NOT NULL DEFAULT 0"),
60
+ ("filtered_count", "INTEGER NOT NULL DEFAULT 0"),
61
+ ):
62
+ with contextlib.suppress(sqlite3.OperationalError):
63
+ db.execute(f"ALTER TABLE access ADD COLUMN {column} {decl}")
64
+ db.execute(
65
+ "CREATE TABLE IF NOT EXISTS read_session ("
66
+ "filename TEXT NOT NULL, session TEXT NOT NULL, PRIMARY KEY(filename, session))"
67
+ )
68
+
69
+
70
+ def record(
71
+ omi_dir: Path | str,
72
+ filename: str,
73
+ *,
74
+ session: str = "",
75
+ organic: bool = True,
76
+ now: float | None = None,
77
+ ) -> None:
78
+ """Record one note read. Advisory and fail-open.
79
+
80
+ ``count``/``last_access`` always advance (the #173 scorer's inputs are
81
+ unchanged from before this table was shared). The usefulness columns advance
82
+ only for an ``organic`` read not already seen this ``session``; every other
83
+ read lands in ``filtered_count`` instead so ``--explain`` can account for it.
84
+ """
85
+ path = paths.access_state_path(Path(omi_dir))
86
+ stamp = now if now is not None else time.time()
87
+ try:
88
+ path.parent.mkdir(parents=True, exist_ok=True)
89
+ with sqlite3.connect(path, timeout=2.0) as db:
90
+ db.execute("PRAGMA busy_timeout=2000")
91
+ _ensure_schema(db)
92
+ db.execute(
93
+ "INSERT INTO access(filename, count, last_access) VALUES (?, 1, ?)"
94
+ " ON CONFLICT(filename) DO UPDATE SET"
95
+ " count = count + 1, last_access = excluded.last_access",
96
+ (filename, stamp),
97
+ )
98
+ first_this_session = False
99
+ if organic:
100
+ # A session id lets a burst of re-reads count once; an unknown
101
+ # session ("") cannot be deduped, so each such read counts (rare
102
+ # — only non-MCP entry points omit it).
103
+ if session:
104
+ cur = db.execute(
105
+ "INSERT OR IGNORE INTO read_session(filename, session)"
106
+ " VALUES (?, ?)",
107
+ (filename, session),
108
+ )
109
+ first_this_session = cur.rowcount > 0
110
+ else:
111
+ first_this_session = True
112
+ if first_this_session:
113
+ db.execute(
114
+ "UPDATE access SET organic_count = organic_count + 1,"
115
+ " organic_last = ? WHERE filename = ?",
116
+ (stamp, filename),
117
+ )
118
+ else:
119
+ db.execute(
120
+ "UPDATE access SET filtered_count = filtered_count + 1"
121
+ " WHERE filename = ?",
122
+ (filename,),
123
+ )
124
+ except (OSError, sqlite3.Error):
125
+ return
126
+
127
+
128
+ def core_members(
129
+ omi_dir: Path | str,
130
+ *,
131
+ limit: int = 3,
132
+ now: float | None = None,
133
+ max_age_days: int = _MAX_AGE_DAYS,
134
+ ) -> list[str]:
135
+ """Best recently/frequently read notes, newest score first (#173 scorer)."""
136
+ path = paths.access_state_path(Path(omi_dir))
137
+ if limit <= 0 or not path.is_file():
138
+ return []
139
+ current = now if now is not None else time.time()
140
+ cutoff = current - max_age_days * 86_400
141
+ try:
142
+ with sqlite3.connect(f"{path.resolve().as_uri()}?mode=ro", uri=True, timeout=1.0) as db:
143
+ rows = db.execute(
144
+ "SELECT filename, count, last_access FROM access WHERE last_access >= ?",
145
+ (cutoff,),
146
+ )
147
+ scored = [
148
+ (
149
+ math.log1p(int(count))
150
+ + 1.0 / (1.0 + max(0.0, current - float(last_access)) / 2_592_000),
151
+ float(last_access),
152
+ str(filename),
153
+ )
154
+ for filename, count, last_access in rows
155
+ ]
156
+ except (OSError, sqlite3.Error, TypeError, ValueError):
157
+ return []
158
+ scored.sort(key=lambda item: (-item[0], -item[1], item[2]))
159
+ return [filename for _score, _last_access, filename in scored[:limit]]
160
+
161
+
162
+ class Usefulness:
163
+ """One note's usefulness signal, as ``search --explain`` reports it."""
164
+
165
+ __slots__ = ("weight", "useful_reads", "filtered_reads")
166
+
167
+ def __init__(self, weight: float, useful_reads: int, filtered_reads: int) -> None:
168
+ #: Multiplicative demotion in ``[floor, 1.0]``; 1.0 means untouched.
169
+ self.weight = weight
170
+ #: Organic, per-session-deduped reads (the counted signal).
171
+ self.useful_reads = useful_reads
172
+ #: Reads excluded as non-organic or same-session re-reads.
173
+ self.filtered_reads = filtered_reads
174
+
175
+
176
+ def _decay_weight(organic_last: float, organic_count: int, current: float) -> float:
177
+ """Demotion for one note. Decay-only: never above 1.0.
178
+
179
+ A note never read organically, or last read within the onset window, is
180
+ untouched (1.0). Past the onset it decays linearly toward the floor, reached
181
+ at ``_MAX_AGE_DAYS`` — a gentle reorder nudge, never a bury.
182
+ """
183
+ if organic_count <= 0 or organic_last <= 0:
184
+ return 1.0
185
+ age_days = max(0.0, (current - organic_last) / 86_400)
186
+ if age_days <= _USEFUL_ONSET_DAYS:
187
+ return 1.0
188
+ span = _MAX_AGE_DAYS - _USEFUL_ONSET_DAYS
189
+ frac = 1.0 if span <= 0 else min(1.0, (age_days - _USEFUL_ONSET_DAYS) / span)
190
+ return 1.0 - (1.0 - _USEFUL_FLOOR) * frac
191
+
192
+
193
+ def usefulness_weights(
194
+ omi_dir: Path | str,
195
+ filenames: set[str] | None = None,
196
+ *,
197
+ now: float | None = None,
198
+ ) -> dict[str, Usefulness]:
199
+ """Usefulness demotion for each note (item #2 scorer), fail-open to ``{}``.
200
+
201
+ Reads only the ``organic_*``/``filtered_count`` columns — never ``count``,
202
+ which belongs to #173. Restricted to ``filenames`` when given so the search
203
+ path never scores notes it did not already match (reorder-never-add)."""
204
+ path = paths.access_state_path(Path(omi_dir))
205
+ if not path.is_file():
206
+ return {}
207
+ current = now if now is not None else time.time()
208
+ try:
209
+ with sqlite3.connect(f"{path.resolve().as_uri()}?mode=ro", uri=True, timeout=1.0) as db:
210
+ rows = db.execute(
211
+ "SELECT filename, organic_count, organic_last, filtered_count FROM access"
212
+ ).fetchall()
213
+ except (OSError, sqlite3.Error):
214
+ return {}
215
+ out: dict[str, Usefulness] = {}
216
+ for filename, organic_count, organic_last, filtered_count in rows:
217
+ name = str(filename)
218
+ if filenames is not None and name not in filenames:
219
+ continue
220
+ with contextlib.suppress(TypeError, ValueError):
221
+ out[name] = Usefulness(
222
+ weight=_decay_weight(float(organic_last), int(organic_count), current),
223
+ useful_reads=int(organic_count),
224
+ filtered_reads=int(filtered_count),
225
+ )
226
+ return out