@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.
- package/LICENSE +190 -0
- package/README.md +136 -2
- package/bin/hindsight-project-index +530 -0
- package/cli/coding-agent/access-policy.mjs +38 -0
- package/cli/coding-agent/agents.mjs +34 -0
- package/cli/coding-agent/bank-from-cwd.mjs +140 -0
- package/cli/coding-agent/capabilities.mjs +23 -0
- package/cli/coding-agent/hook-harnesses.mjs +87 -0
- package/cli/coding-agent/hook-runner.mjs +260 -0
- package/cli/coding-agent/install-access.mjs +93 -0
- package/cli/coding-agent/install-shim.mjs +97 -0
- package/cli/coding-agent/opencode-config.mjs +91 -0
- package/cli/coding-agent/opencode-plugin.mjs +96 -0
- package/cli/coding-agent/runtime.mjs +39 -0
- package/cli/coding-agent/session-facade.mjs +62 -0
- package/cli/cognee-mcp.mjs +260 -0
- package/cli/feed-sync.mjs +11 -0
- package/cli/install-coding-agent.mjs +165 -0
- package/cli/install-memory-mcp.mjs +426 -0
- package/cli/is-main.mjs +18 -0
- package/cli/mcp/context.mjs +61 -0
- package/cli/mcp/export/cognee-dataset-name.mjs +12 -0
- package/cli/mcp/export/cognee.mjs +119 -0
- package/cli/mcp/export/hindsight.mjs +48 -0
- package/cli/mcp/export/index.mjs +26 -0
- package/cli/mcp/export/mem0.mjs +31 -0
- package/cli/mcp/llm-synthesize.mjs +50 -0
- package/cli/mcp/mem0-scope-id.mjs +22 -0
- package/cli/mcp/memory-facade.mjs +455 -0
- package/cli/mcp/memory-reflect-consolidate.mjs +120 -0
- package/cli/mcp/notebooklm-banks.mjs +21 -0
- package/cli/mcp/notebooklm-hindsight-rest.mjs +45 -0
- package/cli/mcp/notebooklm-provider.mjs +225 -0
- package/cli/mcp/pmm-records.mjs +23 -0
- package/cli/mcp/portability.mjs +158 -0
- package/cli/mcp/stdio.mjs +65 -0
- package/cli/mcp/unified-mcp-install.mjs +65 -0
- package/cli/mem0-mcp.mjs +108 -0
- package/cli/notebooklm-mcp.mjs +236 -0
- package/cli/openmem-mcp.mjs +35 -0
- package/cli/openmem.mjs +205 -0
- package/cli/roots.mjs +85 -0
- package/cli/stack.mjs +436 -0
- package/cli/switch-memory-provider.mjs +358 -0
- package/cli/wiring-report.mjs +125 -0
- package/compose.bench.yaml +12 -0
- package/compose.yaml +164 -0
- package/config/coding-agent.json +13 -0
- package/config/hindsight.env.example +24 -0
- package/config/index.yml +54 -0
- package/config/litellm.lemond.yaml +40 -0
- package/config/litellm.ollama.yaml +33 -0
- package/config/notebooklm.yml +33 -0
- package/config/openmem.toml.example +62 -0
- package/docs/OPENMEM.md +23 -0
- package/docs/architecture.md +219 -0
- package/docs/assets/README.md +10 -0
- package/docs/assets/setup-demo.gif +0 -0
- package/docs/coding-agent-hooks.md +51 -0
- package/docs/coding-agent-scopes.md +22 -0
- package/docs/coding-agents.md +51 -0
- package/docs/cognee.md +37 -0
- package/docs/concepts.md +112 -0
- package/docs/hindsight.md +74 -0
- package/docs/index-sources-roadmap.md +38 -0
- package/docs/index-sources.md +39 -0
- package/docs/indexer-sinks.md +122 -0
- package/docs/indexing.md +57 -0
- package/docs/manual-stack-setup.md +74 -0
- package/docs/mcp-install-guide.md +4 -0
- package/docs/mcp-setup.md +101 -0
- package/docs/mem0.md +103 -0
- package/docs/memory-portability.md +73 -0
- package/docs/memory-providers.md +246 -0
- package/docs/model-configuration.md +84 -0
- package/docs/notebooklm.md +83 -0
- package/docs/npx-install.md +170 -0
- package/docs/openmem-config.md +194 -0
- package/docs/provider-wizard-hooks.md +222 -0
- package/docs/quickstart.md +101 -0
- package/docs/setup-wizard.md +116 -0
- package/docs/sources-feeds.md +52 -0
- package/docs/sources-readwise.md +23 -0
- package/docs/sources-zotero.md +22 -0
- package/glama.json +4 -0
- package/indexer/Dockerfile +38 -0
- package/indexer/code_outline.py +146 -0
- package/indexer/feed_sync.py +107 -0
- package/indexer/index_config.py +290 -0
- package/indexer/pytest.ini +5 -0
- package/indexer/sinks/__init__.py +148 -0
- package/indexer/sinks/_http.py +44 -0
- package/indexer/sinks/cognee.py +329 -0
- package/indexer/sinks/hindsight.py +107 -0
- package/indexer/sinks/mem0.py +247 -0
- package/indexer/watch.py +240 -0
- package/package.json +95 -4
- package/providers/_template/compose.yaml +52 -0
- package/providers/cognee/OPERATOR.md +82 -0
- package/providers/cognee/compose.yaml +89 -0
- package/providers/cognee/lib/clack.mjs +157 -0
- package/providers/cognee/lib/coding-agent.mjs +26 -0
- package/providers/cognee/lib/cognee-mcp-install.mjs +153 -0
- package/providers/cognee/lib/config.mjs +98 -0
- package/providers/cognee/lib/env-file.mjs +194 -0
- package/providers/cognee/lib/env-setup.mjs +83 -0
- package/providers/cognee/lib/llm-env.mjs +382 -0
- package/providers/cognee/lib/ports.mjs +25 -0
- package/providers/cognee/lib/smoke.mjs +246 -0
- package/providers/cognee/lib/stack-env.mjs +134 -0
- package/providers/cognee/lib/state.mjs +101 -0
- package/providers/cognee/lib/summary.mjs +56 -0
- package/providers/cognee/lib/validate.mjs +97 -0
- package/providers/cognee/manifest.json +56 -0
- package/providers/cognee/wizard.mjs +134 -0
- package/providers/graphiti/manifest.json +15 -0
- package/providers/hindsight/lib/clack.mjs +245 -0
- package/providers/hindsight/lib/coding-agent.mjs +204 -0
- package/providers/hindsight/lib/config.mjs +73 -0
- package/providers/hindsight/lib/env-setup.mjs +71 -0
- package/providers/hindsight/lib/litellm-routes.mjs +187 -0
- package/providers/hindsight/lib/model-backend.mjs +214 -0
- package/providers/hindsight/lib/notebooklm-mcp-install.mjs +127 -0
- package/providers/hindsight/lib/ollama-compose.mjs +51 -0
- package/providers/hindsight/lib/ports.mjs +30 -0
- package/providers/hindsight/lib/probe.mjs +33 -0
- package/providers/hindsight/lib/stack-env.mjs +132 -0
- package/providers/hindsight/lib/state.mjs +75 -0
- package/providers/hindsight/lib/summary.mjs +43 -0
- package/providers/hindsight/lib/validate.mjs +75 -0
- package/providers/hindsight/manifest.json +58 -0
- package/providers/hindsight/wizard.mjs +149 -0
- package/providers/letta/manifest.json +29 -0
- package/providers/mem0/OPERATOR.md +124 -0
- package/providers/mem0/compose.yaml +57 -0
- package/providers/mem0/lib/coding-agent.mjs +58 -0
- package/providers/mem0/lib/config.mjs +45 -0
- package/providers/mem0/lib/configure.mjs +135 -0
- package/providers/mem0/lib/dotenv.mjs +71 -0
- package/providers/mem0/lib/env-file.mjs +110 -0
- package/providers/mem0/lib/http.mjs +69 -0
- package/providers/mem0/lib/mcp-install.mjs +165 -0
- package/providers/mem0/lib/secret.mjs +30 -0
- package/providers/mem0/lib/smoke.mjs +179 -0
- package/providers/mem0/lib/stack-env.mjs +216 -0
- package/providers/mem0/lib/state.mjs +67 -0
- package/providers/mem0/lib/summary.mjs +60 -0
- package/providers/mem0/lib/url.mjs +106 -0
- package/providers/mem0/lib/validate.mjs +158 -0
- package/providers/mem0/manifest.json +47 -0
- package/providers/mem0/wizard.mjs +133 -0
- package/providers/zep/manifest.json +29 -0
- package/scripts/check-pack.mjs +218 -0
- package/scripts/gen-masks.mjs +83 -0
- package/scripts/gen-masks.sh +4 -0
- package/scripts/promotion-provider-smoke.mjs +34 -0
- package/scripts/run-setup.sh +30 -0
- package/scripts/setup.mjs +94 -0
- package/scripts/setup.sh +5 -0
- package/scripts/spin-up.sh +45 -0
- package/scripts/wizard.sh +5 -0
- package/server.json +34 -0
- package/skills/openmem/SKILL.md +29 -0
- package/sync/Dockerfile +27 -0
- package/sync/memory_backend.py +87 -0
- package/sync/notebooklm_sync.py +664 -0
- package/wizard/bin/pm.mjs +97 -0
- package/wizard/bin/setup.mjs +33 -0
- package/wizard/lib/compose-version.mjs +32 -0
- package/wizard/lib/compose.mjs +70 -0
- package/wizard/lib/curl.mjs +103 -0
- package/wizard/lib/docker-build.mjs +148 -0
- package/wizard/lib/exec.mjs +41 -0
- package/wizard/lib/indexer.mjs +6 -0
- package/wizard/lib/load-machine-env.mjs +4 -0
- package/wizard/lib/log.mjs +32 -0
- package/wizard/lib/notebooklm-agent-access.mjs +92 -0
- package/wizard/lib/notebooklm.mjs +214 -0
- package/wizard/lib/parse-args.mjs +87 -0
- package/wizard/lib/paths.mjs +39 -0
- package/wizard/lib/pm-config.mjs +163 -0
- package/wizard/lib/ports.mjs +94 -0
- package/wizard/lib/prompts.mjs +57 -0
- package/wizard/lib/provider-wizard/helpers.mjs +98 -0
- package/wizard/lib/provider-wizard/run-steps.mjs +129 -0
- package/wizard/lib/providers/registry.mjs +262 -0
- package/wizard/lib/run-clack.mjs +108 -0
- package/wizard/lib/run-wizard.mjs +755 -0
- package/wizard/lib/validate-setup.mjs +62 -0
- package/wizard/lib/wizard-action.mjs +34 -0
- 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,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
|