@bcmyguest/openmem 0.0.0-stage → 0.6.3

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 (191) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +136 -2
  3. package/bin/hindsight-project-index +530 -0
  4. package/cli/coding-agent/access-policy.mjs +38 -0
  5. package/cli/coding-agent/agents.mjs +34 -0
  6. package/cli/coding-agent/bank-from-cwd.mjs +140 -0
  7. package/cli/coding-agent/capabilities.mjs +23 -0
  8. package/cli/coding-agent/hook-harnesses.mjs +87 -0
  9. package/cli/coding-agent/hook-runner.mjs +260 -0
  10. package/cli/coding-agent/install-access.mjs +93 -0
  11. package/cli/coding-agent/install-shim.mjs +97 -0
  12. package/cli/coding-agent/opencode-config.mjs +91 -0
  13. package/cli/coding-agent/opencode-plugin.mjs +96 -0
  14. package/cli/coding-agent/runtime.mjs +39 -0
  15. package/cli/coding-agent/session-facade.mjs +62 -0
  16. package/cli/cognee-mcp.mjs +260 -0
  17. package/cli/feed-sync.mjs +11 -0
  18. package/cli/install-coding-agent.mjs +165 -0
  19. package/cli/install-memory-mcp.mjs +426 -0
  20. package/cli/is-main.mjs +18 -0
  21. package/cli/mcp/context.mjs +61 -0
  22. package/cli/mcp/export/cognee-dataset-name.mjs +12 -0
  23. package/cli/mcp/export/cognee.mjs +119 -0
  24. package/cli/mcp/export/hindsight.mjs +48 -0
  25. package/cli/mcp/export/index.mjs +26 -0
  26. package/cli/mcp/export/mem0.mjs +31 -0
  27. package/cli/mcp/llm-synthesize.mjs +50 -0
  28. package/cli/mcp/mem0-scope-id.mjs +22 -0
  29. package/cli/mcp/memory-facade.mjs +455 -0
  30. package/cli/mcp/memory-reflect-consolidate.mjs +120 -0
  31. package/cli/mcp/notebooklm-banks.mjs +21 -0
  32. package/cli/mcp/notebooklm-hindsight-rest.mjs +45 -0
  33. package/cli/mcp/notebooklm-provider.mjs +225 -0
  34. package/cli/mcp/pmm-records.mjs +23 -0
  35. package/cli/mcp/portability.mjs +158 -0
  36. package/cli/mcp/stdio.mjs +65 -0
  37. package/cli/mcp/unified-mcp-install.mjs +65 -0
  38. package/cli/mem0-mcp.mjs +108 -0
  39. package/cli/notebooklm-mcp.mjs +236 -0
  40. package/cli/openmem-mcp.mjs +35 -0
  41. package/cli/openmem.mjs +205 -0
  42. package/cli/roots.mjs +85 -0
  43. package/cli/stack.mjs +436 -0
  44. package/cli/switch-memory-provider.mjs +358 -0
  45. package/cli/wiring-report.mjs +125 -0
  46. package/compose.bench.yaml +12 -0
  47. package/compose.yaml +164 -0
  48. package/config/coding-agent.json +13 -0
  49. package/config/hindsight.env.example +24 -0
  50. package/config/index.yml +54 -0
  51. package/config/litellm.lemond.yaml +40 -0
  52. package/config/litellm.ollama.yaml +33 -0
  53. package/config/notebooklm.yml +33 -0
  54. package/config/openmem.toml.example +62 -0
  55. package/docs/OPENMEM.md +23 -0
  56. package/docs/architecture.md +219 -0
  57. package/docs/assets/README.md +10 -0
  58. package/docs/assets/setup-demo.gif +0 -0
  59. package/docs/coding-agent-hooks.md +51 -0
  60. package/docs/coding-agent-scopes.md +22 -0
  61. package/docs/coding-agents.md +51 -0
  62. package/docs/cognee.md +37 -0
  63. package/docs/concepts.md +112 -0
  64. package/docs/hindsight.md +74 -0
  65. package/docs/index-sources-roadmap.md +38 -0
  66. package/docs/index-sources.md +39 -0
  67. package/docs/indexer-sinks.md +122 -0
  68. package/docs/indexing.md +57 -0
  69. package/docs/manual-stack-setup.md +74 -0
  70. package/docs/mcp-install-guide.md +4 -0
  71. package/docs/mcp-setup.md +101 -0
  72. package/docs/mem0.md +103 -0
  73. package/docs/memory-portability.md +73 -0
  74. package/docs/memory-providers.md +246 -0
  75. package/docs/model-configuration.md +84 -0
  76. package/docs/notebooklm.md +83 -0
  77. package/docs/npx-install.md +170 -0
  78. package/docs/openmem-config.md +194 -0
  79. package/docs/provider-wizard-hooks.md +222 -0
  80. package/docs/quickstart.md +101 -0
  81. package/docs/setup-wizard.md +116 -0
  82. package/docs/sources-feeds.md +52 -0
  83. package/docs/sources-readwise.md +23 -0
  84. package/docs/sources-zotero.md +22 -0
  85. package/glama.json +4 -0
  86. package/indexer/Dockerfile +38 -0
  87. package/indexer/code_outline.py +146 -0
  88. package/indexer/feed_sync.py +107 -0
  89. package/indexer/index_config.py +290 -0
  90. package/indexer/pytest.ini +5 -0
  91. package/indexer/sinks/__init__.py +148 -0
  92. package/indexer/sinks/_http.py +44 -0
  93. package/indexer/sinks/cognee.py +329 -0
  94. package/indexer/sinks/hindsight.py +107 -0
  95. package/indexer/sinks/mem0.py +247 -0
  96. package/indexer/watch.py +240 -0
  97. package/package.json +95 -4
  98. package/providers/_template/compose.yaml +52 -0
  99. package/providers/cognee/OPERATOR.md +82 -0
  100. package/providers/cognee/compose.yaml +89 -0
  101. package/providers/cognee/lib/clack.mjs +157 -0
  102. package/providers/cognee/lib/coding-agent.mjs +26 -0
  103. package/providers/cognee/lib/cognee-mcp-install.mjs +153 -0
  104. package/providers/cognee/lib/config.mjs +98 -0
  105. package/providers/cognee/lib/env-file.mjs +194 -0
  106. package/providers/cognee/lib/env-setup.mjs +83 -0
  107. package/providers/cognee/lib/llm-env.mjs +382 -0
  108. package/providers/cognee/lib/ports.mjs +25 -0
  109. package/providers/cognee/lib/smoke.mjs +246 -0
  110. package/providers/cognee/lib/stack-env.mjs +134 -0
  111. package/providers/cognee/lib/state.mjs +101 -0
  112. package/providers/cognee/lib/summary.mjs +56 -0
  113. package/providers/cognee/lib/validate.mjs +97 -0
  114. package/providers/cognee/manifest.json +56 -0
  115. package/providers/cognee/wizard.mjs +134 -0
  116. package/providers/graphiti/manifest.json +15 -0
  117. package/providers/hindsight/lib/clack.mjs +245 -0
  118. package/providers/hindsight/lib/coding-agent.mjs +204 -0
  119. package/providers/hindsight/lib/config.mjs +73 -0
  120. package/providers/hindsight/lib/env-setup.mjs +71 -0
  121. package/providers/hindsight/lib/litellm-routes.mjs +187 -0
  122. package/providers/hindsight/lib/model-backend.mjs +214 -0
  123. package/providers/hindsight/lib/notebooklm-mcp-install.mjs +127 -0
  124. package/providers/hindsight/lib/ollama-compose.mjs +51 -0
  125. package/providers/hindsight/lib/ports.mjs +30 -0
  126. package/providers/hindsight/lib/probe.mjs +33 -0
  127. package/providers/hindsight/lib/stack-env.mjs +132 -0
  128. package/providers/hindsight/lib/state.mjs +75 -0
  129. package/providers/hindsight/lib/summary.mjs +43 -0
  130. package/providers/hindsight/lib/validate.mjs +75 -0
  131. package/providers/hindsight/manifest.json +58 -0
  132. package/providers/hindsight/wizard.mjs +149 -0
  133. package/providers/letta/manifest.json +29 -0
  134. package/providers/mem0/OPERATOR.md +124 -0
  135. package/providers/mem0/compose.yaml +57 -0
  136. package/providers/mem0/lib/coding-agent.mjs +58 -0
  137. package/providers/mem0/lib/config.mjs +45 -0
  138. package/providers/mem0/lib/configure.mjs +135 -0
  139. package/providers/mem0/lib/dotenv.mjs +71 -0
  140. package/providers/mem0/lib/env-file.mjs +110 -0
  141. package/providers/mem0/lib/http.mjs +69 -0
  142. package/providers/mem0/lib/mcp-install.mjs +165 -0
  143. package/providers/mem0/lib/secret.mjs +30 -0
  144. package/providers/mem0/lib/smoke.mjs +179 -0
  145. package/providers/mem0/lib/stack-env.mjs +216 -0
  146. package/providers/mem0/lib/state.mjs +67 -0
  147. package/providers/mem0/lib/summary.mjs +60 -0
  148. package/providers/mem0/lib/url.mjs +106 -0
  149. package/providers/mem0/lib/validate.mjs +158 -0
  150. package/providers/mem0/manifest.json +47 -0
  151. package/providers/mem0/wizard.mjs +133 -0
  152. package/providers/zep/manifest.json +29 -0
  153. package/scripts/check-pack.mjs +218 -0
  154. package/scripts/gen-masks.mjs +83 -0
  155. package/scripts/gen-masks.sh +4 -0
  156. package/scripts/promotion-provider-smoke.mjs +34 -0
  157. package/scripts/run-setup.sh +30 -0
  158. package/scripts/setup.mjs +94 -0
  159. package/scripts/setup.sh +5 -0
  160. package/scripts/spin-up.sh +45 -0
  161. package/scripts/wizard.sh +5 -0
  162. package/server.json +34 -0
  163. package/skills/openmem/SKILL.md +29 -0
  164. package/sync/Dockerfile +27 -0
  165. package/sync/memory_backend.py +87 -0
  166. package/sync/notebooklm_sync.py +664 -0
  167. package/wizard/bin/pm.mjs +97 -0
  168. package/wizard/bin/setup.mjs +33 -0
  169. package/wizard/lib/compose-version.mjs +32 -0
  170. package/wizard/lib/compose.mjs +70 -0
  171. package/wizard/lib/curl.mjs +103 -0
  172. package/wizard/lib/docker-build.mjs +148 -0
  173. package/wizard/lib/exec.mjs +41 -0
  174. package/wizard/lib/indexer.mjs +6 -0
  175. package/wizard/lib/load-machine-env.mjs +4 -0
  176. package/wizard/lib/log.mjs +32 -0
  177. package/wizard/lib/notebooklm-agent-access.mjs +92 -0
  178. package/wizard/lib/notebooklm.mjs +214 -0
  179. package/wizard/lib/parse-args.mjs +87 -0
  180. package/wizard/lib/paths.mjs +39 -0
  181. package/wizard/lib/pm-config.mjs +163 -0
  182. package/wizard/lib/ports.mjs +94 -0
  183. package/wizard/lib/prompts.mjs +57 -0
  184. package/wizard/lib/provider-wizard/helpers.mjs +98 -0
  185. package/wizard/lib/provider-wizard/run-steps.mjs +129 -0
  186. package/wizard/lib/providers/registry.mjs +262 -0
  187. package/wizard/lib/run-clack.mjs +108 -0
  188. package/wizard/lib/run-wizard.mjs +755 -0
  189. package/wizard/lib/validate-setup.mjs +62 -0
  190. package/wizard/lib/wizard-action.mjs +34 -0
  191. package/wizard/lib/wizard-conf.mjs +71 -0
@@ -0,0 +1,290 @@
1
+ """Load and validate the project indexer config (config/index.yml).
2
+
3
+ Lookup, first hit wins: --config PATH, $HINDSIGHT_INDEX_CONFIG, ~/.config/hindsight/index.yml, then the shipped default.
4
+ A file found this way replaces the shipped default entirely. A key the file omits takes its built-in value (DEFAULT_DATA).
5
+ """
6
+
7
+ import fnmatch
8
+ import os
9
+ from collections.abc import Mapping
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+
13
+ import yaml
14
+
15
+
16
+ ENV_VAR = "HINDSIGHT_INDEX_CONFIG"
17
+ DEFAULT_PATH = Path(__file__).resolve().parent.parent / "config" / "index.yml"
18
+ USER_PATH = "~/.config/hindsight/index.yml"
19
+ TOP_LEVEL_KEYS = (
20
+ "roots",
21
+ "repos",
22
+ "vaults",
23
+ "notes",
24
+ "papers",
25
+ "bookmarks",
26
+ "feeds",
27
+ "exclude",
28
+ "banks",
29
+ "max_file_kb",
30
+ "skip_dirs",
31
+ "poll_seconds",
32
+ "vault_quiet_seconds",
33
+ "relist_seconds",
34
+ )
35
+ VAULT_KEYS = ("auto", "paths")
36
+ PATH_LIST_KEYS = ("paths",)
37
+ FEEDS_KEYS = ("out_dir", "rss", "openalex_queries")
38
+ ROOT_KEYS = ("path", "depth")
39
+ DEFAULT_SKIP_DIRS = [".cache", ".codegraph", ".decision", ".git", ".next", ".tasks", ".venv", "build", "dist", "node_modules", "target", "vendor"]
40
+ DEFAULT_DATA = {
41
+ "roots": [{"path": "~", "depth": 3}],
42
+ "repos": ["~/.config/nvim"],
43
+ "vaults": {"auto": True, "paths": []},
44
+ "notes": {"paths": []},
45
+ "papers": {"paths": []},
46
+ "bookmarks": {"paths": []},
47
+ "feeds": {"out_dir": "~/.local/share/personal-memory/feeds", "rss": [], "openalex_queries": []},
48
+ "exclude": [],
49
+ "banks": {"~/Documents/sample-project": "coding-agent::documents-sample-project"},
50
+ "max_file_kb": 128,
51
+ "skip_dirs": DEFAULT_SKIP_DIRS,
52
+ "poll_seconds": 60,
53
+ "vault_quiet_seconds": 30,
54
+ "relist_seconds": 600,
55
+ }
56
+
57
+
58
+ class ConfigError(ValueError):
59
+ """A config file that cannot be read or fails validation. The message names the file and the key."""
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class Root:
64
+ path: Path
65
+ depth: int
66
+
67
+
68
+ @dataclass(frozen=True)
69
+ class Config:
70
+ roots: tuple[Root, ...]
71
+ repos: tuple[Path, ...]
72
+ vault_auto: bool
73
+ vault_paths: tuple[Path, ...]
74
+ notes_paths: tuple[Path, ...]
75
+ papers_paths: tuple[Path, ...]
76
+ bookmarks_paths: tuple[Path, ...]
77
+ feeds_out_dir: Path | None
78
+ feeds_rss: tuple[object, ...]
79
+ feeds_openalex_queries: tuple[object, ...]
80
+ exclude: tuple[str, ...]
81
+ banks: tuple[tuple[Path, str], ...] # longest prefix first
82
+ max_file_kb: int
83
+ skip_dirs: frozenset[str]
84
+ poll_seconds: int
85
+ vault_quiet_seconds: int
86
+ relist_seconds: int
87
+ source: str = field(default="built-in", compare=False)
88
+
89
+ @property
90
+ def max_file_bytes(self) -> int:
91
+ return self.max_file_kb * 1000
92
+
93
+ def bank_for(self, path: Path) -> str | None:
94
+ for prefix, bank in self.banks:
95
+ if path == prefix or prefix in path.parents:
96
+ return bank
97
+ return None
98
+
99
+ def is_excluded(self, path: Path) -> bool:
100
+ return any(fnmatch.fnmatchcase(str(path), pattern) for pattern in self.exclude)
101
+
102
+ def markdown_sources(self) -> tuple[tuple[str, Path], ...]:
103
+ """(kind, path) for watched markdown trees: notes, papers, bookmarks, feed cache."""
104
+ out: list[tuple[str, Path]] = []
105
+ for kind, paths in (
106
+ ("notes", self.notes_paths),
107
+ ("papers", self.papers_paths),
108
+ ("bookmarks", self.bookmarks_paths),
109
+ ):
110
+ for path in paths:
111
+ out.append((kind, path))
112
+ if self.feeds_out_dir is not None:
113
+ out.append(("feed", self.feeds_out_dir))
114
+ return tuple(out)
115
+
116
+
117
+ def _kind(value: object) -> str:
118
+ if value is None:
119
+ return "nothing (null)"
120
+ if isinstance(value, bool):
121
+ return "a boolean"
122
+ if isinstance(value, int):
123
+ return "an integer"
124
+ if isinstance(value, float):
125
+ return "a number"
126
+ if isinstance(value, str):
127
+ return "a string"
128
+ if isinstance(value, list):
129
+ return "a list"
130
+ if isinstance(value, dict):
131
+ return "a mapping"
132
+ return type(value).__name__
133
+
134
+
135
+ def _int(source: str, key: str, value: object, minimum: int) -> int:
136
+ if isinstance(value, bool) or not isinstance(value, int):
137
+ raise ConfigError(f"{source}: {key}: expected an integer, got {_kind(value)}")
138
+ if value < minimum:
139
+ raise ConfigError(f"{source}: {key}: must be at least {minimum}, got {value}")
140
+ return value
141
+
142
+
143
+ def _bool(source: str, key: str, value: object) -> bool:
144
+ if not isinstance(value, bool):
145
+ raise ConfigError(f"{source}: {key}: expected true or false, got {_kind(value)}")
146
+ return value
147
+
148
+
149
+ def _text(source: str, key: str, value: object) -> str:
150
+ if not isinstance(value, str) or not value.strip():
151
+ raise ConfigError(f"{source}: {key}: expected a non-empty string, got {_kind(value)}")
152
+ return value
153
+
154
+
155
+ def _list(source: str, key: str, value: object) -> list:
156
+ if not isinstance(value, list):
157
+ raise ConfigError(f"{source}: {key}: expected a list, got {_kind(value)}")
158
+ return value
159
+
160
+
161
+ def _mapping(source: str, key: str, value: object, allowed: tuple[str, ...]) -> dict:
162
+ if not isinstance(value, dict):
163
+ raise ConfigError(f"{source}: {key}: expected a mapping, got {_kind(value)}")
164
+ unknown = [str(name) for name in value if name not in allowed]
165
+ if unknown:
166
+ raise ConfigError(f"{source}: {key}: unknown key(s) {', '.join(unknown)}; allowed: {', '.join(allowed)}")
167
+ return value
168
+
169
+
170
+ def _abs_path(source: str, key: str, text: str) -> Path:
171
+ path = Path(text).expanduser()
172
+ if not path.is_absolute():
173
+ raise ConfigError(f"{source}: {key}: expected an absolute path or one starting with ~, got {text!r}")
174
+ return path
175
+
176
+
177
+ def _roots(source: str, value: object) -> tuple[Root, ...]:
178
+ roots = []
179
+ for index, item in enumerate(_list(source, "roots", value)):
180
+ key = f"roots[{index}]"
181
+ entry = _mapping(source, key, item, ROOT_KEYS)
182
+ if "path" not in entry:
183
+ raise ConfigError(f"{source}: {key}: missing required key 'path'")
184
+ # A bare ~ is YAML null, so null means home.
185
+ text = "~" if entry["path"] is None else _text(source, f"{key}.path", entry["path"])
186
+ depth = _int(source, f"{key}.depth", entry.get("depth", 3), 1)
187
+ roots.append(Root(_abs_path(source, f"{key}.path", text), depth))
188
+ return tuple(roots)
189
+
190
+
191
+ def _paths(source: str, key: str, value: object) -> tuple[Path, ...]:
192
+ return tuple(_abs_path(source, f"{key}[{index}]", _text(source, f"{key}[{index}]", item)) for index, item in enumerate(_list(source, key, value)))
193
+
194
+
195
+ def _banks(source: str, value: object) -> tuple[tuple[Path, str], ...]:
196
+ if not isinstance(value, dict):
197
+ raise ConfigError(f"{source}: banks: expected a mapping of path to bank id, got {_kind(value)}")
198
+ pairs = []
199
+ for prefix, bank in value.items():
200
+ path = _abs_path(source, f"banks[{prefix}]", _text(source, "banks key", prefix))
201
+ pairs.append((path, _text(source, f"banks[{prefix}]", bank)))
202
+ return tuple(sorted(pairs, key=lambda pair: len(pair[0].parts), reverse=True))
203
+
204
+
205
+ def _exclude(source: str, value: object) -> tuple[str, ...]:
206
+ patterns = []
207
+ for index, item in enumerate(_list(source, "exclude", value)):
208
+ pattern = _text(source, f"exclude[{index}]", item)
209
+ if not pattern.startswith(("/", "~")):
210
+ raise ConfigError(f"{source}: exclude[{index}]: expected a glob on an absolute path, got {pattern!r}")
211
+ patterns.append(os.path.expanduser(pattern))
212
+ return tuple(patterns)
213
+
214
+
215
+ def _skip_dirs(source: str, value: object) -> frozenset[str]:
216
+ names = []
217
+ for index, item in enumerate(_list(source, "skip_dirs", value)):
218
+ name = _text(source, f"skip_dirs[{index}]", item)
219
+ if "/" in name:
220
+ raise ConfigError(f"{source}: skip_dirs[{index}]: expected a directory name, not a path, got {name!r}")
221
+ names.append(name)
222
+ return frozenset(names)
223
+
224
+
225
+ def parse(data: object, source: str = "built-in") -> Config:
226
+ """Validate parsed YAML. None (an empty file) and omitted keys take the DEFAULT_DATA values."""
227
+ top = _mapping(source, "top level", DEFAULT_DATA if data is None else data, TOP_LEVEL_KEYS)
228
+ merged = {**DEFAULT_DATA, **top}
229
+ vaults = _mapping(source, "vaults", merged["vaults"], VAULT_KEYS)
230
+ notes = _mapping(source, "notes", merged["notes"], PATH_LIST_KEYS)
231
+ papers = _mapping(source, "papers", merged["papers"], PATH_LIST_KEYS)
232
+ bookmarks = _mapping(source, "bookmarks", merged["bookmarks"], PATH_LIST_KEYS)
233
+ feeds_raw = _mapping(source, "feeds", merged.get("feeds") or DEFAULT_DATA["feeds"], FEEDS_KEYS)
234
+ feeds_out = _paths(source, "feeds.out_dir", [feeds_raw.get("out_dir") or DEFAULT_DATA["feeds"]["out_dir"]])[0]
235
+ feeds_rss = tuple(_list(source, "feeds.rss", feeds_raw.get("rss") or []))
236
+ feeds_oa = tuple(_list(source, "feeds.openalex_queries", feeds_raw.get("openalex_queries") or []))
237
+ has_feeds = bool(feeds_rss or feeds_oa)
238
+ banks = merged["banks"]
239
+ return Config(
240
+ roots=_roots(source, merged["roots"]),
241
+ repos=_paths(source, "repos", merged["repos"]),
242
+ vault_auto=_bool(source, "vaults.auto", vaults.get("auto", True)),
243
+ vault_paths=_paths(source, "vaults.paths", vaults.get("paths", [])),
244
+ notes_paths=_paths(source, "notes.paths", notes.get("paths", [])),
245
+ papers_paths=_paths(source, "papers.paths", papers.get("paths", [])),
246
+ bookmarks_paths=_paths(source, "bookmarks.paths", bookmarks.get("paths", [])),
247
+ feeds_out_dir=feeds_out if has_feeds else None,
248
+ feeds_rss=feeds_rss,
249
+ feeds_openalex_queries=feeds_oa,
250
+ exclude=_exclude(source, merged["exclude"]),
251
+ banks=_banks(source, banks),
252
+ max_file_kb=_int(source, "max_file_kb", merged["max_file_kb"], 1),
253
+ skip_dirs=_skip_dirs(source, merged["skip_dirs"]),
254
+ poll_seconds=_int(source, "poll_seconds", merged["poll_seconds"], 1),
255
+ vault_quiet_seconds=_int(source, "vault_quiet_seconds", merged["vault_quiet_seconds"], 0),
256
+ relist_seconds=_int(source, "relist_seconds", merged["relist_seconds"], 1),
257
+ source=source,
258
+ )
259
+
260
+
261
+ def read(path: Path) -> object:
262
+ try:
263
+ text = path.read_text(encoding="utf-8")
264
+ except OSError as error:
265
+ raise ConfigError(f"{path}: cannot read config: {error.strerror or error}") from error
266
+ try:
267
+ return yaml.safe_load(text)
268
+ except yaml.YAMLError as error:
269
+ raise ConfigError(f"{path}: invalid YAML: {error}") from error
270
+
271
+
272
+ def _user_path() -> Path | None:
273
+ """The per-machine file, or None when absent or not visible (for example, inside a masked directory)."""
274
+ user = Path(USER_PATH).expanduser()
275
+ try:
276
+ return user if user.is_file() else None
277
+ except OSError:
278
+ return None
279
+
280
+
281
+ def load(explicit: str | Path | None = None, environ: Mapping[str, str] = os.environ) -> Config:
282
+ if explicit:
283
+ path = Path(explicit).expanduser()
284
+ elif environ.get(ENV_VAR):
285
+ path = Path(environ[ENV_VAR]).expanduser()
286
+ else:
287
+ path = _user_path() or DEFAULT_PATH
288
+ if not path.is_file():
289
+ raise ConfigError(f"{path}: config file not found")
290
+ return parse(read(path), source=str(path))
@@ -0,0 +1,5 @@
1
+ [pytest]
2
+ testpaths = tests
3
+ python_files = test_*.py
4
+ filterwarnings =
5
+ ignore::DeprecationWarning
@@ -0,0 +1,148 @@
1
+ """Memory sinks: where the indexer writes documents.
2
+
3
+ `bin/hindsight-project-index` produces documents (stable id, text, tags, metadata) and hands them to a sink. A sink
4
+ is any object satisfying the `Sink` protocol below. The indexer holds no provider knowledge beyond choosing a sink
5
+ with `--sink NAME` (env `MEMORY_SINK`, default `hindsight`).
6
+
7
+ Import name: `indexer/` is the import root (PYTHONPATH=/opt/indexer in the image, `indexer/` on sys.path in the
8
+ tests), so this package imports as `sinks`, next to `index_config` and `code_outline`.
9
+
10
+ Adding a sink: drop `indexer/sinks/<name>.py` that defines `create(url=None, **cfg) -> Sink`. Nothing else is
11
+ edited: `get_sink` finds the module by name, and the Dockerfile copies the whole directory. `<name>` is the
12
+ provider id (lowercase letters, digits, underscore) and is what `--sink` and `MEMORY_SINK` accept.
13
+
14
+ Namespace ("bank") mapping. The indexer says `bank` for the logical grouping a document belongs to (a repository's
15
+ bank, an Obsidian vault's bank; for example `coding-agent::proj` or `obsidian::my-vault`). Hindsight uses it
16
+ verbatim as the bank id. A provider without banks maps it to its nearest scoping concept and documents the rule in
17
+ its own module docstring:
18
+
19
+ mem0 bank -> user_id (agent_id may hold a fixed indexer name)
20
+ cognee bank -> dataset name
21
+
22
+ The bank is an opaque, non-empty string: sinks must quote or sanitize it for their own API, and must not assume
23
+ it is URL-safe. `doc_id` is a stable string (`project-index:v1:<hash>`); the same id always names the same
24
+ logical document within a bank.
25
+
26
+ Errors. HTTP error replies raise `SinkError` (or a subclass); `SinkError.status` is the HTTP status. A server
27
+ that cannot be reached raises `SinkError` or `OSError` (urllib's URLError is an OSError). `delete` of a document
28
+ that is already gone is NOT an error. `health()` never raises.
29
+
30
+ Optional `flush(bank: str) -> None`. A sink whose provider ingests asynchronously (for example Cognee, where
31
+ cognify builds the graph after add) may define `flush`. The indexer calls `flush(bank)` with an explicit bank,
32
+ once per bank per run, whenever a batch retained or deleted at least one document for that bank OR a previous
33
+ flush for that bank is still pending (it failed, or the run died before reaching it). The pending state is
34
+ persisted in the indexer state file, so a fresh process retries it even when nothing changed. A sink must
35
+ therefore treat `flush(bank)` as "process this bank now", even if its own in-memory bookkeeping says the bank is
36
+ clean. `flush(None)` is never called, and a dry run never flushes. `flush` is NOT part of the `Sink` protocol
37
+ (so sinks without it still satisfy `isinstance`); callers go through `flush_sink`, which does nothing for a sink
38
+ that has no callable `flush`. A flush error is handled like a retain error: it raises `SinkError` (or `OSError`)
39
+ and fails that source, and the pending marker stays so the next run flushes again.
40
+
41
+ URL. `get_sink(name, url=...)` passes the API base URL; the CLI resolves it from `--url`, then `HINDSIGHT_URL`
42
+ for the hindsight sink, or `MEMORY_API_URL` for every other sink. A sink reads any credentials from its own env
43
+ vars inside `create`, never from the document path.
44
+ """
45
+
46
+ import importlib
47
+ import pkgutil
48
+ import re
49
+ from collections.abc import Callable, Mapping
50
+ from dataclasses import dataclass
51
+ from typing import Any, Protocol, runtime_checkable
52
+
53
+ DEFAULT_SINK = "hindsight"
54
+ NAME = re.compile(r"^[a-z][a-z0-9_]*$")
55
+
56
+
57
+ class SinkError(RuntimeError):
58
+ """A sink request failed. `status` is the HTTP status, or None for a connection-level failure."""
59
+
60
+ def __init__(self, message: str, status: int | None = None):
61
+ super().__init__(message)
62
+ self.status = status
63
+
64
+
65
+ class UnknownSinkError(ValueError):
66
+ """The requested sink name is not registered."""
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class Capabilities:
71
+ """What a sink can do beyond the basics.
72
+
73
+ async_ingest: retain() may return before the provider has made the document searchable (for example Cognee
74
+ add -> cognify). Callers must not read their own writes immediately.
75
+ """
76
+
77
+ async_ingest: bool = False
78
+
79
+
80
+ @runtime_checkable
81
+ class Sink(Protocol):
82
+ capabilities: Capabilities
83
+
84
+ def health(self) -> bool:
85
+ """True when the provider answers its health endpoint. Never raises."""
86
+ ...
87
+
88
+ def prepare(self, bank: str) -> None:
89
+ """Make `bank` ready for retain (create it, set retain strategy). Called before a batch of retains for
90
+ that bank; must be idempotent and may be a no-op."""
91
+ ...
92
+
93
+ def retain(
94
+ self,
95
+ bank: str,
96
+ doc_id: str,
97
+ content: str,
98
+ *,
99
+ tags: list[str] | None = None,
100
+ metadata: Mapping[str, str] | None = None,
101
+ context: str | None = None,
102
+ timestamp: str | None = None,
103
+ ) -> None:
104
+ """Upsert one document: retaining an existing `doc_id` replaces it, never duplicates it.
105
+
106
+ timestamp None means "no event time"; the sink maps that to the provider's own convention.
107
+ """
108
+ ...
109
+
110
+ def delete(self, bank: str, doc_id: str) -> None:
111
+ """Remove a document. A document that does not exist is not an error."""
112
+ ...
113
+
114
+
115
+ def flush_sink(sink: Any, bank: str) -> None:
116
+ """Call the optional `sink.flush(bank)` when the sink defines it; otherwise do nothing."""
117
+ flush = getattr(sink, "flush", None)
118
+ if callable(flush):
119
+ flush(bank)
120
+
121
+
122
+ _registry: dict[str, Callable[..., Sink]] = {}
123
+
124
+
125
+ def register_sink(name: str, factory: Callable[..., Sink]) -> None:
126
+ """Register a factory under `name` for this process, taking precedence over a module of the same name."""
127
+ if not NAME.match(name):
128
+ raise ValueError(f"invalid sink name {name!r}: use lowercase letters, digits and underscore")
129
+ _registry[name] = factory
130
+
131
+
132
+ def registered_sinks() -> list[str]:
133
+ """Sorted sink names: explicit registrations plus every public module in this package."""
134
+ names = {module.name for module in pkgutil.iter_modules(__path__) if NAME.match(module.name)}
135
+ return sorted(names | set(_registry))
136
+
137
+
138
+ def get_sink(name: str, **cfg: Any) -> Sink:
139
+ """Build the sink `name` with `cfg` (normally `url=`). Raises UnknownSinkError listing the valid names."""
140
+ if name in _registry:
141
+ return _registry[name](**cfg)
142
+ if name not in registered_sinks():
143
+ raise UnknownSinkError(f"unknown sink {name!r}; registered sinks: {', '.join(registered_sinks()) or 'none'}")
144
+ module = importlib.import_module(f"{__name__}.{name}")
145
+ factory = getattr(module, "create", None)
146
+ if not callable(factory):
147
+ raise UnknownSinkError(f"sink module {module.__name__} defines no create(url=None, **cfg) factory")
148
+ return factory(**cfg)
@@ -0,0 +1,44 @@
1
+ """Shared HTTP plumbing for sinks (stdlib only). The leading underscore keeps this module out of the sink registry.
2
+
3
+ `open_url` is the one place a sink opens a URL. It differs from `urllib.request.urlopen` in two ways that every
4
+ REST sink wants:
5
+
6
+ - It follows no redirect at all. A 3xx reply surfaces as `urllib.error.HTTPError` (carrying the status), so a sink
7
+ never silently re-issues a write as a GET, and an API key in the request can never be handed to another host.
8
+ - `http.client.HTTPException` (BadStatusLine, IncompleteRead, LineTooLong, ...) becomes `OSError`. A server that
9
+ speaks garbage is then "unreachable", which is the failure the sink contract already allows (`SinkError` or
10
+ `OSError`), and `health()` can catch `OSError` alone.
11
+ """
12
+
13
+ import http.client
14
+ import urllib.request
15
+
16
+
17
+ class NoRedirect(urllib.request.HTTPRedirectHandler):
18
+ """Refuse every redirect: `redirect_request` returns None, so urllib raises the 3xx as an HTTPError."""
19
+
20
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
21
+ return None
22
+
23
+
24
+ OPENER = urllib.request.build_opener(NoRedirect)
25
+
26
+
27
+ def open_url(request: urllib.request.Request, timeout: float):
28
+ """Open `request` without following redirects. Returns the response (a context manager).
29
+
30
+ Raises `urllib.error.HTTPError` for any non-2xx status (3xx included), `urllib.error.URLError` or another
31
+ `OSError` when the server cannot be reached, and `OSError` for a malformed HTTP reply.
32
+ """
33
+ try:
34
+ return OPENER.open(request, timeout=timeout)
35
+ except http.client.HTTPException as error:
36
+ raise OSError(f"{type(error).__name__}: {error}") from error
37
+
38
+
39
+ def read_all(response) -> bytes:
40
+ """`response.read()` for a response or an HTTPError, with `http.client.HTTPException` turned into `OSError`."""
41
+ try:
42
+ return response.read()
43
+ except http.client.HTTPException as error:
44
+ raise OSError(f"{type(error).__name__}: {error}") from error