todo-agent 0.2.6__py3-none-any.whl → 0.2.9__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.
todo_agent/_version.py CHANGED
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '0.2.6'
32
- __version_tuple__ = version_tuple = (0, 2, 6)
31
+ __version__ = version = '0.2.9'
32
+ __version_tuple__ = version_tuple = (0, 2, 9)
33
33
 
34
34
  __commit_id__ = commit_id = None
@@ -35,14 +35,16 @@ class ConversationManager:
35
35
  """Manages conversation state and memory for LLM interactions."""
36
36
 
37
37
  def __init__(
38
- self, max_tokens: int = 16000, max_messages: int = 50, model: str = "gpt-4"
38
+ self, max_tokens: int = 32000, max_messages: int = 50, model: str = "gpt-4"
39
39
  ):
40
40
  self.history: List[ConversationMessage] = []
41
41
  self.max_tokens = max_tokens
42
42
  self.max_messages = max_messages
43
43
  self.system_prompt: Optional[str] = None
44
44
  self.token_counter = get_token_counter(model)
45
- self._total_tokens = 0 # Running total of tokens in conversation
45
+ self._total_tokens: int = 0 # Running total of tokens in conversation
46
+ self._tools_tokens: int = 0 # Cache for tools token count
47
+ self._last_tools_hash: Optional[int] = None # Track if tools have changed
46
48
 
47
49
  def add_message(
48
50
  self,
@@ -140,10 +142,44 @@ class ConversationManager:
140
142
  self._total_tokens -= message.token_count
141
143
  self.history.pop(index)
142
144
 
143
- def _trim_if_needed(self) -> None:
145
+ def get_request_tokens(self, tools: List[Dict[str, Any]]) -> int:
146
+ """
147
+ Get total request tokens (conversation + tools).
148
+
149
+ Args:
150
+ tools: Current tool definitions
151
+
152
+ Returns:
153
+ Total request tokens
154
+ """
155
+ # Check if tools have changed
156
+ tools_hash = hash(str(tools))
157
+ if tools_hash != self._last_tools_hash:
158
+ self._tools_tokens = self.token_counter.count_tools_tokens(tools)
159
+ self._last_tools_hash = tools_hash
160
+
161
+ return self._total_tokens + self._tools_tokens
162
+
163
+ def update_request_tokens(self, actual_prompt_tokens: int) -> None:
164
+ """
165
+ Update the conversation with actual token count from API response.
166
+
167
+ Args:
168
+ actual_prompt_tokens: Actual prompt tokens used by the API
169
+ """
170
+ # Calculate tools tokens by subtracting conversation tokens
171
+ tools_tokens = actual_prompt_tokens - self._total_tokens
172
+ if tools_tokens >= 0:
173
+ self._tools_tokens = tools_tokens
174
+ # Note: logger not available in this context, so we'll handle logging in the calling code
175
+
176
+ def _trim_if_needed(self, tools: Optional[List[Dict[str, Any]]] = None) -> None:
144
177
  """
145
178
  Trim conversation history if it exceeds token or message limits.
146
179
  Preserves most recent messages and system prompt.
180
+
181
+ Args:
182
+ tools: Optional tools for request token calculation
147
183
  """
148
184
  # Check message count limit
149
185
  if len(self.history) > self.max_messages:
@@ -160,8 +196,10 @@ class ConversationManager:
160
196
  # Recalculate total tokens after message count trimming
161
197
  self._recalculate_total_tokens()
162
198
 
163
- # Check token limit - remove oldest non-system messages until under limit
164
- while self._total_tokens > self.max_tokens and len(self.history) > 2:
199
+ # Check token limit using request tokens if tools provided
200
+ current_tokens = self.get_request_tokens(tools) if tools else self._total_tokens
201
+
202
+ while current_tokens > self.max_tokens and len(self.history) > 2:
165
203
  # Find oldest non-system message to remove
166
204
  for i, msg in enumerate(self.history):
167
205
  if msg.role != MessageRole.SYSTEM:
@@ -171,6 +209,11 @@ class ConversationManager:
171
209
  # No non-system messages found, break to avoid infinite loop
172
210
  break
173
211
 
212
+ # Recalculate request tokens
213
+ current_tokens = (
214
+ self.get_request_tokens(tools) if tools else self._total_tokens
215
+ )
216
+
174
217
  def _recalculate_total_tokens(self) -> None:
175
218
  """Recalculate total token count from scratch (used after major restructuring)."""
176
219
  self._total_tokens = 0
@@ -219,10 +262,15 @@ class ConversationManager:
219
262
  self.history.insert(0, system_message)
220
263
  self._total_tokens += token_count
221
264
 
222
- def get_conversation_summary(self) -> Dict[str, Any]:
265
+ def get_conversation_summary(
266
+ self, tools: Optional[List[Dict[str, Any]]] = None
267
+ ) -> Dict[str, Any]:
223
268
  """
224
269
  Get conversation statistics and summary.
225
270
 
271
+ Args:
272
+ tools: Optional tools for request token calculation
273
+
226
274
  Returns:
227
275
  Dictionary with conversation metrics
228
276
  """
@@ -256,6 +304,9 @@ class ConversationManager:
256
304
  return {
257
305
  "total_messages": len(self.history),
258
306
  "estimated_tokens": self._total_tokens,
307
+ "request_tokens": self.get_request_tokens(tools)
308
+ if tools
309
+ else self._total_tokens,
259
310
  "user_messages": len(
260
311
  [msg for msg in self.history if msg.role == MessageRole.USER]
261
312
  ),
@@ -19,6 +19,7 @@ class TodoManager:
19
19
  project: Optional[str] = None,
20
20
  context: Optional[str] = None,
21
21
  due: Optional[str] = None,
22
+ recurring: Optional[str] = None,
22
23
  ) -> str:
23
24
  """Add new task with explicit project/context parameters."""
24
25
  # Validate and sanitize inputs
@@ -54,6 +55,33 @@ class TodoManager:
54
55
  f"Invalid due date format '{due}'. Must be YYYY-MM-DD."
55
56
  )
56
57
 
58
+ if recurring:
59
+ # Validate recurring format
60
+ if not recurring.startswith("rec:"):
61
+ raise ValueError(
62
+ f"Invalid recurring format '{recurring}'. Must start with 'rec:'."
63
+ )
64
+ # Basic validation of recurring syntax
65
+ parts = recurring.split(":")
66
+ if len(parts) < 2 or len(parts) > 3:
67
+ raise ValueError(
68
+ f"Invalid recurring format '{recurring}'. Expected 'rec:frequency' or 'rec:frequency:interval'."
69
+ )
70
+ frequency = parts[1]
71
+ if frequency not in ["daily", "weekly", "monthly", "yearly"]:
72
+ raise ValueError(
73
+ f"Invalid frequency '{frequency}'. Must be one of: daily, weekly, monthly, yearly."
74
+ )
75
+ if len(parts) == 3:
76
+ try:
77
+ interval = int(parts[2])
78
+ if interval < 1:
79
+ raise ValueError("Interval must be at least 1.")
80
+ except ValueError:
81
+ raise ValueError(
82
+ f"Invalid interval '{parts[2]}'. Must be a positive integer."
83
+ )
84
+
57
85
  # Build the full task description with priority, project, and context
58
86
  full_description = description
59
87
 
@@ -69,6 +97,9 @@ class TodoManager:
69
97
  if due:
70
98
  full_description = f"{full_description} due:{due}"
71
99
 
100
+ if recurring:
101
+ full_description = f"{full_description} {recurring}"
102
+
72
103
  self.todo_shell.add(full_description)
73
104
  return f"Added task: {full_description}"
74
105
 
@@ -138,6 +169,114 @@ class TodoManager:
138
169
  result = self.todo_shell.remove_priority(task_number)
139
170
  return f"Removed priority from task {task_number}: {result}"
140
171
 
172
+ def set_due_date(self, task_number: int, due_date: str) -> str:
173
+ """
174
+ Set or update due date for a task by intelligently rewriting it.
175
+
176
+ Args:
177
+ task_number: The task number to modify
178
+ due_date: Due date in YYYY-MM-DD format, or empty string to remove due date
179
+
180
+ Returns:
181
+ Confirmation message with the updated task
182
+ """
183
+ # Validate due date format only if not empty
184
+ if due_date.strip():
185
+ try:
186
+ datetime.strptime(due_date, "%Y-%m-%d")
187
+ except ValueError:
188
+ raise ValueError(
189
+ f"Invalid due date format '{due_date}'. Must be YYYY-MM-DD."
190
+ )
191
+
192
+ result = self.todo_shell.set_due_date(task_number, due_date)
193
+ if due_date.strip():
194
+ return f"Set due date {due_date} for task {task_number}: {result}"
195
+ else:
196
+ return f"Removed due date from task {task_number}: {result}"
197
+
198
+ def set_context(self, task_number: int, context: str) -> str:
199
+ """
200
+ Set or update context for a task by intelligently rewriting it.
201
+
202
+ Args:
203
+ task_number: The task number to modify
204
+ context: Context name (without @ symbol), or empty string to remove context
205
+
206
+ Returns:
207
+ Confirmation message with the updated task
208
+ """
209
+ # Validate context name if not empty
210
+ if context.strip():
211
+ # Remove any existing @ symbols to prevent duplication
212
+ clean_context = context.strip().lstrip("@")
213
+ if not clean_context:
214
+ raise ValueError(
215
+ "Context name cannot be empty after removing @ symbol."
216
+ )
217
+
218
+ result = self.todo_shell.set_context(task_number, context)
219
+ if context.strip():
220
+ clean_context = context.strip().lstrip("@")
221
+ return f"Set context @{clean_context} for task {task_number}: {result}"
222
+ else:
223
+ return f"Removed context from task {task_number}: {result}"
224
+
225
+ def set_project(self, task_number: int, projects: list) -> str:
226
+ """
227
+ Set or update projects for a task by intelligently rewriting it.
228
+
229
+ Args:
230
+ task_number: The task number to modify
231
+ projects: List of project operations. Each item can be:
232
+ - "project" (add project)
233
+ - "-project" (remove project)
234
+ - Empty string removes all projects
235
+
236
+ Returns:
237
+ Confirmation message with the updated task
238
+ """
239
+ # Validate project names if not empty
240
+ if projects:
241
+ for project in projects:
242
+ if project.strip() and not project.startswith("-"):
243
+ # Remove any existing + symbols to prevent duplication
244
+ clean_project = project.strip().lstrip("+")
245
+ if not clean_project:
246
+ raise ValueError(
247
+ "Project name cannot be empty after removing + symbol."
248
+ )
249
+ elif project.startswith("-"):
250
+ clean_project = project[1:].strip().lstrip("+")
251
+ if not clean_project:
252
+ raise ValueError(
253
+ "Project name cannot be empty after removing - and + symbols."
254
+ )
255
+
256
+ result = self.todo_shell.set_project(task_number, projects)
257
+
258
+ if not projects:
259
+ return f"No project changes made to task {task_number}: {result}"
260
+ else:
261
+ # Build operation description
262
+ operations = []
263
+ for project in projects:
264
+ if not project.strip():
265
+ # Empty string is a NOOP - skip
266
+ continue
267
+ elif project.startswith("-"):
268
+ clean_project = project[1:].strip().lstrip("+")
269
+ operations.append(f"removed +{clean_project}")
270
+ else:
271
+ clean_project = project.strip().lstrip("+")
272
+ operations.append(f"added +{clean_project}")
273
+
274
+ if not operations:
275
+ return f"No project changes made to task {task_number}: {result}"
276
+ else:
277
+ operation_desc = ", ".join(operations)
278
+ return f"Updated projects for task {task_number} ({operation_desc}): {result}"
279
+
141
280
  def list_projects(self, **kwargs: Any) -> str:
142
281
  """List all available projects in todo.txt."""
143
282
  result = self.todo_shell.list_projects()
@@ -187,6 +187,18 @@ class Inference:
187
187
  messages=messages, tools=self.tool_handler.tools
188
188
  )
189
189
 
190
+ # Extract actual token usage from API response
191
+ usage = response.get("usage", {})
192
+ actual_prompt_tokens = usage.get("prompt_tokens", 0)
193
+ actual_completion_tokens = usage.get("completion_tokens", 0)
194
+ actual_total_tokens = usage.get("total_tokens", 0)
195
+
196
+ # Update conversation manager with actual token count
197
+ self.conversation_manager.update_request_tokens(actual_prompt_tokens)
198
+ self.logger.debug(
199
+ f"Updated with actual API tokens: prompt={actual_prompt_tokens}, completion={actual_completion_tokens}, total={actual_total_tokens}"
200
+ )
201
+
190
202
  # Handle multiple tool calls in sequence
191
203
  tool_call_count = 0
192
204
  while True:
@@ -237,6 +249,14 @@ class Inference:
237
249
  messages=messages, tools=self.tool_handler.tools
238
250
  )
239
251
 
252
+ # Update with actual tokens from subsequent API calls
253
+ usage = response.get("usage", {})
254
+ actual_prompt_tokens = usage.get("prompt_tokens", 0)
255
+ self.conversation_manager.update_request_tokens(actual_prompt_tokens)
256
+ self.logger.debug(
257
+ f"Updated with actual API tokens after tool calls: prompt={actual_prompt_tokens}"
258
+ )
259
+
240
260
  # Calculate and log total thinking time
241
261
  end_time = time.time()
242
262
  thinking_time = end_time - start_time
@@ -270,7 +290,9 @@ class Inference:
270
290
  Returns:
271
291
  Dictionary with conversation metrics
272
292
  """
273
- return self.conversation_manager.get_conversation_summary()
293
+ return self.conversation_manager.get_conversation_summary(
294
+ self.tool_handler.tools
295
+ )
274
296
 
275
297
  def clear_conversation(self) -> None:
276
298
  """Clear conversation history."""
@@ -72,12 +72,12 @@ class OllamaClient(LLMClient):
72
72
  if "message" in response and "tool_calls" in response["message"]:
73
73
  tool_calls = response["message"]["tool_calls"]
74
74
  self.logger.info(f"Response contains {len(tool_calls)} tool calls")
75
-
75
+
76
76
  # Log thinking content (response body) if present
77
77
  content = response["message"].get("content", "")
78
78
  if content and content.strip():
79
79
  self.logger.info(f"LLM thinking before tool calls: {content}")
80
-
80
+
81
81
  for i, tool_call in enumerate(tool_calls):
82
82
  tool_name = tool_call.get("function", {}).get("name", "unknown")
83
83
  self.logger.info(f" Tool call {i + 1}: {tool_name}")
@@ -72,26 +72,32 @@ class OpenRouterClient(LLMClient):
72
72
  f"Token usage - Prompt: {prompt_tokens}, Completion: {completion_tokens}, Total: {total_tokens}"
73
73
  )
74
74
 
75
- # Log tool call details if present
76
- if response.get("choices"):
77
- choice = response["choices"][0]
78
- if "message" in choice and "tool_calls" in choice["message"]:
79
- tool_calls = choice["message"]["tool_calls"]
80
- self.logger.info(f"Response contains {len(tool_calls)} tool calls")
81
-
82
- # Log thinking content (response body) if present
83
- content = choice["message"].get("content", "")
84
- if content and content.strip():
85
- self.logger.info(f"LLM thinking before tool calls: {content}")
86
-
87
- for i, tool_call in enumerate(tool_calls):
88
- tool_name = tool_call.get("function", {}).get("name", "unknown")
89
- self.logger.info(f" Tool call {i + 1}: {tool_name}")
90
- elif "message" in choice and "content" in choice["message"]:
91
- content = choice["message"]["content"]
92
- self.logger.debug(
93
- f"Response contains content: {content[:100]}{'...' if len(content) > 100 else ''}"
94
- )
75
+ # Extract and log choice details
76
+ choices = response.get("choices", [])
77
+ if not choices:
78
+ return
79
+
80
+ choice = choices[0]
81
+ message = choice.get("message", {})
82
+
83
+ # Always log reasoning and content if present
84
+ reasoning = message.get("reasoning", "")
85
+ if reasoning:
86
+ self.logger.info(f"LLM reasoning: {reasoning}")
87
+
88
+ content = message.get("content", "")
89
+ if content:
90
+ self.logger.info(f"LLM content: {content}")
91
+
92
+ # Handle tool calls
93
+ tool_calls = message.get("tool_calls", [])
94
+ if tool_calls:
95
+ self.logger.info(f"Response contains {len(tool_calls)} tool calls")
96
+
97
+ # Log each tool call
98
+ for i, tool_call in enumerate(tool_calls, 1):
99
+ tool_name = tool_call.get("function", {}).get("name", "unknown")
100
+ self.logger.info(f" Tool call {i}: {tool_name}")
95
101
 
96
102
  self.logger.debug(f"Raw response: {json.dumps(response, indent=2)}")
97
103