@bcmyguest/openmem 0.0.0-stage → 0.8.0

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 (230) hide show
  1. package/.agents/skills/openmem/SKILL.md +34 -0
  2. package/LICENSE +190 -0
  3. package/README.md +140 -2
  4. package/bin/hindsight-project-index +530 -0
  5. package/cli/coding-agent/access-policy.mjs +38 -0
  6. package/cli/coding-agent/agents.mjs +34 -0
  7. package/cli/coding-agent/bank-from-cwd.mjs +140 -0
  8. package/cli/coding-agent/capabilities.mjs +34 -0
  9. package/cli/coding-agent/context-store.mjs +81 -0
  10. package/cli/coding-agent/context-trim.mjs +897 -0
  11. package/cli/coding-agent/file-io.mjs +72 -0
  12. package/cli/coding-agent/hook-harnesses.mjs +88 -0
  13. package/cli/coding-agent/hook-runner.mjs +284 -0
  14. package/cli/coding-agent/install-access.mjs +119 -0
  15. package/cli/coding-agent/install-context-proxy.mjs +333 -0
  16. package/cli/coding-agent/install-rtk.mjs +60 -0
  17. package/cli/coding-agent/install-shim.mjs +201 -0
  18. package/cli/coding-agent/opencode-config.mjs +107 -0
  19. package/cli/coding-agent/opencode-plugin.mjs +132 -0
  20. package/cli/coding-agent/opencode2-plugin/index.js +299 -0
  21. package/cli/coding-agent/recall-frame.mjs +29 -0
  22. package/cli/coding-agent/remove-upstream-access.mjs +122 -0
  23. package/cli/coding-agent/runtime.mjs +40 -0
  24. package/cli/coding-agent/session-db.mjs +626 -0
  25. package/cli/coding-agent/session-facade.mjs +86 -0
  26. package/cli/coding-agent/toml-edit.mjs +161 -0
  27. package/cli/cognee-mcp.mjs +260 -0
  28. package/cli/context-management.mjs +254 -0
  29. package/cli/feed-sync.mjs +27 -0
  30. package/cli/install-coding-agent.mjs +298 -0
  31. package/cli/install-memory-mcp.mjs +426 -0
  32. package/cli/is-main.mjs +18 -0
  33. package/cli/mcp/context-sql-runner.mjs +58 -0
  34. package/cli/mcp/context-store.mjs +670 -0
  35. package/cli/mcp/context.mjs +61 -0
  36. package/cli/mcp/export/cognee-dataset-name.mjs +12 -0
  37. package/cli/mcp/export/cognee.mjs +119 -0
  38. package/cli/mcp/export/hindsight.mjs +48 -0
  39. package/cli/mcp/export/index.mjs +26 -0
  40. package/cli/mcp/export/mem0.mjs +31 -0
  41. package/cli/mcp/knowledge.mjs +333 -0
  42. package/cli/mcp/llm-synthesize.mjs +50 -0
  43. package/cli/mcp/mem0-scope-id.mjs +22 -0
  44. package/cli/mcp/memory-facade.mjs +521 -0
  45. package/cli/mcp/memory-reflect-consolidate.mjs +120 -0
  46. package/cli/mcp/notebooklm-banks.mjs +21 -0
  47. package/cli/mcp/notebooklm-hindsight-rest.mjs +45 -0
  48. package/cli/mcp/notebooklm-provider.mjs +225 -0
  49. package/cli/mcp/pmm-records.mjs +23 -0
  50. package/cli/mcp/portability.mjs +158 -0
  51. package/cli/mcp/stdio.mjs +65 -0
  52. package/cli/mcp/unified-mcp-install.mjs +65 -0
  53. package/cli/mem0-mcp.mjs +108 -0
  54. package/cli/notebooklm-mcp.mjs +236 -0
  55. package/cli/openmem-mcp.mjs +38 -0
  56. package/cli/openmem.mjs +222 -0
  57. package/cli/roots.mjs +85 -0
  58. package/cli/schedule.mjs +176 -0
  59. package/cli/stack.mjs +438 -0
  60. package/cli/switch-memory-provider.mjs +358 -0
  61. package/cli/wiring-report.mjs +125 -0
  62. package/compose.bench.yaml +12 -0
  63. package/compose.yaml +227 -0
  64. package/config/coding-agent.json +13 -0
  65. package/config/hindsight.env.example +26 -0
  66. package/config/index.yml +54 -0
  67. package/config/litellm.lemond.yaml +40 -0
  68. package/config/litellm.ollama.yaml +33 -0
  69. package/config/notebooklm.yml +33 -0
  70. package/config/openmem.toml.example +124 -0
  71. package/docs/OPENMEM.md +23 -0
  72. package/docs/architecture.md +245 -0
  73. package/docs/assets/README.md +10 -0
  74. package/docs/assets/setup-demo.gif +0 -0
  75. package/docs/coding-agent-hooks.md +75 -0
  76. package/docs/coding-agent-scopes.md +22 -0
  77. package/docs/coding-agents.md +71 -0
  78. package/docs/cognee.md +37 -0
  79. package/docs/compatibility.md +80 -0
  80. package/docs/concepts.md +135 -0
  81. package/docs/context-management.md +255 -0
  82. package/docs/context-proxy.md +115 -0
  83. package/docs/context-research.md +215 -0
  84. package/docs/context-store.md +277 -0
  85. package/docs/hindsight.md +77 -0
  86. package/docs/index-sources-roadmap.md +38 -0
  87. package/docs/index-sources.md +39 -0
  88. package/docs/indexer-sinks.md +122 -0
  89. package/docs/indexing.md +57 -0
  90. package/docs/manual-stack-setup.md +74 -0
  91. package/docs/mcp-install-guide.md +4 -0
  92. package/docs/mcp-setup.md +104 -0
  93. package/docs/mem0.md +141 -0
  94. package/docs/memory-portability.md +73 -0
  95. package/docs/memory-providers.md +246 -0
  96. package/docs/meridian-litellm.md +198 -0
  97. package/docs/model-configuration.md +106 -0
  98. package/docs/notebooklm.md +83 -0
  99. package/docs/npx-install.md +172 -0
  100. package/docs/openmem-config.md +194 -0
  101. package/docs/provider-wizard-hooks.md +222 -0
  102. package/docs/quickstart.md +107 -0
  103. package/docs/setup-wizard.md +117 -0
  104. package/docs/sources-feeds.md +39 -0
  105. package/docs/sources-readwise.md +23 -0
  106. package/docs/sources-zotero.md +22 -0
  107. package/glama.json +4 -0
  108. package/indexer/Dockerfile +38 -0
  109. package/indexer/code_outline.py +146 -0
  110. package/indexer/feed_sync.py +107 -0
  111. package/indexer/index_config.py +290 -0
  112. package/indexer/pytest.ini +5 -0
  113. package/indexer/sinks/__init__.py +148 -0
  114. package/indexer/sinks/_http.py +44 -0
  115. package/indexer/sinks/cognee.py +329 -0
  116. package/indexer/sinks/hindsight.py +107 -0
  117. package/indexer/sinks/mem0.py +247 -0
  118. package/indexer/watch.py +240 -0
  119. package/package.json +96 -4
  120. package/providers/_template/compose.yaml +52 -0
  121. package/providers/cognee/OPERATOR.md +82 -0
  122. package/providers/cognee/compose.yaml +89 -0
  123. package/providers/cognee/lib/clack.mjs +157 -0
  124. package/providers/cognee/lib/coding-agent.mjs +26 -0
  125. package/providers/cognee/lib/cognee-mcp-install.mjs +153 -0
  126. package/providers/cognee/lib/config.mjs +98 -0
  127. package/providers/cognee/lib/env-file.mjs +194 -0
  128. package/providers/cognee/lib/env-setup.mjs +83 -0
  129. package/providers/cognee/lib/llm-env.mjs +382 -0
  130. package/providers/cognee/lib/ports.mjs +25 -0
  131. package/providers/cognee/lib/smoke.mjs +246 -0
  132. package/providers/cognee/lib/stack-env.mjs +134 -0
  133. package/providers/cognee/lib/state.mjs +101 -0
  134. package/providers/cognee/lib/summary.mjs +56 -0
  135. package/providers/cognee/lib/validate.mjs +97 -0
  136. package/providers/cognee/manifest.json +56 -0
  137. package/providers/cognee/wizard.mjs +134 -0
  138. package/providers/graphiti/manifest.json +15 -0
  139. package/providers/hindsight/lib/clack.mjs +347 -0
  140. package/providers/hindsight/lib/coding-agent.mjs +204 -0
  141. package/providers/hindsight/lib/config.mjs +79 -0
  142. package/providers/hindsight/lib/env-setup.mjs +72 -0
  143. package/providers/hindsight/lib/litellm-routes.mjs +196 -0
  144. package/providers/hindsight/lib/meridian.mjs +307 -0
  145. package/providers/hindsight/lib/model-backend.mjs +243 -0
  146. package/providers/hindsight/lib/notebooklm-mcp-install.mjs +127 -0
  147. package/providers/hindsight/lib/ollama-compose.mjs +51 -0
  148. package/providers/hindsight/lib/ports.mjs +30 -0
  149. package/providers/hindsight/lib/probe.mjs +54 -0
  150. package/providers/hindsight/lib/stack-env.mjs +125 -0
  151. package/providers/hindsight/lib/state.mjs +80 -0
  152. package/providers/hindsight/lib/summary.mjs +57 -0
  153. package/providers/hindsight/lib/validate.mjs +81 -0
  154. package/providers/hindsight/manifest.json +58 -0
  155. package/providers/hindsight/wizard.mjs +150 -0
  156. package/providers/letta/manifest.json +29 -0
  157. package/providers/mem0/OPERATOR.md +143 -0
  158. package/providers/mem0/compose.yaml +79 -0
  159. package/providers/mem0/lib/coding-agent.mjs +58 -0
  160. package/providers/mem0/lib/config.mjs +45 -0
  161. package/providers/mem0/lib/configure.mjs +201 -0
  162. package/providers/mem0/lib/dotenv.mjs +71 -0
  163. package/providers/mem0/lib/env-file.mjs +110 -0
  164. package/providers/mem0/lib/http.mjs +69 -0
  165. package/providers/mem0/lib/litellm.mjs +457 -0
  166. package/providers/mem0/lib/mcp-install.mjs +165 -0
  167. package/providers/mem0/lib/secret.mjs +30 -0
  168. package/providers/mem0/lib/smoke.mjs +179 -0
  169. package/providers/mem0/lib/stack-env.mjs +255 -0
  170. package/providers/mem0/lib/state.mjs +67 -0
  171. package/providers/mem0/lib/summary.mjs +88 -0
  172. package/providers/mem0/lib/url.mjs +106 -0
  173. package/providers/mem0/lib/validate.mjs +158 -0
  174. package/providers/mem0/manifest.json +47 -0
  175. package/providers/mem0/wizard.mjs +146 -0
  176. package/providers/zep/manifest.json +29 -0
  177. package/proxy/config.yaml +33 -0
  178. package/proxy/openmem_context/__init__.py +6 -0
  179. package/proxy/openmem_context/adapters.py +504 -0
  180. package/proxy/openmem_context/compress.py +53 -0
  181. package/proxy/openmem_context/core.py +606 -0
  182. package/proxy/openmem_context/embed.py +212 -0
  183. package/proxy/openmem_context/recall.py +49 -0
  184. package/proxy/openmem_context/session.py +70 -0
  185. package/proxy/openmem_context/sessiondb.py +276 -0
  186. package/proxy/openmem_context/settings.py +134 -0
  187. package/proxy/openmem_context/store.py +129 -0
  188. package/proxy/openmem_context/tokens.py +123 -0
  189. package/proxy/openmem_context/ttl.py +78 -0
  190. package/proxy/openmem_context_hook.py +34 -0
  191. package/scripts/check-pack.mjs +219 -0
  192. package/scripts/gen-masks.mjs +83 -0
  193. package/scripts/gen-masks.sh +4 -0
  194. package/scripts/promotion-provider-smoke.mjs +34 -0
  195. package/scripts/run-setup.sh +30 -0
  196. package/scripts/setup.mjs +107 -0
  197. package/scripts/setup.sh +5 -0
  198. package/scripts/spin-up.sh +45 -0
  199. package/scripts/wizard.sh +5 -0
  200. package/server.json +34 -0
  201. package/sync/Dockerfile +27 -0
  202. package/sync/memory_backend.py +87 -0
  203. package/sync/notebooklm_sync.py +664 -0
  204. package/wizard/bin/pm.mjs +97 -0
  205. package/wizard/bin/setup.mjs +33 -0
  206. package/wizard/lib/compose-version.mjs +32 -0
  207. package/wizard/lib/compose.mjs +70 -0
  208. package/wizard/lib/context-step.mjs +140 -0
  209. package/wizard/lib/curl.mjs +103 -0
  210. package/wizard/lib/docker-build.mjs +148 -0
  211. package/wizard/lib/exec.mjs +41 -0
  212. package/wizard/lib/indexer.mjs +6 -0
  213. package/wizard/lib/litellm-env.mjs +55 -0
  214. package/wizard/lib/load-machine-env.mjs +4 -0
  215. package/wizard/lib/log.mjs +32 -0
  216. package/wizard/lib/notebooklm-agent-access.mjs +92 -0
  217. package/wizard/lib/notebooklm.mjs +214 -0
  218. package/wizard/lib/parse-args.mjs +87 -0
  219. package/wizard/lib/paths.mjs +39 -0
  220. package/wizard/lib/pm-config.mjs +371 -0
  221. package/wizard/lib/ports.mjs +94 -0
  222. package/wizard/lib/prompts.mjs +85 -0
  223. package/wizard/lib/provider-wizard/helpers.mjs +98 -0
  224. package/wizard/lib/provider-wizard/run-steps.mjs +129 -0
  225. package/wizard/lib/providers/registry.mjs +262 -0
  226. package/wizard/lib/run-clack.mjs +108 -0
  227. package/wizard/lib/run-wizard.mjs +750 -0
  228. package/wizard/lib/validate-setup.mjs +62 -0
  229. package/wizard/lib/wizard-action.mjs +34 -0
  230. package/wizard/lib/wizard-conf.mjs +71 -0
@@ -0,0 +1,146 @@
1
+ """Compact symbol outlines for source files, parsed in-process with tree-sitter (tree-sitter-language-pack)."""
2
+
3
+ import os
4
+ import re
5
+ from pathlib import Path
6
+
7
+ from tree_sitter_language_pack import PackConfig, ProcessConfig, get_parser, init, process
8
+
9
+
10
+ SIGNATURE_LIMIT = 160
11
+ DOC_LIMIT = 120
12
+ MAX_OUTLINE_LINES = 150
13
+ MAX_IMPORTS = 60
14
+ GRAMMAR_DIR_ENV = "TREE_SITTER_GRAMMAR_DIR"
15
+ LANGUAGE_BY_SUFFIX = {
16
+ ".py": "python",
17
+ ".js": "javascript",
18
+ ".jsx": "javascript",
19
+ ".ts": "typescript",
20
+ ".tsx": "tsx",
21
+ ".go": "go",
22
+ ".rs": "rust",
23
+ ".java": "java",
24
+ ".c": "c",
25
+ ".h": "c",
26
+ ".cc": "cpp",
27
+ ".cpp": "cpp",
28
+ ".hpp": "cpp",
29
+ ".cs": "csharp",
30
+ ".rb": "ruby",
31
+ ".lua": "lua",
32
+ ".sh": "bash",
33
+ ".php": "php",
34
+ }
35
+ # The pack's structure extraction does not report imports for these grammars; match their import lines instead.
36
+ FALLBACK_IMPORTS = {
37
+ "c": re.compile(r"^\s*#\s*include\b"),
38
+ "cpp": re.compile(r"^\s*#\s*include\b"),
39
+ "csharp": re.compile(r"^\s*using\s+[\w.]+\s*;"),
40
+ "ruby": re.compile(r"^\s*require(_relative)?\b"),
41
+ "lua": re.compile(r"^\s*(local\s+[\w, ]+=\s*)?require\b"),
42
+ "php": re.compile(r"^\s*use\s+[\w\\ ,{}]+;"),
43
+ }
44
+ # Decorators and attributes sit above the definition; the signature is the first line after them.
45
+ ATTRIBUTE_PREFIXES = ("@", "#[", "[")
46
+ DOC_QUOTES = ('"""', "'''")
47
+ COMMENT_MARKERS = re.compile(r"^\s*(///<|///|//!|//|/\*\*|/\*!|/\*|\*|#|--)\s*")
48
+
49
+ _ready = False
50
+
51
+
52
+ def language_for(path: str) -> str | None:
53
+ return LANGUAGE_BY_SUFFIX.get(Path(path).suffix.lower())
54
+
55
+
56
+ def _load(language: str) -> None:
57
+ """Load the grammar. Raises when it is missing, so a broken image fails the sync instead of emptying code docs."""
58
+ global _ready
59
+ if not _ready:
60
+ init(PackConfig(cache_dir=os.environ.get(GRAMMAR_DIR_ENV) or None))
61
+ _ready = True
62
+ get_parser(language)
63
+
64
+
65
+ def _clip(text: str, limit: int = SIGNATURE_LIMIT) -> str:
66
+ return text if len(text) <= limit else text[: limit - 3] + "..."
67
+
68
+
69
+ def _one_line(text: str) -> str:
70
+ return " ".join(text.split())
71
+
72
+
73
+ def _signature(item, lines: list[str]) -> str:
74
+ for row in range(item.span.start_line, min(item.span.end_line, len(lines) - 1) + 1):
75
+ text = lines[row].strip()
76
+ if text and not text.startswith(ATTRIBUTE_PREFIXES):
77
+ return _clip(text)
78
+ return _clip(f"{item.kind} {item.name or ''}".strip())
79
+
80
+
81
+ def _doc(item, lines: list[str], language: str) -> str:
82
+ if language == "python" and item.body_span is not None:
83
+ # Python docstrings are the first statement of the body; the pack does not extract them.
84
+ for row in range(item.body_span.start_line, min(item.body_span.start_line + 2, len(lines))):
85
+ text = lines[row].strip()
86
+ if not text.startswith(DOC_QUOTES):
87
+ return ""
88
+ cleaned = text[3:].rstrip("\"'").strip()
89
+ if cleaned:
90
+ return _clip(cleaned, DOC_LIMIT)
91
+ return ""
92
+ for raw in (item.doc_comment or "").splitlines():
93
+ cleaned = COMMENT_MARKERS.sub("", raw).strip().rstrip("*/").strip()
94
+ if cleaned:
95
+ return _clip(cleaned, DOC_LIMIT)
96
+ return ""
97
+
98
+
99
+ def _imports(result, language: str, lines: list[str]) -> list[str]:
100
+ found = [_clip(_one_line(item.source)) for item in result.imports]
101
+ if not found and language in FALLBACK_IMPORTS:
102
+ found = [_clip(_one_line(line)) for line in lines if FALLBACK_IMPORTS[language].match(line)]
103
+ return list(dict.fromkeys(item for item in found if item))[:MAX_IMPORTS]
104
+
105
+
106
+ def _symbol_lines(items, lines: list[str], language: str, depth: int, out: list[str], rows: set[int]) -> None:
107
+ indent = " " * depth
108
+ for item in items:
109
+ if len(out) >= MAX_OUTLINE_LINES:
110
+ return
111
+ # A one-line member shares its parent's line; listing that line twice adds nothing.
112
+ if item.span.start_line not in rows:
113
+ rows.add(item.span.start_line)
114
+ out.append(f"{indent}- L{item.span.start_line + 1} {_signature(item, lines)}")
115
+ doc = _doc(item, lines, language)
116
+ if doc:
117
+ out.append(f"{indent} doc: {doc}")
118
+ _symbol_lines(item.children, lines, language, depth + 1, out, rows)
119
+
120
+
121
+ def outline(path: str, data: bytes) -> str | None:
122
+ """Return a text outline of one source file, or None when the suffix is unsupported or the file does not parse."""
123
+ language = language_for(path)
124
+ if language is None:
125
+ return None
126
+ try:
127
+ source = data.decode("utf-8")
128
+ except UnicodeDecodeError:
129
+ return None
130
+ _load(language)
131
+ try:
132
+ result = process(source, ProcessConfig(language=language, structure=True, imports=True, exports=False))
133
+ except Exception:
134
+ return None
135
+ lines = source.split("\n")
136
+ imports = _imports(result, language, lines)
137
+ symbols: list[str] = []
138
+ _symbol_lines(result.structure, lines, language, 0, symbols, set())
139
+ if not symbols and not imports:
140
+ return None
141
+ body = [f"File: {path}", f"Language: {language}"]
142
+ if imports:
143
+ body += ["Imports:"] + [f"- {item}" for item in imports]
144
+ if symbols:
145
+ body += ["Symbols:"] + symbols
146
+ return "\n".join(body)
@@ -0,0 +1,107 @@
1
+ #!/usr/bin/env python3
2
+ """Fetch RSS and OpenAlex query results into markdown under feeds.out_dir for the indexer."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import hashlib
7
+ import json
8
+ import re
9
+ import sys
10
+ import urllib.parse
11
+ import urllib.request
12
+ import xml.etree.ElementTree as ET
13
+ from pathlib import Path
14
+
15
+ import index_config
16
+
17
+ USER_AGENT = "openmem-feed-sync/1.0"
18
+
19
+
20
+ def slugify(name: str) -> str:
21
+ return re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-") or "feed"
22
+
23
+
24
+ def fetch_bytes(url: str, timeout: int = 60) -> bytes:
25
+ req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
26
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
27
+ return resp.read()
28
+
29
+
30
+ def write_markdown(out_dir: Path, bank_slug: str, title: str, body: str, source_url: str) -> Path:
31
+ out_dir.mkdir(parents=True, exist_ok=True)
32
+ digest = hashlib.sha256(source_url.encode()).hexdigest()[:12]
33
+ safe = slugify(title)[:60] or digest
34
+ path = out_dir / f"{bank_slug}--{safe}--{digest}.md"
35
+ text = f"---\nsource_url: {source_url}\nfeed_bank: feed::{bank_slug}\n---\n\n# {title}\n\n{body.strip()}\n"
36
+ path.write_text(text, encoding="utf-8")
37
+ return path
38
+
39
+
40
+ def sync_rss(url: str, out_dir: Path, slug: str | None = None) -> int:
41
+ bank_slug = slug or slugify(urllib.parse.urlparse(url).path or "rss")
42
+ raw = fetch_bytes(url)
43
+ root = ET.fromstring(raw)
44
+ channel = root.find("channel") or root
45
+ items = channel.findall("item") if channel is not None else root.findall(".//item")
46
+ count = 0
47
+ for item in items[:50]:
48
+ title = (item.findtext("title") or "untitled").strip()
49
+ link = (item.findtext("link") or "").strip()
50
+ desc = (item.findtext("description") or item.findtext("{http://www.w3.org/2005/Atom}summary") or "").strip()
51
+ desc = re.sub(r"<[^>]+>", " ", desc)
52
+ if not link:
53
+ continue
54
+ write_markdown(out_dir, bank_slug, title, desc or title, link)
55
+ count += 1
56
+ return count
57
+
58
+
59
+ def sync_openalex(query_url: str, out_dir: Path, slug: str | None = None) -> int:
60
+ bank_slug = slug or "openalex"
61
+ raw = fetch_bytes(query_url)
62
+ data = json.loads(raw.decode())
63
+ results = data.get("results") or []
64
+ count = 0
65
+ for work in results[:40]:
66
+ title = work.get("title") or work.get("display_name") or "work"
67
+ wid = work.get("id") or ""
68
+ abstract = work.get("abstract_inverted_index")
69
+ body = title
70
+ if isinstance(abstract, dict):
71
+ words = sorted(abstract.items(), key=lambda kv: kv[1][0] if kv[1] else 0)
72
+ body = " ".join(w for w, _ in words)
73
+ doi = (work.get("doi") or wid) or title
74
+ write_markdown(out_dir, bank_slug, title, body, str(doi))
75
+ count += 1
76
+ return count
77
+
78
+
79
+ def run(config: index_config.Config | None = None) -> int:
80
+ config = config or index_config.load()
81
+ feeds = getattr(config, "feeds_out_dir", None)
82
+ if feeds is None:
83
+ print("feeds: nothing configured (feeds.rss / feeds.openalex_queries empty)", file=sys.stderr)
84
+ return 0
85
+ out_dir = feeds
86
+ total = 0
87
+ for entry in config.feeds_rss:
88
+ url = entry if isinstance(entry, str) else entry.get("url", "")
89
+ slug = None if isinstance(entry, str) else entry.get("slug")
90
+ if not url:
91
+ continue
92
+ n = sync_rss(url, out_dir, slug)
93
+ print(f"rss {url}: {n} items -> {out_dir}")
94
+ total += n
95
+ for entry in config.feeds_openalex_queries:
96
+ url = entry if isinstance(entry, str) else entry.get("url", "")
97
+ slug = None if isinstance(entry, str) else entry.get("slug")
98
+ if not url:
99
+ continue
100
+ n = sync_openalex(url, out_dir, slug)
101
+ print(f"openalex {url}: {n} works -> {out_dir}")
102
+ total += n
103
+ return 0 if total >= 0 else 1
104
+
105
+
106
+ if __name__ == "__main__":
107
+ raise SystemExit(run())
@@ -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