celltype-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.
Files changed (89) hide show
  1. celltype_cli-0.1.0.dist-info/METADATA +267 -0
  2. celltype_cli-0.1.0.dist-info/RECORD +89 -0
  3. celltype_cli-0.1.0.dist-info/WHEEL +4 -0
  4. celltype_cli-0.1.0.dist-info/entry_points.txt +2 -0
  5. celltype_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  6. ct/__init__.py +3 -0
  7. ct/agent/__init__.py +0 -0
  8. ct/agent/case_studies.py +426 -0
  9. ct/agent/config.py +523 -0
  10. ct/agent/doctor.py +544 -0
  11. ct/agent/knowledge.py +523 -0
  12. ct/agent/loop.py +99 -0
  13. ct/agent/mcp_server.py +478 -0
  14. ct/agent/orchestrator.py +733 -0
  15. ct/agent/runner.py +656 -0
  16. ct/agent/sandbox.py +481 -0
  17. ct/agent/session.py +145 -0
  18. ct/agent/system_prompt.py +186 -0
  19. ct/agent/trace_store.py +228 -0
  20. ct/agent/trajectory.py +169 -0
  21. ct/agent/types.py +182 -0
  22. ct/agent/workflows.py +462 -0
  23. ct/api/__init__.py +1 -0
  24. ct/api/app.py +211 -0
  25. ct/api/config.py +120 -0
  26. ct/api/engine.py +124 -0
  27. ct/cli.py +1448 -0
  28. ct/data/__init__.py +0 -0
  29. ct/data/compute_providers.json +59 -0
  30. ct/data/cro_database.json +395 -0
  31. ct/data/downloader.py +238 -0
  32. ct/data/loaders.py +252 -0
  33. ct/kb/__init__.py +5 -0
  34. ct/kb/benchmarks.py +147 -0
  35. ct/kb/governance.py +106 -0
  36. ct/kb/ingest.py +415 -0
  37. ct/kb/reasoning.py +129 -0
  38. ct/kb/schema_monitor.py +162 -0
  39. ct/kb/substrate.py +387 -0
  40. ct/models/__init__.py +0 -0
  41. ct/models/llm.py +370 -0
  42. ct/tools/__init__.py +195 -0
  43. ct/tools/_compound_resolver.py +297 -0
  44. ct/tools/biomarker.py +368 -0
  45. ct/tools/cellxgene.py +282 -0
  46. ct/tools/chemistry.py +1371 -0
  47. ct/tools/claude.py +390 -0
  48. ct/tools/clinical.py +1153 -0
  49. ct/tools/clue.py +249 -0
  50. ct/tools/code.py +1069 -0
  51. ct/tools/combination.py +397 -0
  52. ct/tools/compute.py +402 -0
  53. ct/tools/cro.py +413 -0
  54. ct/tools/data_api.py +2114 -0
  55. ct/tools/design.py +295 -0
  56. ct/tools/dna.py +575 -0
  57. ct/tools/experiment.py +604 -0
  58. ct/tools/expression.py +655 -0
  59. ct/tools/files.py +957 -0
  60. ct/tools/genomics.py +1387 -0
  61. ct/tools/http_client.py +146 -0
  62. ct/tools/imaging.py +319 -0
  63. ct/tools/intel.py +223 -0
  64. ct/tools/literature.py +743 -0
  65. ct/tools/network.py +422 -0
  66. ct/tools/notification.py +111 -0
  67. ct/tools/omics.py +3330 -0
  68. ct/tools/ops.py +1230 -0
  69. ct/tools/parity.py +649 -0
  70. ct/tools/pk.py +245 -0
  71. ct/tools/protein.py +678 -0
  72. ct/tools/regulatory.py +643 -0
  73. ct/tools/remote_data.py +179 -0
  74. ct/tools/report.py +181 -0
  75. ct/tools/repurposing.py +376 -0
  76. ct/tools/safety.py +1280 -0
  77. ct/tools/shell.py +178 -0
  78. ct/tools/singlecell.py +533 -0
  79. ct/tools/statistics.py +552 -0
  80. ct/tools/structure.py +882 -0
  81. ct/tools/target.py +901 -0
  82. ct/tools/translational.py +123 -0
  83. ct/tools/viability.py +218 -0
  84. ct/ui/__init__.py +0 -0
  85. ct/ui/markdown.py +31 -0
  86. ct/ui/status.py +258 -0
  87. ct/ui/suggestions.py +567 -0
  88. ct/ui/terminal.py +1456 -0
  89. ct/ui/traces.py +112 -0
@@ -0,0 +1,186 @@
1
+ """
2
+ Unified system prompt builder for the Claude Agent SDK runner.
3
+
4
+ Merges the domain knowledge primer, workflow guides, bioinformatics code-gen
5
+ hints, synthesis rules, tool catalog, and dynamic data context into a single
6
+ system prompt that Claude uses throughout the agentic loop.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ from pathlib import Path
13
+
14
+ logger = logging.getLogger("ct.system_prompt")
15
+
16
+
17
+ # ---------------------------------------------------------------------------
18
+ # Identity / role preamble
19
+ # ---------------------------------------------------------------------------
20
+
21
+ _IDENTITY = """\
22
+ You are **celltype-cli**, an autonomous drug discovery research agent.
23
+
24
+ You have access to 190+ domain tools covering target discovery, chemistry,
25
+ expression, viability, safety, clinical development, omics, genomics, literature,
26
+ and more — plus a persistent Python sandbox (``run_python``) for custom analyses.
27
+
28
+ Your job: take a research question and answer it **completely**, using the right
29
+ tools and code, self-correcting as you go, and producing a publication-quality
30
+ synthesis at the end.
31
+
32
+ ## Operating Mode
33
+ - You are in an agentic loop: call tools, see results, call more tools, then
34
+ write your final answer as plain text (no tool call).
35
+ - Think step-by-step. Use tools to gather evidence, then synthesize.
36
+ - If a tool fails or returns unhelpful data, try a different approach or use
37
+ your own knowledge to fill gaps.
38
+ - For data analysis questions, use ``run_python`` to load data, explore it,
39
+ and compute the answer. Variables persist between calls.
40
+ """
41
+
42
+
43
+ # ---------------------------------------------------------------------------
44
+ # Synthesis instructions (injected at the end)
45
+ # ---------------------------------------------------------------------------
46
+
47
+ _SYNTHESIS_INSTRUCTIONS = """\
48
+
49
+ ## When You Are Ready to Answer
50
+
51
+ Write your final answer as a direct text response (do NOT call any more tools).
52
+ Your answer should be:
53
+
54
+ 1. **Complete**: Address every part of the question. Decompose the question into
55
+ sub-parts and make sure each is answered with specifics.
56
+ 2. **Accurate**: Use tool results as primary evidence. Supplement with your
57
+ domain knowledge. Never fabricate data.
58
+ 3. **Data-rich**: Include specific gene names, cell lines, p-values, effect
59
+ sizes, IC50 values, trial names, mutation positions, etc.
60
+ 4. **Mechanistic**: Explain the biological *why*, not just the *what*.
61
+ 5. **Actionable**: End with 3-5 specific experimental next steps (named assays,
62
+ cell lines, concentrations, readouts).
63
+
64
+ BANNED PHRASES — never write these:
65
+ - "cannot be answered with the data retrieved"
66
+ - "failed to retrieve" / "failed to identify"
67
+ - "insufficient data" / "insufficient evidence"
68
+ - "No results were obtained"
69
+ If tools failed, pivot to answering from your knowledge instead.
70
+ """
71
+
72
+
73
+ # ---------------------------------------------------------------------------
74
+ # Builder
75
+ # ---------------------------------------------------------------------------
76
+
77
+ def build_system_prompt(
78
+ session,
79
+ tool_names: list[str] | None = None,
80
+ data_context: str | None = None,
81
+ history: str | None = None,
82
+ ) -> str:
83
+ """Build the unified system prompt for the Agent SDK runner.
84
+
85
+ Args:
86
+ session: Active ct Session.
87
+ tool_names: Names of tools available in the MCP server (for reference).
88
+ data_context: Free-text description of available data files / directories.
89
+ history: Prior conversation turns (for interactive multi-turn sessions).
90
+
91
+ Returns:
92
+ The complete system prompt string.
93
+ """
94
+ parts: list[str] = []
95
+
96
+ # 1. Identity
97
+ parts.append(_IDENTITY)
98
+
99
+ # 2. Tool catalog (concise reference — full descriptions are in MCP tool defs)
100
+ # NOTE: The Agent SDK exposes tool names+descriptions+schemas via MCP natively.
101
+ # We only include a brief orientation here, NOT the full tool_descriptions_for_llm()
102
+ # which would blow up the system prompt to 155K chars and hit ARG_MAX limits.
103
+ if tool_names:
104
+ parts.append(f"\n## Available Tools ({len(tool_names)} total)\n")
105
+ parts.append(
106
+ "You have access to all tools via MCP. Key tools:\n"
107
+ "- **run_python**: Execute Python code in a sandbox (pd, np, plt, scipy, sklearn, pysam, gseapy, pydeseq2, BioPython). Variables persist between calls.\n"
108
+ "- **run_r**: Execute R code directly. Prefer run_r over run_python for: natural splines (ns()), wilcox.test(), p.adjust(), fisher.test(), lm()/predict(), organism-specific KEGG ORA (use KEGGREST package: keggList, keggLink, keggGet for any organism code), and any analysis where R is the reference implementation. R and Python give DIFFERENT results for splines, multiple testing correction, and nonparametric tests — when the expected answer was computed in R, use R.\n"
109
+ "- **literature.pubmed_search**, **literature.chembl_query**, **literature.openalex_search**: Literature/DB search\n"
110
+ "- **data_api.opentargets_search**, **data_api.depmap_search**, **data_api.uniprot_lookup**: Platform APIs\n"
111
+ "- **omics.geo_search**, **omics.geo_fetch**, **omics.deseq2**, **omics.dataset_info**: Omics data discovery + analysis\n"
112
+ "- **expression.pathway_enrichment**, **expression.l1000_similarity**: Expression/pathway tools\n"
113
+ "- **viability.dose_response**, **clinical.indication_map**, **clinical.trial_search**: Viability/clinical\n"
114
+ "- **chemistry.descriptors**, **chemistry.sar_analyze**, **chemistry.pubchem_lookup**: Chemistry tools\n"
115
+ "- **target.coessentiality**, **genomics.gwas_lookup**, **protein.function_predict**: Target/genomics tools\n"
116
+ "- **safety.classify**, **safety.admet_predict**: Safety/ADMET tools\n"
117
+ "\nFor data analysis questions, prefer **run_python** — it's the most powerful tool.\n"
118
+ "For drug discovery questions, combine domain tools with your knowledge.\n"
119
+ )
120
+
121
+ # 3. Workflow guides (compact — key sequences for common tasks)
122
+ try:
123
+ from ct.agent.workflows import format_workflows_for_llm
124
+ workflows = format_workflows_for_llm()
125
+ if workflows:
126
+ parts.append(workflows)
127
+ except Exception as e:
128
+ logger.warning("Could not load workflows: %s", e)
129
+
130
+ # 4. Domain knowledge primer (CRITICAL for drug discovery accuracy)
131
+ # NOTE: The KNOWLEDGE_PRIMER contains both tool orientation AND domain facts.
132
+ # The tool orientation section overlaps with MCP tool descriptions but the
133
+ # cross-disciplinary thinking patterns and domain-specific accuracy anchors
134
+ # are essential. Include in full.
135
+ try:
136
+ from ct.agent.knowledge import KNOWLEDGE_PRIMER
137
+ parts.append("\n" + KNOWLEDGE_PRIMER)
138
+ except Exception as e:
139
+ logger.warning("Could not load knowledge primer: %s", e)
140
+
141
+ # 6. Bioinformatics code-gen hints (CRITICAL for BixBench performance)
142
+ try:
143
+ from ct.tools.code import BIOINFORMATICS_CODE_GEN_PROMPT, AGENTIC_CODE_ADDENDUM
144
+ # Strip the template placeholders and include the raw hints
145
+ hints = BIOINFORMATICS_CODE_GEN_PROMPT
146
+ # Remove the {namespace_description} and {data_files_description} placeholders
147
+ hints = hints.replace("{namespace_description}", "(see run_python tool description)")
148
+ hints = hints.replace("{data_files_description}", "(see data context below)")
149
+ parts.append("\n## Bioinformatics Code Generation Guide\n")
150
+ parts.append(
151
+ "When using ``run_python`` for bioinformatics analysis, follow these "
152
+ "patterns and guidelines:\n"
153
+ )
154
+ parts.append(hints)
155
+ parts.append(AGENTIC_CODE_ADDENDUM)
156
+ except Exception as e:
157
+ logger.warning("Could not load code-gen hints: %s", e)
158
+
159
+ # 7. Synthesis rules
160
+ try:
161
+ from ct.agent.knowledge import SYNTHESIZER_PRIMER
162
+ parts.append("\n## Synthesis Guidelines\n")
163
+ parts.append(SYNTHESIZER_PRIMER)
164
+ except Exception as e:
165
+ logger.warning("Could not load synthesizer primer: %s", e)
166
+
167
+ # 8. Synthesis instructions
168
+ parts.append(_SYNTHESIS_INSTRUCTIONS)
169
+
170
+ # 9. Dynamic data context
171
+ if data_context:
172
+ parts.append("\n## Data Context\n")
173
+ parts.append(data_context)
174
+
175
+ # 10. Session history (for multi-turn interactive mode)
176
+ if history:
177
+ parts.append("\n## Prior Conversation\n")
178
+ parts.append(history)
179
+
180
+ prompt = "\n".join(parts)
181
+ logger.info(
182
+ "Built system prompt: %d chars, %d sections",
183
+ len(prompt),
184
+ len(parts),
185
+ )
186
+ return prompt
@@ -0,0 +1,228 @@
1
+ """
2
+ Trace store: full-fidelity capture of agent message streams.
3
+
4
+ Captures the complete sequence of text blocks, tool calls, and tool results
5
+ from each query execution. Used by the notebook exporter and other
6
+ downstream consumers that need richer data than the compact Trajectory.
7
+
8
+ Operates independently of Trajectory — Trajectory stores compact turn
9
+ summaries for LLM context injection; TraceStore captures the full stream
10
+ for export/replay.
11
+ """
12
+
13
+ import base64
14
+ import json
15
+ import logging
16
+ import mimetypes
17
+ import time
18
+ from pathlib import Path
19
+
20
+ logger = logging.getLogger("ct.trace_store")
21
+
22
+ # Marker used by MCP handlers to embed structured metadata in tool results
23
+ TRACE_META_MARKER = "\n__CT_TRACE_META__\n"
24
+
25
+ # Maximum file size for base64 embedding (10 MB)
26
+ _MAX_EMBED_BYTES = 10 * 1024 * 1024
27
+
28
+
29
+ def _sessions_dir() -> Path:
30
+ """Return the sessions directory, creating it if needed."""
31
+ d = Path.home() / ".ct" / "sessions"
32
+ d.mkdir(parents=True, exist_ok=True)
33
+ return d
34
+
35
+
36
+ def parse_trace_meta(result_text: str) -> dict | None:
37
+ """Extract __CT_TRACE_META__ JSON from a tool result string.
38
+
39
+ Returns the parsed dict, or None if no marker found.
40
+ """
41
+ if TRACE_META_MARKER not in result_text:
42
+ return None
43
+ try:
44
+ _, meta_json = result_text.split(TRACE_META_MARKER, 1)
45
+ return json.loads(meta_json.strip())
46
+ except (ValueError, json.JSONDecodeError) as e:
47
+ logger.warning("Failed to parse trace meta: %s", e)
48
+ return None
49
+
50
+
51
+ def _embed_plots(event: dict) -> None:
52
+ """Read plot files and add base64-encoded data to the event in-place."""
53
+ plots = event.get("plots", [])
54
+ if not plots:
55
+ return
56
+
57
+ embedded = []
58
+ for plot_path_str in plots:
59
+ plot_path = Path(plot_path_str)
60
+ if not plot_path.exists():
61
+ logger.warning("Plot file missing at capture time: %s", plot_path)
62
+ continue
63
+ if plot_path.stat().st_size > _MAX_EMBED_BYTES:
64
+ logger.warning(
65
+ "Plot file too large for embedding (%d bytes): %s",
66
+ plot_path.stat().st_size,
67
+ plot_path,
68
+ )
69
+ continue
70
+
71
+ mime, _ = mimetypes.guess_type(str(plot_path))
72
+ if mime is None:
73
+ suffix_map = {
74
+ ".png": "image/png",
75
+ ".svg": "image/svg+xml",
76
+ ".jpg": "image/jpeg",
77
+ ".jpeg": "image/jpeg",
78
+ ".pdf": "application/pdf",
79
+ }
80
+ mime = suffix_map.get(plot_path.suffix.lower(), "application/octet-stream")
81
+
82
+ try:
83
+ data = base64.b64encode(plot_path.read_bytes()).decode("ascii")
84
+ embedded.append({
85
+ "filename": plot_path.name,
86
+ "mime": mime,
87
+ "data": data,
88
+ })
89
+ except OSError as e:
90
+ logger.warning("Failed to read plot file %s: %s", plot_path, e)
91
+
92
+ if embedded:
93
+ event["plots_base64"] = embedded
94
+
95
+
96
+ class TraceStore:
97
+ """Captures and persists full agent message stream traces.
98
+
99
+ Usage::
100
+
101
+ store = TraceStore(session_id="abc-123")
102
+ events = []
103
+
104
+ # ... pass events list to process_messages() ...
105
+
106
+ store.add_events(events, query="my question", model="claude-sonnet-4-5")
107
+ store.flush() # persist to ~/.ct/sessions/abc-123.trace.jsonl
108
+ """
109
+
110
+ def __init__(self, session_id: str):
111
+ self.session_id = session_id
112
+ self._events: list[dict] = []
113
+ self._path = _sessions_dir() / f"{session_id}.trace.jsonl"
114
+
115
+ @property
116
+ def path(self) -> Path:
117
+ return self._path
118
+
119
+ @property
120
+ def events(self) -> list[dict]:
121
+ return self._events
122
+
123
+ def add_event(self, event: dict) -> None:
124
+ """Add a single trace event."""
125
+ if "timestamp" not in event:
126
+ event["timestamp"] = time.time()
127
+ self._events.append(event)
128
+
129
+ def add_events(
130
+ self,
131
+ events: list[dict],
132
+ query: str = "",
133
+ model: str = "",
134
+ duration_s: float = 0.0,
135
+ cost_usd: float = 0.0,
136
+ ) -> None:
137
+ """Wrap a list of trace events with query_start/query_end and add them.
138
+
139
+ Also performs eager base64 embedding of plots.
140
+ """
141
+ now = time.time()
142
+
143
+ # query_start
144
+ self._events.append({
145
+ "type": "query_start",
146
+ "session_id": self.session_id,
147
+ "query": query,
148
+ "model": model,
149
+ "timestamp": now,
150
+ })
151
+
152
+ # Add all events (with plot embedding)
153
+ for event in events:
154
+ if event.get("type") == "tool_result" and event.get("plots"):
155
+ _embed_plots(event)
156
+ self._events.append(event)
157
+
158
+ # query_end
159
+ self._events.append({
160
+ "type": "query_end",
161
+ "duration_s": duration_s,
162
+ "cost_usd": cost_usd,
163
+ "timestamp": time.time(),
164
+ })
165
+
166
+ def flush(self, path: Path | None = None) -> Path:
167
+ """Persist all events to a JSONL file. Appends if file exists."""
168
+ out = path or self._path
169
+ out.parent.mkdir(parents=True, exist_ok=True)
170
+
171
+ with open(out, "a", encoding="utf-8") as f:
172
+ for event in self._events:
173
+ f.write(json.dumps(event, default=str) + "\n")
174
+
175
+ count = len(self._events)
176
+ self._events.clear()
177
+ logger.info("Flushed %d trace events to %s", count, out)
178
+ return out
179
+
180
+ @staticmethod
181
+ def load(path: Path | str) -> list[dict]:
182
+ """Load trace events from a JSONL file."""
183
+ path = Path(path)
184
+ if not path.exists():
185
+ raise FileNotFoundError(f"Trace file not found: {path}")
186
+
187
+ events = []
188
+ with open(path, "r", encoding="utf-8") as f:
189
+ for line in f:
190
+ line = line.strip()
191
+ if line:
192
+ events.append(json.loads(line))
193
+ return events
194
+
195
+ @staticmethod
196
+ def find_trace(session_id: str | None = None) -> Path | None:
197
+ """Find a trace file by session ID prefix, or return the most recent.
198
+
199
+ Args:
200
+ session_id: Session ID or prefix to match. If None, returns
201
+ the most recent trace file.
202
+
203
+ Returns:
204
+ Path to the trace file, or None if not found.
205
+ """
206
+ sessions_dir = _sessions_dir()
207
+ traces = sorted(
208
+ sessions_dir.glob("*.trace.jsonl"),
209
+ key=lambda p: p.stat().st_mtime,
210
+ reverse=True,
211
+ )
212
+ if not traces:
213
+ return None
214
+
215
+ if session_id is None:
216
+ return traces[0]
217
+
218
+ # Exact match first
219
+ exact = sessions_dir / f"{session_id}.trace.jsonl"
220
+ if exact.exists():
221
+ return exact
222
+
223
+ # Prefix match
224
+ for t in traces:
225
+ if t.stem.replace(".trace", "").startswith(session_id):
226
+ return t
227
+
228
+ return None
ct/agent/trajectory.py ADDED
@@ -0,0 +1,169 @@
1
+ """
2
+ Trajectory: session memory across queries in interactive mode.
3
+
4
+ Records queries, answers, entities mentioned, and tools used so the planner
5
+ can reference prior context ("earlier you found X, now also check Y").
6
+ Supports persistence to JSONL for session resume.
7
+ """
8
+
9
+ import json
10
+ import time
11
+ from dataclasses import dataclass, field, asdict
12
+ from pathlib import Path
13
+
14
+
15
+ @dataclass
16
+ class Turn:
17
+ """A single query-answer turn in a session."""
18
+ query: str
19
+ answer: str
20
+ entities: list[str] = field(default_factory=list)
21
+ tools_used: list[str] = field(default_factory=list)
22
+ timestamp: float = field(default_factory=time.time)
23
+
24
+
25
+ class Trajectory:
26
+ """Session memory: records turns and provides context for the planner."""
27
+
28
+ def __init__(self, max_turns: int = 20, session_id: str = None, title: str = None):
29
+ self.turns: list[Turn] = []
30
+ self.max_turns = max_turns
31
+ self.session_id = session_id
32
+ self.title = title
33
+ self.created_at = time.time()
34
+
35
+ def add_turn(self, query: str, answer: str, plan=None):
36
+ """Record a completed turn with entity extraction."""
37
+ entities = []
38
+ tools_used = []
39
+ if plan and hasattr(plan, "steps"):
40
+ tools_used = [s.tool for s in plan.steps if s.status == "completed"]
41
+
42
+ turn = Turn(
43
+ query=query,
44
+ answer=answer,
45
+ entities=entities,
46
+ tools_used=tools_used,
47
+ )
48
+ self.turns.append(turn)
49
+
50
+ # Drop oldest turns if over limit
51
+ if len(self.turns) > self.max_turns:
52
+ self.turns = self.turns[-self.max_turns:]
53
+
54
+ def context_for_planner(self) -> str:
55
+ """Format recent turns as context for the planner prompt."""
56
+ if not self.turns:
57
+ return ""
58
+
59
+ recent = self.turns[-5:]
60
+ lines = ["## Session Context (prior queries this session)", ""]
61
+ for i, turn in enumerate(recent, 1):
62
+ entities_str = ", ".join(turn.entities) if turn.entities else "none"
63
+ tools_str = ", ".join(turn.tools_used) if turn.tools_used else "none"
64
+ # Truncate answer to first 200 chars for context
65
+ answer_preview = turn.answer[:200] + "..." if len(turn.answer) > 200 else turn.answer
66
+ lines.append(f"**Turn {i}**: {turn.query}")
67
+ lines.append(f" Entities: {entities_str}")
68
+ lines.append(f" Tools: {tools_str}")
69
+ lines.append(f" Finding: {answer_preview}")
70
+ lines.append("")
71
+
72
+ all_entities = self.entities()
73
+ if all_entities:
74
+ lines.append(f"**All entities this session**: {', '.join(all_entities)}")
75
+ lines.append("")
76
+
77
+ lines.append(
78
+ "Use this context to: reference prior findings, avoid repeating work, "
79
+ "resolve ambiguous entity references (e.g., 'it', 'the compound')."
80
+ )
81
+ return "\n".join(lines)
82
+
83
+ def entities(self) -> list[str]:
84
+ """All unique entities mentioned across the session, preserving order."""
85
+ seen = set()
86
+ result = []
87
+ for turn in self.turns:
88
+ for entity in turn.entities:
89
+ if entity not in seen:
90
+ seen.add(entity)
91
+ result.append(entity)
92
+ return result
93
+
94
+ def save(self, path: Path):
95
+ """Persist trajectory to a JSONL file."""
96
+ path.parent.mkdir(parents=True, exist_ok=True)
97
+ with open(path, "w") as f:
98
+ # First line: session metadata
99
+ meta = {
100
+ "type": "meta",
101
+ "session_id": self.session_id,
102
+ "title": self.title,
103
+ "created_at": self.created_at,
104
+ "n_turns": len(self.turns),
105
+ }
106
+ f.write(json.dumps(meta) + "\n")
107
+ # Subsequent lines: turns
108
+ for turn in self.turns:
109
+ record = {"type": "turn", **asdict(turn)}
110
+ f.write(json.dumps(record) + "\n")
111
+
112
+ @classmethod
113
+ def load(cls, path: Path) -> "Trajectory":
114
+ """Load a trajectory from a JSONL file."""
115
+ trajectory = cls()
116
+ with open(path) as f:
117
+ for line in f:
118
+ line = line.strip()
119
+ if not line:
120
+ continue
121
+ try:
122
+ record = json.loads(line)
123
+ except json.JSONDecodeError as e:
124
+ import logging
125
+ logging.getLogger("ct.trajectory").warning(
126
+ "Skipping corrupted line in trajectory %s: %s", path, e,
127
+ )
128
+ continue
129
+ if record.get("type") == "meta":
130
+ trajectory.session_id = record.get("session_id")
131
+ trajectory.title = record.get("title")
132
+ trajectory.created_at = record.get("created_at", 0)
133
+ elif record.get("type") == "turn":
134
+ turn = Turn(
135
+ query=record.get("query", ""),
136
+ answer=record.get("answer", ""),
137
+ entities=record.get("entities", []),
138
+ tools_used=record.get("tools_used", []),
139
+ timestamp=record.get("timestamp", 0),
140
+ )
141
+ trajectory.turns.append(turn)
142
+ return trajectory
143
+
144
+ @staticmethod
145
+ def sessions_dir() -> Path:
146
+ """Return the directory where sessions are stored."""
147
+ d = Path.home() / ".ct" / "sessions"
148
+ d.mkdir(parents=True, exist_ok=True)
149
+ return d
150
+
151
+ @classmethod
152
+ def list_sessions(cls) -> list[dict]:
153
+ """List all saved sessions, most recent first."""
154
+ sessions_dir = cls.sessions_dir()
155
+ sessions = []
156
+ for path in sessions_dir.glob("*.jsonl"):
157
+ try:
158
+ with open(path) as f:
159
+ first_line = f.readline().strip()
160
+ if first_line:
161
+ meta = json.loads(first_line)
162
+ if meta.get("type") == "meta":
163
+ meta["path"] = str(path)
164
+ sessions.append(meta)
165
+ except (json.JSONDecodeError, OSError):
166
+ continue
167
+ # Sort by created_at descending (most recent first)
168
+ sessions.sort(key=lambda s: s.get("created_at", 0), reverse=True)
169
+ return sessions