netizen-cli 0.10.0__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.
Files changed (112) hide show
  1. netizen_cli/__init__.py +3 -0
  2. netizen_cli/__main__.py +4 -0
  3. netizen_cli/admin/__init__.py +1 -0
  4. netizen_cli/admin/auth.py +928 -0
  5. netizen_cli/admin/errors.py +9 -0
  6. netizen_cli/admin/port_config.py +115 -0
  7. netizen_cli/admin/presentation.py +257 -0
  8. netizen_cli/admin/queries.py +337 -0
  9. netizen_cli/admin/static/admin.css +260 -0
  10. netizen_cli/admin/static/admin.js +2898 -0
  11. netizen_cli/admin/static/index.html +327 -0
  12. netizen_cli/admin/transport.py +935 -0
  13. netizen_cli/admin/web.py +2717 -0
  14. netizen_cli/bindings.py +3215 -0
  15. netizen_cli/builtin_skills.py +93 -0
  16. netizen_cli/cards/__init__.py +105 -0
  17. netizen_cli/cards/callbacks.py +565 -0
  18. netizen_cli/cards/controls.py +2273 -0
  19. netizen_cli/cards/defaults.py +213 -0
  20. netizen_cli/cards/model_info.py +80 -0
  21. netizen_cli/cards/questions.py +220 -0
  22. netizen_cli/cards/reply.py +2247 -0
  23. netizen_cli/cards/scheduled.py +836 -0
  24. netizen_cli/channel/__init__.py +1 -0
  25. netizen_cli/channel/completion_mentions.py +60 -0
  26. netizen_cli/channel/input_preparation.py +644 -0
  27. netizen_cli/channel/messages.py +57 -0
  28. netizen_cli/channel/ports.py +52 -0
  29. netizen_cli/channel/question_inputs.py +51 -0
  30. netizen_cli/channel/reactions.py +293 -0
  31. netizen_cli/channel/reply_presenter.py +1505 -0
  32. netizen_cli/channel/topics.py +70 -0
  33. netizen_cli/channel_app.py +6593 -0
  34. netizen_cli/cli.py +287 -0
  35. netizen_cli/cli_data.py +536 -0
  36. netizen_cli/cli_packages.py +526 -0
  37. netizen_cli/cli_services.py +651 -0
  38. netizen_cli/cli_setup.py +242 -0
  39. netizen_cli/cli_update.py +303 -0
  40. netizen_cli/cli_update_restore.py +53 -0
  41. netizen_cli/cli_update_worker.py +333 -0
  42. netizen_cli/codex_runtime.py +7125 -0
  43. netizen_cli/completion_mention.py +16 -0
  44. netizen_cli/database_migrations.py +218 -0
  45. netizen_cli/defaults/__init__.py +5 -0
  46. netizen_cli/defaults/models.py +39 -0
  47. netizen_cli/defaults/service.py +232 -0
  48. netizen_cli/defaults/store.py +260 -0
  49. netizen_cli/deployment/__init__.py +1 -0
  50. netizen_cli/deployment/restart_worker.py +134 -0
  51. netizen_cli/deployment/update_executor.py +258 -0
  52. netizen_cli/deployment/update_protocol.py +281 -0
  53. netizen_cli/domain.py +416 -0
  54. netizen_cli/error_messages.py +124 -0
  55. netizen_cli/experience.py +531 -0
  56. netizen_cli/feishu_app_onboarding.py +187 -0
  57. netizen_cli/feishu_app_permissions.py +123 -0
  58. netizen_cli/git_status.py +63 -0
  59. netizen_cli/image_inputs.py +579 -0
  60. netizen_cli/instance.py +84 -0
  61. netizen_cli/lark_app.py +125 -0
  62. netizen_cli/main.py +903 -0
  63. netizen_cli/management/__init__.py +83 -0
  64. netizen_cli/management/blocking_io.py +352 -0
  65. netizen_cli/management/chat_labels.py +266 -0
  66. netizen_cli/management/coordination.py +32 -0
  67. netizen_cli/management/service.py +2187 -0
  68. netizen_cli/management/updates.py +214 -0
  69. netizen_cli/markdown_images.py +78 -0
  70. netizen_cli/message_content.py +786 -0
  71. netizen_cli/message_history.py +643 -0
  72. netizen_cli/message_preparation.py +60 -0
  73. netizen_cli/message_projection.py +923 -0
  74. netizen_cli/migrations/__init__.py +1 -0
  75. netizen_cli/migrations/schema.py +103 -0
  76. netizen_cli/migrations/v14.py +438 -0
  77. netizen_cli/model_settings.py +269 -0
  78. netizen_cli/package_resources.py +22 -0
  79. netizen_cli/projects.py +327 -0
  80. netizen_cli/prompt_projection.py +327 -0
  81. netizen_cli/quoted_context.py +312 -0
  82. netizen_cli/resources/config.example.yaml +35 -0
  83. netizen_cli/resources/skills/netizen-lark/SKILL.md +64 -0
  84. netizen_cli/resources/skills/netizen-user-guide/SKILL.md +37 -0
  85. netizen_cli/resources/skills/netizen-user-guide/references/user-guide.md +842 -0
  86. netizen_cli/result_images.py +123 -0
  87. netizen_cli/runtime/__init__.py +1 -0
  88. netizen_cli/runtime/contracts.py +792 -0
  89. netizen_cli/runtime/name_writes.py +67 -0
  90. netizen_cli/runtime/thread_naming.py +451 -0
  91. netizen_cli/schedules/__init__.py +1 -0
  92. netizen_cli/schedules/mcp.py +535 -0
  93. netizen_cli/schedules/models.py +394 -0
  94. netizen_cli/schedules/scheduler.py +374 -0
  95. netizen_cli/schedules/service.py +766 -0
  96. netizen_cli/schedules/store.py +771 -0
  97. netizen_cli/sdk_gap_adapter.py +1151 -0
  98. netizen_cli/service_launcher.py +583 -0
  99. netizen_cli/session_settings.py +126 -0
  100. netizen_cli/settings.py +216 -0
  101. netizen_cli/skill_references.py +40 -0
  102. netizen_cli/terminal_cleanup.py +155 -0
  103. netizen_cli/turn_activity.py +688 -0
  104. netizen_cli/turn_files.py +812 -0
  105. netizen_cli/turn_patch_children.py +254 -0
  106. netizen_cli/turn_plan_observer.py +315 -0
  107. netizen_cli/user_questions.py +106 -0
  108. netizen_cli-0.10.0.dist-info/METADATA +18 -0
  109. netizen_cli-0.10.0.dist-info/RECORD +112 -0
  110. netizen_cli-0.10.0.dist-info/WHEEL +5 -0
  111. netizen_cli-0.10.0.dist-info/entry_points.txt +2 -0
  112. netizen_cli-0.10.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,812 @@
1
+ """Derive stateless current files from one native Turn."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ast
6
+ import re
7
+ import stat
8
+ from collections.abc import Iterable, Sequence
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Literal
12
+
13
+ from openai_codex.generated.v2_all import FileChangeThreadItem
14
+ from openai_codex.types import ThreadItem
15
+
16
+ from .turn_patch_children import TaskPatchChildren, TurnPatchBatch
17
+
18
+
19
+ TURN_FILE_PAGE_SIZE = 8
20
+
21
+
22
+ class TurnFileError(RuntimeError):
23
+ pass
24
+
25
+
26
+ @dataclass(frozen=True, slots=True)
27
+ class TurnFile:
28
+ display_path: str
29
+ resolved_path: Path
30
+ size: int | None
31
+ media_kind: Literal["image", "file"] | None
32
+ additions: int | None = None
33
+ deletions: int | None = None
34
+ deleted: bool = False
35
+
36
+ @property
37
+ def available(self) -> bool:
38
+ return self.size is not None and self.media_kind is not None
39
+
40
+
41
+ @dataclass(frozen=True, slots=True)
42
+ class TurnFilePage:
43
+ items: tuple[TurnFile, ...]
44
+ page: int
45
+ total_pages: int
46
+ total_items: int
47
+
48
+
49
+ @dataclass(frozen=True, slots=True)
50
+ class TurnDiffFileStats:
51
+ """Optional line counts associated with one reported file path."""
52
+
53
+ path: str
54
+ additions: int | None = None
55
+ deletions: int | None = None
56
+ deleted: bool = False
57
+
58
+
59
+ @dataclass(frozen=True, slots=True)
60
+ class TurnDiffSummary:
61
+ """Display-safe paths and optional paired line counts."""
62
+
63
+ additions: int | None = None
64
+ deletions: int | None = None
65
+ files: tuple[TurnDiffFileStats, ...] = ()
66
+
67
+
68
+ @dataclass(frozen=True, slots=True)
69
+ class _ReportedTurnPath:
70
+ item_type: Literal["turnDiff", "fileChange", "imageGeneration"]
71
+ value: str
72
+ additions: int | None = None
73
+ deletions: int | None = None
74
+ deleted: bool = False
75
+
76
+
77
+ @dataclass(frozen=True, slots=True)
78
+ class _ParsedDiffBlock:
79
+ path: str | None
80
+ deleted: bool
81
+ binary: bool
82
+ counts: tuple[int, int] | None
83
+ malformed: bool
84
+
85
+
86
+ _HUNK_HEADER = re.compile(
87
+ r"^@@ -\d+(?:,(\d+))? \+\d+(?:,(\d+))? @@(?:.*)$"
88
+ )
89
+
90
+
91
+ def turn_patch_summary(
92
+ items: Sequence[object],
93
+ project_cwd: Path,
94
+ *,
95
+ children: TaskPatchChildren = TaskPatchChildren(),
96
+ turn_diff: str | None = None,
97
+ ) -> TurnDiffSummary:
98
+ """Count successful patch operations, including proven new descendants.
99
+
100
+ Repeated edits accumulate; this is not a final net diff. Native aggregate
101
+ diffs supplement paths only, so their overlapping counts are never added.
102
+ Unreadable children or patches omit the total, retaining known file counts.
103
+ """
104
+ counts: dict[str, tuple[int, int] | None] = {}
105
+ deleted_paths: set[str] = set()
106
+ additions = deletions = 0
107
+ complete = children.complete
108
+ saw_counts = False
109
+ root_paths: set[str] = set()
110
+ seen: set[tuple[str, str, str]] = set()
111
+ batches = (TurnPatchBatch("", "", project_cwd, tuple(items)), *children.batches)
112
+ for batch in batches:
113
+ # Final item views replace earlier lifecycle views of the same item.
114
+ patches: dict[str, FileChangeThreadItem] = {}
115
+ conflicting: set[str] = set()
116
+ for wrapped in batch.items:
117
+ item = getattr(wrapped, "root", wrapped)
118
+ if type(item) is not FileChangeThreadItem:
119
+ continue
120
+ previous_item = patches.get(item.id)
121
+ if (
122
+ previous_item is not None
123
+ and _enum_value(previous_item.status) == "completed"
124
+ and _enum_value(item.status) == "completed"
125
+ and previous_item != item
126
+ ):
127
+ conflicting.add(item.id)
128
+ patches[item.id] = item
129
+ for item in patches.values():
130
+ identity = (batch.thread_id, batch.turn_id, item.id)
131
+ if identity in seen:
132
+ continue
133
+ seen.add(identity)
134
+ if _enum_value(item.status) != "completed":
135
+ if _enum_value(item.status) == "inProgress":
136
+ complete = False
137
+ continue
138
+ for change in item.changes:
139
+ kind = change.kind.root
140
+ raw_path = getattr(kind, "move_path", None) or change.path
141
+ try:
142
+ path = _patch_path(raw_path, batch.cwd)
143
+ except (OSError, RuntimeError, ValueError):
144
+ complete = False
145
+ continue
146
+ if not batch.thread_id:
147
+ root_paths.add(path)
148
+ if kind.type == "delete" and item.id not in conflicting:
149
+ deleted_paths.add(path)
150
+ value = (
151
+ None if item.id in conflicting else _file_change_counts(
152
+ change.diff, kind.type, getattr(kind, "move_path", None),
153
+ )
154
+ )
155
+ if value is None:
156
+ complete = False
157
+ else:
158
+ additions += value[0]
159
+ deletions += value[1]
160
+ saw_counts = True
161
+ if path not in counts:
162
+ counts[path] = value
163
+ elif counts[path] is None or value is None:
164
+ counts[path] = None
165
+ else:
166
+ previous = counts[path]
167
+ assert previous is not None
168
+ counts[path] = (previous[0] + value[0], previous[1] + value[1])
169
+
170
+ # Preserve aggregate-only file discovery without mixing two count metrics.
171
+ for lines in _diff_blocks(turn_diff or ""):
172
+ block = _parse_diff_block(lines)
173
+ if block.path is None:
174
+ complete = False
175
+ continue
176
+ try:
177
+ path = _patch_path(block.path, project_cwd)
178
+ except (OSError, RuntimeError, ValueError):
179
+ complete = False
180
+ continue
181
+ if path not in root_paths:
182
+ complete = False
183
+ if not block.deleted or path in counts:
184
+ counts[path] = None
185
+ return TurnDiffSummary(
186
+ additions if complete and saw_counts else None,
187
+ deletions if complete and saw_counts else None,
188
+ tuple(
189
+ TurnDiffFileStats(
190
+ path, *(value or (None, None)), deleted=path in deleted_paths,
191
+ )
192
+ for path, value in counts.items()
193
+ ),
194
+ )
195
+
196
+
197
+ def _patch_path(value: str, cwd: Path) -> str:
198
+ if not value or "\0" in value:
199
+ raise ValueError("invalid patch path")
200
+ path = Path(value)
201
+ return str((path if path.is_absolute() else cwd / path).resolve())
202
+
203
+
204
+ def _file_change_counts(
205
+ diff: str, kind: str, move_path: str | None,
206
+ ) -> tuple[int, int] | None:
207
+ if "\0" in diff:
208
+ return None
209
+ if kind in {"add", "delete"}:
210
+ # SDK add/delete contain whole text, not a unified diff. Count LF only;
211
+ # a final newline terminates the last line rather than adding a line.
212
+ lines = diff.count("\n") + int(bool(diff) and not diff.endswith("\n"))
213
+ return (lines, 0) if kind == "add" else (0, lines)
214
+ if kind != "update":
215
+ return None
216
+ if move_path:
217
+ suffix = f"\n\nMoved to: {move_path}"
218
+ if diff.endswith(suffix):
219
+ diff = diff[:-len(suffix)]
220
+ if not diff:
221
+ return (0, 0)
222
+ # Generated update patches can have file headers or start directly with a
223
+ # hunk. Reuse the validated hunk parser; paths come from the typed change.
224
+ lines = [line.removesuffix("\r") for line in diff.split("\n")]
225
+ if lines and not lines[-1]:
226
+ lines.pop()
227
+ if diff.startswith("@@ "):
228
+ lines = ["--- a/file", "+++ b/file", *lines]
229
+ if not diff.startswith("diff --git "):
230
+ lines.insert(0, "diff --git a/file b/file")
231
+ if sum(line.startswith("diff --git ") for line in lines) != 1:
232
+ return None
233
+ block = _parse_diff_block(lines)
234
+ return block.counts if not block.binary else None
235
+
236
+
237
+ def extract_turn_files(
238
+ items: Sequence[ThreadItem],
239
+ project_cwd: Path,
240
+ *,
241
+ turn_diff: str | None = None,
242
+ diff_summary: TurnDiffSummary | None = None,
243
+ home_directory: Path | None = None,
244
+ ) -> tuple[TurnFile, ...]:
245
+ """Return current files and missing files with successful deletion evidence.
246
+
247
+ The public ``ThreadItem.root`` discriminator is the only SDK shape used.
248
+ Project is the base for relative paths, not an authorization boundary.
249
+ Other missing and special files are omitted; this function never scans any
250
+ directory to infer extra outputs.
251
+ """
252
+
253
+ try:
254
+ root = project_cwd.resolve(strict=True)
255
+ except (OSError, RuntimeError) as error:
256
+ raise TurnFileError("Project 目录当前不可用。") from error
257
+ if not root.is_dir():
258
+ raise TurnFileError("Project 路径不是目录。")
259
+
260
+ try:
261
+ home = (
262
+ Path.home() if home_directory is None else home_directory
263
+ ).resolve(strict=True)
264
+ except (OSError, RuntimeError):
265
+ home = None
266
+
267
+ results: list[TurnFile] = []
268
+ seen: set[Path] = set()
269
+ for reported in _reported_paths(
270
+ items,
271
+ turn_diff=turn_diff,
272
+ diff_summary=diff_summary,
273
+ ):
274
+ turn_file = _resolve_turn_file(reported, root, home)
275
+ if turn_file is None or turn_file.resolved_path in seen:
276
+ continue
277
+ seen.add(turn_file.resolved_path)
278
+ results.append(turn_file)
279
+ return tuple(results)
280
+
281
+
282
+ def has_turn_file_references(
283
+ items: Sequence[object],
284
+ *,
285
+ turn_diff: str | None = None,
286
+ diff_summary: TurnDiffSummary | None = None,
287
+ ) -> bool:
288
+ """Return whether supported structured items name at least one output path."""
289
+
290
+ return next(
291
+ _reported_paths(
292
+ items,
293
+ turn_diff=turn_diff,
294
+ diff_summary=diff_summary,
295
+ ),
296
+ None,
297
+ ) is not None
298
+
299
+
300
+ def turn_diff_paths(diff: str | None) -> tuple[str, ...]:
301
+ """Return current-side paths from a Git-style aggregate unified diff.
302
+
303
+ App Server's ``turn/diff/updated`` payload is an aggregate snapshot. This
304
+ is the compatibility wrapper for callers that only need paths. Deleted
305
+ targets are excluded; rename destinations and binary-file headers remain
306
+ supported.
307
+ """
308
+
309
+ return tuple(item.path for item in turn_diff_summary(diff).files)
310
+
311
+
312
+ def turn_diff_summary(diff: str | None) -> TurnDiffSummary:
313
+ """Parse paths and line counts from the latest aggregate unified diff.
314
+
315
+ A malformed hunk invalidates all numeric statistics while preserving any
316
+ independently parseable current-side paths. Binary blocks keep their path
317
+ but never receive line counts. Aggregate totals include valid deleted-file
318
+ hunks even though deleted targets cannot appear in the current file list.
319
+ """
320
+
321
+ if not isinstance(diff, str) or not diff:
322
+ return TurnDiffSummary()
323
+
324
+ first_header = re.search(r"(?m)^diff --git ", diff)
325
+ malformed_prelude = (
326
+ first_header is not None
327
+ and bool(diff[: first_header.start()].strip())
328
+ )
329
+ blocks = tuple(_parse_diff_block(lines) for lines in _diff_blocks(diff))
330
+ merged: list[TurnDiffFileStats] = []
331
+ positions: dict[str, int] = {}
332
+ for block in blocks:
333
+ if block.deleted or block.path is None:
334
+ continue
335
+ item = TurnDiffFileStats(
336
+ block.path,
337
+ *(block.counts or (None, None)),
338
+ )
339
+ position = positions.get(item.path)
340
+ if position is None:
341
+ positions[item.path] = len(merged)
342
+ merged.append(item)
343
+ continue
344
+ previous = merged[position]
345
+ if (
346
+ previous.additions is None
347
+ or previous.deletions is None
348
+ or item.additions is None
349
+ or item.deletions is None
350
+ ):
351
+ merged[position] = TurnDiffFileStats(item.path)
352
+ else:
353
+ merged[position] = TurnDiffFileStats(
354
+ item.path,
355
+ previous.additions + item.additions,
356
+ previous.deletions + item.deletions,
357
+ )
358
+ if malformed_prelude or any(block.malformed for block in blocks):
359
+ return TurnDiffSummary(
360
+ files=tuple(TurnDiffFileStats(item.path) for item in merged)
361
+ )
362
+ numeric = tuple(block.counts for block in blocks if block.counts is not None)
363
+ totals_known = bool(numeric) and all(
364
+ block.counts is not None or block.binary for block in blocks
365
+ )
366
+ return TurnDiffSummary(
367
+ additions=sum(counts[0] for counts in numeric) if totals_known else None,
368
+ deletions=sum(counts[1] for counts in numeric) if totals_known else None,
369
+ files=tuple(merged),
370
+ )
371
+
372
+
373
+ def _diff_blocks(diff: str) -> Iterable[tuple[str, ...]]:
374
+ current: list[str] = []
375
+ for line in diff.splitlines():
376
+ if line.startswith("diff --git "):
377
+ if current:
378
+ yield tuple(current)
379
+ current = [line]
380
+ elif current:
381
+ current.append(line)
382
+ if current:
383
+ yield tuple(current)
384
+
385
+
386
+ def _parse_diff_block(lines: Sequence[str]) -> _ParsedDiffBlock:
387
+ header = _git_header_paths(lines[0][len("diff --git ") :])
388
+ header_target = header[1] if header is not None else None
389
+ source_header = False
390
+ target_header = False
391
+ target: str | None = None
392
+ rename_source: str | None = None
393
+ rename_target: str | None = None
394
+ copy_target: str | None = None
395
+ deleted = False
396
+ binary = False
397
+ rename_only = True
398
+ similarity_seen = False
399
+ additions = 0
400
+ deletions = 0
401
+ saw_hunk = False
402
+ malformed = False
403
+ old_expected: int | None = None
404
+ new_expected: int | None = None
405
+ old_seen = 0
406
+ new_seen = 0
407
+
408
+ for line in lines[1:]:
409
+ if line.startswith("@@"):
410
+ if old_expected is not None and (
411
+ old_seen != old_expected or new_seen != new_expected
412
+ ):
413
+ malformed = True
414
+ saw_hunk = True
415
+ rename_only = False
416
+ match = _HUNK_HEADER.fullmatch(line)
417
+ if match is None:
418
+ malformed = True
419
+ old_expected = new_expected = None
420
+ continue
421
+ old_expected = int(match.group(1) or "1")
422
+ new_expected = int(match.group(2) or "1")
423
+ old_seen = new_seen = 0
424
+ continue
425
+ if old_expected is not None:
426
+ if line.startswith("+"):
427
+ additions += 1
428
+ new_seen += 1
429
+ elif line.startswith("-"):
430
+ deletions += 1
431
+ old_seen += 1
432
+ elif line.startswith(" "):
433
+ old_seen += 1
434
+ new_seen += 1
435
+ elif line != r"":
436
+ malformed = True
437
+ if old_seen > old_expected or new_seen > new_expected:
438
+ malformed = True
439
+ continue
440
+ if line.startswith("--- "):
441
+ parsed = _metadata_path(line[4:])
442
+ malformed = malformed or source_header or parsed is None
443
+ source_header = True
444
+ rename_only = False
445
+ elif line.startswith("+++ "):
446
+ parsed = _metadata_path(line[4:])
447
+ malformed = malformed or target_header or parsed is None
448
+ target_header = True
449
+ rename_only = False
450
+ if parsed == "/dev/null":
451
+ deleted = True
452
+ elif parsed is not None:
453
+ target = parsed
454
+ elif line.startswith("rename from "):
455
+ parsed = _metadata_path(line[len("rename from ") :])
456
+ if rename_source is not None or parsed is None:
457
+ rename_only = False
458
+ else:
459
+ rename_source = parsed
460
+ elif line.startswith("rename to "):
461
+ parsed = _metadata_path(line[len("rename to ") :])
462
+ if rename_target is not None or parsed is None:
463
+ rename_only = False
464
+ else:
465
+ rename_target = parsed
466
+ elif line == "similarity index 100%" and not similarity_seen:
467
+ similarity_seen = True
468
+ elif line.startswith("copy to "):
469
+ copy_target = _metadata_path(line[len("copy to ") :])
470
+ rename_only = False
471
+ elif line.startswith("deleted file mode "):
472
+ deleted = True
473
+ rename_only = False
474
+ elif line.startswith("Binary files ") and line.endswith(" differ"):
475
+ binary = True
476
+ rename_only = False
477
+ deleted = deleted or line.endswith(" and /dev/null differ")
478
+ elif line == "GIT binary patch":
479
+ binary = True
480
+ rename_only = False
481
+ elif (
482
+ line.startswith(("+", "-", " "))
483
+ or line == r""
484
+ ):
485
+ malformed = True
486
+ rename_only = False
487
+ elif line:
488
+ rename_only = False
489
+
490
+ if old_expected is not None and (
491
+ old_seen != old_expected or new_seen != new_expected
492
+ ):
493
+ malformed = True
494
+ if (source_header or target_header) and not saw_hunk:
495
+ malformed = True
496
+ if saw_hunk and (
497
+ binary
498
+ or not source_header
499
+ or not target_header
500
+ or (not deleted and target is None)
501
+ ):
502
+ malformed = True
503
+ if saw_hunk and target is not None:
504
+ path = _normalize_diff_path(target)
505
+ elif rename_target is not None:
506
+ path = None if rename_target == "/dev/null" else rename_target
507
+ elif copy_target is not None:
508
+ path = None if copy_target == "/dev/null" else copy_target
509
+ else:
510
+ path = _normalize_diff_path(header_target)
511
+ pure_rename = (
512
+ rename_only
513
+ and similarity_seen
514
+ and rename_source is not None
515
+ and rename_target is not None
516
+ )
517
+ counts = (
518
+ (additions, deletions)
519
+ if not malformed and (saw_hunk or pure_rename)
520
+ else None
521
+ )
522
+ return _ParsedDiffBlock(path, deleted, binary, counts, malformed)
523
+
524
+
525
+ def paginate_turn_files(
526
+ files: Sequence[TurnFile],
527
+ page: int,
528
+ *,
529
+ page_size: int = TURN_FILE_PAGE_SIZE,
530
+ ) -> TurnFilePage:
531
+ if isinstance(page, bool) or not isinstance(page, int) or page < 0:
532
+ raise TurnFileError("本轮文件页码无效,请重新打开原卡片。")
533
+ if (
534
+ isinstance(page_size, bool)
535
+ or not isinstance(page_size, int)
536
+ or page_size < 1
537
+ ):
538
+ raise ValueError("page_size must be a positive integer")
539
+ total_items = len(files)
540
+ total_pages = max(1, (total_items + page_size - 1) // page_size)
541
+ if page >= total_pages:
542
+ raise TurnFileError("本轮文件页码已过期,请重新打开原卡片。")
543
+ start = page * page_size
544
+ return TurnFilePage(
545
+ items=tuple(files[start : start + page_size]),
546
+ page=page,
547
+ total_pages=total_pages,
548
+ total_items=total_items,
549
+ )
550
+
551
+
552
+ def inspect_turn_file_path(path: str, display_path: str) -> TurnFile:
553
+ """Inspect the current file named by a v4 card manifest entry.
554
+
555
+ The absolute path is the callback capability selected for this product
556
+ boundary. It is intentionally not constrained to a registered Project.
557
+ Missing, inaccessible, directory, and special-file targets remain visible
558
+ during pagination but are marked unavailable and cannot be sent.
559
+ """
560
+
561
+ candidate = Path(path)
562
+ if not candidate.is_absolute():
563
+ return TurnFile(display_path, candidate, None, None)
564
+ try:
565
+ resolved = candidate.resolve(strict=True)
566
+ metadata = resolved.stat()
567
+ except (OSError, RuntimeError, ValueError):
568
+ return TurnFile(display_path, candidate, None, None)
569
+ if not stat.S_ISREG(metadata.st_mode):
570
+ return TurnFile(display_path, resolved, None, None)
571
+ return TurnFile(
572
+ display_path=display_path,
573
+ resolved_path=resolved,
574
+ size=metadata.st_size,
575
+ media_kind="image" if _is_supported_image(resolved) else "file",
576
+ )
577
+
578
+
579
+ def require_turn_file_path(path: str, display_path: str = "") -> TurnFile:
580
+ turn_file = inspect_turn_file_path(path, display_path or Path(path).name)
581
+ if not turn_file.available:
582
+ raise TurnFileError(
583
+ "该文件当前已不可用:路径不存在、不可访问或不是普通文件,未发送。"
584
+ )
585
+ return turn_file
586
+
587
+
588
+ def _reported_paths(
589
+ items: Sequence[object],
590
+ *,
591
+ turn_diff: str | None = None,
592
+ diff_summary: TurnDiffSummary | None = None,
593
+ ) -> Iterable[_ReportedTurnPath]:
594
+ summary = diff_summary if diff_summary is not None else turn_diff_summary(turn_diff)
595
+ for file_stats in summary.files:
596
+ yield _ReportedTurnPath(
597
+ "turnDiff",
598
+ file_stats.path,
599
+ file_stats.additions,
600
+ file_stats.deletions,
601
+ deleted=file_stats.deleted,
602
+ )
603
+ for item in items:
604
+ root = getattr(item, "root", item)
605
+ item_type = getattr(root, "type", None)
606
+ status = _enum_value(getattr(root, "status", None))
607
+ if item_type == "fileChange" and status == "completed":
608
+ for change in getattr(root, "changes", ()):
609
+ kind = getattr(getattr(change, "kind", None), "root", None)
610
+ change_type = getattr(kind, "type", None)
611
+ if change_type not in {"add", "update", "delete"}:
612
+ continue
613
+ raw_path = getattr(change, "path", None)
614
+ if change_type == "update":
615
+ move_path = getattr(kind, "move_path", None)
616
+ if isinstance(move_path, str) and move_path:
617
+ raw_path = move_path
618
+ if isinstance(raw_path, str) and raw_path:
619
+ yield _ReportedTurnPath(
620
+ "fileChange", raw_path, deleted=change_type == "delete",
621
+ )
622
+ elif item_type == "imageGeneration" and status == "completed":
623
+ saved_path = _path_value(getattr(root, "saved_path", None))
624
+ if saved_path is not None:
625
+ yield _ReportedTurnPath("imageGeneration", saved_path)
626
+
627
+
628
+ def _resolve_turn_file(
629
+ reported: _ReportedTurnPath,
630
+ root: Path,
631
+ home: Path | None,
632
+ ) -> TurnFile | None:
633
+ try:
634
+ candidate = Path(reported.value)
635
+ if not candidate.is_absolute():
636
+ candidate = root / candidate
637
+ resolved = candidate.resolve(strict=True)
638
+ metadata = resolved.stat()
639
+ except FileNotFoundError:
640
+ if not reported.deleted:
641
+ return None
642
+ try:
643
+ resolved = candidate.resolve()
644
+ except (OSError, RuntimeError, ValueError):
645
+ return None
646
+ return TurnFile(
647
+ display_path=_display_path(reported, resolved, project_root=root, home=home),
648
+ resolved_path=resolved,
649
+ size=None,
650
+ media_kind=None,
651
+ additions=reported.additions,
652
+ deletions=reported.deletions,
653
+ deleted=True,
654
+ )
655
+ except (OSError, RuntimeError, ValueError):
656
+ return None
657
+ if not stat.S_ISREG(metadata.st_mode):
658
+ return None
659
+ display_path = _display_path(
660
+ reported,
661
+ resolved,
662
+ project_root=root,
663
+ home=home,
664
+ )
665
+ media_kind: Literal["image", "file"] = (
666
+ "image" if _is_supported_image(resolved) else "file"
667
+ )
668
+ return TurnFile(
669
+ display_path=display_path,
670
+ resolved_path=resolved,
671
+ size=metadata.st_size,
672
+ media_kind=media_kind,
673
+ additions=(reported.additions if media_kind == "file" else None),
674
+ deletions=(reported.deletions if media_kind == "file" else None),
675
+ )
676
+
677
+ def _display_path(
678
+ reported: _ReportedTurnPath,
679
+ resolved: Path,
680
+ *,
681
+ project_root: Path,
682
+ home: Path | None,
683
+ ) -> str:
684
+ if resolved.is_relative_to(project_root):
685
+ return resolved.relative_to(project_root).as_posix()
686
+ if reported.item_type == "imageGeneration":
687
+ return f"生成图片/{resolved.name}"
688
+ if home is not None and resolved.is_relative_to(home):
689
+ return f"~/{resolved.relative_to(home).as_posix()}"
690
+ parts = tuple(part for part in resolved.parts if part != resolved.anchor)
691
+ visible = parts[-3:]
692
+ prefix = "外部文件/…/" if len(parts) > len(visible) else "外部文件/"
693
+ return prefix + "/".join(visible)
694
+
695
+
696
+ def _enum_value(value: object) -> object:
697
+ return getattr(value, "value", value)
698
+
699
+
700
+ def _path_value(value: object) -> str | None:
701
+ if isinstance(value, str) and value:
702
+ return value
703
+ root = getattr(value, "root", None)
704
+ return root if isinstance(root, str) and root else None
705
+
706
+
707
+ def _is_supported_image(path: Path) -> bool:
708
+ try:
709
+ with path.open("rb") as stream:
710
+ header = stream.read(12)
711
+ except OSError:
712
+ return False
713
+ return is_supported_image_header(header)
714
+
715
+
716
+ def is_supported_image_header(header: bytes) -> bool:
717
+ """Use the same PNG/JPEG/GIF/WebP signature for file sending and previews."""
718
+ return (
719
+ header.startswith(b"\x89PNG\r\n\x1a\n")
720
+ or header.startswith(b"\xff\xd8\xff")
721
+ or header.startswith((b"GIF87a", b"GIF89a"))
722
+ or (
723
+ len(header) >= 12
724
+ and header[:4] == b"RIFF"
725
+ and header[8:12] == b"WEBP"
726
+ )
727
+ )
728
+
729
+
730
+ def _git_header_paths(value: str) -> tuple[str, str] | None:
731
+ tokens: list[str] = []
732
+ index = 0
733
+ while index < len(value):
734
+ while index < len(value) and value[index].isspace():
735
+ index += 1
736
+ if index >= len(value):
737
+ break
738
+ start = index
739
+ if value[index] == '"':
740
+ index += 1
741
+ escaped = False
742
+ while index < len(value):
743
+ character = value[index]
744
+ index += 1
745
+ if escaped:
746
+ escaped = False
747
+ elif character == "\\":
748
+ escaped = True
749
+ elif character == '"':
750
+ break
751
+ else:
752
+ return None
753
+ else:
754
+ while index < len(value) and not value[index].isspace():
755
+ index += 1
756
+ token = _decode_git_path(value[start:index])
757
+ if token is None:
758
+ return None
759
+ tokens.append(token)
760
+ if len(tokens) == 2:
761
+ return tokens[0], tokens[1]
762
+ same_path = re.fullmatch(r"a/(.+) b/\1", value)
763
+ if same_path is None:
764
+ return None
765
+ path = same_path.group(1)
766
+ return f"a/{path}", f"b/{path}"
767
+
768
+
769
+ def _metadata_path(value: str) -> str | None:
770
+ value = value.strip()
771
+ if not value:
772
+ return None
773
+ if value.startswith('"'):
774
+ index = 1
775
+ escaped = False
776
+ while index < len(value):
777
+ character = value[index]
778
+ index += 1
779
+ if escaped:
780
+ escaped = False
781
+ elif character == "\\":
782
+ escaped = True
783
+ elif character == '"':
784
+ return _decode_git_path(value[:index])
785
+ return None
786
+ # Traditional unified diffs may append a timestamp after a tab.
787
+ return value.split("\t", 1)[0]
788
+
789
+
790
+ def _decode_git_path(value: str) -> str | None:
791
+ if not value:
792
+ return None
793
+ if not value.startswith('"'):
794
+ return value
795
+ try:
796
+ decoded = ast.literal_eval(value)
797
+ except (SyntaxError, ValueError):
798
+ return None
799
+ if not isinstance(decoded, str):
800
+ return None
801
+ try:
802
+ return decoded.encode("latin-1").decode("utf-8")
803
+ except (UnicodeEncodeError, UnicodeDecodeError):
804
+ return decoded
805
+
806
+
807
+ def _normalize_diff_path(value: str | None) -> str | None:
808
+ if not isinstance(value, str) or not value or value == "/dev/null":
809
+ return None
810
+ if value.startswith(("a/", "b/")):
811
+ value = value[2:]
812
+ return value or None