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.
- talkpipe_vault/__init__.py +8 -0
- talkpipe_vault/apps/__init__.py +1 -0
- talkpipe_vault/apps/access_control.py +94 -0
- talkpipe_vault/apps/credentials.py +196 -0
- talkpipe_vault/apps/query.py +2173 -0
- talkpipe_vault/apps/static/favicon.svg +4 -0
- talkpipe_vault/apps/static/logo.jpg +0 -0
- talkpipe_vault/apps/static/marked.min.js +69 -0
- talkpipe_vault/apps/templates/_dir_picker.html +300 -0
- talkpipe_vault/apps/templates/_document_modal.html +142 -0
- talkpipe_vault/apps/templates/_search_interactions.html +238 -0
- talkpipe_vault/apps/templates/_search_results.html +40 -0
- talkpipe_vault/apps/templates/base.html +921 -0
- talkpipe_vault/apps/templates/chat.html +507 -0
- talkpipe_vault/apps/templates/documents.html +145 -0
- talkpipe_vault/apps/templates/home.html +33 -0
- talkpipe_vault/apps/templates/keyword_search.html +165 -0
- talkpipe_vault/apps/templates/search.html +30 -0
- talkpipe_vault/apps/templates/settings.html +309 -0
- talkpipe_vault/apps/templates/vaults.html +90 -0
- talkpipe_vault/apps/user_settings.py +183 -0
- talkpipe_vault/apps/vault_server.py +93 -0
- talkpipe_vault/initialize_plugin.py +17 -0
- talkpipe_vault/pipelines/building_and_watching.py +350 -0
- talkpipe_vault/pipelines/cli.py +158 -0
- talkpipe_vault/pipelines/config.py +323 -0
- talkpipe_vault/pipelines/diagnostics.py +856 -0
- talkpipe_vault/pipelines/searching_and_prompting.py +349 -0
- talkpipe_vault/pipelines/vault_metadata.py +128 -0
- talkpipe_vault/watchdog.py +251 -0
- talkpipe_vault-0.0.1.dist-info/METADATA +707 -0
- talkpipe_vault-0.0.1.dist-info/RECORD +36 -0
- talkpipe_vault-0.0.1.dist-info/WHEEL +5 -0
- talkpipe_vault-0.0.1.dist-info/entry_points.txt +17 -0
- talkpipe_vault-0.0.1.dist-info/licenses/LICENSE +189 -0
- talkpipe_vault-0.0.1.dist-info/top_level.txt +1 -0
|
@@ -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
|