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,688 @@
1
+ """Safe, bounded projection of native Turn notifications for reply cards."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from enum import Enum
8
+
9
+ from openai_codex.generated.v2_all import (
10
+ AgentMessageThreadItem,
11
+ AsyncUserInputQuestion,
12
+ CollabAgentToolCallThreadItem,
13
+ CommandExecutionThreadItem,
14
+ ContextCompactionThreadItem,
15
+ DynamicToolCallThreadItem,
16
+ EnteredReviewModeThreadItem,
17
+ ExitedReviewModeThreadItem,
18
+ FileChangeThreadItem,
19
+ FindInPageWebSearchAction,
20
+ ImageGenerationThreadItem,
21
+ ImageViewThreadItem,
22
+ ItemCompletedNotification,
23
+ ItemStartedNotification,
24
+ ListFilesCommandAction,
25
+ MessagePhase,
26
+ McpToolCallThreadItem,
27
+ OpenPageWebSearchAction,
28
+ ReadCommandAction,
29
+ SearchCommandAction,
30
+ SearchWebSearchAction,
31
+ SubAgentActivityThreadItem,
32
+ ThreadItem,
33
+ TurnCompletedNotification,
34
+ TurnPlanStep,
35
+ TurnPlanStepStatus,
36
+ TurnPlanUpdatedNotification,
37
+ TurnStartedNotification,
38
+ WebSearchThreadItem,
39
+ )
40
+ from openai_codex.models import Notification
41
+
42
+ from .user_questions import QuestionRequest, UserQuestion
43
+
44
+
45
+ ACTIVITY_COMMENTARY_LIMIT = 4
46
+ ACTIVITY_OPERATION_LIMIT = 8
47
+ ACTIVITY_PLAN_LIMIT = 12
48
+ ACTIVITY_TEXT_LIMIT = 160
49
+ ACTIVITY_OPERATION_TEXT_LIMIT = 120
50
+ ACTIVITY_TAB_SPACES = 4
51
+ SIDE_ACTIVITY_QUEUE_HIGH_WATER = 4_096
52
+
53
+ _ITEM_STARTED_METHOD = "item/started"
54
+ _ITEM_COMPLETED_METHOD = "item/completed"
55
+ _PLAN_METHOD = "turn/plan/updated"
56
+ _TURN_STARTED_METHOD = "turn/started"
57
+ _TURN_COMPLETED_METHOD = "turn/completed"
58
+
59
+ _SECRET_ASSIGNMENT = re.compile(
60
+ r"(?i)(?:api[ _-]?key|access[ _-]?token|refresh[ _-]?token|"
61
+ r"auth(?:entication|orization)?|bearer|cookie|credential|password|"
62
+ r"passwd|secret|session[ _-]?token|密码|口令|密钥|令牌|凭据|授权|认证)"
63
+ r"[\"']?\s*(?:=|:|:|(?<![A-Za-z0-9_])is(?![A-Za-z0-9_]))"
64
+ r"\s*[^\s,;,;]+"
65
+ )
66
+ _SECRET_FLAG = re.compile(
67
+ r"(?i)(?<!\S)--(?:api[ _-]?key|access[ _-]?token|refresh[ _-]?token|"
68
+ r"session[ _-]?token|token|password|passwd|secret|credential|cookie)"
69
+ r"(?:\s+|=)\S+"
70
+ )
71
+ _URL_SECRET_QUERY = re.compile(
72
+ r"(?i)[?&](?:token|key|signature|sig|x-amz-signature)=[^\s&#]+"
73
+ )
74
+ _BEARER_TOKEN = re.compile(
75
+ r"(?i)(?<![A-Za-z0-9_])bearer(?![A-Za-z0-9_])\s+\S+"
76
+ )
77
+ _PEM = re.compile(r"-----BEGIN [^-]+-----", re.IGNORECASE)
78
+ _URL_CREDENTIAL = re.compile(
79
+ r"(?i)(?<![A-Za-z0-9_])[A-Za-z][A-Za-z0-9+.-]*://"
80
+ r"[^\s/@:]*:[^\s/@]+@"
81
+ )
82
+ _KNOWN_TOKEN = re.compile(
83
+ r"(?<![A-Za-z0-9_])(?:AKIA[0-9A-Z]{16}|"
84
+ r"(?:sk|gh[pousr])[-_][A-Za-z0-9_-]{16,}|"
85
+ r"eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,})"
86
+ r"(?![A-Za-z0-9_])"
87
+ )
88
+ _TIME_VALUE = (
89
+ r"\d+(?:\.\d+)?\s*(?:milliseconds?|seconds?|minutes?|hours?|"
90
+ r"ms|s|m|h|毫秒|秒钟?|分钟?|小时)(?![A-Za-z0-9_])"
91
+ )
92
+ _ELAPSED = re.compile(
93
+ r"(?i)(?:(?<![A-Za-z0-9_])(?:elapsed|worked\s+for)"
94
+ r"(?![A-Za-z0-9_])|(?:耗时|用时))"
95
+ r"\s*[:=:]?\s*" + _TIME_VALUE
96
+ )
97
+ _PERCENT = re.compile(r"(?<!\d)\d{1,3}(?:[..]\d+)?\s*[%%]")
98
+ _ETA = re.compile(
99
+ r"(?i)(?<![A-Za-z0-9_])ETA(?![A-Za-z0-9_])"
100
+ r"\s*[:=:]?\s*" + _TIME_VALUE
101
+ )
102
+
103
+
104
+ class TurnActivityProjectionUnavailable(RuntimeError):
105
+ """An allowlisted native notification no longer has its pinned shape."""
106
+
107
+
108
+ class TurnPlanStepState(str, Enum):
109
+ PENDING = "pending"
110
+ IN_PROGRESS = "inProgress"
111
+ COMPLETED = "completed"
112
+
113
+
114
+ class TurnActivityKind(str, Enum):
115
+ COMMENTARY = "commentary"
116
+ COMMAND = "command"
117
+ TOOL = "tool"
118
+ FILE_CHANGE = "fileChange"
119
+ WEB_SEARCH = "webSearch"
120
+ IMAGE = "image"
121
+ SUBAGENT = "subagent"
122
+ REVIEW = "review"
123
+ COMPACTION = "compaction"
124
+
125
+
126
+ ACTIVITY_DETAIL_KINDS = frozenset(
127
+ {TurnActivityKind.COMMAND, TurnActivityKind.FILE_CHANGE, TurnActivityKind.WEB_SEARCH}
128
+ )
129
+
130
+
131
+ class TurnActivityStatus(str, Enum):
132
+ IN_PROGRESS = "inProgress"
133
+ COMPLETED = "completed"
134
+ FAILED = "failed"
135
+ DECLINED = "declined"
136
+ INTERRUPTED = "interrupted"
137
+
138
+
139
+ @dataclass(frozen=True, slots=True)
140
+ class TurnPlanStepSnapshot:
141
+ step: str
142
+ status: TurnPlanStepState
143
+
144
+
145
+ @dataclass(frozen=True, slots=True)
146
+ class TurnActivityEvent:
147
+ item_id: str
148
+ kind: TurnActivityKind
149
+ status: TurnActivityStatus
150
+ event_timestamp_ms: int
151
+ text: str | None = None
152
+ count: int = 1
153
+
154
+ def __post_init__(self) -> None:
155
+ _validate_exact_id(self.item_id, label="item")
156
+ _validate_timestamp_ms(self.event_timestamp_ms)
157
+ _validate_activity_text(self.kind, self.text)
158
+ if isinstance(self.count, bool) or not isinstance(self.count, int) or self.count < 0:
159
+ raise ValueError("activity count must be a non-negative integer")
160
+
161
+
162
+ @dataclass(frozen=True, slots=True)
163
+ class TurnActivityEntrySnapshot:
164
+ """One identity-free activity row safe to hand to the Channel layer."""
165
+
166
+ kind: TurnActivityKind
167
+ status: TurnActivityStatus
168
+ event_timestamp_ms: int
169
+ text: str | None = None
170
+ count: int = 1
171
+
172
+ def __post_init__(self) -> None:
173
+ _validate_timestamp_ms(self.event_timestamp_ms)
174
+ _validate_activity_text(self.kind, self.text)
175
+ if isinstance(self.count, bool) or not isinstance(self.count, int) or self.count < 0:
176
+ raise ValueError("activity count must be a non-negative integer")
177
+
178
+
179
+ @dataclass(frozen=True, slots=True)
180
+ class TurnActivityNotificationProjection:
181
+ turn_id: str | None = None
182
+ turn_started: bool = False
183
+ turn_completed: bool = False
184
+ plan_updated: bool = False
185
+ steps: tuple[TurnPlanStepSnapshot, ...] = ()
186
+ event: TurnActivityEvent | None = None
187
+ question: QuestionRequest | None = None
188
+
189
+ def __post_init__(self) -> None:
190
+ if self.turn_id is not None:
191
+ _validate_exact_id(self.turn_id, label="Turn")
192
+ if self.turn_id is None and (
193
+ self.turn_started
194
+ or self.turn_completed
195
+ or self.plan_updated
196
+ or self.event is not None
197
+ or self.question is not None
198
+ ):
199
+ raise ValueError("activity projection requires an exact Turn ID")
200
+ if not self.plan_updated and self.steps:
201
+ raise ValueError("activity plan steps require a full plan replacement")
202
+
203
+
204
+ def project_turn_activity_notification(
205
+ notification: Notification,
206
+ *,
207
+ expected_thread_id: str,
208
+ expected_turn_id: str | None,
209
+ ) -> TurnActivityNotificationProjection:
210
+ """Project one raw notification into a safe internal control event.
211
+
212
+ Exact Turn and item identities remain process-local so consumers can reject
213
+ stale events and coalesce lifecycle updates. Channel-facing snapshots strip
214
+ item identities and never receive arguments, output, or native payloads.
215
+ """
216
+
217
+ if type(notification) is not Notification:
218
+ raise TurnActivityProjectionUnavailable(
219
+ "native Turn notification item shape changed"
220
+ )
221
+ _validate_exact_id(expected_thread_id, label="Thread")
222
+ if expected_turn_id is not None:
223
+ _validate_exact_id(expected_turn_id, label="Turn")
224
+
225
+ method = notification.method
226
+ payload = notification.payload
227
+ if method == _TURN_STARTED_METHOD:
228
+ if type(payload) is not TurnStartedNotification:
229
+ raise TurnActivityProjectionUnavailable("Turn started payload shape changed")
230
+ turn_id = _turn_identity(payload.thread_id, payload.turn.id, expected_thread_id)
231
+ if turn_id is None:
232
+ return TurnActivityNotificationProjection()
233
+ return TurnActivityNotificationProjection(turn_id=turn_id, turn_started=True)
234
+ if method == _TURN_COMPLETED_METHOD:
235
+ if type(payload) is not TurnCompletedNotification:
236
+ raise TurnActivityProjectionUnavailable("Turn completed payload shape changed")
237
+ turn_id = _turn_identity(payload.thread_id, payload.turn.id, expected_thread_id)
238
+ if turn_id is None or (
239
+ expected_turn_id is not None and turn_id != expected_turn_id
240
+ ):
241
+ return TurnActivityNotificationProjection()
242
+ return TurnActivityNotificationProjection(turn_id=turn_id, turn_completed=True)
243
+ if method == _PLAN_METHOD:
244
+ if type(payload) is not TurnPlanUpdatedNotification:
245
+ raise TurnActivityProjectionUnavailable("Turn plan payload shape changed")
246
+ turn_id = _turn_identity(payload.thread_id, payload.turn_id, expected_thread_id)
247
+ if turn_id is None or (
248
+ expected_turn_id is not None and turn_id != expected_turn_id
249
+ ):
250
+ return TurnActivityNotificationProjection()
251
+ return TurnActivityNotificationProjection(
252
+ turn_id=turn_id,
253
+ plan_updated=True,
254
+ steps=project_plan_steps(payload.plan),
255
+ )
256
+ if method not in {_ITEM_STARTED_METHOD, _ITEM_COMPLETED_METHOD}:
257
+ return TurnActivityNotificationProjection()
258
+
259
+ expected_payload_type = (
260
+ ItemStartedNotification
261
+ if method == _ITEM_STARTED_METHOD
262
+ else ItemCompletedNotification
263
+ )
264
+ if type(payload) is not expected_payload_type:
265
+ raise TurnActivityProjectionUnavailable("Turn item payload shape changed")
266
+ turn_id = _turn_identity(payload.thread_id, payload.turn_id, expected_thread_id)
267
+ if turn_id is None or (
268
+ expected_turn_id is not None and turn_id != expected_turn_id
269
+ ):
270
+ return TurnActivityNotificationProjection()
271
+ if type(payload.item) is not ThreadItem:
272
+ raise TurnActivityProjectionUnavailable("native Thread item shape changed")
273
+ completed = method == _ITEM_COMPLETED_METHOD
274
+ event_timestamp_ms = (
275
+ payload.completed_at_ms if completed else payload.started_at_ms
276
+ )
277
+ if (
278
+ isinstance(event_timestamp_ms, bool)
279
+ or not isinstance(event_timestamp_ms, int)
280
+ or event_timestamp_ms < 0
281
+ ):
282
+ raise TurnActivityProjectionUnavailable(
283
+ "native Turn item lifecycle timestamp changed"
284
+ )
285
+ event = _project_item(
286
+ payload.item.root,
287
+ completed=completed,
288
+ event_timestamp_ms=event_timestamp_ms,
289
+ )
290
+ return TurnActivityNotificationProjection(
291
+ turn_id=turn_id,
292
+ event=event,
293
+ question=project_question(payload.item.root) if completed else None,
294
+ )
295
+
296
+
297
+ def project_question(item: object) -> QuestionRequest | None:
298
+ """Project the native user-facing question fields, independently of Activity.
299
+
300
+ Delivery and phase describe the message, not whether its structured questions
301
+ should be shown. Preserve full question/option text for the answer protocol.
302
+ """
303
+
304
+ if type(item) is not AgentMessageThreadItem or not item.questions:
305
+ return None
306
+ if type(item.questions) is not list:
307
+ raise TurnActivityProjectionUnavailable("native questions shape changed")
308
+ try:
309
+ _validate_exact_id(item.id, label="item")
310
+ except ValueError as error:
311
+ raise TurnActivityProjectionUnavailable("native question item ID changed") from error
312
+ questions: list[UserQuestion] = []
313
+ for question in item.questions:
314
+ if (
315
+ type(question) is not AsyncUserInputQuestion
316
+ or not isinstance(question.title, str)
317
+ or not question.title.strip()
318
+ or (
319
+ question.options is not None
320
+ and (
321
+ type(question.options) is not list
322
+ or any(not isinstance(option, str) for option in question.options)
323
+ )
324
+ )
325
+ ):
326
+ raise TurnActivityProjectionUnavailable("native question fields changed")
327
+ questions.append(UserQuestion(question.title, tuple(question.options or ())))
328
+ return QuestionRequest(item.id, tuple(questions))
329
+
330
+
331
+ def project_plan_steps(items: object) -> tuple[TurnPlanStepSnapshot, ...]:
332
+ if not isinstance(items, list):
333
+ raise TurnActivityProjectionUnavailable("native Turn plan is not a list")
334
+ steps: list[TurnPlanStepSnapshot] = []
335
+ for item in items:
336
+ if type(item) is not TurnPlanStep:
337
+ raise TurnActivityProjectionUnavailable("native Turn plan step shape changed")
338
+ step = item.step
339
+ if not isinstance(step, str) or not step.strip():
340
+ raise TurnActivityProjectionUnavailable("native Turn plan step is empty")
341
+ status = getattr(item.status, "value", None)
342
+ try:
343
+ mapped = TurnPlanStepState(status)
344
+ except (TypeError, ValueError) as error:
345
+ raise TurnActivityProjectionUnavailable(
346
+ "native Turn plan step status changed"
347
+ ) from error
348
+ safe_step = sanitize_activity_text(step)
349
+ if safe_step is None:
350
+ raise TurnActivityProjectionUnavailable("native Turn plan step is empty")
351
+ steps.append(TurnPlanStepSnapshot(step=safe_step, status=mapped))
352
+ return tuple(steps)
353
+
354
+
355
+ def sanitize_activity_text(value: str) -> str | None:
356
+ """Retain useful commentary/checklist text while filtering credentials and estimates."""
357
+
358
+ normalized = normalize_activity_text_layout(value)
359
+ if not normalized.strip():
360
+ return None
361
+ redacted = _redact_activity_credentials(normalized)
362
+ redacted = _ELAPSED.sub("[时间信息已隐藏]", redacted)
363
+ redacted = _PERCENT.sub("[百分比已隐藏]", redacted)
364
+ redacted = _ETA.sub("[时间估算已隐藏]", redacted)
365
+ return _bounded_activity_text(redacted)
366
+
367
+
368
+ def sanitize_activity_operation_text(
369
+ value: str, *, limit: int = ACTIVITY_OPERATION_TEXT_LIMIT
370
+ ) -> str | None:
371
+ """Bound a literal native-field preview without interpreting command syntax."""
372
+
373
+ normalized = normalize_activity_text_layout(value)
374
+ if not normalized.strip():
375
+ return None
376
+ # Filter before truncation so a clipped credential never becomes visible.
377
+ redacted = _redact_activity_credentials(normalized)
378
+ return _bounded_activity_text(" ".join(redacted.split()), limit)
379
+
380
+
381
+ def _redact_activity_credentials(value: str) -> str:
382
+ if (
383
+ _SECRET_ASSIGNMENT.search(value)
384
+ or _SECRET_FLAG.search(value)
385
+ or _URL_SECRET_QUERY.search(value)
386
+ or _BEARER_TOKEN.search(value)
387
+ or _PEM.search(value)
388
+ or _URL_CREDENTIAL.search(value)
389
+ ):
390
+ return "[敏感内容已隐藏]"
391
+ return _KNOWN_TOKEN.sub("[敏感内容已隐藏]", value)
392
+
393
+
394
+ def _bounded_activity_text(value: str, limit: int = ACTIVITY_TEXT_LIMIT) -> str:
395
+ if len(value) > limit:
396
+ return value[: limit - 1].rstrip() + "…"
397
+ return value
398
+
399
+
400
+ def normalize_activity_text_layout(value: str) -> str:
401
+ """Preserve supported Markdown layout and replace other controls."""
402
+
403
+ if not isinstance(value, str):
404
+ raise TurnActivityProjectionUnavailable("activity text shape changed")
405
+ canonical = (
406
+ value.replace("\r\n", "\n")
407
+ .replace("\r", "\n")
408
+ .replace("\t", " " * ACTIVITY_TAB_SPACES)
409
+ )
410
+ return "".join(
411
+ character
412
+ if character == "\n" or character.isprintable()
413
+ else "�"
414
+ for character in canonical
415
+ )
416
+
417
+
418
+ def _project_item(
419
+ item: object,
420
+ *,
421
+ completed: bool,
422
+ event_timestamp_ms: int,
423
+ ) -> TurnActivityEvent | None:
424
+ lifecycle_status = (
425
+ TurnActivityStatus.COMPLETED
426
+ if completed
427
+ else TurnActivityStatus.IN_PROGRESS
428
+ )
429
+ if type(item) is AgentMessageThreadItem:
430
+ if not completed or item.phase is not MessagePhase.commentary:
431
+ return None
432
+ text = sanitize_activity_text(item.text)
433
+ if text is None:
434
+ return None
435
+ return TurnActivityEvent(
436
+ item_id=item.id,
437
+ kind=TurnActivityKind.COMMENTARY,
438
+ status=TurnActivityStatus.COMPLETED,
439
+ event_timestamp_ms=event_timestamp_ms,
440
+ text=text,
441
+ )
442
+ if type(item) is CommandExecutionThreadItem:
443
+ return _status_event(
444
+ item.id,
445
+ TurnActivityKind.COMMAND,
446
+ item.status,
447
+ lifecycle_status,
448
+ event_timestamp_ms=event_timestamp_ms,
449
+ text=_command_activity_summary(item),
450
+ )
451
+ if type(item) is McpToolCallThreadItem:
452
+ return _status_event(
453
+ item.id,
454
+ TurnActivityKind.TOOL,
455
+ item.status,
456
+ lifecycle_status,
457
+ event_timestamp_ms=event_timestamp_ms,
458
+ text=_tool_name(item.tool),
459
+ )
460
+ if type(item) is DynamicToolCallThreadItem:
461
+ tool = _tool_name(item.tool)
462
+ namespace = item.namespace
463
+ if namespace:
464
+ namespace = _tool_name(namespace)
465
+ tool = f"{namespace}.{tool}"
466
+ return _status_event(
467
+ item.id,
468
+ TurnActivityKind.TOOL,
469
+ item.status,
470
+ lifecycle_status,
471
+ event_timestamp_ms=event_timestamp_ms,
472
+ text=tool,
473
+ )
474
+ if type(item) is FileChangeThreadItem:
475
+ return _status_event(
476
+ item.id,
477
+ TurnActivityKind.FILE_CHANGE,
478
+ item.status,
479
+ lifecycle_status,
480
+ event_timestamp_ms=event_timestamp_ms,
481
+ text=_file_change_activity_summary(item),
482
+ count=len(item.changes),
483
+ )
484
+ if type(item) is WebSearchThreadItem:
485
+ return TurnActivityEvent(
486
+ item.id,
487
+ TurnActivityKind.WEB_SEARCH,
488
+ lifecycle_status,
489
+ event_timestamp_ms,
490
+ text=_web_search_activity_summary(item),
491
+ )
492
+ if type(item) in {ImageViewThreadItem, ImageGenerationThreadItem}:
493
+ status = item.status if type(item) is ImageGenerationThreadItem else None
494
+ return _status_event(
495
+ item.id,
496
+ TurnActivityKind.IMAGE,
497
+ status,
498
+ lifecycle_status,
499
+ event_timestamp_ms=event_timestamp_ms,
500
+ )
501
+ if type(item) is CollabAgentToolCallThreadItem:
502
+ count = max(len(item.receiver_thread_ids), len(item.agents_states), 1)
503
+ return _status_event(
504
+ item.id,
505
+ TurnActivityKind.SUBAGENT,
506
+ item.status,
507
+ lifecycle_status,
508
+ event_timestamp_ms=event_timestamp_ms,
509
+ count=count,
510
+ )
511
+ if type(item) is SubAgentActivityThreadItem:
512
+ # The SDK emits started/completed item envelopes for these activity
513
+ # records. A completed child must not briefly appear to be running
514
+ # when its activity record first arrives.
515
+ status = {
516
+ "interrupted": TurnActivityStatus.INTERRUPTED,
517
+ "completed": TurnActivityStatus.COMPLETED,
518
+ }.get(getattr(item.kind, "value", None), lifecycle_status)
519
+ return TurnActivityEvent(
520
+ item.id,
521
+ TurnActivityKind.SUBAGENT,
522
+ status,
523
+ event_timestamp_ms,
524
+ )
525
+ if type(item) in {EnteredReviewModeThreadItem, ExitedReviewModeThreadItem}:
526
+ return TurnActivityEvent(
527
+ item.id,
528
+ TurnActivityKind.REVIEW,
529
+ lifecycle_status,
530
+ event_timestamp_ms,
531
+ )
532
+ if type(item) is ContextCompactionThreadItem:
533
+ return TurnActivityEvent(
534
+ item.id,
535
+ TurnActivityKind.COMPACTION,
536
+ lifecycle_status,
537
+ event_timestamp_ms,
538
+ )
539
+ return None
540
+
541
+
542
+ def _status_event(
543
+ item_id: str,
544
+ kind: TurnActivityKind,
545
+ native_status: object,
546
+ lifecycle_status: TurnActivityStatus,
547
+ *,
548
+ event_timestamp_ms: int,
549
+ text: str | None = None,
550
+ count: int = 1,
551
+ ) -> TurnActivityEvent:
552
+ value = getattr(native_status, "value", native_status)
553
+ try:
554
+ status = TurnActivityStatus(value)
555
+ except (TypeError, ValueError):
556
+ status = lifecycle_status
557
+ return TurnActivityEvent(
558
+ item_id,
559
+ kind,
560
+ status,
561
+ event_timestamp_ms,
562
+ text=text,
563
+ count=count,
564
+ )
565
+
566
+
567
+ def _operation_summary(label: str, *details: str | None) -> str:
568
+ # Sanitize each native field before combining it with display labels.
569
+ parts = [label]
570
+ for detail in details:
571
+ if detail is not None:
572
+ safe = sanitize_activity_operation_text(detail)
573
+ if safe:
574
+ parts.append(safe)
575
+ return _bounded_activity_text(" · ".join(parts), ACTIVITY_OPERATION_TEXT_LIMIT)
576
+
577
+
578
+ def _command_activity_summary(item: CommandExecutionThreadItem) -> str:
579
+ summary = None
580
+ # commandActions is Codex's best-effort classification. Do not build a
581
+ # second shell parser, infer purposes, or summarize composite commands.
582
+ if len(item.command_actions) == 1:
583
+ action = item.command_actions[0].root
584
+ if type(action) is ReadCommandAction and action.path.root.strip():
585
+ summary = _operation_summary("读取文件", action.path.root)
586
+ elif (
587
+ type(action) is ListFilesCommandAction
588
+ and action.path
589
+ and action.path.strip()
590
+ ):
591
+ summary = _operation_summary("列出文件", action.path)
592
+ elif type(action) is SearchCommandAction and (
593
+ (action.query and action.query.strip()) or (action.path and action.path.strip())
594
+ ):
595
+ summary = _operation_summary("搜索内容", action.query, action.path)
596
+ if summary is None:
597
+ summary = _operation_summary("执行命令", item.command)
598
+ if item.exit_code is not None and item.exit_code != 0:
599
+ suffix = f" · 退出码 {item.exit_code}"
600
+ summary = (
601
+ _bounded_activity_text(summary, ACTIVITY_OPERATION_TEXT_LIMIT - len(suffix))
602
+ + suffix
603
+ )
604
+ return summary
605
+
606
+
607
+ def _file_change_activity_summary(item: FileChangeThreadItem) -> str | None:
608
+ if not item.changes:
609
+ return None
610
+ labels = {"add": "新增", "delete": "删除", "update": "更新"}
611
+ changes = []
612
+ for change in item.changes[:3]:
613
+ kind = change.kind.root
614
+ label = labels.get(kind.type, "修改")
615
+ path = change.path
616
+ if kind.type == "update" and kind.move_path:
617
+ path += f" → {kind.move_path}"
618
+ safe_path = sanitize_activity_operation_text(path)
619
+ changes.append(f"{label} {safe_path}" if safe_path else label)
620
+ detail = "、".join(changes) + ("、…" if len(item.changes) > 3 else "")
621
+ return _operation_summary("修改文件", detail)
622
+
623
+
624
+ def _web_search_activity_summary(item: WebSearchThreadItem) -> str:
625
+ action = item.action.root if item.action is not None else None
626
+ if type(action) is OpenPageWebSearchAction:
627
+ return _operation_summary("打开网页", action.url or item.query)
628
+ if type(action) is FindInPageWebSearchAction:
629
+ return _operation_summary(
630
+ "查找网页内容", action.pattern, action.url or item.query
631
+ )
632
+ if type(action) is SearchWebSearchAction:
633
+ queries = [query for query in (action.queries or ()) if query.strip()]
634
+ if queries:
635
+ detail = "、".join(
636
+ sanitize_activity_operation_text(query) or "" for query in queries[:3]
637
+ ) + ("、…" if len(queries) > 3 else "")
638
+ return _operation_summary("搜索网页", detail)
639
+ return _operation_summary("搜索网页", action.query or item.query)
640
+ return _operation_summary("搜索网页", item.query)
641
+
642
+
643
+ def _tool_name(value: object) -> str:
644
+ if not isinstance(value, str) or not value:
645
+ raise TurnActivityProjectionUnavailable("native tool name shape changed")
646
+ return value
647
+
648
+
649
+ def _validate_timestamp_ms(value: object) -> None:
650
+ if isinstance(value, bool) or not isinstance(value, int) or value < 0:
651
+ raise ValueError("activity timestamp must be a non-negative integer")
652
+
653
+
654
+ def _validate_activity_text(
655
+ kind: TurnActivityKind,
656
+ value: str | None,
657
+ ) -> None:
658
+ if value is None:
659
+ return
660
+ if not isinstance(value, str) or not value:
661
+ raise ValueError("activity text must be a non-empty string")
662
+ if kind == TurnActivityKind.COMMENTARY and len(value) > ACTIVITY_TEXT_LIMIT:
663
+ raise ValueError("activity text must be bounded")
664
+ if kind in ACTIVITY_DETAIL_KINDS and len(value) > ACTIVITY_OPERATION_TEXT_LIMIT:
665
+ raise ValueError("activity text must be bounded")
666
+ if kind not in {
667
+ TurnActivityKind.COMMENTARY,
668
+ TurnActivityKind.TOOL,
669
+ *ACTIVITY_DETAIL_KINDS,
670
+ }:
671
+ raise ValueError("activity text is unsupported for this kind")
672
+
673
+
674
+ def _turn_identity(
675
+ actual_thread_id: object,
676
+ actual_turn_id: object,
677
+ expected_thread_id: str,
678
+ ) -> str | None:
679
+ if actual_thread_id != expected_thread_id:
680
+ return None
681
+ if not isinstance(actual_turn_id, str) or not actual_turn_id:
682
+ raise TurnActivityProjectionUnavailable("native Turn ID shape changed")
683
+ return actual_turn_id
684
+
685
+
686
+ def _validate_exact_id(value: str, *, label: str) -> None:
687
+ if not isinstance(value, str) or not value or value.strip() != value:
688
+ raise ValueError(f"native {label} ID must be a non-empty trimmed string")