aisc-cli 0.1.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.
- aisc/VERSION +1 -0
- aisc/__init__.py +49 -0
- aisc/__main__.py +6 -0
- aisc/adapters/__init__.py +1 -0
- aisc/adapters/config_reader.py +215 -0
- aisc/adapters/container_registry.py +718 -0
- aisc/adapters/data_root_store.py +296 -0
- aisc/adapters/docker_.py +1183 -0
- aisc/adapters/docker_gateway.py +1244 -0
- aisc/adapters/docker_sdk_backed.py +274 -0
- aisc/adapters/maintenance_lock.py +97 -0
- aisc/adapters/state_file.py +184 -0
- aisc/adapters/system.py +101 -0
- aisc/adapters/windows_config_reader.py +249 -0
- aisc/adapters/workspace_lease_store.py +189 -0
- aisc/application/__init__.py +1 -0
- aisc/application/artifact.py +265 -0
- aisc/application/bundle_fetch.py +836 -0
- aisc/application/cc_switch_provider.py +245 -0
- aisc/application/cc_switch_resolver.py +363 -0
- aisc/application/config_service.py +452 -0
- aisc/application/conversation.py +516 -0
- aisc/application/data_migration.py +440 -0
- aisc/application/data_root.py +275 -0
- aisc/application/docker_lifecycle.py +659 -0
- aisc/application/doctor.py +821 -0
- aisc/application/legacy_scan.py +252 -0
- aisc/application/network_subscription.py +642 -0
- aisc/application/profile_service.py +105 -0
- aisc/application/provider.py +92 -0
- aisc/application/repair.py +387 -0
- aisc/application/resources.py +270 -0
- aisc/application/runtime.py +1826 -0
- aisc/application/session.py +320 -0
- aisc/application/tool_versions.py +210 -0
- aisc/application/toolchain.py +91 -0
- aisc/application/update.py +286 -0
- aisc/application/usage.py +305 -0
- aisc/application/version.py +133 -0
- aisc/application/web_gateway.py +459 -0
- aisc/application/workspace_reconcile.py +318 -0
- aisc/applog.py +103 -0
- aisc/cli/__init__.py +1 -0
- aisc/cli/build_progress_parser.py +217 -0
- aisc/cli/commands/__init__.py +1 -0
- aisc/cli/commands/agents.py +117 -0
- aisc/cli/commands/artifact.py +210 -0
- aisc/cli/commands/build.py +566 -0
- aisc/cli/commands/bundle.py +107 -0
- aisc/cli/commands/cc_switch.py +158 -0
- aisc/cli/commands/config.py +104 -0
- aisc/cli/commands/container.py +787 -0
- aisc/cli/commands/conversation.py +72 -0
- aisc/cli/commands/data_root.py +84 -0
- aisc/cli/commands/logs.py +89 -0
- aisc/cli/commands/network.py +132 -0
- aisc/cli/commands/profile.py +52 -0
- aisc/cli/commands/provider.py +49 -0
- aisc/cli/commands/run.py +494 -0
- aisc/cli/commands/runs.py +125 -0
- aisc/cli/commands/runtime.py +450 -0
- aisc/cli/commands/serve.py +550 -0
- aisc/cli/commands/serve_fs.py +369 -0
- aisc/cli/commands/session.py +167 -0
- aisc/cli/commands/update.py +145 -0
- aisc/cli/commands/usage.py +74 -0
- aisc/cli/commands/wizard.py +130 -0
- aisc/cli/commands/workspaces.py +162 -0
- aisc/cli/help_i18n.py +385 -0
- aisc/cli/main.py +2926 -0
- aisc/cli/output.py +239 -0
- aisc/cli/tutorial.py +109 -0
- aisc/domain/__init__.py +1 -0
- aisc/domain/artifacts.py +279 -0
- aisc/domain/cc_switch_release.py +303 -0
- aisc/domain/config.py +64 -0
- aisc/domain/data_migration.py +259 -0
- aisc/domain/data_root.py +124 -0
- aisc/domain/docker_ownership.py +148 -0
- aisc/domain/gateway.py +168 -0
- aisc/domain/models.py +674 -0
- aisc/domain/toolchain.py +72 -0
- aisc/domain/web_services.py +355 -0
- aisc/domain/workspace_lease.py +139 -0
- aisc/schemas/__init__.py +1 -0
- aisc/schemas/config_schema.py +145 -0
- aisc_cli-0.1.0.dist-info/METADATA +420 -0
- aisc_cli-0.1.0.dist-info/RECORD +92 -0
- aisc_cli-0.1.0.dist-info/WHEEL +5 -0
- aisc_cli-0.1.0.dist-info/entry_points.txt +2 -0
- aisc_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- aisc_cli-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
"""Agent conversation discovery (v2.1.8 T3).
|
|
2
|
+
|
|
3
|
+
Read-only JSONL scanning per docs/plans/2.1.8-dev-plans/01-design.md
|
|
4
|
+
§1a-1e. Thin read over provider-native session files — no new storage
|
|
5
|
+
(方案 A): Claude ``<ws>/claude/projects/**/*.jsonl`` (filename stem =
|
|
6
|
+
conversation id), Codex ``<ws>/codex/sessions/**/*.jsonl`` (rollout
|
|
7
|
+
filename carries the id).
|
|
8
|
+
|
|
9
|
+
Oversized files (>10MB) are never fully parsed: a streaming head scan of
|
|
10
|
+
the first 200 parseable lines yields title/time, ``message_count`` stays
|
|
11
|
+
``null`` and the entry is annotated ``file_too_large``. Malformed JSON
|
|
12
|
+
lines are skipped; a file with at least one parseable user message but
|
|
13
|
+
some corrupt lines is annotated ``malformed`` and kept, ``resumable:
|
|
14
|
+
false``. Entries with zero parseable user messages are excluded.
|
|
15
|
+
|
|
16
|
+
D-5 (decisions.md): conversation ids are provider-native — Codex uses
|
|
17
|
+
time-ordered UUIDv7 in the wild (see the frozen T0 codex fixtures), so id
|
|
18
|
+
validation accepts any RFC-4122 version, not only v4. Non-UUID garbage is
|
|
19
|
+
still rejected (the check's stated intent).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import re
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
30
|
+
|
|
31
|
+
from aisc.application.data_root import DataRootResolver
|
|
32
|
+
from aisc.domain.models import CliError
|
|
33
|
+
|
|
34
|
+
# ---------------------------------------------------------------------------
|
|
35
|
+
# Frozen constants (design §1a/1d/1e)
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
MAX_JSONL_BYTES = 10 * 1024 * 1024
|
|
39
|
+
HEAD_SCAN_PARSEABLE_LINES = 200
|
|
40
|
+
TITLE_MAX_CHARS = 80
|
|
41
|
+
UNREADABLE_TITLE = "(无法读取)"
|
|
42
|
+
|
|
43
|
+
ERROR_INVALID_ID = "AISC_ERR_CONVERSATION_INVALID_ID"
|
|
44
|
+
ERROR_INVALID_AGENT = "AISC_ERR_CONVERSATION_INVALID_AGENT"
|
|
45
|
+
ERROR_UNRESUMABLE = "AISC_ERR_CONVERSATION_UNRESUMABLE"
|
|
46
|
+
|
|
47
|
+
# D-5: any RFC-4122 version (provider-native ids; Codex ships v7). The
|
|
48
|
+
# design's v4-only pattern predated the T0 probe fixtures and would have
|
|
49
|
+
# rejected every real Codex session.
|
|
50
|
+
_UUID_RE = re.compile(
|
|
51
|
+
r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}"
|
|
52
|
+
r"-[0-9a-fA-F]{12}$"
|
|
53
|
+
)
|
|
54
|
+
_CODEX_FILENAME_RE = re.compile(
|
|
55
|
+
r"^rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-"
|
|
56
|
+
r"([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}"
|
|
57
|
+
r"-[0-9a-fA-F]{12})\.jsonl$"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
_AGENTS = ("claude", "codex")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
# Title sanitization (design §1d)
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
_ANSI_RE = re.compile(r"\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)")
|
|
68
|
+
_REDACT_PATTERNS = (
|
|
69
|
+
re.compile(r"sk-[a-zA-Z0-9]{20,}"),
|
|
70
|
+
re.compile(r"Bearer\s+\S+"),
|
|
71
|
+
re.compile(r"api[_-]?key.*['\"]\s*\w{16,}"),
|
|
72
|
+
)
|
|
73
|
+
_CONTROL_CHARS_RE = re.compile(r"[\x00-\x1f\x7f]")
|
|
74
|
+
|
|
75
|
+
# Injected-context user messages (手测反馈 #1: providers record setup context
|
|
76
|
+
# — AGENTS.md/skills/permissions blocks, command caveats — as user role, so
|
|
77
|
+
# the FIRST user line is often not what the user typed). Title selection
|
|
78
|
+
# prefers the first message that does not look like injected context and
|
|
79
|
+
# falls back to the first message when every candidate does.
|
|
80
|
+
_CONTEXT_TITLE_RE = re.compile(
|
|
81
|
+
r"^(?:"
|
|
82
|
+
r"<" # XML-ish context blocks (<INSTRUCTIONS>,
|
|
83
|
+
# <skills_instructions>, <local-command-
|
|
84
|
+
# caveat>, <command-name>, <environment…)
|
|
85
|
+
r"|# AGENTS\.md instructions" # Codex AGENTS.md injection preamble
|
|
86
|
+
r"|Caveat:" # Claude command caveat body
|
|
87
|
+
r")"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def sanitize_title(text: str) -> str:
|
|
92
|
+
"""Sanitize a raw user message into a display title.
|
|
93
|
+
|
|
94
|
+
Order: strip ANSI escapes → apply redaction patterns → drop control
|
|
95
|
+
chars (newlines become spaces) → collapse whitespace → truncate to 80
|
|
96
|
+
Unicode scalar values. Empty results degrade to ``UNREADABLE_TITLE``.
|
|
97
|
+
"""
|
|
98
|
+
# 2.1.10 (field report #5): a non-string payload (nested content object
|
|
99
|
+
# from a foreign session schema) must degrade, never render as
|
|
100
|
+
# [object Object] upstream.
|
|
101
|
+
if not isinstance(text, str):
|
|
102
|
+
return UNREADABLE_TITLE
|
|
103
|
+
out = _ANSI_RE.sub("", text)
|
|
104
|
+
for pat in _REDACT_PATTERNS:
|
|
105
|
+
out = pat.sub("[REDACTED]", out)
|
|
106
|
+
out = _CONTROL_CHARS_RE.sub(" ", out)
|
|
107
|
+
out = " ".join(out.split())
|
|
108
|
+
out = out[:TITLE_MAX_CHARS]
|
|
109
|
+
return out or UNREADABLE_TITLE
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
# Per-provider user-message extraction (design §1a)
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
def _extract_claude_user(obj: Dict[str, Any]) -> Tuple[bool, Optional[str], bool]:
|
|
117
|
+
"""Return (is_user_line, extracted_text, title_eligible) for a Claude
|
|
118
|
+
line. ``title_eligible`` excludes SDK-internal prompts ("init" and
|
|
119
|
+
friends — 手测反馈 #1) from title selection."""
|
|
120
|
+
if obj.get("type") != "user":
|
|
121
|
+
return False, None, False
|
|
122
|
+
msg = obj.get("message")
|
|
123
|
+
if not isinstance(msg, dict) or msg.get("role") != "user":
|
|
124
|
+
return False, None, False
|
|
125
|
+
sdk = msg.get("promptSource") == "sdk"
|
|
126
|
+
return True, _text_from_content(msg.get("content"), "text"), not sdk
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _extract_codex_user(obj: Dict[str, Any]) -> Tuple[bool, Optional[str], bool]:
|
|
130
|
+
"""Return (is_user_line, extracted_text, title_eligible) for a Codex
|
|
131
|
+
line. Title eligibility is decided later by the context-text heuristic."""
|
|
132
|
+
if obj.get("type") != "response_item":
|
|
133
|
+
return False, None, False
|
|
134
|
+
payload = obj.get("payload")
|
|
135
|
+
if not isinstance(payload, dict) or payload.get("type") != "message":
|
|
136
|
+
return False, None, False
|
|
137
|
+
if payload.get("role") != "user":
|
|
138
|
+
return False, None, False
|
|
139
|
+
return True, _text_from_content(payload.get("content"), "input_text"), True
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _text_from_content(content: Any, block_type: str) -> Optional[str]:
|
|
143
|
+
"""Content is a string or a list of typed blocks; extract the first
|
|
144
|
+
text block's text (design §1d extraction). Returns None when the line
|
|
145
|
+
is a user line without extractable text (e.g. tool results)."""
|
|
146
|
+
if isinstance(content, str):
|
|
147
|
+
return content
|
|
148
|
+
if isinstance(content, list):
|
|
149
|
+
for block in content:
|
|
150
|
+
if isinstance(block, dict) and block.get("type") == block_type:
|
|
151
|
+
text = block.get("text")
|
|
152
|
+
if isinstance(text, str):
|
|
153
|
+
return text
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
_EXTRACTORS = {
|
|
158
|
+
"claude": _extract_claude_user,
|
|
159
|
+
"codex": _extract_codex_user,
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def is_conversation_uuid(value: str) -> bool:
|
|
164
|
+
"""True for a well-formed conversation id — any RFC-4122 version (D-5:
|
|
165
|
+
Codex ships UUIDv7). Non-UUID garbage is rejected."""
|
|
166
|
+
return bool(value) and bool(_UUID_RE.match(value))
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ---------------------------------------------------------------------------
|
|
170
|
+
# File scanning
|
|
171
|
+
# ---------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
@dataclass
|
|
174
|
+
class _FileScan:
|
|
175
|
+
parseable: int = 0
|
|
176
|
+
user_messages: int = 0
|
|
177
|
+
first_ts: Optional[str] = None
|
|
178
|
+
last_ts: Optional[str] = None
|
|
179
|
+
title: Optional[str] = None
|
|
180
|
+
malformed_seen: bool = False
|
|
181
|
+
_first_raw: Optional[str] = None
|
|
182
|
+
_good_raw: Optional[str] = None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _scan_file(path: Path, agent: str, head_only: bool) -> _FileScan:
|
|
186
|
+
"""Stream a JSONL session file. ``head_only`` (oversized files) stops
|
|
187
|
+
after HEAD_SCAN_PARSEABLE_LINES parseable lines — the file is never
|
|
188
|
+
fully parsed (design §1a max_file_size)."""
|
|
189
|
+
state = _FileScan()
|
|
190
|
+
extract = _EXTRACTORS[agent]
|
|
191
|
+
# 2.1.10 (field report #4): one unreadable session file (permissions,
|
|
192
|
+
# owner mismatch on a data-root restored from elsewhere) must not kill
|
|
193
|
+
# the whole conversation list — skip it like a malformed file.
|
|
194
|
+
try:
|
|
195
|
+
fh = path.open("r", encoding="utf-8", errors="replace")
|
|
196
|
+
except OSError:
|
|
197
|
+
state.malformed_seen = True
|
|
198
|
+
return state
|
|
199
|
+
with fh:
|
|
200
|
+
for line in fh:
|
|
201
|
+
line = line.strip()
|
|
202
|
+
if not line:
|
|
203
|
+
continue
|
|
204
|
+
try:
|
|
205
|
+
obj = json.loads(line)
|
|
206
|
+
except (ValueError, TypeError):
|
|
207
|
+
state.malformed_seen = True
|
|
208
|
+
continue
|
|
209
|
+
if not isinstance(obj, dict):
|
|
210
|
+
continue
|
|
211
|
+
state.parseable += 1
|
|
212
|
+
ts = obj.get("timestamp")
|
|
213
|
+
if isinstance(ts, str) and ts:
|
|
214
|
+
if state.first_ts is None:
|
|
215
|
+
state.first_ts = ts
|
|
216
|
+
state.last_ts = ts
|
|
217
|
+
is_user, text, title_eligible = extract(obj)
|
|
218
|
+
if is_user:
|
|
219
|
+
state.user_messages += 1
|
|
220
|
+
if text:
|
|
221
|
+
# 手测反馈 #1: prefer the first non-context user message
|
|
222
|
+
# for the title; fall back to the first message when
|
|
223
|
+
# every candidate is injected context.
|
|
224
|
+
if state._first_raw is None:
|
|
225
|
+
state._first_raw = text
|
|
226
|
+
if state._good_raw is None and title_eligible \
|
|
227
|
+
and not _CONTEXT_TITLE_RE.match(text.lstrip()):
|
|
228
|
+
state._good_raw = text
|
|
229
|
+
if head_only and state.parseable >= HEAD_SCAN_PARSEABLE_LINES:
|
|
230
|
+
break
|
|
231
|
+
chosen = state._good_raw if state._good_raw else state._first_raw
|
|
232
|
+
state.title = sanitize_title(chosen) if chosen else None
|
|
233
|
+
return state
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _conversation_id_for(agent: str, filename: str) -> Optional[str]:
|
|
237
|
+
"""Derive the conversation id from the filename (design §1a id_source).
|
|
238
|
+
|
|
239
|
+
Claude: filename stem IS the id. Codex: ``rollout-<ts>-<uuid>.jsonl``.
|
|
240
|
+
Returns None for files that do not carry a well-formed id — they are
|
|
241
|
+
not sessions and are skipped.
|
|
242
|
+
"""
|
|
243
|
+
if agent == "claude":
|
|
244
|
+
stem = filename[:-len(".jsonl")] if filename.endswith(".jsonl") else filename
|
|
245
|
+
return stem if _UUID_RE.match(stem) else None
|
|
246
|
+
m = _CODEX_FILENAME_RE.match(filename)
|
|
247
|
+
return m.group(1) if m else None
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _summarize(agent: str, conv_id: str, file_size: int,
|
|
251
|
+
scan: _FileScan) -> Optional[Dict[str, Any]]:
|
|
252
|
+
"""Apply §1e filtering/annotation. Returns None when the entry is
|
|
253
|
+
excluded from the list (zero parseable user messages)."""
|
|
254
|
+
if scan.user_messages == 0:
|
|
255
|
+
return None
|
|
256
|
+
resumable = True
|
|
257
|
+
reason: Optional[str] = None
|
|
258
|
+
message_count: Optional[int] = scan.user_messages
|
|
259
|
+
if file_size > MAX_JSONL_BYTES:
|
|
260
|
+
resumable = False
|
|
261
|
+
reason = "file_too_large"
|
|
262
|
+
message_count = None # head scan only — a full count was not taken
|
|
263
|
+
elif scan.malformed_seen:
|
|
264
|
+
resumable = False
|
|
265
|
+
reason = "malformed"
|
|
266
|
+
entry: Dict[str, Any] = {
|
|
267
|
+
"conversation_id": conv_id,
|
|
268
|
+
"agent": agent,
|
|
269
|
+
"title": scan.title if scan.title else UNREADABLE_TITLE,
|
|
270
|
+
"started_at": scan.first_ts,
|
|
271
|
+
"last_at": scan.last_ts,
|
|
272
|
+
"message_count": message_count,
|
|
273
|
+
"file_size": file_size,
|
|
274
|
+
"resumable": resumable,
|
|
275
|
+
}
|
|
276
|
+
if not resumable:
|
|
277
|
+
entry["unavailable_reason"] = reason
|
|
278
|
+
return entry
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _scan_agent_dir(base: Path, agent: str) -> List[Dict[str, Any]]:
|
|
282
|
+
if not base.is_dir():
|
|
283
|
+
return []
|
|
284
|
+
out: List[Dict[str, Any]] = []
|
|
285
|
+
for path in sorted(base.rglob("*.jsonl")):
|
|
286
|
+
if not path.is_file():
|
|
287
|
+
continue
|
|
288
|
+
conv_id = _conversation_id_for(agent, path.name)
|
|
289
|
+
if conv_id is None:
|
|
290
|
+
continue
|
|
291
|
+
try:
|
|
292
|
+
size = path.stat().st_size
|
|
293
|
+
except OSError:
|
|
294
|
+
continue
|
|
295
|
+
scan = _scan_file(path, agent, head_only=(size > MAX_JSONL_BYTES))
|
|
296
|
+
entry = _summarize(agent, conv_id, size, scan)
|
|
297
|
+
if entry is not None:
|
|
298
|
+
out.append(entry)
|
|
299
|
+
return out
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
# ---------------------------------------------------------------------------
|
|
303
|
+
# Public entry points
|
|
304
|
+
# ---------------------------------------------------------------------------
|
|
305
|
+
|
|
306
|
+
def _workspace_dir(workspace: str) -> Path:
|
|
307
|
+
"""The workspace's data-root dir (read-only resolve — never creates)."""
|
|
308
|
+
ws = Path(workspace).resolve() if workspace else Path.cwd()
|
|
309
|
+
return DataRootResolver().resolve(ws).workspace_dir
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
# ---------------------------------------------------------------------------
|
|
313
|
+
# Title overrides (v2.1.8 T4 手测反馈 #2: 右键重命名). Providers own session
|
|
314
|
+
# titles (first user message); the Workbench label lives in a small override
|
|
315
|
+
# map under the workspace runtime dir (设计 3a: that dir is the workspace
|
|
316
|
+
# runtime persistence home). <agent>:<id-lowercase> → display title.
|
|
317
|
+
# ---------------------------------------------------------------------------
|
|
318
|
+
|
|
319
|
+
_TITLES_SCHEMA = "aisc.conversation-titles/v1"
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _titles_path(ws_dir: Path) -> Path:
|
|
323
|
+
return ws_dir / "runtime" / "conversation_titles.json"
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _load_titles(ws_dir: Path) -> Dict[str, str]:
|
|
327
|
+
try:
|
|
328
|
+
data = json.loads(_titles_path(ws_dir).read_text(encoding="utf-8"))
|
|
329
|
+
except (FileNotFoundError, ValueError, OSError):
|
|
330
|
+
return {}
|
|
331
|
+
if not isinstance(data, dict) or not isinstance(data.get("titles"), dict):
|
|
332
|
+
return {}
|
|
333
|
+
return {
|
|
334
|
+
str(k): str(v)
|
|
335
|
+
for k, v in data["titles"].items()
|
|
336
|
+
if isinstance(v, str) and v
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _save_titles(ws_dir: Path, titles: Dict[str, str]) -> None:
|
|
341
|
+
path = _titles_path(ws_dir)
|
|
342
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
343
|
+
payload = json.dumps(
|
|
344
|
+
{"schema": _TITLES_SCHEMA, "titles": titles},
|
|
345
|
+
ensure_ascii=False, indent=2, sort_keys=True,
|
|
346
|
+
) + "\n"
|
|
347
|
+
tmp = path.with_name(f".{path.name}.tmp.{os.getpid()}")
|
|
348
|
+
tmp.write_text(payload, encoding="utf-8")
|
|
349
|
+
os.replace(tmp, path) # atomic — readers never see a partial file
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def list_conversations(workspace: str) -> Dict[str, Any]:
|
|
353
|
+
"""List agent history conversations for a workspace (design §1b).
|
|
354
|
+
|
|
355
|
+
Read-only, no Docker. Sorted most-recent-first for the picker UI
|
|
356
|
+
(``last_at`` descending; entries without timestamps sort last).
|
|
357
|
+
"""
|
|
358
|
+
ws_dir = _workspace_dir(workspace)
|
|
359
|
+
entries: List[Dict[str, Any]] = []
|
|
360
|
+
entries.extend(_scan_agent_dir(ws_dir / "claude" / "projects", "claude"))
|
|
361
|
+
entries.extend(_scan_agent_dir(ws_dir / "codex" / "sessions", "codex"))
|
|
362
|
+
entries.sort(
|
|
363
|
+
key=lambda e: (e["last_at"] or "", e["conversation_id"]),
|
|
364
|
+
reverse=True,
|
|
365
|
+
)
|
|
366
|
+
# Title overrides (rename) win over the extracted first-user-message.
|
|
367
|
+
overrides = _load_titles(ws_dir)
|
|
368
|
+
if overrides:
|
|
369
|
+
for entry in entries:
|
|
370
|
+
key = f"{entry['agent']}:{entry['conversation_id'].lower()}"
|
|
371
|
+
if key in overrides:
|
|
372
|
+
entry["title"] = overrides[key]
|
|
373
|
+
return {"schema_version": 1, "conversations": entries}
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _find_conversation_file(ws_dir: Path, agent: str,
|
|
377
|
+
conversation_id: str) -> Optional[Path]:
|
|
378
|
+
"""Exact provider-specific file match for a conversation id (design §2).
|
|
379
|
+
Claude: filename stem == id. Codex: rollout filename's trailing id,
|
|
380
|
+
case-insensitive. Returns None when absent — never a glob substring."""
|
|
381
|
+
if agent == "claude":
|
|
382
|
+
base = ws_dir / "claude" / "projects"
|
|
383
|
+
if not base.is_dir():
|
|
384
|
+
return None
|
|
385
|
+
for p in base.rglob("*.jsonl"):
|
|
386
|
+
if p.is_file() and p.stem == conversation_id:
|
|
387
|
+
return p
|
|
388
|
+
return None
|
|
389
|
+
base = ws_dir / "codex" / "sessions"
|
|
390
|
+
if not base.is_dir():
|
|
391
|
+
return None
|
|
392
|
+
for p in base.rglob("*.jsonl"):
|
|
393
|
+
if not p.is_file():
|
|
394
|
+
continue
|
|
395
|
+
m = _CODEX_FILENAME_RE.match(p.name)
|
|
396
|
+
if m is not None and m.group(1).lower() == conversation_id.lower():
|
|
397
|
+
return p
|
|
398
|
+
return None
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def preflight_conversation(workspace: str, conversation_id: str,
|
|
402
|
+
agent: str) -> Dict[str, Any]:
|
|
403
|
+
"""Validate a conversation is resumable BEFORE any PTY spawn (design
|
|
404
|
+
§2: sidecar captured preflight). Exact provider-specific file match —
|
|
405
|
+
never glob substring matching."""
|
|
406
|
+
if not is_conversation_uuid(conversation_id):
|
|
407
|
+
raise CliError(
|
|
408
|
+
message=f"Invalid conversation ID: {conversation_id}",
|
|
409
|
+
exit_code=2,
|
|
410
|
+
error_code=ERROR_INVALID_ID,
|
|
411
|
+
)
|
|
412
|
+
if agent not in _AGENTS:
|
|
413
|
+
raise CliError(
|
|
414
|
+
message=f"Unsupported agent for resume: {agent}",
|
|
415
|
+
exit_code=2,
|
|
416
|
+
error_code=ERROR_INVALID_AGENT,
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
ws_dir = _workspace_dir(workspace)
|
|
420
|
+
if _find_conversation_file(ws_dir, agent, conversation_id) is None:
|
|
421
|
+
raise CliError(
|
|
422
|
+
message=f"Conversation {conversation_id} not found for agent {agent}",
|
|
423
|
+
exit_code=3,
|
|
424
|
+
error_code=ERROR_UNRESUMABLE,
|
|
425
|
+
)
|
|
426
|
+
return {
|
|
427
|
+
"preflight_ok": True,
|
|
428
|
+
"conversation_id": conversation_id,
|
|
429
|
+
"agent": agent,
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def delete_conversation(workspace: str, conversation_id: str,
|
|
434
|
+
agent: str) -> Dict[str, Any]:
|
|
435
|
+
"""Delete a conversation's session file (v2.1.8 T4 手测反馈 #4: 变更页
|
|
436
|
+
右键删除). Exact match only; the id must be well-formed. Idempotent
|
|
437
|
+
failure mode: an absent conversation raises the same UNRESUMABLE error
|
|
438
|
+
as preflight — the caller has just shown it in a list, so absence is
|
|
439
|
+
an anomaly worth reporting, not a silent success."""
|
|
440
|
+
if not is_conversation_uuid(conversation_id):
|
|
441
|
+
raise CliError(
|
|
442
|
+
message=f"Invalid conversation ID: {conversation_id}",
|
|
443
|
+
exit_code=2,
|
|
444
|
+
error_code=ERROR_INVALID_ID,
|
|
445
|
+
)
|
|
446
|
+
if agent not in _AGENTS:
|
|
447
|
+
raise CliError(
|
|
448
|
+
message=f"Unsupported agent: {agent}",
|
|
449
|
+
exit_code=2,
|
|
450
|
+
error_code=ERROR_INVALID_AGENT,
|
|
451
|
+
)
|
|
452
|
+
ws_dir = _workspace_dir(workspace)
|
|
453
|
+
path = _find_conversation_file(ws_dir, agent, conversation_id)
|
|
454
|
+
if path is None:
|
|
455
|
+
raise CliError(
|
|
456
|
+
message=f"Conversation {conversation_id} not found for agent {agent}",
|
|
457
|
+
exit_code=3,
|
|
458
|
+
error_code=ERROR_UNRESUMABLE,
|
|
459
|
+
)
|
|
460
|
+
try:
|
|
461
|
+
path.unlink()
|
|
462
|
+
except OSError as exc:
|
|
463
|
+
raise CliError(
|
|
464
|
+
message=f"Failed to delete conversation {conversation_id}: {exc}",
|
|
465
|
+
exit_code=1,
|
|
466
|
+
error_code="AISC_ERR_GENERAL",
|
|
467
|
+
) from exc
|
|
468
|
+
# Drop the rename override with the conversation (best-effort — a stale
|
|
469
|
+
# entry is inert but must not accumulate).
|
|
470
|
+
titles = _load_titles(ws_dir)
|
|
471
|
+
key = f"{agent}:{conversation_id.lower()}"
|
|
472
|
+
if key in titles:
|
|
473
|
+
del titles[key]
|
|
474
|
+
_save_titles(ws_dir, titles)
|
|
475
|
+
return {"deleted": True, "conversation_id": conversation_id, "agent": agent}
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def rename_conversation(workspace: str, conversation_id: str, agent: str,
|
|
479
|
+
title: str) -> Dict[str, Any]:
|
|
480
|
+
"""Set a Workbench display title for a conversation (手测反馈 #2). The
|
|
481
|
+
provider's own title is untouched; the override wins in ``list``."""
|
|
482
|
+
if not is_conversation_uuid(conversation_id):
|
|
483
|
+
raise CliError(
|
|
484
|
+
message=f"Invalid conversation ID: {conversation_id}",
|
|
485
|
+
exit_code=2,
|
|
486
|
+
error_code=ERROR_INVALID_ID,
|
|
487
|
+
)
|
|
488
|
+
if agent not in _AGENTS:
|
|
489
|
+
raise CliError(
|
|
490
|
+
message=f"Unsupported agent: {agent}",
|
|
491
|
+
exit_code=2,
|
|
492
|
+
error_code=ERROR_INVALID_AGENT,
|
|
493
|
+
)
|
|
494
|
+
if not isinstance(title, str) or not title.strip():
|
|
495
|
+
raise CliError(
|
|
496
|
+
message="Title must be a non-empty string",
|
|
497
|
+
exit_code=2,
|
|
498
|
+
error_code="AISC_ERR_USAGE",
|
|
499
|
+
)
|
|
500
|
+
ws_dir = _workspace_dir(workspace)
|
|
501
|
+
if _find_conversation_file(ws_dir, agent, conversation_id) is None:
|
|
502
|
+
raise CliError(
|
|
503
|
+
message=f"Conversation {conversation_id} not found for agent {agent}",
|
|
504
|
+
exit_code=3,
|
|
505
|
+
error_code=ERROR_UNRESUMABLE,
|
|
506
|
+
)
|
|
507
|
+
clean = sanitize_title(title.strip())
|
|
508
|
+
titles = _load_titles(ws_dir)
|
|
509
|
+
titles[f"{agent}:{conversation_id.lower()}"] = clean
|
|
510
|
+
_save_titles(ws_dir, titles)
|
|
511
|
+
return {
|
|
512
|
+
"renamed": True,
|
|
513
|
+
"conversation_id": conversation_id,
|
|
514
|
+
"agent": agent,
|
|
515
|
+
"title": clean,
|
|
516
|
+
}
|