@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,664 @@
1
+ #!/usr/bin/env python3
2
+ """Sync NotebookLM notebooks into memory banks (Hindsight, mem0, Cognee, …), one bank per notebook.
3
+
4
+ Backends:
5
+ client unofficial notebooklm-py client, using the browser login stored in NOTEBOOKLM_HOME
6
+ enterprise documented NotebookLM Enterprise REST API; lists notebooks only (no source or note text)
7
+
8
+ Each item becomes one Hindsight document with a stable ID (notebooklm:v1:<notebook>:<kind>:<item>).
9
+ A state file records each document's bank and content digest, so unchanged items are skipped and
10
+ removed items are deleted from Hindsight. The state file is the only record of what this tool wrote.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import argparse
16
+ import asyncio
17
+ import fnmatch
18
+ import hashlib
19
+ import json
20
+ import logging
21
+ import os
22
+ import re
23
+ import subprocess
24
+ import sys
25
+ import time
26
+ import urllib.error
27
+ import urllib.parse
28
+ import urllib.request
29
+ from dataclasses import dataclass, field
30
+ from pathlib import Path
31
+
32
+ import yaml
33
+
34
+ DEFAULT_CONFIG = Path(os.environ.get("NOTEBOOKLM_CONFIG", "/opt/config/notebooklm.yml"))
35
+ DEFAULT_OVERRIDE = Path(os.environ.get("NOTEBOOKLM_OVERRIDE", "/etc/notebooklm/override.yml"))
36
+ DEFAULT_STATE = Path(os.environ.get("NOTEBOOKLM_STATE", "/data/state/state.json"))
37
+ DEFAULT_HINDSIGHT_URL = "http://hindsight:8888"
38
+ HINDSIGHT_STRATEGY = "notebooklm"
39
+ HINDSIGHT_RETAIN_STRATEGY = {"retain_extraction_mode": "chunks", "retain_chunk_size": 3000}
40
+ CHAT_HISTORY_LIMIT = 50
41
+ AUTH_RETRY_SECONDS = 900
42
+ DISABLED_SLEEP_SECONDS = 3600
43
+ ERROR_SLEEP_SECONDS = 300
44
+ LOGIN_INSTRUCTION = (
45
+ "NotebookLM is not logged in. On the host re-run setup and enable NotebookLM "
46
+ "(it opens Google sign-in and sets NOTEBOOKLM_HOME), or: "
47
+ "mkdir -p ~/.config/notebooklm && uv tool install 'notebooklm-py[browser]==0.8.5' && "
48
+ "uv tool run --from 'notebooklm-py[browser]==0.8.5' python -m playwright install chromium && "
49
+ "NOTEBOOKLM_HOME=$HOME/.config/notebooklm notebooklm login. "
50
+ "Then restart: docker compose --profile notebooklm up -d notebooklm"
51
+ )
52
+ ENTERPRISE_SCOPE = "https://www.googleapis.com/auth/cloud-platform"
53
+ ENTERPRISE_LOCATIONS = ("global", "us", "eu")
54
+ ENTERPRISE_PAGE_MAX = 500
55
+ BANK_PREFIX_PATTERN = re.compile(r"^[a-z0-9][a-z0-9_-]*$")
56
+ TOP_LEVEL_KEYS = {"enabled", "backend", "interval_minutes", "bank_prefix", "include_notebooks", "exclude_notebooks", "include", "client", "enterprise"}
57
+ INCLUDE_KEYS = {"notes", "sources", "chats"}
58
+ CLIENT_KEYS = {"storage_path"}
59
+ ENTERPRISE_KEYS = {"project_number", "location", "service_account_file", "notebook_limit"}
60
+
61
+ log = logging.getLogger("notebooklm-sync")
62
+
63
+
64
+ class ConfigError(ValueError):
65
+ """The config file is missing, malformed, or fails validation."""
66
+
67
+
68
+ class AuthProblem(RuntimeError):
69
+ """NotebookLM rejects or lacks the stored login."""
70
+
71
+
72
+ class HttpError(RuntimeError):
73
+ def __init__(self, method: str, url: str, status: int, body: str):
74
+ super().__init__(f"{method} {url} -> HTTP {status}: {body}")
75
+ self.status = status
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class EnterpriseConfig:
80
+ project_number: str
81
+ location: str
82
+ service_account_file: str
83
+ notebook_limit: int
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class Config:
88
+ enabled: bool
89
+ backend: str
90
+ interval_minutes: int
91
+ bank_prefix: str
92
+ include_notebooks: tuple[str, ...]
93
+ exclude_notebooks: tuple[str, ...]
94
+ notes: bool
95
+ sources: bool
96
+ chats: bool
97
+ storage_path: str
98
+ enterprise: EnterpriseConfig
99
+
100
+ def selects(self, title: str) -> bool:
101
+ lowered = title.lower()
102
+ if any(fnmatch.fnmatchcase(lowered, pattern.lower()) for pattern in self.exclude_notebooks):
103
+ return False
104
+ return not self.include_notebooks or any(fnmatch.fnmatchcase(lowered, pattern.lower()) for pattern in self.include_notebooks)
105
+
106
+
107
+ @dataclass(frozen=True)
108
+ class Item:
109
+ kind: str
110
+ item_id: str
111
+ title: str
112
+ body: str
113
+ url: str = ""
114
+
115
+
116
+ @dataclass(frozen=True)
117
+ class NotebookSnapshot:
118
+ id: str
119
+ title: str
120
+ items: tuple[Item, ...]
121
+ # False when a list call failed: the notebook's stored documents are left alone this run.
122
+ complete: bool = True
123
+
124
+
125
+ @dataclass(frozen=True)
126
+ class FetchResult:
127
+ snapshots: tuple[NotebookSnapshot, ...]
128
+ # True when the listing covers every notebook, so a notebook missing from it has been deleted or deselected.
129
+ listing_complete: bool
130
+
131
+
132
+ @dataclass
133
+ class SyncReport:
134
+ retained: int = 0
135
+ deleted: int = 0
136
+ skipped: int = 0
137
+ failed: int = 0
138
+
139
+ def summary(self) -> str:
140
+ return f"retained={self.retained} deleted={self.deleted} skipped={self.skipped} failed={self.failed}"
141
+
142
+
143
+ # ---------- configuration ----------
144
+
145
+
146
+ def _merge(base: dict, override: dict) -> dict:
147
+ merged = dict(base)
148
+ for key, value in override.items():
149
+ if isinstance(value, dict) and isinstance(merged.get(key), dict):
150
+ merged[key] = _merge(merged[key], value)
151
+ else:
152
+ merged[key] = value
153
+ return merged
154
+
155
+
156
+ def _read_yaml(path: Path) -> dict:
157
+ try:
158
+ data = yaml.safe_load(path.read_text())
159
+ except OSError as error:
160
+ raise ConfigError(f"cannot read {path}: {error}") from error
161
+ except yaml.YAMLError as error:
162
+ raise ConfigError(f"{path} is not valid YAML: {error}") from error
163
+ if data is None:
164
+ return {}
165
+ if not isinstance(data, dict):
166
+ raise ConfigError(f"{path} must contain a mapping at the top level")
167
+ return data
168
+
169
+
170
+ def _check_keys(section: str, data: dict, allowed: set[str]) -> None:
171
+ unknown = sorted(set(data) - allowed)
172
+ if unknown:
173
+ raise ConfigError(f"{section}: unknown key(s) {', '.join(unknown)}; allowed: {', '.join(sorted(allowed))}")
174
+
175
+
176
+ def _bool(name: str, value) -> bool:
177
+ if not isinstance(value, bool):
178
+ raise ConfigError(f"{name} must be true or false, got {value!r}")
179
+ return value
180
+
181
+
182
+ def _int(name: str, value, low: int, high: int | None = None) -> int:
183
+ if isinstance(value, bool) or not isinstance(value, int):
184
+ raise ConfigError(f"{name} must be an integer, got {value!r}")
185
+ if value < low or (high is not None and value > high):
186
+ bound = f"{low}..{high}" if high is not None else f">= {low}"
187
+ raise ConfigError(f"{name} must be {bound}, got {value}")
188
+ return value
189
+
190
+
191
+ def _globs(name: str, value) -> tuple[str, ...]:
192
+ if not isinstance(value, list) or not all(isinstance(item, str) and item.strip() for item in value):
193
+ raise ConfigError(f"{name} must be a list of non-empty title globs")
194
+ return tuple(value)
195
+
196
+
197
+ def validate_config(raw: dict) -> Config:
198
+ _check_keys("config", raw, TOP_LEVEL_KEYS)
199
+ enabled = _bool("enabled", raw.get("enabled", False))
200
+ backend = raw.get("backend", "client")
201
+ if backend not in ("client", "enterprise"):
202
+ raise ConfigError(f"backend must be 'client' or 'enterprise', got {backend!r}")
203
+ interval = _int("interval_minutes", raw.get("interval_minutes", 60), 1)
204
+ prefix = raw.get("bank_prefix", "notebooklm")
205
+ if not isinstance(prefix, str) or not BANK_PREFIX_PATTERN.match(prefix):
206
+ raise ConfigError(f"bank_prefix must match {BANK_PREFIX_PATTERN.pattern}, got {prefix!r}")
207
+ include_notebooks = _globs("include_notebooks", raw.get("include_notebooks", []))
208
+ exclude_notebooks = _globs("exclude_notebooks", raw.get("exclude_notebooks", []))
209
+
210
+ include = raw.get("include", {})
211
+ if not isinstance(include, dict):
212
+ raise ConfigError("include must be a mapping of notes/sources/chats to true or false")
213
+ _check_keys("include", include, INCLUDE_KEYS)
214
+ flags = {key: _bool(f"include.{key}", include.get(key, default)) for key, default in (("notes", True), ("sources", True), ("chats", False))}
215
+
216
+ client = raw.get("client", {})
217
+ if not isinstance(client, dict):
218
+ raise ConfigError("client must be a mapping")
219
+ _check_keys("client", client, CLIENT_KEYS)
220
+ storage_path = client.get("storage_path", "") or ""
221
+ if not isinstance(storage_path, str):
222
+ raise ConfigError("client.storage_path must be a path string")
223
+
224
+ ent = raw.get("enterprise", {})
225
+ if not isinstance(ent, dict):
226
+ raise ConfigError("enterprise must be a mapping")
227
+ _check_keys("enterprise", ent, ENTERPRISE_KEYS)
228
+ project = str(ent.get("project_number", "") or "")
229
+ location = ent.get("location", "global")
230
+ if location not in ENTERPRISE_LOCATIONS:
231
+ raise ConfigError(f"enterprise.location must be one of {', '.join(ENTERPRISE_LOCATIONS)}, got {location!r}")
232
+ sa_file = ent.get("service_account_file", "") or ""
233
+ if not isinstance(sa_file, str):
234
+ raise ConfigError("enterprise.service_account_file must be a path string")
235
+ limit = _int("enterprise.notebook_limit", ent.get("notebook_limit", ENTERPRISE_PAGE_MAX), 1, ENTERPRISE_PAGE_MAX)
236
+ if project and not project.isdigit():
237
+ raise ConfigError(f"enterprise.project_number must be the numeric project number, got {project!r}")
238
+ if enabled and backend == "enterprise" and not project:
239
+ raise ConfigError("enterprise.project_number is required when backend is enterprise")
240
+
241
+ return Config(
242
+ enabled=enabled,
243
+ backend=backend,
244
+ interval_minutes=interval,
245
+ bank_prefix=prefix,
246
+ include_notebooks=include_notebooks,
247
+ exclude_notebooks=exclude_notebooks,
248
+ notes=flags["notes"],
249
+ sources=flags["sources"],
250
+ chats=flags["chats"],
251
+ storage_path=storage_path,
252
+ enterprise=EnterpriseConfig(project, location, sa_file, limit),
253
+ )
254
+
255
+
256
+ def load_config(default_path: Path = DEFAULT_CONFIG, override_path: Path = DEFAULT_OVERRIDE) -> Config:
257
+ if not default_path.is_file():
258
+ raise ConfigError(f"shipped config missing: {default_path}")
259
+ raw = _read_yaml(default_path)
260
+ if override_path.is_file():
261
+ raw = _merge(raw, _read_yaml(override_path))
262
+ return validate_config(raw)
263
+
264
+
265
+ # ---------- naming and IDs ----------
266
+
267
+
268
+ def slugify(text: str) -> str:
269
+ slug = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")[:80].strip("-")
270
+ return slug or "untitled"
271
+
272
+
273
+ def assign_banks(notebooks: list[tuple[str, str]], prefix: str) -> dict[str, str]:
274
+ """Map notebook ID to bank. Duplicate titles get the first 8 characters of their ID appended."""
275
+ slugs = {nb_id: slugify(title) for nb_id, title in notebooks}
276
+ counts: dict[str, int] = {}
277
+ for slug in slugs.values():
278
+ counts[slug] = counts.get(slug, 0) + 1
279
+ banks = {}
280
+ for nb_id, slug in slugs.items():
281
+ name = slug if counts[slug] == 1 else f"{slug}-{slugify(nb_id)[:8]}"
282
+ banks[nb_id] = f"{prefix}::{name}"
283
+ return banks
284
+
285
+
286
+ def document_id(notebook_id: str, kind: str, item_id: str) -> str:
287
+ return f"notebooklm:v1:{notebook_id}:{kind}:{item_id}"
288
+
289
+
290
+ def chat_item_id(question: str) -> str:
291
+ return hashlib.sha256(question.encode()).hexdigest()[:16]
292
+
293
+
294
+ def build_documents(snapshot: NotebookSnapshot, bank: str) -> dict[str, dict]:
295
+ documents = {}
296
+ for item in snapshot.items:
297
+ doc_id = document_id(snapshot.id, item.kind, item.item_id)
298
+ content = f"# {item.title}\n\n{item.body}".rstrip() + "\n"
299
+ context = f"NotebookLM notebook '{snapshot.title}', {item.kind}: '{item.title}'"
300
+ metadata = {"source": "notebooklm", "notebook_id": snapshot.id, "notebook_title": snapshot.title, "kind": item.kind, "item_id": item.item_id, "url": item.url}
301
+ digest = hashlib.sha256(json.dumps({"content": content, "context": context, "metadata": metadata}, sort_keys=True).encode()).hexdigest()
302
+ documents[doc_id] = {"bank": bank, "notebook": snapshot.id, "content": content, "context": context, "metadata": metadata, "digest": digest}
303
+ return documents
304
+
305
+
306
+ # ---------- state ----------
307
+
308
+
309
+ def load_state(path: Path) -> dict[str, dict]:
310
+ """Return document ID -> {bank, notebook, digest}. A missing file is an empty state; a corrupt file is an error."""
311
+ if not path.exists():
312
+ return {}
313
+ try:
314
+ data = json.loads(path.read_text())
315
+ except json.JSONDecodeError as error:
316
+ raise RuntimeError(f"state file {path} is corrupt ({error}); move it aside to rebuild") from error
317
+ return data.get("documents", {})
318
+
319
+
320
+ def save_state(path: Path, documents: dict[str, dict]) -> None:
321
+ path.parent.mkdir(parents=True, exist_ok=True)
322
+ temp = path.with_suffix(".tmp")
323
+ temp.write_text(json.dumps({"version": 1, "documents": documents}, indent=2, sort_keys=True))
324
+ temp.replace(path)
325
+
326
+
327
+ # ---------- Hindsight HTTP ----------
328
+
329
+
330
+ class Hindsight:
331
+ def __init__(self, base_url: str, timeout: float = 300):
332
+ self.base = base_url.rstrip("/")
333
+ self.timeout = timeout
334
+
335
+ def _request(self, method: str, path: str, payload: dict | None = None) -> dict:
336
+ url = self.base + path
337
+ data = json.dumps(payload).encode() if payload is not None else None
338
+ headers = {"Content-Type": "application/json"} if data is not None else {}
339
+ request = urllib.request.Request(url, data=data, headers=headers, method=method)
340
+ try:
341
+ with urllib.request.urlopen(request, timeout=self.timeout) as response:
342
+ body = response.read()
343
+ except urllib.error.HTTPError as error:
344
+ raise HttpError(method, path, error.code, error.read().decode(errors="replace")[:300]) from error
345
+ return json.loads(body) if body else {}
346
+
347
+ @staticmethod
348
+ def _bank_path(bank: str) -> str:
349
+ return "/v1/default/banks/" + urllib.parse.quote(bank, safe="")
350
+
351
+ def ensure_bank(self, bank: str) -> None:
352
+ path = self._bank_path(bank)
353
+ try:
354
+ response = self._request("GET", path + "/config")
355
+ except HttpError as error:
356
+ if error.status != 404:
357
+ raise
358
+ self._request("PUT", path, {})
359
+ response = self._request("GET", path + "/config")
360
+ config = response.get("config", response)
361
+ strategies = config.get("retain_strategies") or {}
362
+ updates = {}
363
+ if strategies.get(HINDSIGHT_STRATEGY) != HINDSIGHT_RETAIN_STRATEGY:
364
+ updates["retain_strategies"] = {**strategies, HINDSIGHT_STRATEGY: HINDSIGHT_RETAIN_STRATEGY}
365
+ if config.get("enable_auto_consolidation") is not False:
366
+ updates["enable_auto_consolidation"] = False
367
+ if updates:
368
+ self._request("PATCH", path + "/config", {"updates": updates})
369
+
370
+ def retain(self, bank: str, doc_id: str, document: dict) -> None:
371
+ item = {
372
+ "content": document["content"],
373
+ "document_id": doc_id,
374
+ "context": document["context"],
375
+ "metadata": document["metadata"],
376
+ "timestamp": "unset",
377
+ "strategy": HINDSIGHT_STRATEGY,
378
+ }
379
+ self._request("POST", self._bank_path(bank) + "/memories", {"items": [item], "async": False})
380
+
381
+ def delete(self, bank: str, doc_id: str) -> None:
382
+ try:
383
+ self._request("DELETE", self._bank_path(bank) + "/documents/" + urllib.parse.quote(doc_id, safe=""))
384
+ except HttpError as error:
385
+ if error.status != 404: # 404: already gone. Treat as deleted.
386
+ raise
387
+
388
+
389
+ # ---------- sync core ----------
390
+
391
+
392
+ def sync_snapshots(fetch: FetchResult, bank_prefix: str, backend, state: dict[str, dict], state_path: Path) -> SyncReport:
393
+ """Retain changed documents, delete removed ones, and persist state after each successful write."""
394
+ report = SyncReport()
395
+ complete = [snap for snap in fetch.snapshots if snap.complete]
396
+ banks = assign_banks([(snap.id, snap.title) for snap in fetch.snapshots], bank_prefix)
397
+ ensured: set[str] = set()
398
+ desired: dict[str, dict] = {}
399
+ for snap in fetch.snapshots:
400
+ if not snap.complete:
401
+ report.skipped += 1
402
+ log.warning("notebook %r: listing failed this run; keeping its stored documents", snap.title)
403
+ continue
404
+ desired.update(build_documents(snap, banks[snap.id]))
405
+
406
+ for doc_id, document in desired.items():
407
+ recorded = state.get(doc_id)
408
+ if recorded and recorded["bank"] == document["bank"] and recorded["digest"] == document["digest"]:
409
+ report.skipped += 1
410
+ continue
411
+ try:
412
+ if document["bank"] not in ensured:
413
+ backend.ensure_bank(document["bank"])
414
+ ensured.add(document["bank"])
415
+ backend.retain(document["bank"], doc_id, document)
416
+ except HttpError as error:
417
+ report.failed += 1
418
+ log.error("retain failed for %s: %s", doc_id, error)
419
+ continue
420
+ report.retained += 1
421
+ state[doc_id] = {"bank": document["bank"], "notebook": document["notebook"], "digest": document["digest"]}
422
+ if recorded and recorded["bank"] != document["bank"]:
423
+ _delete_remote(backend, doc_id, recorded["bank"], report)
424
+ save_state(state_path, state)
425
+
426
+ for bank in ensured:
427
+ after = getattr(backend, "after_bank_batch", None)
428
+ if callable(after):
429
+ after(bank)
430
+
431
+ complete_ids = {snap.id for snap in complete}
432
+ listed_ids = {snap.id for snap in fetch.snapshots}
433
+ for doc_id, recorded in list(state.items()):
434
+ if doc_id in desired:
435
+ continue
436
+ notebook = recorded["notebook"]
437
+ if notebook in complete_ids or (fetch.listing_complete and notebook not in listed_ids):
438
+ if _delete_remote(backend, doc_id, recorded["bank"], report):
439
+ state.pop(doc_id, None)
440
+ save_state(state_path, state)
441
+ return report
442
+
443
+
444
+ def _delete_remote(backend, doc_id: str, bank: str, report: SyncReport) -> bool:
445
+ """Delete a document from the active memory backend; the caller owns the state entry."""
446
+ try:
447
+ backend.delete(bank, doc_id)
448
+ except HttpError as error:
449
+ report.failed += 1
450
+ log.error("delete failed for %s: %s", doc_id, error)
451
+ return False
452
+ report.deleted += 1
453
+ return True
454
+
455
+
456
+ # ---------- client backend ----------
457
+
458
+
459
+ def refresh_client_auth() -> None:
460
+ """Refresh stored cookies once before a run. A failed refresh is logged; the fetch then reports the real state."""
461
+ try:
462
+ result = subprocess.run(["notebooklm", "auth", "refresh", "--quiet"], capture_output=True, text=True, timeout=180)
463
+ except (OSError, subprocess.TimeoutExpired) as error:
464
+ log.warning("notebooklm auth refresh did not run: %s", error)
465
+ return
466
+ if result.returncode != 0:
467
+ log.warning("notebooklm auth refresh exited %d", result.returncode)
468
+
469
+
470
+ async def fetch_client(cfg: Config) -> FetchResult:
471
+ from notebooklm import AuthError, NotebookLMClient, NotebookLMError
472
+
473
+ snapshots = []
474
+ try:
475
+ async with NotebookLMClient.from_storage(path=cfg.storage_path or None) as client:
476
+ notebooks = [nb for nb in await client.notebooks.list() if cfg.selects(nb.title)]
477
+ for nb in notebooks:
478
+ items: list[Item] = []
479
+ complete = True
480
+ try:
481
+ if cfg.notes:
482
+ for note in await client.notes.list(nb.id):
483
+ items.append(Item("note", note.id, note.title or "Untitled note", note.content or ""))
484
+ if cfg.sources:
485
+ for source in await client.sources.list(nb.id):
486
+ items.append(await _client_source_item(client, nb.id, source, AuthError, NotebookLMError))
487
+ if cfg.chats:
488
+ for question, answer in await client.chat.get_history(nb.id, limit=CHAT_HISTORY_LIMIT):
489
+ items.append(Item("chat", chat_item_id(question), question[:120], answer, ""))
490
+ except AuthError:
491
+ raise
492
+ except NotebookLMError as error:
493
+ complete = False
494
+ log.error("notebook %r: %s", nb.title, error)
495
+ title = nb.title or "Untitled notebook"
496
+ if complete and not items:
497
+ # ensure_bank runs only when retaining documents; empty notebooks never appear as banks.
498
+ log.info("notebook %r (%s): nothing to sync (no notes/sources/chats); skipping bank", title, nb.id)
499
+ snapshots.append(NotebookSnapshot(nb.id, title, tuple(items), complete))
500
+ except FileNotFoundError as error:
501
+ raise AuthProblem(f"{error}\n{LOGIN_INSTRUCTION}") from error
502
+ except AuthError as error:
503
+ raise AuthProblem(f"{error}\n{LOGIN_INSTRUCTION}") from error
504
+ return FetchResult(tuple(snapshots), listing_complete=True)
505
+
506
+
507
+ async def _client_source_item(client, notebook_id: str, source, auth_error: type, base_error: type) -> Item:
508
+ text = ""
509
+ summary = ""
510
+ try:
511
+ text = (await client.sources.get_fulltext(notebook_id, source.id, output_format="markdown")).content or ""
512
+ except auth_error:
513
+ raise
514
+ except base_error as error:
515
+ log.warning("source %r: no full text (%s)", source.title, error.__class__.__name__)
516
+ try:
517
+ summary = (await client.sources.get_guide(notebook_id, source.id)).summary or ""
518
+ except auth_error:
519
+ raise
520
+ except base_error:
521
+ pass
522
+ parts = []
523
+ if source.url:
524
+ parts.append(f"URL: {source.url}")
525
+ if summary:
526
+ parts.append(f"## Guide summary\n\n{summary}")
527
+ if text:
528
+ parts.append(f"## Full text\n\n{text}")
529
+ return Item("source", source.id, source.title or "Untitled source", "\n\n".join(parts), source.url or "")
530
+
531
+
532
+ def fetch_client_sync(cfg: Config) -> FetchResult:
533
+ return asyncio.run(fetch_client(cfg))
534
+
535
+
536
+ # ---------- enterprise backend ----------
537
+
538
+
539
+ def fetch_enterprise(cfg: Config) -> FetchResult:
540
+ """List recently viewed notebooks through the documented v1alpha REST method.
541
+
542
+ The documented API has no sources or notes list, so each notebook yields one 'notebook' item.
543
+ Response field names (notebooks, nextPageToken, name, title) follow the REST reference and are not verified against a live project.
544
+ """
545
+ import google.auth
546
+ from google.auth.transport.requests import Request
547
+ from google.oauth2 import service_account
548
+
549
+ ent = cfg.enterprise
550
+ if ent.service_account_file:
551
+ credentials = service_account.Credentials.from_service_account_file(ent.service_account_file, scopes=[ENTERPRISE_SCOPE])
552
+ else:
553
+ credentials, _ = google.auth.default(scopes=[ENTERPRISE_SCOPE])
554
+ credentials.refresh(Request())
555
+ base = f"https://{ent.location}-discoveryengine.googleapis.com/v1alpha/projects/{ent.project_number}/locations/{ent.location}"
556
+
557
+ notebooks: list[dict] = []
558
+ token = ""
559
+ while True:
560
+ query = {"pageSize": str(ent.notebook_limit)}
561
+ if token:
562
+ query["pageToken"] = token
563
+ url = f"{base}/notebooks:listRecentlyViewed?" + urllib.parse.urlencode(query)
564
+ request = urllib.request.Request(url, headers={"Authorization": f"Bearer {credentials.token}"})
565
+ try:
566
+ with urllib.request.urlopen(request, timeout=60) as response:
567
+ page = json.loads(response.read())
568
+ except urllib.error.HTTPError as error:
569
+ raise HttpError("GET", url.split("?")[0], error.code, error.read().decode(errors="replace")[:300]) from error
570
+ notebooks.extend(page.get("notebooks", []))
571
+ token = page.get("nextPageToken", "")
572
+ if not token:
573
+ break
574
+
575
+ snapshots = []
576
+ for entry in notebooks:
577
+ notebook_id = str(entry.get("name", "")).rsplit("/", 1)[-1]
578
+ if not notebook_id:
579
+ continue
580
+ title = entry.get("title") or "Untitled notebook"
581
+ if not cfg.selects(title):
582
+ continue
583
+ body = f"Resource: {entry.get('name')}\n" + "".join(f"{key}: {entry[key]}\n" for key in ("createTime", "lastViewTime") if key in entry)
584
+ snapshots.append(NotebookSnapshot(notebook_id, title, (Item("notebook", notebook_id, title, body),)))
585
+ return FetchResult(tuple(snapshots), listing_complete=not token)
586
+
587
+
588
+ # ---------- entry points ----------
589
+
590
+
591
+ def run_cycle(cfg: Config, backend, state_path: Path) -> SyncReport:
592
+ if cfg.backend == "client":
593
+ refresh_client_auth()
594
+ fetch = fetch_client_sync(cfg)
595
+ else:
596
+ fetch = fetch_enterprise(cfg)
597
+ state = load_state(state_path)
598
+ report = sync_snapshots(fetch, cfg.bank_prefix, backend, state, state_path)
599
+ log.info("sync done: %d notebook(s); %s", len(fetch.snapshots), report.summary())
600
+ return report
601
+
602
+
603
+ def main(argv: list[str] | None = None) -> int:
604
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
605
+ parser.add_argument("--config", type=Path, default=DEFAULT_CONFIG)
606
+ parser.add_argument("--override", type=Path, default=DEFAULT_OVERRIDE)
607
+ parser.add_argument("--state", type=Path, default=DEFAULT_STATE)
608
+ parser.add_argument("--hindsight-url", default=os.environ.get("HINDSIGHT_URL", DEFAULT_HINDSIGHT_URL))
609
+ parser.add_argument("--once", action="store_true", help="run one sync and exit")
610
+ parser.add_argument("--check-config", action="store_true", help="validate config and exit")
611
+ args = parser.parse_args(argv)
612
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
613
+
614
+ if args.check_config:
615
+ try:
616
+ cfg = load_config(args.config, args.override)
617
+ except ConfigError as error:
618
+ print(f"config error: {error}", file=sys.stderr)
619
+ return 2
620
+ print(f"config ok: enabled={cfg.enabled} backend={cfg.backend} interval_minutes={cfg.interval_minutes}")
621
+ return 0
622
+
623
+ from memory_backend import create_backend
624
+
625
+ try:
626
+ backend = create_backend(args.hindsight_url)
627
+ except Exception as error:
628
+ log.error("memory backend: %s", error)
629
+ return 2
630
+ while True:
631
+ try:
632
+ cfg = load_config(args.config, args.override)
633
+ except ConfigError as error:
634
+ log.error("config error: %s; retrying in %ds", error, ERROR_SLEEP_SECONDS)
635
+ if args.once:
636
+ return 2
637
+ time.sleep(ERROR_SLEEP_SECONDS)
638
+ continue
639
+ if not cfg.enabled:
640
+ log.info("disabled: set enabled: true in %s and restart the service", args.override)
641
+ if args.once:
642
+ return 0
643
+ time.sleep(DISABLED_SLEEP_SECONDS)
644
+ continue
645
+ try:
646
+ run_cycle(cfg, backend, args.state)
647
+ delay = cfg.interval_minutes * 60
648
+ except AuthProblem as error:
649
+ log.error("%s", error)
650
+ if args.once:
651
+ return 3
652
+ delay = AUTH_RETRY_SECONDS
653
+ except Exception: # keep the loop alive; the next cycle retries
654
+ log.exception("sync failed; retrying in %ds", ERROR_SLEEP_SECONDS)
655
+ if args.once:
656
+ return 1
657
+ delay = ERROR_SLEEP_SECONDS
658
+ if args.once:
659
+ return 0
660
+ time.sleep(delay)
661
+
662
+
663
+ if __name__ == "__main__":
664
+ sys.exit(main())