deepy-cli 0.2.3__tar.gz → 0.2.4__tar.gz

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 (86) hide show
  1. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/PKG-INFO +1 -1
  2. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/pyproject.toml +1 -1
  3. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/__init__.py +1 -1
  4. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/data/tools/AskUserQuestion.md +6 -0
  5. deepy_cli-0.2.4/src/deepy/data/tools/todo_write.md +16 -0
  6. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/llm/compaction.py +14 -8
  7. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/llm/runner.py +10 -6
  8. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/prompts/compact.py +10 -1
  9. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/prompts/system.py +11 -0
  10. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/prompts/tool_docs.py +1 -0
  11. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/sessions/jsonl.py +112 -61
  12. deepy_cli-0.2.4/src/deepy/todos.py +111 -0
  13. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/tools/agents.py +47 -0
  14. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/tools/builtin.py +99 -22
  15. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/ui/message_view.py +111 -5
  16. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/ui/terminal.py +138 -88
  17. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/README.md +0 -0
  18. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/__main__.py +0 -0
  19. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/cli.py +0 -0
  20. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/config/__init__.py +0 -0
  21. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/config/settings.py +0 -0
  22. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/data/__init__.py +0 -0
  23. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/data/skills/skill-creator/SKILL.md +0 -0
  24. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/data/skills/skill-installer/SKILL.md +0 -0
  25. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/data/tools/WebFetch.md +0 -0
  26. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/data/tools/WebSearch.md +0 -0
  27. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/data/tools/__init__.py +0 -0
  28. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/data/tools/edit.md +0 -0
  29. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/data/tools/modify.md +0 -0
  30. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/data/tools/read.md +0 -0
  31. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/data/tools/shell.md +0 -0
  32. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/data/tools/write.md +0 -0
  33. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/errors.py +0 -0
  34. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/llm/__init__.py +0 -0
  35. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/llm/agent.py +0 -0
  36. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/llm/context.py +0 -0
  37. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/llm/events.py +0 -0
  38. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/llm/model_capabilities.py +0 -0
  39. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/llm/provider.py +0 -0
  40. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/llm/replay.py +0 -0
  41. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/llm/thinking.py +0 -0
  42. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/mcp.py +0 -0
  43. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/prompts/__init__.py +0 -0
  44. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/prompts/init_agents.py +0 -0
  45. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/prompts/rules.py +0 -0
  46. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/prompts/runtime_context.py +0 -0
  47. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/sessions/__init__.py +0 -0
  48. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/sessions/manager.py +0 -0
  49. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/skill_market.py +0 -0
  50. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/skills.py +0 -0
  51. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/status.py +0 -0
  52. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/tools/__init__.py +0 -0
  53. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/tools/file_state.py +0 -0
  54. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/tools/result.py +0 -0
  55. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/tools/shell_output.py +0 -0
  56. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/tools/shell_utils.py +0 -0
  57. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/types/__init__.py +0 -0
  58. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/types/sdk.py +0 -0
  59. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/types/tool_payloads.py +0 -0
  60. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/ui/__init__.py +0 -0
  61. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/ui/app.py +0 -0
  62. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/ui/ask_user_question.py +0 -0
  63. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/ui/exit_summary.py +0 -0
  64. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/ui/file_mentions.py +0 -0
  65. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/ui/loading_text.py +0 -0
  66. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/ui/local_command.py +0 -0
  67. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/ui/markdown.py +0 -0
  68. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/ui/model_picker.py +0 -0
  69. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/ui/prompt_buffer.py +0 -0
  70. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/ui/prompt_input.py +0 -0
  71. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/ui/session_list.py +0 -0
  72. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/ui/session_picker.py +0 -0
  73. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/ui/skill_picker.py +0 -0
  74. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/ui/slash_commands.py +0 -0
  75. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/ui/status_footer.py +0 -0
  76. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/ui/styles.py +0 -0
  77. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/ui/theme_picker.py +0 -0
  78. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/ui/thinking_state.py +0 -0
  79. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/ui/welcome.py +0 -0
  80. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/update_check.py +0 -0
  81. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/usage.py +0 -0
  82. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/utils/__init__.py +0 -0
  83. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/utils/debug_logger.py +0 -0
  84. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/utils/error_logger.py +0 -0
  85. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/utils/json.py +0 -0
  86. {deepy_cli-0.2.3 → deepy_cli-0.2.4}/src/deepy/utils/notify.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: deepy-cli
3
- Version: 0.2.3
3
+ Version: 0.2.4
4
4
  Summary: Deepy - Vibe coding for DeepSeek models in your terminal
5
5
  Keywords: deepseek,coding-agent,terminal,cli,agents
6
6
  Author: kirineko
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "deepy-cli"
3
- version = "0.2.3"
3
+ version = "0.2.4"
4
4
  description = "Deepy - Vibe coding for DeepSeek models in your terminal"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- __version__ = "0.2.3"
3
+ __version__ = "0.2.4"
4
4
 
5
5
 
6
6
  def main() -> None:
@@ -10,6 +10,12 @@
10
10
  标注 `(Recommended)` 或中文等价表达。不要为了低影响细节提问;可以合理假设时
11
11
  继续推进并简短说明假设。
12
12
 
13
+ Agent Skills 可能使用通用说法,例如 ask the user、ask one question at a time、
14
+ wait for the user's response、get approval、review 或 confirm before continuing。
15
+ 在 Deepy 中,除非 skill 明确要求不要使用工具,否则这些等待用户输入的步骤都应通过
16
+ `AskUserQuestion` 完成。开放问题也必须提供选项;可加入“自定义回答”/`Custom answer`
17
+ 作为选项,让用户输入自由文本。
18
+
13
19
  Args: `questions` (non-empty array). Each question needs `question` and non-empty `options`;
14
20
  each option needs `label` and may include `description`. Use `multiSelect=true` only when
15
21
  multiple choices are allowed.
@@ -0,0 +1,16 @@
1
+ ## todo_write
2
+
3
+ Maintain a session-scoped todo list for complex work.
4
+
5
+ Use `todo_write` when the user asks for several deliverables, the work touches
6
+ multiple files, or the task naturally breaks into three or more meaningful
7
+ steps. Do not use it for simple questions, tiny one-step edits, or routine
8
+ commands where a todo list would add noise.
9
+
10
+ Each update replaces the complete list. Keep item `id` values stable across
11
+ updates. Use exactly one `in_progress` item at a time, mark completed work as
12
+ `completed`, and update the list only when real progress changes. Omit `todos`
13
+ to read the current list; pass an empty list to clear it.
14
+
15
+ This tool only tracks progress. It does not delegate to subagents and it does
16
+ not request plan approval.
@@ -8,6 +8,7 @@ from typing import Any, Literal
8
8
  from deepy.config import Settings
9
9
  from deepy.prompts.compact import build_compact_prompt, build_compact_summary_message
10
10
  from deepy.sessions.jsonl import DeepyJsonlSession
11
+ from deepy.todos import todo_state_prompt_text
11
12
  from deepy.usage import TokenUsage, usage_from_run_result
12
13
 
13
14
  from .context import estimate_tokens_for_item, estimate_tokens_for_items
@@ -81,6 +82,7 @@ async def compact_session(
81
82
  settings,
82
83
  provider=provider,
83
84
  focus_instruction=focus_instruction,
85
+ todo_state=session.todo_state(),
84
86
  )
85
87
  replacement = sanitize_sdk_items_for_replay(
86
88
  [build_compact_summary_message(summary), *to_preserve]
@@ -124,9 +126,7 @@ async def ensure_context_ready(
124
126
  before_tokens = state.active_tokens + additional_tokens
125
127
  latest_context_usage = session.latest_context_window_usage()
126
128
  trigger_tokens = (
127
- latest_context_usage.used_tokens
128
- if latest_context_usage is not None
129
- else before_tokens
129
+ latest_context_usage.used_tokens if latest_context_usage is not None else before_tokens
130
130
  )
131
131
  compacted: CompactionResult | None = None
132
132
  if trigger_tokens >= settings.context.resolved_compact_threshold:
@@ -144,9 +144,7 @@ async def ensure_context_ready(
144
144
  else:
145
145
  after_context_usage = session.latest_context_window_usage()
146
146
  fit_tokens = (
147
- after_context_usage.used_tokens
148
- if after_context_usage is not None
149
- else after_tokens
147
+ after_context_usage.used_tokens if after_context_usage is not None else after_tokens
150
148
  )
151
149
  if fit_tokens + settings.context.reserved_context_tokens >= settings.context.window_tokens:
152
150
  raise ContextCompactionError(
@@ -198,11 +196,16 @@ async def run_compaction_model(
198
196
  *,
199
197
  provider: ProviderBundle | None = None,
200
198
  focus_instruction: str | None = None,
199
+ todo_state: list[dict[str, str]] | None = None,
201
200
  ) -> tuple[str, TokenUsage]:
202
201
  from agents import Agent, RunConfig, Runner
203
202
 
204
203
  resolved_provider = provider or build_provider_bundle(settings)
205
- prompt = build_compact_prompt(items, focus_instruction=focus_instruction)
204
+ prompt = build_compact_prompt(
205
+ items,
206
+ focus_instruction=focus_instruction,
207
+ todo_context=todo_state_prompt_text(todo_state or []),
208
+ )
206
209
  agent = Agent(
207
210
  name="Deepy Context Compactor",
208
211
  instructions="Create a compact continuation summary. Do not call tools.",
@@ -254,7 +257,10 @@ def _expand_preserve_start_for_tool_group(items: list[dict[str, Any]], preserve_
254
257
  if not needed_call_ids:
255
258
  return preserve_start
256
259
  for index in range(preserve_start - 1, -1, -1):
257
- if items[index].get("type") == "function_call" and _call_id(items[index]) in needed_call_ids:
260
+ if (
261
+ items[index].get("type") == "function_call"
262
+ and _call_id(items[index]) in needed_call_ids
263
+ ):
258
264
  preserve_start = index
259
265
  needed_call_ids.discard(_call_id(items[index]))
260
266
  if not needed_call_ids:
@@ -12,6 +12,7 @@ from deepy.config import Settings, load_settings
12
12
  from deepy.sessions.jsonl import DeepyJsonlSession
13
13
  from deepy.skills import find_skill
14
14
  from deepy.mcp import DeepyMcpRuntime
15
+ from deepy.todos import normalize_todo_items
15
16
  from deepy.tools import ToolRuntime
16
17
  from deepy.usage import TokenUsage, merge_usage, normalize_usage, usage_from_run_result
17
18
  from deepy.utils import json as json_utils
@@ -60,7 +61,15 @@ async def run_prompt_once(
60
61
  root = (project_root or Path.cwd()).resolve()
61
62
  resolved_settings = settings or load_settings()
62
63
  resolved_provider = provider or build_provider_bundle(resolved_settings)
63
- runtime = ToolRuntime(cwd=root, settings=resolved_settings)
64
+ session = (
65
+ DeepyJsonlSession.open(root, session_id) if session_id else DeepyJsonlSession.create(root)
66
+ )
67
+ initial_todos, _ = normalize_todo_items(session.todo_state())
68
+ runtime = ToolRuntime(
69
+ cwd=root,
70
+ settings=resolved_settings,
71
+ todo_items=initial_todos or [],
72
+ )
64
73
  created_mcp_runtime: DeepyMcpRuntime | None = None
65
74
  if mcp_runtime is None:
66
75
  created_mcp_runtime = DeepyMcpRuntime(resolved_settings, project_root=root)
@@ -76,11 +85,6 @@ async def run_prompt_once(
76
85
  mcp_servers=mcp_runtime.active_servers,
77
86
  preferred_mcp_web_search_tools=mcp_runtime.preferred_web_search_tools,
78
87
  )
79
- session = (
80
- DeepyJsonlSession.open(root, session_id)
81
- if session_id
82
- else DeepyJsonlSession.create(root)
83
- )
84
88
  started_at = time.time()
85
89
  try:
86
90
  readiness = await ensure_context_ready(
@@ -94,6 +94,7 @@ def build_compact_prompt(
94
94
  session_messages: list[dict[str, Any]],
95
95
  *,
96
96
  focus_instruction: str | None = None,
97
+ todo_context: str | None = None,
97
98
  ) -> str:
98
99
  jsonl = "\n".join(_compact_message_json(message) for message in session_messages)
99
100
  focus = ""
@@ -104,7 +105,15 @@ def build_compact_prompt(
104
105
  "dropping current task state:\n"
105
106
  f"{focus_instruction.strip()}"
106
107
  )
107
- return f"{COMPACT_PROMPT_BASE}{focus}\n\nconversation below:\n\n```jsonl\n{jsonl}\n```"
108
+ todo = ""
109
+ if todo_context and todo_context.strip():
110
+ todo = (
111
+ "\n\nCurrent todo state:\n"
112
+ "Preserve this active task plan in the summary so the next model turn can continue "
113
+ "or reconcile progress:\n"
114
+ f"{todo_context.strip()}"
115
+ )
116
+ return f"{COMPACT_PROMPT_BASE}{focus}{todo}\n\nconversation below:\n\n```jsonl\n{jsonl}\n```"
108
117
 
109
118
 
110
119
  def build_compact_summary_message(summary: str) -> dict[str, str]:
@@ -53,6 +53,13 @@ Core rules:
53
53
  - Ask when clarification would materially improve the result: ambiguous intent, unclear scope,
54
54
  user preferences, high-impact trade-offs, or required approval. For low-impact details,
55
55
  proceed with a reasonable assumption and state it briefly.
56
+ - Use `todo_write` for complex multi-step work, multi-file changes, or several
57
+ distinct deliverables. Keep one item `in_progress`, update the complete list
58
+ only when real task state changes, and reconcile completed work before the
59
+ final answer. Skip `todo_write` for simple questions and obvious one-step
60
+ tasks so progress tracking does not create noise.
61
+ - `todo_write` is only for local task tracking. Do not treat it as subagent
62
+ delegation, a `task` tool, or a plan approval mode.
56
63
 
57
64
  Tool protocol:
58
65
  Tool results are JSON strings: ok, name, output, error, metadata, awaitUserResponse.
@@ -63,6 +70,10 @@ Skill protocol:
63
70
  - If the user's task matches an available skill, call `load_skill` with the exact skill name before relying on that skill's detailed instructions, scripts, references, or assets.
64
71
  - If the user explicitly invoked a skill, follow the loaded skill instructions and use the user's request inside that context.
65
72
  - Skill files are standard Agent Skills. Resolve relative scripts, references, and assets from the skill root returned by `load_skill`.
73
+ - If a loaded skill says to ask the user, ask one question at a time, wait for
74
+ the user's response, get approval, or have the user review or confirm before
75
+ continuing, satisfy that wait point with `AskUserQuestion` unless the skill
76
+ explicitly says to ask in the final answer without tools.
66
77
 
67
78
  Tool documentation:
68
79
  {tool_docs_block}
@@ -10,6 +10,7 @@ TOOL_DOC_FILES = (
10
10
  "AskUserQuestion.md",
11
11
  "WebSearch.md",
12
12
  "WebFetch.md",
13
+ "todo_write.md",
13
14
  )
14
15
 
15
16
 
@@ -8,13 +8,21 @@ from typing import Any
8
8
 
9
9
  from deepy.llm.context import estimate_tokens_for_item
10
10
  from deepy.llm.replay import sanitize_sdk_items_for_replay
11
- from deepy.usage import ContextWindowUsage, TokenUsage, context_window_usage, merge_usage, normalize_usage
11
+ from deepy.todos import normalize_persisted_todo_state, todo_state_from_tool_output
12
+ from deepy.usage import (
13
+ ContextWindowUsage,
14
+ TokenUsage,
15
+ context_window_usage,
16
+ merge_usage,
17
+ normalize_usage,
18
+ )
12
19
  from deepy.utils import json as json_utils
13
20
 
14
21
  SESSION_INDEX_VERSION = 2
15
22
  MAX_SESSION_INDEX_ENTRIES = 50
16
23
  CONTEXT_UNDERCOUNT_REPAIR_RATIO = 2
17
24
  CONTEXT_UNDERCOUNT_REPAIR_MIN_DELTA = 128
25
+ _UNSET = object()
18
26
 
19
27
 
20
28
  @dataclass(frozen=True)
@@ -30,6 +38,7 @@ class SessionEntry:
30
38
  last_usage_tokens: int | None = None
31
39
  pending_tokens: int = 0
32
40
  last_usage_record_count: int | None = None
41
+ todo_state: list[dict[str, str]] | None = None
33
42
 
34
43
 
35
44
  def project_code(project_root: Path) -> str:
@@ -123,7 +132,9 @@ class DeepyJsonlSession:
123
132
  with self.path.open("w", encoding="utf-8") as fh:
124
133
  for record in records:
125
134
  fh.write(json_utils.dumps(record) + "\n")
126
- self._loaded_items = [item for item in (_sdk_item_from_record(record) for record in records) if item]
135
+ self._loaded_items = [
136
+ item for item in (_sdk_item_from_record(record) for record in records) if item
137
+ ]
127
138
  state = self.context_token_state(records)
128
139
  self._touch_index(
129
140
  active_tokens=state.active_tokens,
@@ -144,6 +155,7 @@ class DeepyJsonlSession:
144
155
  last_usage_tokens=0,
145
156
  pending_tokens=0,
146
157
  last_usage_record_count=0,
158
+ todo_state=[],
147
159
  )
148
160
 
149
161
  def record_usage(self, usage: TokenUsage | dict[str, Any] | None) -> None:
@@ -186,7 +198,9 @@ class DeepyJsonlSession:
186
198
  last_usage_record_count=None,
187
199
  estimated=True,
188
200
  )
189
- pending_tokens = sum(_estimate_record_tokens(record) for record in source[last_usage_record_count:])
201
+ pending_tokens = sum(
202
+ _estimate_record_tokens(record) for record in source[last_usage_record_count:]
203
+ )
190
204
  checkpoint_tokens = last_usage_tokens + pending_tokens
191
205
  estimated_tokens = self._estimate_active_tokens(source)
192
206
  active_tokens = _repair_undercounted_context_tokens(
@@ -211,7 +225,9 @@ class DeepyJsonlSession:
211
225
 
212
226
  def latest_context_window_usage(self) -> ContextWindowUsage | None:
213
227
  previous = _entry_for_session(self.path.parent / "sessions-index.json", self.session_id)
214
- latest_tokens = _optional_int(previous.get("latestContextWindowTokens")) if previous else None
228
+ latest_tokens = (
229
+ _optional_int(previous.get("latestContextWindowTokens")) if previous else None
230
+ )
215
231
  if latest_tokens is not None:
216
232
  return ContextWindowUsage(
217
233
  used_tokens=latest_tokens,
@@ -223,6 +239,13 @@ class DeepyJsonlSession:
223
239
  return context_window_usage(usage)
224
240
  return None
225
241
 
242
+ def todo_state(self) -> list[dict[str, str]]:
243
+ previous = _entry_for_session(self.path.parent / "sessions-index.json", self.session_id)
244
+ if not previous:
245
+ return []
246
+ todo_state = normalize_persisted_todo_state(previous.get("todoState"))
247
+ return todo_state or []
248
+
226
249
  async def replace_items(
227
250
  self,
228
251
  items: list[dict[str, Any]],
@@ -308,6 +331,7 @@ class DeepyJsonlSession:
308
331
  last_usage_tokens: int | None = None,
309
332
  pending_tokens: int | None = None,
310
333
  last_usage_record_count: int | None = None,
334
+ todo_state: object = _UNSET,
311
335
  ) -> None:
312
336
  self.path.parent.mkdir(parents=True, exist_ok=True)
313
337
  index_path = self.path.parent / "sessions-index.json"
@@ -316,61 +340,64 @@ class DeepyJsonlSession:
316
340
  sessions = _index_sessions(raw)
317
341
  previous = next((entry for entry in sessions if entry.get("id") == self.session_id), {})
318
342
  sessions = [entry for entry in sessions if entry.get("id") != self.session_id]
319
- sessions.insert(
320
- 0,
321
- {
322
- "id": self.session_id,
323
- "path": self.path.name,
324
- "activeTokens": active_tokens
325
- if active_tokens is not None
326
- else _coerce_int(previous.get("activeTokens"), 0),
327
- **(
328
- {"latestContextWindowTokens": latest_context_window_tokens}
329
- if latest_context_window_tokens is not None
330
- else (
331
- {"latestContextWindowTokens": previous["latestContextWindowTokens"]}
332
- if "latestContextWindowTokens" in previous
333
- else {}
334
- )
335
- ),
336
- **(
337
- {"lastUsageTokens": last_usage_tokens}
338
- if last_usage_tokens is not None
339
- else (
340
- {"lastUsageTokens": previous["lastUsageTokens"]}
341
- if "lastUsageTokens" in previous
342
- else {}
343
- )
344
- ),
345
- **(
346
- {"pendingTokens": pending_tokens}
347
- if pending_tokens is not None
348
- else (
349
- {"pendingTokens": previous["pendingTokens"]}
350
- if "pendingTokens" in previous
351
- else {}
352
- )
353
- ),
354
- **(
355
- {"lastUsageRecordCount": last_usage_record_count}
356
- if last_usage_record_count is not None
357
- else (
358
- {"lastUsageRecordCount": previous["lastUsageRecordCount"]}
359
- if "lastUsageRecordCount" in previous
360
- else {}
361
- )
362
- ),
363
- "createdAt": _coerce_int(previous.get("createdAt"), now),
364
- "updatedAt": now,
365
- **({"usage": usage} if usage is not None else {}),
366
- **(
367
- {"usage": previous["usage"]}
368
- if usage is None and isinstance(previous.get("usage"), dict)
343
+ entry = {
344
+ "id": self.session_id,
345
+ "path": self.path.name,
346
+ "activeTokens": active_tokens
347
+ if active_tokens is not None
348
+ else _coerce_int(previous.get("activeTokens"), 0),
349
+ **(
350
+ {"latestContextWindowTokens": latest_context_window_tokens}
351
+ if latest_context_window_tokens is not None
352
+ else (
353
+ {"latestContextWindowTokens": previous["latestContextWindowTokens"]}
354
+ if "latestContextWindowTokens" in previous
369
355
  else {}
370
- ),
371
- **({"processes": previous["processes"]} if "processes" in previous else {}),
372
- },
373
- )
356
+ )
357
+ ),
358
+ **(
359
+ {"lastUsageTokens": last_usage_tokens}
360
+ if last_usage_tokens is not None
361
+ else (
362
+ {"lastUsageTokens": previous["lastUsageTokens"]}
363
+ if "lastUsageTokens" in previous
364
+ else {}
365
+ )
366
+ ),
367
+ **(
368
+ {"pendingTokens": pending_tokens}
369
+ if pending_tokens is not None
370
+ else (
371
+ {"pendingTokens": previous["pendingTokens"]}
372
+ if "pendingTokens" in previous
373
+ else {}
374
+ )
375
+ ),
376
+ **(
377
+ {"lastUsageRecordCount": last_usage_record_count}
378
+ if last_usage_record_count is not None
379
+ else (
380
+ {"lastUsageRecordCount": previous["lastUsageRecordCount"]}
381
+ if "lastUsageRecordCount" in previous
382
+ else {}
383
+ )
384
+ ),
385
+ "createdAt": _coerce_int(previous.get("createdAt"), now),
386
+ "updatedAt": now,
387
+ **({"usage": usage} if usage is not None else {}),
388
+ **(
389
+ {"usage": previous["usage"]}
390
+ if usage is None and isinstance(previous.get("usage"), dict)
391
+ else {}
392
+ ),
393
+ **({"processes": previous["processes"]} if "processes" in previous else {}),
394
+ }
395
+ if todo_state is _UNSET:
396
+ if "todoState" in previous:
397
+ entry["todoState"] = previous["todoState"]
398
+ elif todo_state is not None:
399
+ entry["todoState"] = todo_state
400
+ sessions.insert(0, entry)
374
401
  payload = {
375
402
  "version": SESSION_INDEX_VERSION,
376
403
  "sessions": sessions[:MAX_SESSION_INDEX_ENTRIES],
@@ -379,10 +406,13 @@ class DeepyJsonlSession:
379
406
 
380
407
  def _touch_index_after_append(self, appended_items: list[dict[str, Any]]) -> None:
381
408
  previous = _entry_for_session(self.path.parent / "sessions-index.json", self.session_id)
409
+ todo_state = _latest_todo_state_from_items(appended_items)
382
410
  if previous and _optional_int(previous.get("lastUsageTokens")) is not None:
383
411
  last_usage_tokens = _optional_int(previous.get("lastUsageTokens")) or 0
384
412
  previous_pending = _coerce_int(previous.get("pendingTokens"), 0)
385
- pending_tokens = previous_pending + sum(estimate_tokens_for_item(item) for item in appended_items)
413
+ pending_tokens = previous_pending + sum(
414
+ estimate_tokens_for_item(item) for item in appended_items
415
+ )
386
416
  self._touch_index(
387
417
  active_tokens=last_usage_tokens + pending_tokens,
388
418
  last_usage_tokens=last_usage_tokens,
@@ -391,9 +421,13 @@ class DeepyJsonlSession:
391
421
  previous.get("lastUsageRecordCount"),
392
422
  max(0, len(self._load_records()) - len(appended_items)),
393
423
  ),
424
+ todo_state=todo_state if todo_state is not None else _UNSET,
394
425
  )
395
426
  return
396
- self._touch_index(active_tokens=self._estimate_active_tokens())
427
+ self._touch_index(
428
+ active_tokens=self._estimate_active_tokens(),
429
+ todo_state=todo_state if todo_state is not None else _UNSET,
430
+ )
397
431
 
398
432
 
399
433
  @dataclass(frozen=True)
@@ -436,6 +470,7 @@ def list_session_entries(project_root: Path, deepy_home: Path | None = None) ->
436
470
  last_usage_tokens=_optional_int(item.get("lastUsageTokens")),
437
471
  pending_tokens=_coerce_int(item.get("pendingTokens"), 0),
438
472
  last_usage_record_count=_optional_int(item.get("lastUsageRecordCount")),
473
+ todo_state=normalize_persisted_todo_state(item.get("todoState")),
439
474
  )
440
475
  )
441
476
  return entries
@@ -460,7 +495,11 @@ def _index_sessions(raw: dict[str, Any]) -> list[dict[str, Any]]:
460
495
 
461
496
  def _entry_for_session(index_path: Path, session_id: str) -> dict[str, Any] | None:
462
497
  return next(
463
- (entry for entry in _index_sessions(_read_index(index_path)) if entry.get("id") == session_id),
498
+ (
499
+ entry
500
+ for entry in _index_sessions(_read_index(index_path))
501
+ if entry.get("id") == session_id
502
+ ),
464
503
  None,
465
504
  )
466
505
 
@@ -473,6 +512,18 @@ def _sdk_item_from_record(record: dict[str, Any]) -> dict[str, Any] | None:
473
512
  return dict(item) if isinstance(item, dict) else None
474
513
 
475
514
 
515
+ def _latest_todo_state_from_items(items: list[dict[str, Any]]) -> list[dict[str, str]] | None:
516
+ latest: list[dict[str, str]] | None = None
517
+ for item in items:
518
+ output = item.get("output")
519
+ if output is None and item.get("role") == "tool":
520
+ output = item.get("content")
521
+ todo_state = todo_state_from_tool_output(output)
522
+ if todo_state is not None:
523
+ latest = todo_state
524
+ return latest
525
+
526
+
476
527
  def _coerce_int(value: Any, default: int) -> int:
477
528
  if isinstance(value, bool):
478
529
  return default
@@ -0,0 +1,111 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping
4
+ from dataclasses import dataclass
5
+ from typing import Any, cast
6
+
7
+ from deepy.utils import json as json_utils
8
+
9
+
10
+ TODO_STATUSES = {"pending", "in_progress", "completed"}
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class TodoItem:
15
+ id: str
16
+ content: str
17
+ status: str
18
+
19
+ def to_dict(self) -> dict[str, str]:
20
+ return {"id": self.id, "content": self.content, "status": self.status}
21
+
22
+
23
+ def normalize_todo_items(value: object) -> tuple[list[TodoItem] | None, str | None]:
24
+ if not isinstance(value, list):
25
+ return None, "todos must be a list."
26
+ items: list[TodoItem] = []
27
+ seen_ids: set[str] = set()
28
+ in_progress_count = 0
29
+ for index, raw_item in enumerate(value):
30
+ if not isinstance(raw_item, Mapping):
31
+ return None, f"todo item {index + 1} must be an object."
32
+ item = cast(Mapping[str, object], raw_item)
33
+ todo_id = _clean_string(item.get("id"))
34
+ content = _clean_string(item.get("content"))
35
+ status = _clean_string(item.get("status"))
36
+ if not todo_id:
37
+ return None, f"todo item {index + 1} id must not be empty."
38
+ if todo_id in seen_ids:
39
+ return None, f"duplicate todo id: {todo_id}"
40
+ if not content:
41
+ return None, f"todo item {todo_id} content must not be empty."
42
+ if status not in TODO_STATUSES:
43
+ return None, f"todo item {todo_id} has unsupported status: {status or '(empty)'}"
44
+ if status == "in_progress":
45
+ in_progress_count += 1
46
+ seen_ids.add(todo_id)
47
+ items.append(TodoItem(id=todo_id, content=content, status=status))
48
+ if in_progress_count > 1:
49
+ return None, "only one todo item may be in_progress."
50
+ return items, None
51
+
52
+
53
+ def todo_items_to_payload(items: list[TodoItem]) -> list[dict[str, str]]:
54
+ return [item.to_dict() for item in items]
55
+
56
+
57
+ def todo_counts(items: list[TodoItem]) -> dict[str, int]:
58
+ return {
59
+ "total": len(items),
60
+ "pending": sum(1 for item in items if item.status == "pending"),
61
+ "in_progress": sum(1 for item in items if item.status == "in_progress"),
62
+ "completed": sum(1 for item in items if item.status == "completed"),
63
+ }
64
+
65
+
66
+ def todo_state_from_tool_output(output: object) -> list[dict[str, str]] | None:
67
+ payload = _tool_output_payload(output)
68
+ if not isinstance(payload, dict):
69
+ return None
70
+ metadata = payload.get("metadata")
71
+ if not isinstance(metadata, dict) or metadata.get("kind") != "todo_list":
72
+ return None
73
+ todos = metadata.get("todos")
74
+ items, error = normalize_todo_items(todos)
75
+ if error is not None or items is None:
76
+ return None
77
+ return todo_items_to_payload(items)
78
+
79
+
80
+ def normalize_persisted_todo_state(value: object) -> list[dict[str, str]] | None:
81
+ items, error = normalize_todo_items(value)
82
+ if error is not None or items is None:
83
+ return None
84
+ return todo_items_to_payload(items)
85
+
86
+
87
+ def todo_state_prompt_text(items: list[dict[str, str]]) -> str:
88
+ normalized, error = normalize_todo_items(items)
89
+ if error is not None or normalized is None or not normalized:
90
+ return ""
91
+ counts = todo_counts(normalized)
92
+ lines = [
93
+ "Active todo plan:",
94
+ f"- Progress: {counts['completed']}/{counts['total']} completed",
95
+ ]
96
+ for item in normalized:
97
+ lines.append(f"- [{item.status}] {item.id}: {item.content}")
98
+ return "\n".join(lines)
99
+
100
+
101
+ def _tool_output_payload(output: object) -> Any:
102
+ if isinstance(output, str):
103
+ try:
104
+ return json_utils.loads(output)
105
+ except json_utils.JSONDecodeError:
106
+ return None
107
+ return output
108
+
109
+
110
+ def _clean_string(value: object) -> str:
111
+ return value.strip() if isinstance(value, str) else ""
@@ -58,6 +58,10 @@ def build_function_tools(
58
58
  args = _tool_args(raw_input)
59
59
  return runtime.load_skill(_string_arg(args, "name"))
60
60
 
61
+ async def invoke_todo_write(_context: object, raw_input: str) -> str:
62
+ args = _tool_args(raw_input)
63
+ return runtime.todo_write(args.get("todos") if "todos" in args else None)
64
+
61
65
  web_search_description = (
62
66
  "Perform web searching using a natural language query. Use a small number of "
63
67
  "targeted searches, then stop and synthesize once enough sources are available; "
@@ -139,6 +143,17 @@ def build_function_tools(
139
143
  on_invoke_tool=invoke_load_skill,
140
144
  strict_json_schema=False,
141
145
  ),
146
+ FunctionTool(
147
+ name="todo_write",
148
+ description=(
149
+ "Create, replace, read, or clear the session todo list for complex multi-step "
150
+ "work. Use it for meaningful task tracking, not simple questions or one-step edits. "
151
+ "Provide the complete todo list when updating; omit todos only to read current state."
152
+ ),
153
+ params_json_schema=TODO_WRITE_SCHEMA,
154
+ on_invoke_tool=invoke_todo_write,
155
+ strict_json_schema=False,
156
+ ),
142
157
  ]
143
158
 
144
159
 
@@ -395,3 +410,35 @@ LOAD_SKILL_SCHEMA: dict[str, Any] = {
395
410
  "required": ["name"],
396
411
  "additionalProperties": False,
397
412
  }
413
+ TODO_WRITE_SCHEMA: dict[str, Any] = {
414
+ "type": "object",
415
+ "properties": {
416
+ "todos": {
417
+ "type": "array",
418
+ "description": (
419
+ "Complete replacement todo list. Omit this property to read the current todo list; "
420
+ "pass an empty list to clear it."
421
+ ),
422
+ "items": {
423
+ "type": "object",
424
+ "properties": {
425
+ "id": {
426
+ "type": "string",
427
+ "description": "Stable id for this todo item.",
428
+ },
429
+ "content": {
430
+ "type": "string",
431
+ "description": "User-facing task text.",
432
+ },
433
+ "status": {
434
+ "type": "string",
435
+ "enum": ["pending", "in_progress", "completed"],
436
+ },
437
+ },
438
+ "required": ["id", "content", "status"],
439
+ "additionalProperties": False,
440
+ },
441
+ },
442
+ },
443
+ "additionalProperties": False,
444
+ }