subactor-shell 0.2.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.
- subactor_shell/__init__.py +3 -0
- subactor_shell/__main__.py +4 -0
- subactor_shell/acp_agent.py +363 -0
- subactor_shell/app.py +482 -0
- subactor_shell/artifacts.py +198 -0
- subactor_shell/catalog.py +416 -0
- subactor_shell/chat.py +517 -0
- subactor_shell/compiler.py +191 -0
- subactor_shell/config.py +374 -0
- subactor_shell/connectors.py +503 -0
- subactor_shell/context_builder.py +153 -0
- subactor_shell/control.py +141 -0
- subactor_shell/control_env.py +93 -0
- subactor_shell/intent_ir.py +187 -0
- subactor_shell/models.py +78 -0
- subactor_shell/operations.py +287 -0
- subactor_shell/orchestration.py +324 -0
- subactor_shell/policy.py +38 -0
- subactor_shell/providers/__init__.py +63 -0
- subactor_shell/providers/anthropic.py +101 -0
- subactor_shell/providers/base.py +81 -0
- subactor_shell/providers/mock.py +32 -0
- subactor_shell/providers/openai_compat.py +303 -0
- subactor_shell/providers/subactor_control.py +191 -0
- subactor_shell/redaction.py +62 -0
- subactor_shell/repl.py +480 -0
- subactor_shell/routing.py +334 -0
- subactor_shell/secret_refs.py +82 -0
- subactor_shell/store.py +857 -0
- subactor_shell/terminal.py +79 -0
- subactor_shell/token_budget.py +46 -0
- subactor_shell/vault.py +170 -0
- subactor_shell-0.2.2.dist-info/METADATA +449 -0
- subactor_shell-0.2.2.dist-info/RECORD +38 -0
- subactor_shell-0.2.2.dist-info/WHEEL +5 -0
- subactor_shell-0.2.2.dist-info/entry_points.txt +2 -0
- subactor_shell-0.2.2.dist-info/licenses/LICENSE +13 -0
- subactor_shell-0.2.2.dist-info/top_level.txt +1 -0
subactor_shell/app.py
ADDED
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import asyncio
|
|
5
|
+
import getpass
|
|
6
|
+
import json
|
|
7
|
+
import stat
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
|
|
14
|
+
from . import __version__
|
|
15
|
+
from .acp_agent import AcpAgent
|
|
16
|
+
from .chat import ChatService
|
|
17
|
+
from .compiler import ExecutionPlan
|
|
18
|
+
from .config import initialize_layout, load_config
|
|
19
|
+
from .control import SubactorControlClient
|
|
20
|
+
from .control_env import apply_control_environment
|
|
21
|
+
from .operations import OperationSettings, OperationsClient, run_operational_command
|
|
22
|
+
from .repl import ShellRepl
|
|
23
|
+
from .store import Store
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
27
|
+
parser = argparse.ArgumentParser(
|
|
28
|
+
prog="subactor-shell",
|
|
29
|
+
description="Token-aware, bezpieczna i trwała rozmowa w shellu dla Subactor.",
|
|
30
|
+
)
|
|
31
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
32
|
+
parser.add_argument("--config", type=Path, default=None, help="ścieżka config.toml")
|
|
33
|
+
parser.add_argument("--data-dir", type=Path, default=None, help="katalog danych i SQLite")
|
|
34
|
+
sub = parser.add_subparsers(dest="command")
|
|
35
|
+
|
|
36
|
+
sub.add_parser("init", help="utwórz prywatny config i katalog danych")
|
|
37
|
+
|
|
38
|
+
chat = sub.add_parser("chat", help="uruchom interaktywny REPL")
|
|
39
|
+
chat.add_argument("--session", help="ID istniejącej sesji")
|
|
40
|
+
chat.add_argument("--provider", help="profil providera dla nowej sesji")
|
|
41
|
+
chat.add_argument("--model", help="model dla nowej sesji")
|
|
42
|
+
chat.add_argument("--name", default="Rozmowa shell", help="nazwa nowej sesji")
|
|
43
|
+
|
|
44
|
+
one = sub.add_parser("one", help="wyślij jedną wiadomość")
|
|
45
|
+
one.add_argument("message")
|
|
46
|
+
one.add_argument("--session", help="ID istniejącej sesji")
|
|
47
|
+
one.add_argument("--provider", help="profil providera dla nowej sesji")
|
|
48
|
+
one.add_argument("--model", help="model dla nowej sesji")
|
|
49
|
+
one.add_argument("--attach", action="append", type=Path, default=[])
|
|
50
|
+
one.add_argument("--grant", action="append", default=[], help="jednorazowy grant aliasu sekretu")
|
|
51
|
+
|
|
52
|
+
data = sub.add_parser("data", help="zapisuj jawne dane tekstowe i pliki")
|
|
53
|
+
data_sub = data.add_subparsers(dest="data_command", required=True)
|
|
54
|
+
data_set = data_sub.add_parser("set", help="zapisz jawne dane tekstowe")
|
|
55
|
+
data_set.add_argument("name")
|
|
56
|
+
data_set.add_argument("value")
|
|
57
|
+
data_put = data_sub.add_parser("put", help="zapisz plik jako artefakt")
|
|
58
|
+
data_put.add_argument("name")
|
|
59
|
+
data_put.add_argument("path", type=Path)
|
|
60
|
+
data_put.add_argument("--session", help="sesja audytowa; domyślnie tworzona automatycznie")
|
|
61
|
+
data_sub.add_parser("list", help="lista danych")
|
|
62
|
+
data_delete = data_sub.add_parser("delete", help="usuń dane")
|
|
63
|
+
data_delete.add_argument("name")
|
|
64
|
+
|
|
65
|
+
vault = sub.add_parser("vault", help="bindingi i zapis sekretów Vault")
|
|
66
|
+
vault_sub = vault.add_subparsers(dest="vault_command", required=True)
|
|
67
|
+
vault_bind = vault_sub.add_parser("bind", help="zapisz wyłącznie referencję sekretu")
|
|
68
|
+
vault_bind.add_argument("alias")
|
|
69
|
+
vault_bind.add_argument("reference")
|
|
70
|
+
vault_put = vault_sub.add_parser("put", help="zapisz wartość bez echa do KV v2")
|
|
71
|
+
vault_put.add_argument("alias")
|
|
72
|
+
vault_put.add_argument("reference")
|
|
73
|
+
vault_sub.add_parser("list", help="lista aliasów i referencji")
|
|
74
|
+
vault_unbind = vault_sub.add_parser("unbind", help="usuń binding")
|
|
75
|
+
vault_unbind.add_argument("alias")
|
|
76
|
+
vault_wrap = vault_sub.add_parser("wrap", help="utwórz Vault response-wrapping token")
|
|
77
|
+
vault_wrap.add_argument("alias")
|
|
78
|
+
vault_wrap.add_argument("--ttl", default="5m")
|
|
79
|
+
|
|
80
|
+
sessions = sub.add_parser("sessions", help="lista zapisanych sesji")
|
|
81
|
+
sessions.add_argument("--json", action="store_true")
|
|
82
|
+
|
|
83
|
+
export = sub.add_parser("export", help="eksport sesji do JSON")
|
|
84
|
+
export.add_argument("session")
|
|
85
|
+
export.add_argument("output", type=Path)
|
|
86
|
+
|
|
87
|
+
plans = sub.add_parser("plans", help="przeglądaj i stosuj skompilowane plany")
|
|
88
|
+
plans_sub = plans.add_subparsers(dest="plans_command", required=True)
|
|
89
|
+
plans_list = plans_sub.add_parser("list")
|
|
90
|
+
plans_list.add_argument("--session")
|
|
91
|
+
plans_list.add_argument("--json", action="store_true")
|
|
92
|
+
plans_show = plans_sub.add_parser("show")
|
|
93
|
+
plans_show.add_argument("plan_id")
|
|
94
|
+
plans_apply = plans_sub.add_parser("apply")
|
|
95
|
+
plans_apply.add_argument("plan_id")
|
|
96
|
+
plans_apply.add_argument("--confirm", default="", help="dla zmian stanu: dokładnie EXECUTE")
|
|
97
|
+
plans_remote = plans_sub.add_parser("remote", help="plany operacyjne z Subactor Control")
|
|
98
|
+
plans_remote.add_argument("--status", default="")
|
|
99
|
+
|
|
100
|
+
status = sub.add_parser("status", help="status autonomii z Subactor Control")
|
|
101
|
+
status.add_argument("--json", action="store_true")
|
|
102
|
+
tickets = sub.add_parser("tickets", help="lista ticketów Planfile")
|
|
103
|
+
tickets.add_argument("--open", action="store_true")
|
|
104
|
+
tickets.add_argument("--urgent", action="store_true")
|
|
105
|
+
tickets.add_argument("--queue", default="")
|
|
106
|
+
tickets.add_argument("--state", default="")
|
|
107
|
+
tickets.add_argument("--priority", default="")
|
|
108
|
+
tickets.add_argument("--project", default="")
|
|
109
|
+
tickets.add_argument("--text", default="")
|
|
110
|
+
tickets.add_argument("--json", action="store_true")
|
|
111
|
+
sub.add_parser("health", help="publiczny health Subactor Control")
|
|
112
|
+
dispatch = sub.add_parser("dispatch", help="rozdysponuj pracę przez Control")
|
|
113
|
+
dispatch.add_argument("--confirm", default="")
|
|
114
|
+
uri = sub.add_parser("uri", help="uruchom kontrolowany proces URI")
|
|
115
|
+
uri.add_argument("uri")
|
|
116
|
+
uri.add_argument("payload", nargs="?")
|
|
117
|
+
uri.add_argument("--confirm", default="")
|
|
118
|
+
api = sub.add_parser("api", help="ograniczone wywołanie API tego samego Control origin")
|
|
119
|
+
api.add_argument("method")
|
|
120
|
+
api.add_argument("path")
|
|
121
|
+
api.add_argument("payload", nargs="?")
|
|
122
|
+
api.add_argument("--confirm", default="")
|
|
123
|
+
get = sub.add_parser("get", help="GET z Subactor Control")
|
|
124
|
+
get.add_argument("path")
|
|
125
|
+
post = sub.add_parser("post", help="POST do Subactor Control")
|
|
126
|
+
post.add_argument("path")
|
|
127
|
+
post.add_argument("payload", nargs="?", default="{}")
|
|
128
|
+
post.add_argument("--confirm", default="")
|
|
129
|
+
sub.add_parser("endpoints", help="katalog operacyjnego API")
|
|
130
|
+
|
|
131
|
+
receipts = sub.add_parser("receipts", help="przeglądaj krótkie receipts wykonania")
|
|
132
|
+
receipts_sub = receipts.add_subparsers(dest="receipts_command", required=True)
|
|
133
|
+
receipts_list = receipts_sub.add_parser("list")
|
|
134
|
+
receipts_list.add_argument("--session")
|
|
135
|
+
receipts_list.add_argument("--json", action="store_true")
|
|
136
|
+
receipts_show = receipts_sub.add_parser("show")
|
|
137
|
+
receipts_show.add_argument("receipt_id")
|
|
138
|
+
|
|
139
|
+
metrics = sub.add_parser("metrics", help="zużycie tokenów, koszt i udział tras lokalnych")
|
|
140
|
+
metrics.add_argument("--session")
|
|
141
|
+
metrics.add_argument("--json", action="store_true")
|
|
142
|
+
|
|
143
|
+
catalog = sub.add_parser("catalog", help="lokalny katalog intentów")
|
|
144
|
+
catalog.add_argument("--json", action="store_true")
|
|
145
|
+
|
|
146
|
+
connectors = sub.add_parser("connectors", help="allowlista nazwanych connectorów")
|
|
147
|
+
connectors.add_argument("--json", action="store_true")
|
|
148
|
+
|
|
149
|
+
sub.add_parser("doctor", help="diagnostyka bez ujawniania sekretów")
|
|
150
|
+
sub.add_parser("acp-agent", help="uruchom agenta ACP v1 po stdio")
|
|
151
|
+
return parser
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _build_services(args: argparse.Namespace, *, create: bool = True):
|
|
155
|
+
config = load_config(args.config, args.data_dir, create=create)
|
|
156
|
+
store = Store(config.data_dir / "subactor-shell.sqlite3")
|
|
157
|
+
chat = ChatService(config, store)
|
|
158
|
+
return config, store, chat
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _mode(path: Path) -> str:
|
|
162
|
+
try:
|
|
163
|
+
return oct(stat.S_IMODE(path.stat().st_mode))
|
|
164
|
+
except OSError:
|
|
165
|
+
return "brak"
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
async def _one(chat: ChatService, args: argparse.Namespace, console: Console) -> int:
|
|
169
|
+
session = chat.get_or_create_session(args.session, provider=args.provider, model=args.model)
|
|
170
|
+
for alias in args.grant:
|
|
171
|
+
chat.grant_secret(alias)
|
|
172
|
+
async for chunk in chat.stream_message(session.id, args.message, attachment_paths=args.attach):
|
|
173
|
+
console.print(chunk, end="", markup=False, soft_wrap=True)
|
|
174
|
+
console.print()
|
|
175
|
+
console.print(f"[dim]session={session.id}[/dim]")
|
|
176
|
+
route = chat.store.last_routing_decision(session.id)
|
|
177
|
+
if route and bool(chat.config.orchestration.get("show_route", False)):
|
|
178
|
+
console.print(
|
|
179
|
+
f"[dim]route={route['route']} intent={route['intent_id']} confidence={route['confidence']:.3f}[/dim]"
|
|
180
|
+
)
|
|
181
|
+
return 0
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _doctor(config, store: Store, chat: ChatService, console: Console) -> int:
|
|
185
|
+
table = Table("Test", "Wynik", "Szczegóły")
|
|
186
|
+
failures = 0
|
|
187
|
+
|
|
188
|
+
def add(name: str, ok: bool, details: str) -> None:
|
|
189
|
+
nonlocal failures
|
|
190
|
+
if not ok:
|
|
191
|
+
failures += 1
|
|
192
|
+
table.add_row(name, "OK" if ok else "BŁĄD", details)
|
|
193
|
+
|
|
194
|
+
add("config", config.config_path.exists(), f"{config.config_path} mode={_mode(config.config_path)}")
|
|
195
|
+
add("data dir", config.data_dir.exists(), f"{config.data_dir} mode={_mode(config.data_dir)}")
|
|
196
|
+
add("SQLite", store.db_path.exists(), f"{store.db_path} mode={_mode(store.db_path)}")
|
|
197
|
+
try:
|
|
198
|
+
names = config.provider_names()
|
|
199
|
+
for name in names:
|
|
200
|
+
config.provider(name)
|
|
201
|
+
add("providers", bool(names), ", ".join(names) or "brak")
|
|
202
|
+
except Exception as exc:
|
|
203
|
+
add("providers", False, str(exc))
|
|
204
|
+
|
|
205
|
+
orch = chat.orchestration
|
|
206
|
+
add(
|
|
207
|
+
"orchestration",
|
|
208
|
+
orch.mode in {"active", "shadow", "off"},
|
|
209
|
+
f"mode={orch.mode}; intents={len(orch.catalog.list())}; connectors={len(orch.registry.list())}",
|
|
210
|
+
)
|
|
211
|
+
add(
|
|
212
|
+
"context budget",
|
|
213
|
+
True,
|
|
214
|
+
(
|
|
215
|
+
f"recent={chat.context_builder.recent_messages}; "
|
|
216
|
+
f"history_chars={chat.context_builder.max_history_chars}; "
|
|
217
|
+
f"message_chars={chat.context_builder.max_message_chars}"
|
|
218
|
+
),
|
|
219
|
+
)
|
|
220
|
+
add("intent catalog", True, orch.catalog.fingerprint[:16])
|
|
221
|
+
add("connector registry", True, orch.registry.fingerprint[:16])
|
|
222
|
+
|
|
223
|
+
vault_ok, vault_details = chat.resolver.vault.health()
|
|
224
|
+
add("Vault HTTP", vault_ok, vault_details)
|
|
225
|
+
|
|
226
|
+
try:
|
|
227
|
+
operations = OperationsClient(OperationSettings.from_environment())
|
|
228
|
+
health, _ = operations.request("GET", "/health", authenticated=False)
|
|
229
|
+
control_ok = isinstance(health, dict) and health.get("ok") is True
|
|
230
|
+
add("Subactor Control", control_ok, "ok=true" if control_ok else "nieprawidłowy health payload")
|
|
231
|
+
except Exception as exc:
|
|
232
|
+
add("Subactor Control", False, str(exc))
|
|
233
|
+
|
|
234
|
+
try:
|
|
235
|
+
control = SubactorControlClient(config.control, chat.resolver)
|
|
236
|
+
names = [str(item.get("name")) for item in control.list_tools(strict=True)]
|
|
237
|
+
add("MCP boundary", True, ", ".join(sorted(names)))
|
|
238
|
+
except Exception as exc:
|
|
239
|
+
add("MCP boundary", False, str(exc))
|
|
240
|
+
|
|
241
|
+
console.print(table)
|
|
242
|
+
console.print("Diagnostyka nie odczytuje ani nie drukuje wartości sekretów.")
|
|
243
|
+
return 1 if failures else 0
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _data_command(chat: ChatService, args: argparse.Namespace, console: Console) -> int:
|
|
247
|
+
action = args.data_command
|
|
248
|
+
if action == "set":
|
|
249
|
+
chat.set_data_text(args.name, args.value)
|
|
250
|
+
console.print(f"Zapisano dane [cyan]{args.name}[/cyan].")
|
|
251
|
+
elif action == "put":
|
|
252
|
+
session = chat.get_or_create_session(args.session) if args.session else chat.new_session(name="Import danych")
|
|
253
|
+
artifact = chat.set_data_file(args.name, args.path, session.id)
|
|
254
|
+
console.print(f"Zapisano [cyan]{args.name}[/cyan] jako sha256:{artifact.id}; session={session.id}")
|
|
255
|
+
elif action == "list":
|
|
256
|
+
table = Table("Nazwa", "Typ", "Wartość/ID")
|
|
257
|
+
for name, kind, value in chat.store.list_data():
|
|
258
|
+
shown = value if kind == "artifact" else f"{len(value)} znaków"
|
|
259
|
+
table.add_row(name, kind, shown)
|
|
260
|
+
console.print(table)
|
|
261
|
+
elif action == "delete":
|
|
262
|
+
removed = chat.store.delete_data(args.name)
|
|
263
|
+
console.print("Usunięto." if removed else "Nie znaleziono.")
|
|
264
|
+
return 0 if removed else 1
|
|
265
|
+
return 0
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _vault_command(chat: ChatService, args: argparse.Namespace, console: Console) -> int:
|
|
269
|
+
action = args.vault_command
|
|
270
|
+
if action == "bind":
|
|
271
|
+
chat.bind_secret(args.alias, args.reference)
|
|
272
|
+
console.print(f"Binding [cyan]{args.alias}[/cyan] zapisany; wartość nie została odczytana.")
|
|
273
|
+
elif action == "put":
|
|
274
|
+
if not args.reference.startswith("vault://"):
|
|
275
|
+
raise ValueError("vault put wymaga referencji vault://")
|
|
276
|
+
value = getpass.getpass("Wartość sekretu (bez echa): ")
|
|
277
|
+
if not value:
|
|
278
|
+
raise ValueError("Pusta wartość sekretu")
|
|
279
|
+
chat.resolver.vault.write_field(args.reference, value)
|
|
280
|
+
chat.bind_secret(args.alias, args.reference)
|
|
281
|
+
console.print(f"Zapisano sekret i binding [cyan]{args.alias}[/cyan].")
|
|
282
|
+
elif action == "list":
|
|
283
|
+
table = Table("Alias", "Referencja")
|
|
284
|
+
for alias, reference in chat.store.list_secret_bindings():
|
|
285
|
+
table.add_row(alias, reference)
|
|
286
|
+
console.print(table)
|
|
287
|
+
elif action == "unbind":
|
|
288
|
+
removed = chat.store.unbind_secret(args.alias)
|
|
289
|
+
console.print("Usunięto binding." if removed else "Nie znaleziono bindingu.")
|
|
290
|
+
return 0 if removed else 1
|
|
291
|
+
elif action == "wrap":
|
|
292
|
+
reference = chat.store.get_secret_binding(args.alias)
|
|
293
|
+
if not reference:
|
|
294
|
+
raise ValueError(f"Brak bindingu {args.alias}")
|
|
295
|
+
if not reference.startswith("vault://"):
|
|
296
|
+
raise ValueError("Response wrapping działa tylko dla vault://")
|
|
297
|
+
token = chat.resolver.vault.wrap_read(reference, args.ttl)
|
|
298
|
+
console.print(token, markup=False)
|
|
299
|
+
return 0
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _print_plans(store: Store, args: argparse.Namespace, console: Console) -> int:
|
|
303
|
+
if args.plans_command == "show":
|
|
304
|
+
payload = store.get_execution_plan(args.plan_id)
|
|
305
|
+
if not payload:
|
|
306
|
+
raise ValueError(f"Nie ma planu {args.plan_id}")
|
|
307
|
+
console.print_json(json.dumps(payload, ensure_ascii=False))
|
|
308
|
+
return 0
|
|
309
|
+
if args.plans_command == "list":
|
|
310
|
+
plans = store.list_execution_plans(args.session)
|
|
311
|
+
if args.json:
|
|
312
|
+
console.print_json(json.dumps(plans, ensure_ascii=False))
|
|
313
|
+
else:
|
|
314
|
+
table = Table("ID", "Session", "Intent", "Effect", "Status", "Utworzono")
|
|
315
|
+
for item in plans:
|
|
316
|
+
table.add_row(
|
|
317
|
+
str(item.get("id", "")),
|
|
318
|
+
str(item.get("session_id", "")),
|
|
319
|
+
str(item.get("intent_id", "")),
|
|
320
|
+
str(item.get("effect", "")),
|
|
321
|
+
str(item.get("status", "")),
|
|
322
|
+
str(item.get("created_at", "")),
|
|
323
|
+
)
|
|
324
|
+
console.print(table)
|
|
325
|
+
return 0
|
|
326
|
+
return 0
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
async def _apply_plan(chat: ChatService, args: argparse.Namespace, console: Console) -> int:
|
|
330
|
+
payload = chat.store.get_execution_plan(args.plan_id)
|
|
331
|
+
if not payload:
|
|
332
|
+
raise ValueError(f"Nie ma planu {args.plan_id}")
|
|
333
|
+
plan = ExecutionPlan.from_dict(payload)
|
|
334
|
+
confirmation = args.confirm
|
|
335
|
+
if plan.effect != "read" and not confirmation and sys.stdin.isatty():
|
|
336
|
+
confirmation = input("Wpisz dokładnie EXECUTE, aby zastosować plan: ")
|
|
337
|
+
receipt = await chat.orchestration.apply_plan(args.plan_id, confirmation=confirmation)
|
|
338
|
+
console.print(chat.orchestration.format_receipt(receipt), markup=False)
|
|
339
|
+
return 0 if receipt.ok else 1
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _receipts_command(store: Store, args: argparse.Namespace, console: Console) -> int:
|
|
343
|
+
if args.receipts_command == "show":
|
|
344
|
+
payload = store.get_execution_receipt(args.receipt_id)
|
|
345
|
+
if not payload:
|
|
346
|
+
raise ValueError(f"Nie ma receiptu {args.receipt_id}")
|
|
347
|
+
console.print_json(json.dumps(payload, ensure_ascii=False))
|
|
348
|
+
return 0
|
|
349
|
+
receipts = store.list_execution_receipts(args.session)
|
|
350
|
+
if args.json:
|
|
351
|
+
console.print_json(json.dumps(receipts, ensure_ascii=False))
|
|
352
|
+
else:
|
|
353
|
+
table = Table("ID", "Plan", "Session", "OK", "Utworzono")
|
|
354
|
+
for item in receipts:
|
|
355
|
+
table.add_row(
|
|
356
|
+
str(item.get("id", "")),
|
|
357
|
+
str(item.get("plan_id", "")),
|
|
358
|
+
str(item.get("session_id", "")),
|
|
359
|
+
str(bool(item.get("ok"))),
|
|
360
|
+
str(item.get("created_at", "")),
|
|
361
|
+
)
|
|
362
|
+
console.print(table)
|
|
363
|
+
return 0
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def main(argv: list[str] | None = None) -> None:
|
|
367
|
+
parser = build_parser()
|
|
368
|
+
args = parser.parse_args(argv)
|
|
369
|
+
console = Console()
|
|
370
|
+
command = args.command or "chat"
|
|
371
|
+
if args.command is None:
|
|
372
|
+
args.session = None
|
|
373
|
+
args.provider = None
|
|
374
|
+
args.model = None
|
|
375
|
+
args.name = "Rozmowa shell"
|
|
376
|
+
try:
|
|
377
|
+
if command == "init":
|
|
378
|
+
config_path, data_dir = initialize_layout(args.config, args.data_dir)
|
|
379
|
+
console.print(f"Config: [cyan]{config_path}[/cyan] mode={_mode(config_path)}")
|
|
380
|
+
console.print(f"Dane: [cyan]{data_dir}[/cyan] mode={_mode(data_dir)}")
|
|
381
|
+
return
|
|
382
|
+
|
|
383
|
+
apply_control_environment()
|
|
384
|
+
|
|
385
|
+
operational = {"status", "tickets", "health", "dispatch", "uri", "api", "get", "post", "endpoints"}
|
|
386
|
+
if command in operational or (command == "plans" and args.plans_command == "remote"):
|
|
387
|
+
client = OperationsClient(OperationSettings.from_environment())
|
|
388
|
+
raise SystemExit(run_operational_command(args, console, client))
|
|
389
|
+
|
|
390
|
+
config, store, chat = _build_services(args)
|
|
391
|
+
if command == "chat":
|
|
392
|
+
session = chat.get_or_create_session(args.session) if args.session else chat.new_session(
|
|
393
|
+
name=args.name, provider=args.provider, model=args.model
|
|
394
|
+
)
|
|
395
|
+
asyncio.run(ShellRepl(chat, session, console).run())
|
|
396
|
+
elif command == "one":
|
|
397
|
+
raise SystemExit(asyncio.run(_one(chat, args, console)))
|
|
398
|
+
elif command == "data":
|
|
399
|
+
raise SystemExit(_data_command(chat, args, console))
|
|
400
|
+
elif command == "vault":
|
|
401
|
+
raise SystemExit(_vault_command(chat, args, console))
|
|
402
|
+
elif command == "sessions":
|
|
403
|
+
sessions = store.list_sessions()
|
|
404
|
+
if args.json:
|
|
405
|
+
console.print_json(
|
|
406
|
+
json.dumps(
|
|
407
|
+
[
|
|
408
|
+
{
|
|
409
|
+
"id": item.id,
|
|
410
|
+
"name": item.name,
|
|
411
|
+
"provider": item.provider,
|
|
412
|
+
"model": item.model,
|
|
413
|
+
"created_at": item.created_at,
|
|
414
|
+
"updated_at": item.updated_at,
|
|
415
|
+
}
|
|
416
|
+
for item in sessions
|
|
417
|
+
],
|
|
418
|
+
ensure_ascii=False,
|
|
419
|
+
)
|
|
420
|
+
)
|
|
421
|
+
else:
|
|
422
|
+
table = Table("ID", "Nazwa", "Provider", "Model", "Aktualizacja")
|
|
423
|
+
for item in sessions:
|
|
424
|
+
table.add_row(item.id, item.name, item.provider, item.model, item.updated_at)
|
|
425
|
+
console.print(table)
|
|
426
|
+
elif command == "export":
|
|
427
|
+
payload = store.export_session(args.session)
|
|
428
|
+
output = args.output.expanduser()
|
|
429
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
430
|
+
output.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
431
|
+
console.print(f"Zapisano {output}")
|
|
432
|
+
elif command == "plans":
|
|
433
|
+
if args.plans_command == "apply":
|
|
434
|
+
raise SystemExit(asyncio.run(_apply_plan(chat, args, console)))
|
|
435
|
+
raise SystemExit(_print_plans(store, args, console))
|
|
436
|
+
elif command == "receipts":
|
|
437
|
+
raise SystemExit(_receipts_command(store, args, console))
|
|
438
|
+
elif command == "metrics":
|
|
439
|
+
payload = store.usage_summary(args.session)
|
|
440
|
+
if args.json:
|
|
441
|
+
console.print_json(json.dumps(payload, ensure_ascii=False))
|
|
442
|
+
else:
|
|
443
|
+
console.print_json(json.dumps(payload, ensure_ascii=False))
|
|
444
|
+
elif command == "catalog":
|
|
445
|
+
payload = [item.to_summary() for item in chat.orchestration.catalog.list()]
|
|
446
|
+
if args.json:
|
|
447
|
+
console.print_json(json.dumps(payload, ensure_ascii=False))
|
|
448
|
+
else:
|
|
449
|
+
table = Table("Intent", "Execution", "Risk", "Źródło")
|
|
450
|
+
for item in chat.orchestration.catalog.list():
|
|
451
|
+
table.add_row(
|
|
452
|
+
item.id,
|
|
453
|
+
str(item.execution.get("kind", "chat")),
|
|
454
|
+
item.risk,
|
|
455
|
+
item.source,
|
|
456
|
+
)
|
|
457
|
+
console.print(table)
|
|
458
|
+
elif command == "connectors":
|
|
459
|
+
payload = [item.public_dict() for item in chat.orchestration.registry.list()]
|
|
460
|
+
if args.json:
|
|
461
|
+
console.print_json(json.dumps(payload, ensure_ascii=False))
|
|
462
|
+
else:
|
|
463
|
+
table = Table("Nazwa", "Kind", "Effect", "Operations")
|
|
464
|
+
for item in chat.orchestration.registry.list():
|
|
465
|
+
table.add_row(item.name, item.kind, item.effect, ", ".join(item.allowed_operations))
|
|
466
|
+
console.print(table)
|
|
467
|
+
elif command == "doctor":
|
|
468
|
+
raise SystemExit(_doctor(config, store, chat, console))
|
|
469
|
+
elif command == "acp-agent":
|
|
470
|
+
asyncio.run(AcpAgent(chat).run_stdio())
|
|
471
|
+
else:
|
|
472
|
+
parser.error(f"Nieznana komenda: {command}")
|
|
473
|
+
except KeyboardInterrupt:
|
|
474
|
+
Console(stderr=True).print("\nAnulowano.")
|
|
475
|
+
raise SystemExit(130)
|
|
476
|
+
except Exception as exc:
|
|
477
|
+
Console(stderr=True).print(f"[red]Błąd:[/red] {exc}")
|
|
478
|
+
raise SystemExit(1)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
if __name__ == "__main__":
|
|
482
|
+
main()
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import mimetypes
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import stat
|
|
8
|
+
import tempfile
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from .config import ensure_private_dir, ensure_private_file
|
|
12
|
+
from .models import Artifact, utc_now
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ArtifactError(RuntimeError):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
_WORD_RE = re.compile(r"[\w.-]+", re.UNICODE)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _tokens(value: str) -> set[str]:
|
|
23
|
+
return {item.casefold() for item in _WORD_RE.findall(value) if len(item) > 1}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def select_relevant_text(
|
|
27
|
+
content: str,
|
|
28
|
+
query: str,
|
|
29
|
+
*,
|
|
30
|
+
max_chars: int,
|
|
31
|
+
chunk_chars: int = 1800,
|
|
32
|
+
max_chunks: int = 4,
|
|
33
|
+
) -> tuple[str, bool]:
|
|
34
|
+
"""Select lexical chunks locally instead of sending a whole artifact.
|
|
35
|
+
|
|
36
|
+
This intentionally uses a cheap deterministic algorithm. It avoids an
|
|
37
|
+
embedding dependency and keeps private source text local until a concrete
|
|
38
|
+
query selects a bounded subset.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
max_chars = max(256, int(max_chars))
|
|
42
|
+
chunk_chars = max(256, int(chunk_chars))
|
|
43
|
+
max_chunks = max(1, int(max_chunks))
|
|
44
|
+
if len(content) <= max_chars:
|
|
45
|
+
return content, False
|
|
46
|
+
|
|
47
|
+
query_tokens = _tokens(query)
|
|
48
|
+
overlap = max(96, chunk_chars // 6)
|
|
49
|
+
step = max(1, chunk_chars - overlap)
|
|
50
|
+
chunks: list[tuple[int, str]] = []
|
|
51
|
+
for start in range(0, len(content), step):
|
|
52
|
+
chunk = content[start : start + chunk_chars]
|
|
53
|
+
if not chunk:
|
|
54
|
+
break
|
|
55
|
+
chunks.append((start, chunk))
|
|
56
|
+
if start + chunk_chars >= len(content):
|
|
57
|
+
break
|
|
58
|
+
|
|
59
|
+
if not query_tokens:
|
|
60
|
+
selected = chunks[:max_chunks]
|
|
61
|
+
else:
|
|
62
|
+
ranked: list[tuple[float, int, str]] = []
|
|
63
|
+
for start, chunk in chunks:
|
|
64
|
+
chunk_tokens = _tokens(chunk)
|
|
65
|
+
shared = query_tokens & chunk_tokens
|
|
66
|
+
coverage = len(shared) / max(1, len(query_tokens))
|
|
67
|
+
density = len(shared) / max(1, len(chunk_tokens))
|
|
68
|
+
early_bonus = 0.01 / (1 + start)
|
|
69
|
+
ranked.append((0.82 * coverage + 0.18 * density + early_bonus, start, chunk))
|
|
70
|
+
ranked.sort(key=lambda item: (-item[0], item[1]))
|
|
71
|
+
useful = [item for item in ranked if item[0] > 0]
|
|
72
|
+
selected = [(start, chunk) for _, start, chunk in (useful or ranked)[:max_chunks]]
|
|
73
|
+
selected.sort(key=lambda item: item[0])
|
|
74
|
+
|
|
75
|
+
rendered: list[str] = []
|
|
76
|
+
used = 0
|
|
77
|
+
for start, chunk in selected:
|
|
78
|
+
prefix = f"[fragment offset={start}]\n"
|
|
79
|
+
remaining = max_chars - used
|
|
80
|
+
if remaining <= len(prefix):
|
|
81
|
+
break
|
|
82
|
+
body = chunk[: remaining - len(prefix)]
|
|
83
|
+
rendered.append(prefix + body)
|
|
84
|
+
used += len(prefix) + len(body)
|
|
85
|
+
if used >= max_chars:
|
|
86
|
+
break
|
|
87
|
+
return "\n\n".join(rendered), True
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class ArtifactManager:
|
|
91
|
+
def __init__(self, root: Path, *, max_bytes: int, max_text_chars: int):
|
|
92
|
+
self.root = ensure_private_dir(root.expanduser())
|
|
93
|
+
self.max_bytes = max_bytes
|
|
94
|
+
self.max_text_chars = max_text_chars
|
|
95
|
+
|
|
96
|
+
def import_file(self, source: Path) -> Artifact:
|
|
97
|
+
source = source.expanduser()
|
|
98
|
+
flags = os.O_RDONLY
|
|
99
|
+
if hasattr(os, "O_NOFOLLOW"):
|
|
100
|
+
flags |= os.O_NOFOLLOW
|
|
101
|
+
try:
|
|
102
|
+
descriptor = os.open(source, flags)
|
|
103
|
+
except OSError as exc:
|
|
104
|
+
raise ArtifactError(f"Nie można otworzyć pliku: {source}") from exc
|
|
105
|
+
temp_path: Path | None = None
|
|
106
|
+
try:
|
|
107
|
+
info = os.fstat(descriptor)
|
|
108
|
+
if not stat.S_ISREG(info.st_mode):
|
|
109
|
+
raise ArtifactError("Załącznik musi być zwykłym plikiem")
|
|
110
|
+
if info.st_size > self.max_bytes:
|
|
111
|
+
raise ArtifactError(
|
|
112
|
+
f"Plik ma {info.st_size} B; limit wynosi {self.max_bytes} B"
|
|
113
|
+
)
|
|
114
|
+
digest = hashlib.sha256()
|
|
115
|
+
with os.fdopen(descriptor, "rb", closefd=True) as source_handle:
|
|
116
|
+
descriptor = -1
|
|
117
|
+
with tempfile.NamedTemporaryFile(
|
|
118
|
+
dir=self.root, prefix=".incoming-", delete=False
|
|
119
|
+
) as target:
|
|
120
|
+
temp_path = Path(target.name)
|
|
121
|
+
while True:
|
|
122
|
+
chunk = source_handle.read(1024 * 1024)
|
|
123
|
+
if not chunk:
|
|
124
|
+
break
|
|
125
|
+
digest.update(chunk)
|
|
126
|
+
target.write(chunk)
|
|
127
|
+
artifact_id = digest.hexdigest()
|
|
128
|
+
destination = self.root / artifact_id[:2] / artifact_id
|
|
129
|
+
ensure_private_dir(destination.parent)
|
|
130
|
+
if destination.exists():
|
|
131
|
+
assert temp_path is not None
|
|
132
|
+
temp_path.unlink(missing_ok=True)
|
|
133
|
+
else:
|
|
134
|
+
assert temp_path is not None
|
|
135
|
+
os.replace(temp_path, destination)
|
|
136
|
+
ensure_private_file(destination)
|
|
137
|
+
mime_type = mimetypes.guess_type(source.name)[0] or "application/octet-stream"
|
|
138
|
+
return Artifact(
|
|
139
|
+
id=artifact_id,
|
|
140
|
+
original_path=str(source.resolve(strict=False)),
|
|
141
|
+
stored_path=destination,
|
|
142
|
+
mime_type=mime_type,
|
|
143
|
+
size=info.st_size,
|
|
144
|
+
created_at=utc_now(),
|
|
145
|
+
)
|
|
146
|
+
finally:
|
|
147
|
+
if descriptor >= 0:
|
|
148
|
+
os.close(descriptor)
|
|
149
|
+
if temp_path is not None:
|
|
150
|
+
temp_path.unlink(missing_ok=True)
|
|
151
|
+
|
|
152
|
+
@staticmethod
|
|
153
|
+
def _is_textual(artifact: Artifact) -> bool:
|
|
154
|
+
return artifact.mime_type.startswith("text/") or artifact.mime_type in {
|
|
155
|
+
"application/json",
|
|
156
|
+
"application/xml",
|
|
157
|
+
"application/yaml",
|
|
158
|
+
"application/x-yaml",
|
|
159
|
+
"application/toml",
|
|
160
|
+
"application/javascript",
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
def read_text(self, artifact: Artifact) -> tuple[str, bool]:
|
|
164
|
+
if not self._is_textual(artifact):
|
|
165
|
+
return "", False
|
|
166
|
+
try:
|
|
167
|
+
with artifact.stored_path.open("r", encoding="utf-8", errors="replace") as handle:
|
|
168
|
+
content = handle.read(self.max_text_chars + 1)
|
|
169
|
+
except OSError as exc:
|
|
170
|
+
raise ArtifactError(f"Nie można odczytać artefaktu {artifact.id}") from exc
|
|
171
|
+
return content[: self.max_text_chars], len(content) > self.max_text_chars
|
|
172
|
+
|
|
173
|
+
def render_for_prompt(
|
|
174
|
+
self,
|
|
175
|
+
artifact: Artifact,
|
|
176
|
+
*,
|
|
177
|
+
query: str = "",
|
|
178
|
+
max_chars: int | None = None,
|
|
179
|
+
chunk_chars: int = 1800,
|
|
180
|
+
max_chunks: int = 4,
|
|
181
|
+
) -> str:
|
|
182
|
+
header = (
|
|
183
|
+
f'<attachment id="sha256:{artifact.id}" name="{Path(artifact.original_path).name}" '
|
|
184
|
+
f'mime="{artifact.mime_type}" bytes="{artifact.size}">'
|
|
185
|
+
)
|
|
186
|
+
if not self._is_textual(artifact):
|
|
187
|
+
return f"{header}\n[BINARNY ZAŁĄCZNIK — treść nie została wstrzyknięta]\n</attachment>"
|
|
188
|
+
content, source_truncated = self.read_text(artifact)
|
|
189
|
+
limit = min(self.max_text_chars, max_chars or self.max_text_chars)
|
|
190
|
+
selected, selection_truncated = select_relevant_text(
|
|
191
|
+
content,
|
|
192
|
+
query,
|
|
193
|
+
max_chars=limit,
|
|
194
|
+
chunk_chars=chunk_chars,
|
|
195
|
+
max_chunks=max_chunks,
|
|
196
|
+
)
|
|
197
|
+
suffix = "\n[TRUNCATED/SELECTED LOCALLY]" if source_truncated or selection_truncated else ""
|
|
198
|
+
return f"{header}\n{selected}{suffix}\n</attachment>"
|