psydcar 0.1.2__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.
- psydcar/__init__.py +5 -0
- psydcar/__main__.py +3 -0
- psydcar/cli.py +269 -0
- psydcar/dashboard.py +428 -0
- psydcar/dashboard_assets/dashboard.css +69 -0
- psydcar/dashboard_assets/dashboard.js +512 -0
- psydcar/dashboard_assets/index.html +190 -0
- psydcar/embeddings.py +284 -0
- psydcar/evidence.py +839 -0
- psydcar/mcp_server.py +51 -0
- psydcar/pdf_text.py +71 -0
- psydcar/store.py +189 -0
- psydcar/watcher.py +108 -0
- psydcar-0.1.2.dist-info/METADATA +223 -0
- psydcar-0.1.2.dist-info/RECORD +18 -0
- psydcar-0.1.2.dist-info/WHEEL +5 -0
- psydcar-0.1.2.dist-info/entry_points.txt +2 -0
- psydcar-0.1.2.dist-info/top_level.txt +1 -0
psydcar/__init__.py
ADDED
psydcar/__main__.py
ADDED
psydcar/cli.py
ADDED
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""Human-facing commands for local sidecar management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import signal
|
|
8
|
+
import sys
|
|
9
|
+
import threading
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
12
|
+
|
|
13
|
+
from .embeddings import ProviderStore
|
|
14
|
+
from .evidence import EvidenceStore
|
|
15
|
+
from .dashboard import run_dashboard
|
|
16
|
+
from .mcp_server import run_mcp
|
|
17
|
+
from .store import Sidecar, SidecarStore, StoreError
|
|
18
|
+
from .watcher import SidecarWatcher, WatchEvent
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _display_status(status: str) -> str:
|
|
22
|
+
return status.replace("_", " ").capitalize()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _details(sidecar: Sidecar) -> str:
|
|
26
|
+
return "\n".join(
|
|
27
|
+
(
|
|
28
|
+
f"Name: {sidecar.name}",
|
|
29
|
+
f"ID: {sidecar.id}",
|
|
30
|
+
f"Source: {sidecar.source_path}",
|
|
31
|
+
f"Status: {_display_status(sidecar.status)}",
|
|
32
|
+
)
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _parser() -> argparse.ArgumentParser:
|
|
37
|
+
parser = argparse.ArgumentParser(prog="psydcar", description="Manage local knowledge sidecars.")
|
|
38
|
+
try:
|
|
39
|
+
installed_version = version("psydcar")
|
|
40
|
+
except PackageNotFoundError:
|
|
41
|
+
installed_version = "development"
|
|
42
|
+
parser.add_argument("--version", action="version", version=f"psydcar {installed_version}")
|
|
43
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
44
|
+
sidecar = commands.add_parser("sidecar", help="Create and manage sidecars")
|
|
45
|
+
actions = sidecar.add_subparsers(dest="action", required=True)
|
|
46
|
+
|
|
47
|
+
create = actions.add_parser("create", help="Create a sidecar from one local directory tree")
|
|
48
|
+
create.add_argument("name", help="Unique sidecar name")
|
|
49
|
+
create.add_argument("source", help="Existing source directory")
|
|
50
|
+
|
|
51
|
+
actions.add_parser("list", help="List managed sidecars")
|
|
52
|
+
|
|
53
|
+
show = actions.add_parser("show", help="Inspect a sidecar")
|
|
54
|
+
show.add_argument("reference", help="Sidecar name or ID")
|
|
55
|
+
|
|
56
|
+
rename = actions.add_parser("rename", help="Change a sidecar name without changing its ID")
|
|
57
|
+
rename.add_argument("reference", help="Sidecar name or ID")
|
|
58
|
+
rename.add_argument("new_name", help="New unique name")
|
|
59
|
+
|
|
60
|
+
remove = actions.add_parser("remove", help="Remove a sidecar record; source files remain in place")
|
|
61
|
+
remove.add_argument("reference", help="Sidecar name or ID")
|
|
62
|
+
|
|
63
|
+
index = actions.add_parser("index", help="Build or refresh a sidecar index")
|
|
64
|
+
index.add_argument("reference", help="Sidecar name or ID")
|
|
65
|
+
|
|
66
|
+
update = actions.add_parser("update", help="Refresh added, changed, and deleted source files")
|
|
67
|
+
update.add_argument("reference", help="Sidecar name or ID")
|
|
68
|
+
|
|
69
|
+
provider = commands.add_parser("provider", help="Configure and inspect embedding access")
|
|
70
|
+
provider_actions = provider.add_subparsers(dest="provider_action", required=True)
|
|
71
|
+
configure = provider_actions.add_parser("set", help="Configure an OpenAI-compatible embeddings route")
|
|
72
|
+
configure.add_argument("--base-url", required=True, help="Base URL ending before /embeddings")
|
|
73
|
+
configure.add_argument("--model", required=True, help="Embedding model name")
|
|
74
|
+
configure.add_argument("--api-key-env", help="Environment variable containing the API key")
|
|
75
|
+
configure.add_argument("--allow-insecure-http", action="store_true", help="Allow remote HTTP without TLS")
|
|
76
|
+
provider_actions.add_parser("show", help="Show provider settings without credential values")
|
|
77
|
+
provider_actions.add_parser("clear", help="Remove provider settings and stored embeddings")
|
|
78
|
+
|
|
79
|
+
search = commands.add_parser("search", help="Search explicitly selected sidecars; output JSON evidence")
|
|
80
|
+
search.add_argument("--sidecar", action="append", required=True, metavar="NAME_OR_ID", help="Select a sidecar; repeat to search several")
|
|
81
|
+
search.add_argument("--limit", type=int, default=10, metavar="N", help="Maximum results (1–100; default 10)")
|
|
82
|
+
search.add_argument("query", help="Words to find across indexed text, code, and PDFs")
|
|
83
|
+
|
|
84
|
+
retrieve = commands.add_parser("retrieve", help="Read current source for evidence in selected sidecars")
|
|
85
|
+
retrieve.add_argument("--sidecar", action="append", required=True, metavar="NAME_OR_ID", help="Select a sidecar; repeat to allow several")
|
|
86
|
+
retrieve.add_argument("evidence_id", help="Evidence ID returned by search")
|
|
87
|
+
|
|
88
|
+
watch = commands.add_parser("watch", help="Follow sidecar sources and reconcile changes after restart")
|
|
89
|
+
watch.add_argument("--interval", type=float, default=1.0, metavar="SECONDS", help="Source check interval (default 1 second)")
|
|
90
|
+
watch.add_argument("--reconcile-interval", type=float, default=60.0, metavar="SECONDS", help="Full reconciliation interval (default 60 seconds)")
|
|
91
|
+
watch.add_argument("--once", action="store_true", help="Reconcile all sidecars once and exit")
|
|
92
|
+
|
|
93
|
+
dashboard = commands.add_parser("dashboard", help="Open the local sidecar management dashboard")
|
|
94
|
+
dashboard.add_argument("--port", type=int, default=8765, metavar="PORT", help="Local port (default 8765; 0 chooses a free port)")
|
|
95
|
+
dashboard.add_argument("--no-open", action="store_true", help="Do not open a browser automatically")
|
|
96
|
+
|
|
97
|
+
mcp = commands.add_parser("mcp", help="Serve selected sidecars to one MCP client over stdio")
|
|
98
|
+
mcp.add_argument("--sidecar-id", action="append", default=[], metavar="ID", help="Stable sidecar ID to expose; repeat to select several")
|
|
99
|
+
return parser
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _watch_event(event: WatchEvent) -> None:
|
|
103
|
+
if event.error:
|
|
104
|
+
print(f'Watch {event.reason} for "{event.sidecar_name or "store"}": {event.error}', file=sys.stderr, flush=True)
|
|
105
|
+
return
|
|
106
|
+
assert event.report is not None
|
|
107
|
+
report = event.report
|
|
108
|
+
print(
|
|
109
|
+
f'Watch {event.reason}: "{report.sidecar.name}". {report.added} added, '
|
|
110
|
+
f'{report.changed} changed, {report.deleted} deleted; '
|
|
111
|
+
f'status {_display_status(report.sidecar.status)}.',
|
|
112
|
+
flush=True,
|
|
113
|
+
)
|
|
114
|
+
for issue in (
|
|
115
|
+
*report.skipped,
|
|
116
|
+
*([report.provider_error] if report.provider_error else []),
|
|
117
|
+
*(f"Removed source: {path}" for path in report.deleted_paths),
|
|
118
|
+
):
|
|
119
|
+
print(f'Needs attention: {issue}', file=sys.stderr, flush=True)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
123
|
+
args = _parser().parse_args(argv)
|
|
124
|
+
try:
|
|
125
|
+
store = SidecarStore()
|
|
126
|
+
if args.command == "sidecar":
|
|
127
|
+
if args.action == "create":
|
|
128
|
+
sidecar = store.create(args.name, args.source)
|
|
129
|
+
print(f'Created sidecar "{sidecar.name}".\n{_details(sidecar)}')
|
|
130
|
+
elif args.action == "list":
|
|
131
|
+
sidecars = store.list()
|
|
132
|
+
if not sidecars:
|
|
133
|
+
print("No sidecars yet. Use 'psydcar sidecar create NAME DIRECTORY' to add one.")
|
|
134
|
+
else:
|
|
135
|
+
headers = ("NAME", "ID", "SOURCE", "STATUS")
|
|
136
|
+
rows = [(item.name, item.id, str(item.source_path), _display_status(item.status)) for item in sidecars]
|
|
137
|
+
widths = [max(len(str(row[index])) for row in [headers, *rows]) for index in range(4)]
|
|
138
|
+
for row in [headers, *rows]:
|
|
139
|
+
print(" ".join(str(value).ljust(widths[index]) for index, value in enumerate(row)).rstrip())
|
|
140
|
+
elif args.action == "show":
|
|
141
|
+
sidecar = store.get(args.reference)
|
|
142
|
+
print(_details(sidecar))
|
|
143
|
+
evidence_store = EvidenceStore(store)
|
|
144
|
+
update_state = evidence_store.update_state(sidecar.id)
|
|
145
|
+
state_label = {
|
|
146
|
+
"current": "Current", "pending": "Pending", "attention": "Needs attention",
|
|
147
|
+
"not_indexed": "Not indexed",
|
|
148
|
+
}[update_state["state"]]
|
|
149
|
+
print(f"Update state: {state_label}")
|
|
150
|
+
if update_state["started_at"]:
|
|
151
|
+
print(f'Last update started: {update_state["started_at"]}')
|
|
152
|
+
print(f'Last update completed: {update_state["completed_at"] or "Pending"}')
|
|
153
|
+
print(f'Last successful update: {update_state["last_success_at"] or "None"}')
|
|
154
|
+
print(
|
|
155
|
+
f'Changes: {update_state["added"]} added, {update_state["changed"]} changed, '
|
|
156
|
+
f'{update_state["deleted"]} deleted, {update_state["unchanged"]} unchanged'
|
|
157
|
+
)
|
|
158
|
+
for issue in update_state["issues"]:
|
|
159
|
+
print(f"Needs attention: {issue}")
|
|
160
|
+
provider = ProviderStore(store)
|
|
161
|
+
state = provider.sidecar_state(sidecar.id)
|
|
162
|
+
if provider.get() is None:
|
|
163
|
+
print("Embeddings: Not configured")
|
|
164
|
+
elif state is None:
|
|
165
|
+
print("Embeddings: Pending (reindex this sidecar)")
|
|
166
|
+
else:
|
|
167
|
+
print(f'Embeddings: {state["status"].capitalize()} ({state["embedded_count"]}/{state["expected_count"]})')
|
|
168
|
+
if state["message"]:
|
|
169
|
+
print(f'Embedding note: {state["message"]}')
|
|
170
|
+
for notice in evidence_store.pdf_page_notices(sidecar.id):
|
|
171
|
+
print(f'PDF page: {notice["source"]}, page {notice["page"]}: {notice["message"]}')
|
|
172
|
+
elif args.action == "rename":
|
|
173
|
+
sidecar = store.rename(args.reference, args.new_name)
|
|
174
|
+
print(f'Renamed sidecar to "{sidecar.name}".\n{_details(sidecar)}')
|
|
175
|
+
elif args.action == "remove":
|
|
176
|
+
sidecar = store.remove(args.reference)
|
|
177
|
+
print(f'Removed sidecar "{sidecar.name}" (ID: {sidecar.id}). Source files were not changed.')
|
|
178
|
+
elif args.action in {"index", "update"}:
|
|
179
|
+
report = EvidenceStore(store).update(args.reference)
|
|
180
|
+
if args.action == "index":
|
|
181
|
+
print(f'Indexed {report.files} files and {report.passages} passages in "{report.sidecar.name}".')
|
|
182
|
+
else:
|
|
183
|
+
print(
|
|
184
|
+
f'Updated "{report.sidecar.name}": scanned {report.files} readable files; '
|
|
185
|
+
f'the index contains {report.passages} passages.'
|
|
186
|
+
)
|
|
187
|
+
print(
|
|
188
|
+
f'Changes: {report.added} added, {report.changed} changed, '
|
|
189
|
+
f'{report.deleted} deleted, {report.unchanged} unchanged.'
|
|
190
|
+
)
|
|
191
|
+
if report.embedding_status == "not_configured":
|
|
192
|
+
print("Embeddings: Not configured; lexical index is available.")
|
|
193
|
+
else:
|
|
194
|
+
print(f"Embeddings: {report.embedding_status.capitalize()} ({report.embeddings}/{report.passages}).")
|
|
195
|
+
if report.provider_error:
|
|
196
|
+
print(f"Embedding error: {report.provider_error}", file=sys.stderr)
|
|
197
|
+
for notice in report.pdf_page_notices:
|
|
198
|
+
print(f"PDF page: {notice}")
|
|
199
|
+
for reason in report.skipped:
|
|
200
|
+
print(f"Skipped: {reason}", file=sys.stderr)
|
|
201
|
+
for path in report.deleted_paths:
|
|
202
|
+
print(f"Removed source: {path}")
|
|
203
|
+
if report.skipped:
|
|
204
|
+
print("Some files could not be refreshed. Sidecar status: Index error; update needs attention.", file=sys.stderr)
|
|
205
|
+
elif report.deleted_paths:
|
|
206
|
+
print("Sidecar status: Source attention. Review removed sources in 'sidecar show'.")
|
|
207
|
+
if report.provider_error:
|
|
208
|
+
print("Embedding processing is incomplete; lexical evidence remains available.", file=sys.stderr)
|
|
209
|
+
if report.skipped or report.provider_error:
|
|
210
|
+
return 1
|
|
211
|
+
elif args.command == "provider":
|
|
212
|
+
provider = ProviderStore(store)
|
|
213
|
+
if args.provider_action == "set":
|
|
214
|
+
config = provider.set(args.base_url, args.model, args.api_key_env, args.allow_insecure_http)
|
|
215
|
+
print(f"Embedding provider configured.\nBase URL: {config.base_url}\nModel: {config.model}")
|
|
216
|
+
print(f"Credential: {config.api_key_env or 'None (gateway without authentication)'}")
|
|
217
|
+
print("Reindex sidecars to build embeddings.")
|
|
218
|
+
elif args.provider_action == "show":
|
|
219
|
+
config = provider.get()
|
|
220
|
+
if config is None:
|
|
221
|
+
print("Embedding provider: Not configured")
|
|
222
|
+
else:
|
|
223
|
+
print(f"Embedding provider: Configured\nBase URL: {config.base_url}\nModel: {config.model}")
|
|
224
|
+
if config.api_key_env:
|
|
225
|
+
print(f"Credential environment variable: {config.api_key_env}")
|
|
226
|
+
print(f"Credential available: {'Yes' if config.credential_available else 'No'}")
|
|
227
|
+
else:
|
|
228
|
+
print("Credential: None (gateway without authentication)")
|
|
229
|
+
elif args.provider_action == "clear":
|
|
230
|
+
provider.clear()
|
|
231
|
+
print("Embedding provider cleared. Stored embeddings removed; lexical evidence remains available.")
|
|
232
|
+
elif args.command == "search":
|
|
233
|
+
evidence = EvidenceStore(store)
|
|
234
|
+
results = evidence.search(args.sidecar, args.query, args.limit)
|
|
235
|
+
print(json.dumps(results, ensure_ascii=False, indent=2))
|
|
236
|
+
if evidence.last_search_warning:
|
|
237
|
+
print(f"Search notice: {evidence.last_search_warning}", file=sys.stderr)
|
|
238
|
+
elif args.command == "retrieve":
|
|
239
|
+
result = EvidenceStore(store).retrieve(args.sidecar, args.evidence_id)
|
|
240
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
241
|
+
elif args.command == "watch":
|
|
242
|
+
watcher = SidecarWatcher(store, args.interval, args.reconcile_interval)
|
|
243
|
+
if args.once:
|
|
244
|
+
events = watcher.step()
|
|
245
|
+
for event in events:
|
|
246
|
+
_watch_event(event)
|
|
247
|
+
return int(any(event.error or (event.report and (event.report.skipped or event.report.provider_error)) for event in events))
|
|
248
|
+
stop = threading.Event()
|
|
249
|
+
prior_handler = signal.getsignal(signal.SIGTERM)
|
|
250
|
+
signal.signal(signal.SIGTERM, lambda _signum, _frame: stop.set())
|
|
251
|
+
print("Watching sidecar sources. Press Ctrl-C to stop.", flush=True)
|
|
252
|
+
try:
|
|
253
|
+
watcher.run(stop, _watch_event)
|
|
254
|
+
except KeyboardInterrupt:
|
|
255
|
+
stop.set()
|
|
256
|
+
finally:
|
|
257
|
+
signal.signal(signal.SIGTERM, prior_handler)
|
|
258
|
+
elif args.command == "dashboard":
|
|
259
|
+
run_dashboard(store, args.port, not args.no_open)
|
|
260
|
+
elif args.command == "mcp":
|
|
261
|
+
run_mcp(store, args.sidecar_id)
|
|
262
|
+
except StoreError as exc:
|
|
263
|
+
print(f"psydcar: {exc}", file=sys.stderr)
|
|
264
|
+
return 2
|
|
265
|
+
return 0
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
if __name__ == "__main__":
|
|
269
|
+
raise SystemExit(main())
|