@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.
- package/.agents/skills/openmem/SKILL.md +34 -0
- package/LICENSE +190 -0
- package/README.md +140 -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 +34 -0
- package/cli/coding-agent/context-store.mjs +81 -0
- package/cli/coding-agent/context-trim.mjs +897 -0
- package/cli/coding-agent/file-io.mjs +72 -0
- package/cli/coding-agent/hook-harnesses.mjs +88 -0
- package/cli/coding-agent/hook-runner.mjs +284 -0
- package/cli/coding-agent/install-access.mjs +119 -0
- package/cli/coding-agent/install-context-proxy.mjs +333 -0
- package/cli/coding-agent/install-rtk.mjs +60 -0
- package/cli/coding-agent/install-shim.mjs +201 -0
- package/cli/coding-agent/opencode-config.mjs +107 -0
- package/cli/coding-agent/opencode-plugin.mjs +132 -0
- package/cli/coding-agent/opencode2-plugin/index.js +299 -0
- package/cli/coding-agent/recall-frame.mjs +29 -0
- package/cli/coding-agent/remove-upstream-access.mjs +122 -0
- package/cli/coding-agent/runtime.mjs +40 -0
- package/cli/coding-agent/session-db.mjs +626 -0
- package/cli/coding-agent/session-facade.mjs +86 -0
- package/cli/coding-agent/toml-edit.mjs +161 -0
- package/cli/cognee-mcp.mjs +260 -0
- package/cli/context-management.mjs +254 -0
- package/cli/feed-sync.mjs +27 -0
- package/cli/install-coding-agent.mjs +298 -0
- package/cli/install-memory-mcp.mjs +426 -0
- package/cli/is-main.mjs +18 -0
- package/cli/mcp/context-sql-runner.mjs +58 -0
- package/cli/mcp/context-store.mjs +670 -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/knowledge.mjs +333 -0
- package/cli/mcp/llm-synthesize.mjs +50 -0
- package/cli/mcp/mem0-scope-id.mjs +22 -0
- package/cli/mcp/memory-facade.mjs +521 -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 +38 -0
- package/cli/openmem.mjs +222 -0
- package/cli/roots.mjs +85 -0
- package/cli/schedule.mjs +176 -0
- package/cli/stack.mjs +438 -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 +227 -0
- package/config/coding-agent.json +13 -0
- package/config/hindsight.env.example +26 -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 +124 -0
- package/docs/OPENMEM.md +23 -0
- package/docs/architecture.md +245 -0
- package/docs/assets/README.md +10 -0
- package/docs/assets/setup-demo.gif +0 -0
- package/docs/coding-agent-hooks.md +75 -0
- package/docs/coding-agent-scopes.md +22 -0
- package/docs/coding-agents.md +71 -0
- package/docs/cognee.md +37 -0
- package/docs/compatibility.md +80 -0
- package/docs/concepts.md +135 -0
- package/docs/context-management.md +255 -0
- package/docs/context-proxy.md +115 -0
- package/docs/context-research.md +215 -0
- package/docs/context-store.md +277 -0
- package/docs/hindsight.md +77 -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 +104 -0
- package/docs/mem0.md +141 -0
- package/docs/memory-portability.md +73 -0
- package/docs/memory-providers.md +246 -0
- package/docs/meridian-litellm.md +198 -0
- package/docs/model-configuration.md +106 -0
- package/docs/notebooklm.md +83 -0
- package/docs/npx-install.md +172 -0
- package/docs/openmem-config.md +194 -0
- package/docs/provider-wizard-hooks.md +222 -0
- package/docs/quickstart.md +107 -0
- package/docs/setup-wizard.md +117 -0
- package/docs/sources-feeds.md +39 -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 +96 -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 +347 -0
- package/providers/hindsight/lib/coding-agent.mjs +204 -0
- package/providers/hindsight/lib/config.mjs +79 -0
- package/providers/hindsight/lib/env-setup.mjs +72 -0
- package/providers/hindsight/lib/litellm-routes.mjs +196 -0
- package/providers/hindsight/lib/meridian.mjs +307 -0
- package/providers/hindsight/lib/model-backend.mjs +243 -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 +54 -0
- package/providers/hindsight/lib/stack-env.mjs +125 -0
- package/providers/hindsight/lib/state.mjs +80 -0
- package/providers/hindsight/lib/summary.mjs +57 -0
- package/providers/hindsight/lib/validate.mjs +81 -0
- package/providers/hindsight/manifest.json +58 -0
- package/providers/hindsight/wizard.mjs +150 -0
- package/providers/letta/manifest.json +29 -0
- package/providers/mem0/OPERATOR.md +143 -0
- package/providers/mem0/compose.yaml +79 -0
- package/providers/mem0/lib/coding-agent.mjs +58 -0
- package/providers/mem0/lib/config.mjs +45 -0
- package/providers/mem0/lib/configure.mjs +201 -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/litellm.mjs +457 -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 +255 -0
- package/providers/mem0/lib/state.mjs +67 -0
- package/providers/mem0/lib/summary.mjs +88 -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 +146 -0
- package/providers/zep/manifest.json +29 -0
- package/proxy/config.yaml +33 -0
- package/proxy/openmem_context/__init__.py +6 -0
- package/proxy/openmem_context/adapters.py +504 -0
- package/proxy/openmem_context/compress.py +53 -0
- package/proxy/openmem_context/core.py +606 -0
- package/proxy/openmem_context/embed.py +212 -0
- package/proxy/openmem_context/recall.py +49 -0
- package/proxy/openmem_context/session.py +70 -0
- package/proxy/openmem_context/sessiondb.py +276 -0
- package/proxy/openmem_context/settings.py +134 -0
- package/proxy/openmem_context/store.py +129 -0
- package/proxy/openmem_context/tokens.py +123 -0
- package/proxy/openmem_context/ttl.py +78 -0
- package/proxy/openmem_context_hook.py +34 -0
- package/scripts/check-pack.mjs +219 -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 +107 -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/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/context-step.mjs +140 -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/litellm-env.mjs +55 -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 +371 -0
- package/wizard/lib/ports.mjs +94 -0
- package/wizard/lib/prompts.mjs +85 -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 +750 -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,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())
|