@openlucaskaka/kagent 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/README.md +353 -0
  2. package/npm/bin/kagent-serve.js +6 -0
  3. package/npm/bin/kagent.js +6 -0
  4. package/npm/lib/App.js +524 -0
  5. package/npm/lib/app-state.js +224 -0
  6. package/npm/lib/approval-choice.js +25 -0
  7. package/npm/lib/commands.js +59 -0
  8. package/npm/lib/editor.js +188 -0
  9. package/npm/lib/ink-runner.js +41 -0
  10. package/npm/lib/kagent-home.js +39 -0
  11. package/npm/lib/launcher.js +221 -0
  12. package/npm/lib/protocol.js +33 -0
  13. package/npm/lib/provider-setup.js +139 -0
  14. package/npm/lib/python-runner.js +892 -0
  15. package/npm/lib/runtime-client.js +390 -0
  16. package/npm/lib/terminal-input.js +127 -0
  17. package/npm/lib/terminal-text.js +19 -0
  18. package/npm/lib/terminal-width.js +14 -0
  19. package/npm/lib/transcript.js +227 -0
  20. package/npm/lib/ui-components.js +247 -0
  21. package/npm/lib/update-manager.js +334 -0
  22. package/package.json +39 -0
  23. package/pyproject.toml +55 -0
  24. package/src/kagent/__init__.py +90 -0
  25. package/src/kagent/cli/__init__.py +5 -0
  26. package/src/kagent/cli/__main__.py +6 -0
  27. package/src/kagent/cli/commands.py +112 -0
  28. package/src/kagent/cli/conversation.py +127 -0
  29. package/src/kagent/cli/interactive.py +841 -0
  30. package/src/kagent/cli/main.py +685 -0
  31. package/src/kagent/cli/memory.py +460 -0
  32. package/src/kagent/cli/pending_approval.py +169 -0
  33. package/src/kagent/cli/provider.py +27 -0
  34. package/src/kagent/cli/session_commands.py +401 -0
  35. package/src/kagent/cli/stdio_runtime.py +784 -0
  36. package/src/kagent/cli/trace.py +67 -0
  37. package/src/kagent/cli/ui.py +931 -0
  38. package/src/kagent/core/__init__.py +0 -0
  39. package/src/kagent/core/agent.py +296 -0
  40. package/src/kagent/core/faults.py +11 -0
  41. package/src/kagent/core/invariants.py +47 -0
  42. package/src/kagent/core/normalization.py +81 -0
  43. package/src/kagent/core/planning.py +48 -0
  44. package/src/kagent/core/state.py +73 -0
  45. package/src/kagent/core/summary.py +64 -0
  46. package/src/kagent/core/tools.py +196 -0
  47. package/src/kagent/core/trace.py +57 -0
  48. package/src/kagent/eval/__init__.py +11 -0
  49. package/src/kagent/eval/cases.py +229 -0
  50. package/src/kagent/eval/evaluator.py +216 -0
  51. package/src/kagent/integrations/__init__.py +3 -0
  52. package/src/kagent/integrations/audit.py +95 -0
  53. package/src/kagent/integrations/backends.py +131 -0
  54. package/src/kagent/integrations/memory.py +301 -0
  55. package/src/kagent/ops/__init__.py +0 -0
  56. package/src/kagent/ops/batch.py +156 -0
  57. package/src/kagent/ops/doctor.py +255 -0
  58. package/src/kagent/ops/metrics.py +214 -0
  59. package/src/kagent/ops/release_evidence.py +877 -0
  60. package/src/kagent/ops/release_manifest.py +142 -0
  61. package/src/kagent/ops/trace_replay.py +285 -0
  62. package/src/kagent/providers/__init__.py +7 -0
  63. package/src/kagent/providers/embeddings.py +187 -0
  64. package/src/kagent/providers/llm.py +770 -0
  65. package/src/kagent/py.typed +1 -0
  66. package/src/kagent/runtime/__init__.py +28 -0
  67. package/src/kagent/runtime/action_graph.py +543 -0
  68. package/src/kagent/runtime/agent.py +2089 -0
  69. package/src/kagent/runtime/approval.py +64 -0
  70. package/src/kagent/runtime/cancellation.py +64 -0
  71. package/src/kagent/runtime/checkpoint_state.py +146 -0
  72. package/src/kagent/runtime/checkpoint_storage.py +270 -0
  73. package/src/kagent/runtime/context.py +65 -0
  74. package/src/kagent/runtime/file_transaction.py +195 -0
  75. package/src/kagent/runtime/hooks.py +74 -0
  76. package/src/kagent/runtime/metadata.py +116 -0
  77. package/src/kagent/runtime/patch_checkpoints.py +385 -0
  78. package/src/kagent/runtime/policy.py +50 -0
  79. package/src/kagent/runtime/presentation.py +205 -0
  80. package/src/kagent/runtime/redaction.py +130 -0
  81. package/src/kagent/runtime/sandbox.py +331 -0
  82. package/src/kagent/runtime/skills.py +66 -0
  83. package/src/kagent/runtime/steering.py +56 -0
  84. package/src/kagent/runtime/steps.py +255 -0
  85. package/src/kagent/runtime/task_state.py +40 -0
  86. package/src/kagent/runtime/tools.py +3532 -0
  87. package/src/kagent/runtime/types.py +240 -0
  88. package/src/kagent/runtime/workspace.py +597 -0
  89. package/src/kagent/service/__init__.py +26 -0
  90. package/src/kagent/service/__main__.py +6 -0
  91. package/src/kagent/service/active_runs.py +178 -0
  92. package/src/kagent/service/cli.py +737 -0
  93. package/src/kagent/service/contract.py +2571 -0
  94. package/src/kagent/service/errors.py +71 -0
  95. package/src/kagent/service/idempotency.py +584 -0
  96. package/src/kagent/service/router.py +884 -0
  97. package/src/kagent/service/run.py +150 -0
  98. package/src/kagent/service/runtime.py +1915 -0
  99. package/src/kagent/service/runtime_approval.py +20 -0
  100. package/src/kagent/service/runtime_cancel.py +192 -0
  101. package/src/kagent/service/runtime_lifecycle.py +229 -0
  102. package/src/kagent/service/runtime_metadata.py +21 -0
  103. package/src/kagent/service/runtime_policy.py +115 -0
  104. package/src/kagent/service/runtime_recovery.py +573 -0
  105. package/src/kagent/service/runtime_resume.py +382 -0
  106. package/src/kagent/service/runtime_resume_claim.py +116 -0
  107. package/src/kagent/service/runtime_run.py +350 -0
  108. package/src/kagent/service/runtime_status.py +2007 -0
  109. package/src/kagent/service/safety.py +139 -0
  110. package/src/kagent/service/server.py +114 -0
  111. package/src/kagent/service/status.py +233 -0
  112. package/src/kagent/service/trace_store.py +322 -0
  113. package/src/kagent/service/transport.py +24 -0
  114. package/src/kagent/utils/__init__.py +0 -0
  115. package/src/kagent/utils/config_validation.py +21 -0
  116. package/src/kagent/utils/json_output.py +23 -0
  117. package/src/kagent/utils/paths.py +473 -0
@@ -0,0 +1,473 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import secrets
5
+ import stat
6
+ from contextlib import contextmanager
7
+ from pathlib import Path
8
+ from typing import List, Mapping, Optional, Tuple
9
+
10
+ KAGENT_HOME_ENV_VAR = "KAGENT_HOME"
11
+ _MIGRATION_MARKER = ".migration-v1-complete"
12
+ _OWNER_DIRECTORY_MODE = 0o700
13
+ _OWNER_FILE_MODE = 0o600
14
+
15
+
16
+ def _absolute_user_path(value: str, env: Mapping[str, str]) -> Path:
17
+ if value == "~" or value.startswith("~/"):
18
+ home = env.get("HOME", "").strip()
19
+ if not home:
20
+ home = str(Path.home())
21
+ suffix = value[2:] if value != "~" else ""
22
+ path = Path(home) / suffix
23
+ else:
24
+ path = Path(value)
25
+ return Path(os.path.abspath(path))
26
+
27
+
28
+ def kagent_home(env: Optional[Mapping[str, str]] = None) -> Path:
29
+ environment = os.environ if env is None else env
30
+ if KAGENT_HOME_ENV_VAR in environment:
31
+ configured = environment[KAGENT_HOME_ENV_VAR]
32
+ if not configured.strip():
33
+ raise ValueError("KAGENT_HOME must not be empty")
34
+ if (
35
+ not (configured == "~" or configured.startswith("~/"))
36
+ and not Path(configured).is_absolute()
37
+ ):
38
+ raise ValueError("KAGENT_HOME must be an absolute or tilde-prefixed path")
39
+ return _absolute_user_path(configured, environment)
40
+
41
+ home = environment.get("HOME", "").strip()
42
+ if not home:
43
+ home = str(Path.home())
44
+ return _absolute_user_path(home, environment) / ".kagent"
45
+
46
+
47
+ def kagent_config_dir(env: Optional[Mapping[str, str]] = None) -> Path:
48
+ return kagent_home(env) / "config"
49
+
50
+
51
+ def kagent_state_dir(env: Optional[Mapping[str, str]] = None) -> Path:
52
+ return kagent_home(env) / "state"
53
+
54
+
55
+ def kagent_cache_dir(env: Optional[Mapping[str, str]] = None) -> Path:
56
+ return kagent_home(env) / "cache"
57
+
58
+
59
+ def _directory_open_flags() -> int:
60
+ return (
61
+ os.O_RDONLY
62
+ | getattr(os, "O_DIRECTORY", 0)
63
+ | getattr(os, "O_CLOEXEC", 0)
64
+ | getattr(os, "O_NOFOLLOW", 0)
65
+ )
66
+
67
+
68
+ def _open_directory(path: Path, *, create: bool = False) -> int:
69
+ if not path.is_absolute():
70
+ raise ValueError(f"migration directory must be absolute: {path}")
71
+ flags = _directory_open_flags()
72
+ current_fd = os.open(path.anchor, flags)
73
+ try:
74
+ for part in path.parts[1:]:
75
+ try:
76
+ next_fd = os.open(part, flags, dir_fd=current_fd)
77
+ except FileNotFoundError:
78
+ if not create:
79
+ raise
80
+ try:
81
+ os.mkdir(part, _OWNER_DIRECTORY_MODE, dir_fd=current_fd)
82
+ except FileExistsError:
83
+ pass
84
+ try:
85
+ next_fd = os.open(part, flags, dir_fd=current_fd)
86
+ except OSError as exc:
87
+ raise ValueError(
88
+ f"migration path must not contain symlinks or non-directories: {path}"
89
+ ) from exc
90
+ except OSError as exc:
91
+ raise ValueError(
92
+ f"migration path must not contain symlinks or non-directories: {path}"
93
+ ) from exc
94
+ os.close(current_fd)
95
+ current_fd = next_fd
96
+ return current_fd
97
+ except BaseException:
98
+ os.close(current_fd)
99
+ raise
100
+
101
+
102
+ def _ensure_directory_fd_matches_path(path: Path, directory_fd: int) -> None:
103
+ try:
104
+ path_stat = os.stat(path, follow_symlinks=False)
105
+ except OSError as exc:
106
+ raise ValueError(f"migration directory changed during operation: {path}") from exc
107
+ descriptor_stat = os.fstat(directory_fd)
108
+ if (
109
+ not stat.S_ISDIR(path_stat.st_mode)
110
+ or path_stat.st_dev != descriptor_stat.st_dev
111
+ or path_stat.st_ino != descriptor_stat.st_ino
112
+ ):
113
+ raise ValueError(f"migration directory changed or became a symlink: {path}")
114
+
115
+
116
+ def _ensure_private_directory(path: Path) -> None:
117
+ directory_fd = _open_directory(path, create=True)
118
+ try:
119
+ os.fchmod(directory_fd, _OWNER_DIRECTORY_MODE)
120
+ _ensure_directory_fd_matches_path(path, directory_fd)
121
+ finally:
122
+ os.close(directory_fd)
123
+
124
+
125
+ def _legacy_root(
126
+ environment: Mapping[str, str], variable: str, default_suffix: Tuple[str, ...]
127
+ ) -> Path:
128
+ configured = environment.get(variable, "").strip()
129
+ if configured:
130
+ return _absolute_user_path(configured, environment) / "kagent"
131
+ home = environment.get("HOME", "").strip()
132
+ if not home:
133
+ raise ValueError(f"HOME must be set when {variable} is not configured")
134
+ return _absolute_user_path(home, environment).joinpath(*default_suffix, "kagent")
135
+
136
+
137
+ def _path_kind(path: Path) -> Optional[str]:
138
+ try:
139
+ parent_fd = _open_directory(path.parent)
140
+ except FileNotFoundError:
141
+ return None
142
+ try:
143
+ try:
144
+ mode = os.stat(path.name, dir_fd=parent_fd, follow_symlinks=False).st_mode
145
+ except FileNotFoundError:
146
+ return None
147
+ _ensure_directory_fd_matches_path(path.parent, parent_fd)
148
+ finally:
149
+ os.close(parent_fd)
150
+ if stat.S_ISREG(mode):
151
+ return "file"
152
+ if stat.S_ISDIR(mode):
153
+ return "directory"
154
+ if stat.S_ISLNK(mode):
155
+ raise ValueError(f"migration path must not contain symlinks: {path}")
156
+ return "other"
157
+
158
+
159
+ def _open_source_file(path: Path) -> int:
160
+ parent_fd = _open_directory(path.parent)
161
+ try:
162
+ flags = (
163
+ os.O_RDONLY
164
+ | getattr(os, "O_CLOEXEC", 0)
165
+ | getattr(os, "O_NOFOLLOW", 0)
166
+ | getattr(os, "O_NONBLOCK", 0)
167
+ )
168
+ try:
169
+ source_fd = os.open(path.name, flags, dir_fd=parent_fd)
170
+ except OSError as exc:
171
+ raise ValueError(
172
+ f"migration source must be a regular file without symlinks: {path}"
173
+ ) from exc
174
+ try:
175
+ if not stat.S_ISREG(os.fstat(source_fd).st_mode):
176
+ raise ValueError(f"migration source must be a regular file: {path}")
177
+ _ensure_directory_fd_matches_path(path.parent, parent_fd)
178
+ return source_fd
179
+ except BaseException:
180
+ os.close(source_fd)
181
+ raise
182
+ finally:
183
+ os.close(parent_fd)
184
+
185
+
186
+ def _open_temporary_file(parent_fd: int, destination_name: str) -> Tuple[int, str]:
187
+ flags = (
188
+ os.O_WRONLY
189
+ | os.O_CREAT
190
+ | os.O_EXCL
191
+ | getattr(os, "O_CLOEXEC", 0)
192
+ | getattr(os, "O_NOFOLLOW", 0)
193
+ )
194
+ for _ in range(128):
195
+ name = f".{destination_name}.{secrets.token_hex(8)}"
196
+ try:
197
+ return os.open(name, flags, _OWNER_FILE_MODE, dir_fd=parent_fd), name
198
+ except FileExistsError:
199
+ continue
200
+ raise OSError("unable to allocate an atomic migration temporary file")
201
+
202
+
203
+ @contextmanager
204
+ def _fdopen_stream(file_descriptor: int, mode: str, *, closefd: bool = True):
205
+ try:
206
+ stream = os.fdopen(file_descriptor, mode, closefd=closefd)
207
+ except BaseException:
208
+ if closefd:
209
+ os.close(file_descriptor)
210
+ raise
211
+ try:
212
+ yield stream
213
+ finally:
214
+ stream.close()
215
+
216
+
217
+ def _destination_entry_exists(parent_fd: int, name: str, path: Path) -> bool:
218
+ try:
219
+ mode = os.stat(name, dir_fd=parent_fd, follow_symlinks=False).st_mode
220
+ except FileNotFoundError:
221
+ return False
222
+ if stat.S_ISLNK(mode):
223
+ raise ValueError(f"migration path must not contain symlinks: {path}")
224
+ return True
225
+
226
+
227
+ def _tighten_existing_destination(parent_fd: int, name: str, path: Path) -> bool:
228
+ flags = (
229
+ os.O_RDONLY
230
+ | getattr(os, "O_CLOEXEC", 0)
231
+ | getattr(os, "O_NOFOLLOW", 0)
232
+ | getattr(os, "O_NONBLOCK", 0)
233
+ )
234
+ try:
235
+ destination_fd = os.open(name, flags, dir_fd=parent_fd)
236
+ except FileNotFoundError:
237
+ return False
238
+ except OSError as exc:
239
+ raise ValueError(f"migration path must not contain symlinks: {path}") from exc
240
+ try:
241
+ descriptor_stat = os.fstat(destination_fd)
242
+ if stat.S_ISREG(descriptor_stat.st_mode):
243
+ os.fchmod(destination_fd, _OWNER_FILE_MODE)
244
+ entry_stat = os.stat(name, dir_fd=parent_fd, follow_symlinks=False)
245
+ if stat.S_ISLNK(entry_stat.st_mode):
246
+ raise ValueError(f"migration path must not contain symlinks: {path}")
247
+ if (
248
+ entry_stat.st_dev != descriptor_stat.st_dev
249
+ or entry_stat.st_ino != descriptor_stat.st_ino
250
+ ):
251
+ raise ValueError(f"migration destination changed during operation: {path}")
252
+ _ensure_directory_fd_matches_path(path.parent, parent_fd)
253
+ return True
254
+ finally:
255
+ os.close(destination_fd)
256
+
257
+
258
+ def _atomic_copy_file(source: Path, destination: Path) -> None:
259
+ _ensure_private_directory(destination.parent)
260
+ destination_parent_fd = _open_directory(destination.parent)
261
+ try:
262
+ _ensure_directory_fd_matches_path(destination.parent, destination_parent_fd)
263
+ if _destination_entry_exists(destination_parent_fd, destination.name, destination):
264
+ if _tighten_existing_destination(
265
+ destination_parent_fd, destination.name, destination
266
+ ):
267
+ return
268
+ source_fd = _open_source_file(source)
269
+ try:
270
+ with _fdopen_stream(source_fd, "rb", closefd=False) as source_stream:
271
+ temporary_fd, temporary_name = _open_temporary_file(
272
+ destination_parent_fd, destination.name
273
+ )
274
+ try:
275
+ with _fdopen_stream(temporary_fd, "wb") as destination_stream:
276
+ while True:
277
+ chunk = source_stream.read(1024 * 1024)
278
+ if not chunk:
279
+ break
280
+ destination_stream.write(chunk)
281
+ destination_stream.flush()
282
+ os.fsync(destination_stream.fileno())
283
+ try:
284
+ os.link(
285
+ temporary_name,
286
+ destination.name,
287
+ src_dir_fd=destination_parent_fd,
288
+ dst_dir_fd=destination_parent_fd,
289
+ follow_symlinks=False,
290
+ )
291
+ except FileExistsError:
292
+ if not _tighten_existing_destination(
293
+ destination_parent_fd, destination.name, destination
294
+ ):
295
+ raise ValueError(
296
+ f"migration destination changed during operation: {destination}"
297
+ )
298
+ _ensure_directory_fd_matches_path(destination.parent, destination_parent_fd)
299
+ finally:
300
+ try:
301
+ os.unlink(temporary_name, dir_fd=destination_parent_fd)
302
+ except FileNotFoundError:
303
+ pass
304
+ finally:
305
+ os.close(source_fd)
306
+ finally:
307
+ os.close(destination_parent_fd)
308
+
309
+
310
+ def _scan_directory(source: Path) -> Tuple[List[Path], List[Path]]:
311
+ directories: List[Path] = []
312
+ files: List[Path] = []
313
+ root_fd = _open_directory(source)
314
+
315
+ def scan(directory_fd: int, directory: Path) -> None:
316
+ _ensure_directory_fd_matches_path(directory, directory_fd)
317
+ with os.scandir(directory_fd) as entries:
318
+ for entry in entries:
319
+ entry_path = directory / entry.name
320
+ mode = entry.stat(follow_symlinks=False).st_mode
321
+ if stat.S_ISLNK(mode):
322
+ raise ValueError(
323
+ f"migration directories must contain only regular files and directories, "
324
+ f"not symlinks: {entry_path}"
325
+ )
326
+ if stat.S_ISDIR(mode):
327
+ directories.append(entry_path)
328
+ try:
329
+ child_fd = os.open(entry.name, _directory_open_flags(), dir_fd=directory_fd)
330
+ except OSError as exc:
331
+ raise ValueError(
332
+ f"migration directory changed or became a symlink: {entry_path}"
333
+ ) from exc
334
+ try:
335
+ scan(child_fd, entry_path)
336
+ finally:
337
+ os.close(child_fd)
338
+ elif stat.S_ISREG(mode):
339
+ files.append(entry_path)
340
+ else:
341
+ raise ValueError(
342
+ "migration directories must contain only regular files and directories: "
343
+ f"{entry_path}"
344
+ )
345
+ _ensure_directory_fd_matches_path(directory, directory_fd)
346
+
347
+ try:
348
+ scan(root_fd, source)
349
+ finally:
350
+ os.close(root_fd)
351
+ return sorted(directories), sorted(files)
352
+
353
+
354
+ def _copy_directory(source: Path, destination: Path) -> None:
355
+ directories, files = _scan_directory(source)
356
+ destination_kind = _path_kind(destination)
357
+ if destination_kind is not None and destination_kind != "directory":
358
+ return
359
+ _ensure_private_directory(destination)
360
+ blocked_prefixes: List[Path] = []
361
+ for directory in directories:
362
+ relative = directory.relative_to(source)
363
+ if any(prefix == relative or prefix in relative.parents for prefix in blocked_prefixes):
364
+ continue
365
+ target = destination / relative
366
+ target_kind = _path_kind(target)
367
+ if target_kind is not None and target_kind != "directory":
368
+ blocked_prefixes.append(relative)
369
+ continue
370
+ _ensure_private_directory(target)
371
+ for source_file in files:
372
+ relative = source_file.relative_to(source)
373
+ if any(prefix == relative or prefix in relative.parents for prefix in blocked_prefixes):
374
+ continue
375
+ _atomic_copy_file(source_file, destination / relative)
376
+
377
+
378
+ def _write_marker(marker: Path) -> None:
379
+ marker_kind = _path_kind(marker)
380
+ if marker_kind is not None:
381
+ if marker_kind != "file":
382
+ raise ValueError(f"migration marker must be a regular file: {marker}")
383
+ parent_fd = _open_directory(marker.parent)
384
+ try:
385
+ flags = (
386
+ os.O_RDONLY
387
+ | getattr(os, "O_CLOEXEC", 0)
388
+ | getattr(os, "O_NOFOLLOW", 0)
389
+ | getattr(os, "O_NONBLOCK", 0)
390
+ )
391
+ marker_fd = os.open(marker.name, flags, dir_fd=parent_fd)
392
+ try:
393
+ if not stat.S_ISREG(os.fstat(marker_fd).st_mode):
394
+ raise ValueError(f"migration marker must be a regular file: {marker}")
395
+ os.fchmod(marker_fd, _OWNER_FILE_MODE)
396
+ _ensure_directory_fd_matches_path(marker.parent, parent_fd)
397
+ finally:
398
+ os.close(marker_fd)
399
+ finally:
400
+ os.close(parent_fd)
401
+ return
402
+ _ensure_private_directory(marker.parent)
403
+ parent_fd = _open_directory(marker.parent)
404
+ try:
405
+ temporary_fd, temporary_name = _open_temporary_file(parent_fd, marker.name)
406
+ try:
407
+ with _fdopen_stream(temporary_fd, "wb") as stream:
408
+ stream.write(b"complete\n")
409
+ stream.flush()
410
+ os.fsync(stream.fileno())
411
+ try:
412
+ os.link(
413
+ temporary_name,
414
+ marker.name,
415
+ src_dir_fd=parent_fd,
416
+ dst_dir_fd=parent_fd,
417
+ follow_symlinks=False,
418
+ )
419
+ except FileExistsError:
420
+ if _path_kind(marker) != "file":
421
+ raise ValueError(f"migration marker must be a regular file: {marker}")
422
+ _ensure_directory_fd_matches_path(marker.parent, parent_fd)
423
+ finally:
424
+ try:
425
+ os.unlink(temporary_name, dir_fd=parent_fd)
426
+ except FileNotFoundError:
427
+ pass
428
+ finally:
429
+ os.close(parent_fd)
430
+
431
+
432
+ def migrate_legacy_kagent_state(env: Optional[Mapping[str, str]] = None) -> Path:
433
+ environment = os.environ if env is None else env
434
+ root = kagent_home(environment)
435
+ marker = root / _MIGRATION_MARKER
436
+ if KAGENT_HOME_ENV_VAR in environment:
437
+ return marker
438
+
439
+ marker_kind = _path_kind(marker)
440
+ if marker_kind is not None:
441
+ if marker_kind != "file":
442
+ raise ValueError(f"migration marker must be a regular file: {marker}")
443
+ _ensure_private_directory(root)
444
+ _write_marker(marker)
445
+ return marker
446
+
447
+ legacy_config = _legacy_root(environment, "XDG_CONFIG_HOME", (".config",))
448
+ legacy_state = _legacy_root(environment, "XDG_STATE_HOME", (".local", "state"))
449
+ migrations = [
450
+ (legacy_config / "provider.json", root / "config" / "provider.json", "file"),
451
+ (legacy_state / "session-memory.json", root / "state" / "session-memory.json", "file"),
452
+ (legacy_state / "history", root / "state" / "history", "file"),
453
+ (
454
+ legacy_state / "pending-approvals",
455
+ root / "state" / "pending-approvals",
456
+ "directory",
457
+ ),
458
+ (legacy_state / "patches", root / "state" / "patches", "directory"),
459
+ ]
460
+
461
+ for source, destination, expected_kind in migrations:
462
+ kind = _path_kind(source)
463
+ if kind is None:
464
+ continue
465
+ if kind != expected_kind:
466
+ raise ValueError(f"unexpected legacy migration source type: {source}")
467
+ if kind == "file":
468
+ _atomic_copy_file(source, destination)
469
+ else:
470
+ _copy_directory(source, destination)
471
+
472
+ _write_marker(marker)
473
+ return marker