kimi-cli 0.40__py3-none-any.whl → 0.42__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.

Potentially problematic release.


This version of kimi-cli might be problematic. Click here for more details.

Files changed (55) hide show
  1. kimi_cli/CHANGELOG.md +27 -0
  2. kimi_cli/__init__.py +127 -359
  3. kimi_cli/agents/{koder → default}/agent.yaml +1 -1
  4. kimi_cli/agents/{koder → default}/system.md +1 -1
  5. kimi_cli/agentspec.py +115 -0
  6. kimi_cli/cli.py +249 -0
  7. kimi_cli/config.py +28 -14
  8. kimi_cli/constant.py +4 -0
  9. kimi_cli/exception.py +16 -0
  10. kimi_cli/llm.py +70 -0
  11. kimi_cli/metadata.py +5 -68
  12. kimi_cli/prompts/__init__.py +2 -2
  13. kimi_cli/session.py +81 -0
  14. kimi_cli/soul/__init__.py +102 -6
  15. kimi_cli/soul/agent.py +152 -0
  16. kimi_cli/soul/approval.py +1 -1
  17. kimi_cli/soul/compaction.py +4 -4
  18. kimi_cli/soul/kimisoul.py +39 -46
  19. kimi_cli/soul/runtime.py +94 -0
  20. kimi_cli/tools/dmail/__init__.py +1 -1
  21. kimi_cli/tools/file/glob.md +1 -1
  22. kimi_cli/tools/file/glob.py +2 -2
  23. kimi_cli/tools/file/grep.py +1 -1
  24. kimi_cli/tools/file/patch.py +2 -2
  25. kimi_cli/tools/file/read.py +1 -1
  26. kimi_cli/tools/file/replace.py +2 -2
  27. kimi_cli/tools/file/write.py +2 -2
  28. kimi_cli/tools/task/__init__.py +48 -40
  29. kimi_cli/tools/task/task.md +1 -1
  30. kimi_cli/tools/todo/__init__.py +1 -1
  31. kimi_cli/tools/utils.py +1 -1
  32. kimi_cli/tools/web/search.py +5 -2
  33. kimi_cli/ui/__init__.py +0 -69
  34. kimi_cli/ui/acp/__init__.py +8 -9
  35. kimi_cli/ui/print/__init__.py +21 -37
  36. kimi_cli/ui/shell/__init__.py +8 -19
  37. kimi_cli/ui/shell/liveview.py +1 -1
  38. kimi_cli/ui/shell/metacmd.py +5 -10
  39. kimi_cli/ui/shell/prompt.py +10 -3
  40. kimi_cli/ui/shell/setup.py +5 -5
  41. kimi_cli/ui/shell/update.py +2 -2
  42. kimi_cli/ui/shell/visualize.py +10 -7
  43. kimi_cli/utils/changelog.py +3 -1
  44. kimi_cli/wire/__init__.py +69 -0
  45. kimi_cli/{soul/wire.py → wire/message.py} +4 -39
  46. {kimi_cli-0.40.dist-info → kimi_cli-0.42.dist-info}/METADATA +51 -18
  47. kimi_cli-0.42.dist-info/RECORD +86 -0
  48. kimi_cli-0.42.dist-info/entry_points.txt +3 -0
  49. kimi_cli/agent.py +0 -261
  50. kimi_cli/agents/koder/README.md +0 -3
  51. kimi_cli/utils/provider.py +0 -70
  52. kimi_cli-0.40.dist-info/RECORD +0 -81
  53. kimi_cli-0.40.dist-info/entry_points.txt +0 -3
  54. /kimi_cli/agents/{koder → default}/sub.yaml +0 -0
  55. {kimi_cli-0.40.dist-info → kimi_cli-0.42.dist-info}/WHEEL +0 -0
kimi_cli/cli.py ADDED
@@ -0,0 +1,249 @@
1
+ import asyncio
2
+ import json
3
+ import sys
4
+ from collections.abc import Callable
5
+ from pathlib import Path
6
+ from typing import Any, Literal, get_args
7
+
8
+ import click
9
+
10
+ from kimi_cli import KimiCLI
11
+ from kimi_cli.constant import VERSION
12
+ from kimi_cli.session import Session
13
+ from kimi_cli.share import get_share_dir
14
+ from kimi_cli.ui.print import InputFormat, OutputFormat
15
+ from kimi_cli.utils.logging import logger
16
+
17
+
18
+ class Reload(Exception):
19
+ """Reload configuration."""
20
+
21
+ pass
22
+
23
+
24
+ UIMode = Literal["shell", "print", "acp"]
25
+
26
+
27
+ @click.command(context_settings=dict(help_option_names=["-h", "--help"]))
28
+ @click.version_option(VERSION)
29
+ @click.option(
30
+ "--verbose",
31
+ is_flag=True,
32
+ default=False,
33
+ help="Print verbose information. Default: no.",
34
+ )
35
+ @click.option(
36
+ "--debug",
37
+ is_flag=True,
38
+ default=False,
39
+ help="Log debug information. Default: no.",
40
+ )
41
+ @click.option(
42
+ "--agent-file",
43
+ type=click.Path(exists=True, file_okay=True, dir_okay=False, path_type=Path),
44
+ default=None,
45
+ help="Custom agent specification file. Default: builtin default agent.",
46
+ )
47
+ @click.option(
48
+ "--model",
49
+ "-m",
50
+ "model_name",
51
+ type=str,
52
+ default=None,
53
+ help="LLM model to use. Default: default model set in config file.",
54
+ )
55
+ @click.option(
56
+ "--work-dir",
57
+ "-w",
58
+ type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path),
59
+ default=Path.cwd(),
60
+ help="Working directory for the agent. Default: current directory.",
61
+ )
62
+ @click.option(
63
+ "--continue",
64
+ "-C",
65
+ "continue_",
66
+ is_flag=True,
67
+ default=False,
68
+ help="Continue the previous session for the working directory. Default: no.",
69
+ )
70
+ @click.option(
71
+ "--command",
72
+ "-c",
73
+ "--query",
74
+ "-q",
75
+ "command",
76
+ type=str,
77
+ default=None,
78
+ help="User query to the agent. Default: prompt interactively.",
79
+ )
80
+ @click.option(
81
+ "--ui",
82
+ "ui",
83
+ type=click.Choice(get_args(UIMode)),
84
+ default="shell",
85
+ help="UI mode to use. Default: shell.",
86
+ )
87
+ @click.option(
88
+ "--print",
89
+ "ui",
90
+ flag_value="print",
91
+ help="Run in print mode. Shortcut for `--ui print`. Note: print mode implicitly adds `--yolo`.",
92
+ )
93
+ @click.option(
94
+ "--acp",
95
+ "ui",
96
+ flag_value="acp",
97
+ help="Start ACP server. Shortcut for `--ui acp`.",
98
+ )
99
+ @click.option(
100
+ "--input-format",
101
+ type=click.Choice(get_args(InputFormat)),
102
+ default=None,
103
+ help=(
104
+ "Input format to use. Must be used with `--print` "
105
+ "and the input must be piped in via stdin. "
106
+ "Default: text."
107
+ ),
108
+ )
109
+ @click.option(
110
+ "--output-format",
111
+ type=click.Choice(get_args(OutputFormat)),
112
+ default=None,
113
+ help="Output format to use. Must be used with `--print`. Default: text.",
114
+ )
115
+ @click.option(
116
+ "--mcp-config-file",
117
+ type=click.Path(exists=True, file_okay=True, dir_okay=False, path_type=Path),
118
+ multiple=True,
119
+ help=(
120
+ "MCP config file to load. Add this option multiple times to specify multiple MCP configs. "
121
+ "Default: none."
122
+ ),
123
+ )
124
+ @click.option(
125
+ "--mcp-config",
126
+ type=str,
127
+ multiple=True,
128
+ help=(
129
+ "MCP config JSON to load. Add this option multiple times to specify multiple MCP configs. "
130
+ "Default: none."
131
+ ),
132
+ )
133
+ @click.option(
134
+ "--yolo",
135
+ "--yes",
136
+ "-y",
137
+ "--auto-approve",
138
+ "yolo",
139
+ is_flag=True,
140
+ default=False,
141
+ help="Automatically approve all actions. Default: no.",
142
+ )
143
+ def kimi(
144
+ verbose: bool,
145
+ debug: bool,
146
+ agent_file: Path | None,
147
+ model_name: str | None,
148
+ work_dir: Path,
149
+ continue_: bool,
150
+ command: str | None,
151
+ ui: UIMode,
152
+ input_format: InputFormat | None,
153
+ output_format: OutputFormat | None,
154
+ mcp_config_file: list[Path],
155
+ mcp_config: list[str],
156
+ yolo: bool,
157
+ ):
158
+ """Kimi, your next CLI agent."""
159
+
160
+ def _noop_echo(*args: Any, **kwargs: Any):
161
+ pass
162
+
163
+ echo: Callable[..., None] = click.echo if verbose else _noop_echo
164
+
165
+ logger.add(
166
+ get_share_dir() / "logs" / "kimi.log",
167
+ level="DEBUG" if debug else "INFO",
168
+ rotation="06:00",
169
+ retention="10 days",
170
+ )
171
+
172
+ work_dir = work_dir.absolute()
173
+ if continue_:
174
+ session = Session.continue_(work_dir)
175
+ if session is None:
176
+ raise click.BadOptionUsage(
177
+ "--continue", "No previous session found for the working directory"
178
+ )
179
+ echo(f"✓ Continuing previous session: {session.id}")
180
+ else:
181
+ session = Session.create(work_dir)
182
+ echo(f"✓ Created new session: {session.id}")
183
+ echo(f"✓ Session history file: {session.history_file}")
184
+
185
+ if command is not None:
186
+ command = command.strip()
187
+ if not command:
188
+ raise click.BadOptionUsage("--command", "Command cannot be empty")
189
+
190
+ if input_format is not None and ui != "print":
191
+ raise click.BadOptionUsage(
192
+ "--input-format",
193
+ "Input format is only supported for print UI",
194
+ )
195
+ if output_format is not None and ui != "print":
196
+ raise click.BadOptionUsage(
197
+ "--output-format",
198
+ "Output format is only supported for print UI",
199
+ )
200
+
201
+ try:
202
+ mcp_configs = [json.loads(conf.read_text(encoding="utf-8")) for conf in mcp_config_file]
203
+ except json.JSONDecodeError as e:
204
+ raise click.BadOptionUsage("--mcp-config-file", f"Invalid JSON: {e}") from e
205
+
206
+ try:
207
+ mcp_configs += [json.loads(conf) for conf in mcp_config]
208
+ except json.JSONDecodeError as e:
209
+ raise click.BadOptionUsage("--mcp-config", f"Invalid JSON: {e}") from e
210
+
211
+ async def _run() -> bool:
212
+ instance = await KimiCLI.create(
213
+ session,
214
+ yolo=yolo or (ui == "print"), # print mode implies yolo
215
+ stream=ui != "print", # use non-streaming mode only for print UI
216
+ mcp_configs=mcp_configs,
217
+ model_name=model_name,
218
+ agent_file=agent_file,
219
+ )
220
+ match ui:
221
+ case "shell":
222
+ return await instance.run_shell_mode(command)
223
+ case "print":
224
+ return await instance.run_print_mode(
225
+ input_format or "text",
226
+ output_format or "text",
227
+ command,
228
+ )
229
+ case "acp":
230
+ if command is not None:
231
+ logger.warning("ACP server ignores command argument")
232
+ return await instance.run_acp_server()
233
+
234
+ while True:
235
+ try:
236
+ succeeded = asyncio.run(_run())
237
+ if not succeeded:
238
+ sys.exit(1)
239
+ break
240
+ except Reload:
241
+ continue
242
+
243
+
244
+ def main():
245
+ kimi()
246
+
247
+
248
+ if __name__ == "__main__":
249
+ main()
kimi_cli/config.py CHANGED
@@ -4,6 +4,7 @@ from typing import Literal, Self
4
4
 
5
5
  from pydantic import BaseModel, Field, SecretStr, ValidationError, field_serializer, model_validator
6
6
 
7
+ from kimi_cli.exception import ConfigError
7
8
  from kimi_cli.share import get_share_dir
8
9
  from kimi_cli.utils.logging import logger
9
10
 
@@ -17,6 +18,8 @@ class LLMProvider(BaseModel):
17
18
  """API base URL"""
18
19
  api_key: SecretStr
19
20
  """API key"""
21
+ custom_headers: dict[str, str] = Field(default_factory=dict)
22
+ """Custom headers to include in API requests"""
20
23
 
21
24
  @field_serializer("api_key", when_used="json")
22
25
  def dump_secret(self, v: SecretStr):
@@ -50,6 +53,8 @@ class MoonshotSearchConfig(BaseModel):
50
53
  """Base URL for Moonshot Search service."""
51
54
  api_key: SecretStr
52
55
  """API key for Moonshot Search service."""
56
+ custom_headers: dict[str, str] = Field(default_factory=dict)
57
+ """Custom headers to include in API requests."""
53
58
 
54
59
  @field_serializer("api_key", when_used="json")
55
60
  def dump_secret(self, v: SecretStr):
@@ -99,13 +104,21 @@ def get_default_config() -> Config:
99
104
  )
100
105
 
101
106
 
102
- def load_config() -> Config:
103
- """Load configuration from config file.
107
+ def load_config(config_file: Path | None = None) -> Config:
108
+ """
109
+ Load configuration from config file.
110
+ If the config file does not exist, create it with default configuration.
111
+
112
+ Args:
113
+ config_file (Path | None): Path to the configuration file. If None, use default path.
104
114
 
105
115
  Returns:
106
116
  Validated Config object.
117
+
118
+ Raises:
119
+ ConfigError: If the configuration file is invalid.
107
120
  """
108
- config_file = get_config_file()
121
+ config_file = config_file or get_config_file()
109
122
  logger.debug("Loading config from file: {file}", file=config_file)
110
123
 
111
124
  if not config_file.exists():
@@ -119,20 +132,21 @@ def load_config() -> Config:
119
132
  with open(config_file, encoding="utf-8") as f:
120
133
  data = json.load(f)
121
134
  return Config(**data)
122
- except (json.JSONDecodeError, ValidationError) as e:
123
- raise ConfigError(f"Invalid configuration file: {config_file}") from e
124
-
135
+ except json.JSONDecodeError as e:
136
+ raise ConfigError(f"Invalid JSON in configuration file: {e}") from e
137
+ except ValidationError as e:
138
+ raise ConfigError(f"Invalid configuration file: {e}") from e
125
139
 
126
- class ConfigError(Exception):
127
- """Configuration error."""
128
-
129
- def __init__(self, message: str):
130
- super().__init__(message)
131
140
 
141
+ def save_config(config: Config, config_file: Path | None = None):
142
+ """
143
+ Save configuration to config file.
132
144
 
133
- def save_config(config: Config):
134
- """Save configuration to config file."""
135
- config_file = get_config_file()
145
+ Args:
146
+ config (Config): Config object to save.
147
+ config_file (Path | None): Path to the configuration file. If None, use default path.
148
+ """
149
+ config_file = config_file or get_config_file()
136
150
  logger.debug("Saving config to file: {file}", file=config_file)
137
151
  with open(config_file, "w", encoding="utf-8") as f:
138
152
  f.write(config.model_dump_json(indent=2, exclude_none=True))
kimi_cli/constant.py ADDED
@@ -0,0 +1,4 @@
1
+ import importlib.metadata
2
+
3
+ VERSION = importlib.metadata.version("kimi-cli")
4
+ USER_AGENT = f"KimiCLI/{VERSION}"
kimi_cli/exception.py ADDED
@@ -0,0 +1,16 @@
1
+ class KimiCLIException(Exception):
2
+ """Base exception class for Kimi CLI."""
3
+
4
+ pass
5
+
6
+
7
+ class ConfigError(KimiCLIException):
8
+ """Configuration error."""
9
+
10
+ pass
11
+
12
+
13
+ class AgentSpecError(KimiCLIException):
14
+ """Agent specification error."""
15
+
16
+ pass
kimi_cli/llm.py CHANGED
@@ -1,8 +1,78 @@
1
+ import os
1
2
  from typing import NamedTuple
2
3
 
3
4
  from kosong.base.chat_provider import ChatProvider
5
+ from kosong.chat_provider.chaos import ChaosChatProvider, ChaosConfig
6
+ from kosong.chat_provider.kimi import Kimi
7
+ from kosong.chat_provider.openai_legacy import OpenAILegacy
8
+ from pydantic import SecretStr
9
+
10
+ from kimi_cli.config import LLMModel, LLMProvider
11
+ from kimi_cli.constant import USER_AGENT
4
12
 
5
13
 
6
14
  class LLM(NamedTuple):
7
15
  chat_provider: ChatProvider
8
16
  max_context_size: int
17
+
18
+
19
+ def augment_provider_with_env_vars(provider: LLMProvider, model: LLMModel):
20
+ match provider.type:
21
+ case "kimi":
22
+ if base_url := os.getenv("KIMI_BASE_URL"):
23
+ provider.base_url = base_url
24
+ if api_key := os.getenv("KIMI_API_KEY"):
25
+ provider.api_key = SecretStr(api_key)
26
+ if model_name := os.getenv("KIMI_MODEL_NAME"):
27
+ model.model = model_name
28
+ if max_context_size := os.getenv("KIMI_MODEL_MAX_CONTEXT_SIZE"):
29
+ model.max_context_size = int(max_context_size)
30
+ case "openai_legacy":
31
+ if base_url := os.getenv("OPENAI_BASE_URL"):
32
+ provider.base_url = base_url
33
+ if api_key := os.getenv("OPENAI_API_KEY"):
34
+ provider.api_key = SecretStr(api_key)
35
+ case _:
36
+ pass
37
+
38
+
39
+ def create_llm(
40
+ provider: LLMProvider,
41
+ model: LLMModel,
42
+ *,
43
+ stream: bool = True,
44
+ session_id: str | None = None,
45
+ ) -> LLM:
46
+ match provider.type:
47
+ case "kimi":
48
+ chat_provider = Kimi(
49
+ model=model.model,
50
+ base_url=provider.base_url,
51
+ api_key=provider.api_key.get_secret_value(),
52
+ stream=stream,
53
+ default_headers={
54
+ "User-Agent": USER_AGENT,
55
+ **provider.custom_headers,
56
+ },
57
+ )
58
+ if session_id:
59
+ chat_provider = chat_provider.with_generation_kwargs(prompt_cache_key=session_id)
60
+ case "openai_legacy":
61
+ chat_provider = OpenAILegacy(
62
+ model=model.model,
63
+ base_url=provider.base_url,
64
+ api_key=provider.api_key.get_secret_value(),
65
+ stream=stream,
66
+ )
67
+ case "_chaos":
68
+ chat_provider = ChaosChatProvider(
69
+ model=model.model,
70
+ base_url=provider.base_url,
71
+ api_key=provider.api_key.get_secret_value(),
72
+ chaos_config=ChaosConfig(
73
+ error_probability=0.8,
74
+ error_types=[429, 500, 503],
75
+ ),
76
+ )
77
+
78
+ return LLM(chat_provider=chat_provider, max_context_size=model.max_context_size)
kimi_cli/metadata.py CHANGED
@@ -1,8 +1,6 @@
1
1
  import json
2
- import uuid
3
2
  from hashlib import md5
4
3
  from pathlib import Path
5
- from typing import NamedTuple
6
4
 
7
5
  from pydantic import BaseModel, Field
8
6
 
@@ -33,10 +31,12 @@ class WorkDirMeta(BaseModel):
33
31
  class Metadata(BaseModel):
34
32
  """Kimi metadata structure."""
35
33
 
36
- work_dirs: list[WorkDirMeta] = Field(default_factory=list, description="Work directory list")
34
+ work_dirs: list[WorkDirMeta] = Field(
35
+ default_factory=list[WorkDirMeta], description="Work directory list"
36
+ )
37
37
 
38
38
 
39
- def _load_metadata() -> Metadata:
39
+ def load_metadata() -> Metadata:
40
40
  metadata_file = get_metadata_file()
41
41
  logger.debug("Loading metadata from file: {file}", file=metadata_file)
42
42
  if not metadata_file.exists():
@@ -47,71 +47,8 @@ def _load_metadata() -> Metadata:
47
47
  return Metadata(**data)
48
48
 
49
49
 
50
- def _save_metadata(metadata: Metadata):
50
+ def save_metadata(metadata: Metadata):
51
51
  metadata_file = get_metadata_file()
52
52
  logger.debug("Saving metadata to file: {file}", file=metadata_file)
53
53
  with open(metadata_file, "w", encoding="utf-8") as f:
54
54
  json.dump(metadata.model_dump(), f, indent=2, ensure_ascii=False)
55
-
56
-
57
- class Session(NamedTuple):
58
- """A session of a work directory."""
59
-
60
- id: str
61
- work_dir: WorkDirMeta
62
- history_file: Path
63
-
64
-
65
- def new_session(work_dir: Path, _history_file: Path | None = None) -> Session:
66
- """Create a new session for a work directory."""
67
- logger.debug("Creating new session for work directory: {work_dir}", work_dir=work_dir)
68
-
69
- metadata = _load_metadata()
70
- work_dir_meta = next((wd for wd in metadata.work_dirs if wd.path == str(work_dir)), None)
71
- if work_dir_meta is None:
72
- work_dir_meta = WorkDirMeta(path=str(work_dir))
73
- metadata.work_dirs.append(work_dir_meta)
74
-
75
- session_id = str(uuid.uuid4())
76
- if _history_file is None:
77
- history_file = work_dir_meta.sessions_dir / f"{session_id}.jsonl"
78
- work_dir_meta.last_session_id = session_id
79
- else:
80
- logger.warning("Using provided history file: {history_file}", history_file=_history_file)
81
- _history_file.parent.mkdir(parents=True, exist_ok=True)
82
- if _history_file.exists():
83
- assert _history_file.is_file()
84
- history_file = _history_file
85
-
86
- if history_file.exists():
87
- # truncate if exists
88
- logger.warning(
89
- "History file already exists, truncating: {history_file}", history_file=history_file
90
- )
91
- history_file.unlink()
92
- history_file.touch()
93
-
94
- _save_metadata(metadata)
95
- return Session(id=session_id, work_dir=work_dir_meta, history_file=history_file)
96
-
97
-
98
- def continue_session(work_dir: Path) -> Session | None:
99
- """Get the last session for a work directory."""
100
- logger.debug("Continuing session for work directory: {work_dir}", work_dir=work_dir)
101
-
102
- metadata = _load_metadata()
103
- work_dir_meta = next((wd for wd in metadata.work_dirs if wd.path == str(work_dir)), None)
104
- if work_dir_meta is None:
105
- logger.debug("Work directory never been used")
106
- return None
107
- if work_dir_meta.last_session_id is None:
108
- logger.debug("Work directory never had a session")
109
- return None
110
-
111
- logger.debug(
112
- "Found last session for work directory: {session_id}",
113
- session_id=work_dir_meta.last_session_id,
114
- )
115
- session_id = work_dir_meta.last_session_id
116
- history_file = work_dir_meta.sessions_dir / f"{session_id}.jsonl"
117
- return Session(id=session_id, work_dir=work_dir_meta, history_file=history_file)
@@ -1,4 +1,4 @@
1
1
  from pathlib import Path
2
2
 
3
- INIT = (Path(__file__).parent / "init.md").read_text()
4
- COMPACT = (Path(__file__).parent / "compact.md").read_text()
3
+ INIT = (Path(__file__).parent / "init.md").read_text(encoding="utf-8")
4
+ COMPACT = (Path(__file__).parent / "compact.md").read_text(encoding="utf-8")
kimi_cli/session.py ADDED
@@ -0,0 +1,81 @@
1
+ import uuid
2
+ from pathlib import Path
3
+ from typing import NamedTuple
4
+
5
+ from kimi_cli.metadata import WorkDirMeta, load_metadata, save_metadata
6
+ from kimi_cli.utils.logging import logger
7
+
8
+
9
+ class Session(NamedTuple):
10
+ """A session of a work directory."""
11
+
12
+ id: str
13
+ work_dir: Path
14
+ history_file: Path
15
+
16
+ @staticmethod
17
+ def create(work_dir: Path, _history_file: Path | None = None) -> "Session":
18
+ """Create a new session for a work directory."""
19
+ logger.debug("Creating new session for work directory: {work_dir}", work_dir=work_dir)
20
+
21
+ metadata = load_metadata()
22
+ work_dir_meta = next((wd for wd in metadata.work_dirs if wd.path == str(work_dir)), None)
23
+ if work_dir_meta is None:
24
+ work_dir_meta = WorkDirMeta(path=str(work_dir))
25
+ metadata.work_dirs.append(work_dir_meta)
26
+
27
+ session_id = str(uuid.uuid4())
28
+ if _history_file is None:
29
+ history_file = work_dir_meta.sessions_dir / f"{session_id}.jsonl"
30
+ work_dir_meta.last_session_id = session_id
31
+ else:
32
+ logger.warning(
33
+ "Using provided history file: {history_file}", history_file=_history_file
34
+ )
35
+ _history_file.parent.mkdir(parents=True, exist_ok=True)
36
+ if _history_file.exists():
37
+ assert _history_file.is_file()
38
+ history_file = _history_file
39
+
40
+ if history_file.exists():
41
+ # truncate if exists
42
+ logger.warning(
43
+ "History file already exists, truncating: {history_file}", history_file=history_file
44
+ )
45
+ history_file.unlink()
46
+ history_file.touch()
47
+
48
+ save_metadata(metadata)
49
+
50
+ return Session(
51
+ id=session_id,
52
+ work_dir=work_dir,
53
+ history_file=history_file,
54
+ )
55
+
56
+ @staticmethod
57
+ def continue_(work_dir: Path) -> "Session | None":
58
+ """Get the last session for a work directory."""
59
+ logger.debug("Continuing session for work directory: {work_dir}", work_dir=work_dir)
60
+
61
+ metadata = load_metadata()
62
+ work_dir_meta = next((wd for wd in metadata.work_dirs if wd.path == str(work_dir)), None)
63
+ if work_dir_meta is None:
64
+ logger.debug("Work directory never been used")
65
+ return None
66
+ if work_dir_meta.last_session_id is None:
67
+ logger.debug("Work directory never had a session")
68
+ return None
69
+
70
+ logger.debug(
71
+ "Found last session for work directory: {session_id}",
72
+ session_id=work_dir_meta.last_session_id,
73
+ )
74
+ session_id = work_dir_meta.last_session_id
75
+ history_file = work_dir_meta.sessions_dir / f"{session_id}.jsonl"
76
+
77
+ return Session(
78
+ id=session_id,
79
+ work_dir=work_dir,
80
+ history_file=history_file,
81
+ )