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/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>"