talkpipe-vault 0.0.1__py3-none-any.whl

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 (36) hide show
  1. talkpipe_vault/__init__.py +8 -0
  2. talkpipe_vault/apps/__init__.py +1 -0
  3. talkpipe_vault/apps/access_control.py +94 -0
  4. talkpipe_vault/apps/credentials.py +196 -0
  5. talkpipe_vault/apps/query.py +2173 -0
  6. talkpipe_vault/apps/static/favicon.svg +4 -0
  7. talkpipe_vault/apps/static/logo.jpg +0 -0
  8. talkpipe_vault/apps/static/marked.min.js +69 -0
  9. talkpipe_vault/apps/templates/_dir_picker.html +300 -0
  10. talkpipe_vault/apps/templates/_document_modal.html +142 -0
  11. talkpipe_vault/apps/templates/_search_interactions.html +238 -0
  12. talkpipe_vault/apps/templates/_search_results.html +40 -0
  13. talkpipe_vault/apps/templates/base.html +921 -0
  14. talkpipe_vault/apps/templates/chat.html +507 -0
  15. talkpipe_vault/apps/templates/documents.html +145 -0
  16. talkpipe_vault/apps/templates/home.html +33 -0
  17. talkpipe_vault/apps/templates/keyword_search.html +165 -0
  18. talkpipe_vault/apps/templates/search.html +30 -0
  19. talkpipe_vault/apps/templates/settings.html +309 -0
  20. talkpipe_vault/apps/templates/vaults.html +90 -0
  21. talkpipe_vault/apps/user_settings.py +183 -0
  22. talkpipe_vault/apps/vault_server.py +93 -0
  23. talkpipe_vault/initialize_plugin.py +17 -0
  24. talkpipe_vault/pipelines/building_and_watching.py +350 -0
  25. talkpipe_vault/pipelines/cli.py +158 -0
  26. talkpipe_vault/pipelines/config.py +323 -0
  27. talkpipe_vault/pipelines/diagnostics.py +856 -0
  28. talkpipe_vault/pipelines/searching_and_prompting.py +349 -0
  29. talkpipe_vault/pipelines/vault_metadata.py +128 -0
  30. talkpipe_vault/watchdog.py +251 -0
  31. talkpipe_vault-0.0.1.dist-info/METADATA +707 -0
  32. talkpipe_vault-0.0.1.dist-info/RECORD +36 -0
  33. talkpipe_vault-0.0.1.dist-info/WHEEL +5 -0
  34. talkpipe_vault-0.0.1.dist-info/entry_points.txt +17 -0
  35. talkpipe_vault-0.0.1.dist-info/licenses/LICENSE +189 -0
  36. talkpipe_vault-0.0.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,8 @@
1
+ """TalkPipe Vault - AI-powered personal information assistant.
2
+
3
+ This package provides TalkPipe Vault components and plugin initialization.
4
+ """
5
+
6
+ __version__ = "0.1.0"
7
+
8
+ __all__ = ["__version__"]
@@ -0,0 +1 @@
1
+ """TalkPipe Vault applications for searching and chatting with vault contents."""
@@ -0,0 +1,94 @@
1
+ """Optional filesystem fences for the web interface.
2
+
3
+ Two environment variables restrict which filesystem paths the web routes
4
+ accept. Both are unset by default (an empty or whitespace-only value counts
5
+ as unset), which leaves every route unrestricted — the right default for a
6
+ single-user desktop install. Containerized or shared deployments set them so
7
+ the browser UI cannot create vaults in ephemeral container paths or browse
8
+ the server's whole filesystem:
9
+
10
+ - ``TALKPIPE_VAULT_ROOT`` — a single directory; vaults may only be created,
11
+ opened, or deleted inside it.
12
+ - ``TALKPIPE_DOCUMENT_ROOTS`` — ``os.pathsep``-separated directories; the
13
+ folder picker and document indexing are confined to them.
14
+
15
+ Paths are fully resolved (symlinks followed) before containment checks, so a
16
+ symlink inside an allowed root that points outside of it is rejected.
17
+ """
18
+
19
+ import os
20
+ from pathlib import Path
21
+
22
+ VAULT_ROOT_ENV = "TALKPIPE_VAULT_ROOT"
23
+ DOCUMENT_ROOTS_ENV = "TALKPIPE_DOCUMENT_ROOTS"
24
+
25
+
26
+ def vault_root() -> Path | None:
27
+ """The directory vaults are confined to, or None when unrestricted."""
28
+ raw = os.environ.get(VAULT_ROOT_ENV, "").strip()
29
+ if not raw:
30
+ return None
31
+ return Path(raw).expanduser().resolve()
32
+
33
+
34
+ def document_roots() -> list[Path]:
35
+ """Directories browsing/indexing are confined to; empty when unrestricted."""
36
+ raw = os.environ.get(DOCUMENT_ROOTS_ENV, "")
37
+ roots: list[Path] = []
38
+ for part in raw.split(os.pathsep):
39
+ part = part.strip()
40
+ if not part:
41
+ continue
42
+ resolved = Path(part).expanduser().resolve()
43
+ if resolved not in roots:
44
+ roots.append(resolved)
45
+ return roots
46
+
47
+
48
+ def browse_roots() -> list[Path]:
49
+ """Union of vault and document roots for the folder picker.
50
+
51
+ The picker chooses both vault locations and document folders, so it may
52
+ see every configured root. Order is vault root first, then document
53
+ roots; empty means browsing is unrestricted.
54
+ """
55
+ roots: list[Path] = []
56
+ root = vault_root()
57
+ if root is not None:
58
+ roots.append(root)
59
+ for doc_root in document_roots():
60
+ if doc_root not in roots:
61
+ roots.append(doc_root)
62
+ return roots
63
+
64
+
65
+ def is_allowed(path: str | Path, roots: list[Path]) -> bool:
66
+ """True when the fully-resolved path lies inside one of the roots.
67
+
68
+ An empty roots list means unrestricted, so everything is allowed.
69
+ """
70
+ if not roots:
71
+ return True
72
+ resolved = Path(path).expanduser().resolve()
73
+ return any(resolved.is_relative_to(root) for root in roots)
74
+
75
+
76
+ def describe(roots: list[Path]) -> str:
77
+ """Human-readable list of allowed roots for error messages."""
78
+ return ", ".join(str(root) for root in roots)
79
+
80
+
81
+ def startup_errors() -> list[str]:
82
+ """Configuration problems that should fail startup loudly.
83
+
84
+ A configured root that does not exist would otherwise lock the user out
85
+ of every path with no hint about why.
86
+ """
87
+ errors: list[str] = []
88
+ root = vault_root()
89
+ if root is not None and not root.is_dir():
90
+ errors.append(f"{VAULT_ROOT_ENV} points to {root}, which is not a directory.")
91
+ for doc_root in document_roots():
92
+ if not doc_root.is_dir():
93
+ errors.append(f"{DOCUMENT_ROOTS_ENV} entry {doc_root} is not a directory.")
94
+ return errors
@@ -0,0 +1,196 @@
1
+ """Persistent, vault-scoped provider credentials.
2
+
3
+ Secrets and connection settings a user enters on the Settings page (API keys,
4
+ the OpenAI base URL, the Ollama server URL) are stored in ``credentials.json``
5
+ under the vault home (``$TALKPIPE_VAULT_HOME``) with owner-only permissions,
6
+ and applied into the *process* environment at startup and whenever they change.
7
+
8
+ Applying to ``os.environ`` — rather than the user's shell or ``~/.talkpipe.toml``
9
+ — is what scopes these to the vault app: the OpenAI and Anthropic SDKs read
10
+ their key and base URL directly from the environment, and TalkPipe reads the
11
+ Ollama server URL from its own config (which we refresh from the same env var).
12
+ Nothing is written outside the vault home, so other TalkPipe usage on the
13
+ machine is unaffected.
14
+
15
+ Precedence: a stored value overrides a pre-existing environment variable, so
16
+ what a user types in the UI wins. We only ever unset variables we set
17
+ ourselves, so a deployment that provides credentials purely through the
18
+ environment (e.g. the container ``.env``) keeps working as long as the user
19
+ leaves the corresponding field blank.
20
+ """
21
+
22
+ import json
23
+ import logging
24
+ import os
25
+ from dataclasses import dataclass
26
+ from pathlib import Path
27
+
28
+ from talkpipe.util.config import reset_config
29
+
30
+ from talkpipe_vault.apps import user_settings
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+ CREDENTIALS_FILENAME = "credentials.json"
35
+
36
+
37
+ @dataclass(frozen=True)
38
+ class _Field:
39
+ """A managed credential: its storage key, target env var, and metadata."""
40
+
41
+ key: str
42
+ env_var: str
43
+ secret: bool
44
+ label: str
45
+
46
+
47
+ # Order here is the order rendered on the Settings page.
48
+ FIELDS: tuple[_Field, ...] = (
49
+ _Field("openai_api_key", "OPENAI_API_KEY", True, "OpenAI API key"),
50
+ _Field("openai_base_url", "OPENAI_BASE_URL", False, "OpenAI base URL"),
51
+ _Field("anthropic_api_key", "ANTHROPIC_API_KEY", True, "Anthropic API key"),
52
+ _Field(
53
+ "ollama_server_url", "TALKPIPE_OLLAMA_SERVER_URL", False, "Ollama server URL"
54
+ ),
55
+ )
56
+
57
+ _FIELDS_BY_KEY = {field.key: field for field in FIELDS}
58
+
59
+ # Env vars we set from the stored credentials, so we can safely unset only our
60
+ # own when a value is cleared (never a variable the environment already had).
61
+ _managed_env: set[str] = set()
62
+
63
+
64
+ def _path() -> Path:
65
+ return user_settings.get_vault_home() / CREDENTIALS_FILENAME
66
+
67
+
68
+ def store_path() -> Path:
69
+ """Absolute path to the credentials file (whether or not it exists yet)."""
70
+ return _path()
71
+
72
+
73
+ def load() -> dict[str, str]:
74
+ """Load stored credentials, keeping only known non-empty string values."""
75
+ path = _path()
76
+ try:
77
+ with open(path, encoding="utf-8") as handle:
78
+ data = json.load(handle)
79
+ if not isinstance(data, dict):
80
+ raise ValueError("credentials root is not an object")
81
+ except FileNotFoundError:
82
+ return {}
83
+ except (OSError, ValueError) as exc:
84
+ logger.warning("Ignoring unreadable credentials file %s: %s", path, exc)
85
+ return {}
86
+
87
+ result: dict[str, str] = {}
88
+ for field in FIELDS:
89
+ value = data.get(field.key)
90
+ if isinstance(value, str) and value.strip():
91
+ result[field.key] = value.strip()
92
+ return result
93
+
94
+
95
+ def _write(values: dict[str, str]) -> None:
96
+ """Persist credentials as JSON with owner-only (0600) permissions."""
97
+ path = _path()
98
+ path.parent.mkdir(parents=True, exist_ok=True)
99
+ tmp_path = path.with_suffix(".json.tmp")
100
+ # Create with 0600 from the start so the secret is never briefly world-readable.
101
+ fd = os.open(str(tmp_path), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
102
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
103
+ json.dump(values, handle, indent=2)
104
+ os.replace(tmp_path, path)
105
+
106
+
107
+ def set_values(changes: dict[str, str | None]) -> None:
108
+ """Merge credential changes, persist them, and apply to the environment.
109
+
110
+ ``changes`` maps field keys to values. A blank or ``None`` value clears
111
+ that field; fields absent from ``changes`` are left untouched. Unknown
112
+ keys are ignored.
113
+ """
114
+ data = load()
115
+ for key, value in changes.items():
116
+ if key not in _FIELDS_BY_KEY:
117
+ continue
118
+ cleaned = (value or "").strip()
119
+ if cleaned:
120
+ data[key] = cleaned
121
+ else:
122
+ data.pop(key, None)
123
+ _write(data)
124
+ apply()
125
+
126
+
127
+ def apply() -> None:
128
+ """Apply stored credentials to the process environment.
129
+
130
+ Sets each configured value's env var, unsets any var we previously set but
131
+ is now cleared, and refreshes TalkPipe's cached config so a changed Ollama
132
+ URL takes effect. Leaves untouched any env var the vault never set.
133
+ """
134
+ global _managed_env
135
+ data = load()
136
+ now_managed: set[str] = set()
137
+ for field in FIELDS:
138
+ value = data.get(field.key)
139
+ if value:
140
+ os.environ[field.env_var] = value
141
+ now_managed.add(field.env_var)
142
+ elif field.env_var in _managed_env:
143
+ os.environ.pop(field.env_var, None)
144
+ _managed_env = now_managed
145
+ # TalkPipe caches config (including the Ollama URL) on first read; refresh
146
+ # so a newly applied TALKPIPE_OLLAMA_SERVER_URL is picked up.
147
+ reset_config()
148
+
149
+
150
+ def source_for(env_var: str) -> str:
151
+ """Describe where a managed env var's current value comes from."""
152
+ if env_var in _managed_env:
153
+ return "Vault settings"
154
+ if os.environ.get(env_var):
155
+ return "environment"
156
+ return "unset"
157
+
158
+
159
+ def _mask(secret: str) -> str:
160
+ """Mask a secret, revealing only the last few characters."""
161
+ if len(secret) <= 8:
162
+ return "••••"
163
+ return f"••••{secret[-4:]}"
164
+
165
+
166
+ def describe() -> list[dict[str, object]]:
167
+ """Return per-field state for rendering the Settings form.
168
+
169
+ Secret values are never returned verbatim — only whether one is saved and
170
+ a masked hint. Non-secret values (URLs) are returned so the field can be
171
+ pre-filled.
172
+ """
173
+ data = load()
174
+ rows: list[dict[str, object]] = []
175
+ for field in FIELDS:
176
+ value = data.get(field.key, "")
177
+ # A non-secret value may be active from the environment (e.g. a
178
+ # container's TALKPIPE_OLLAMA_SERVER_URL) without being stored here.
179
+ # Surface it so the field isn't misleadingly blank. Secrets are never
180
+ # revealed, so we only report whether one is active, not its value.
181
+ active = "" if field.secret else os.environ.get(field.env_var, "")
182
+ rows.append(
183
+ {
184
+ "key": field.key,
185
+ "label": field.label,
186
+ "secret": field.secret,
187
+ "env_var": field.env_var,
188
+ "present": bool(value),
189
+ "masked": _mask(value) if value else "",
190
+ # Only non-secret values are safe to send back to the browser.
191
+ "value": "" if field.secret else value,
192
+ "active": active,
193
+ "source": source_for(field.env_var),
194
+ }
195
+ )
196
+ return rows