deepy-cli 0.1.1__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.
- deepy/__init__.py +9 -0
- deepy/__main__.py +7 -0
- deepy/cli.py +413 -0
- deepy/config/__init__.py +21 -0
- deepy/config/settings.py +237 -0
- deepy/data/__init__.py +1 -0
- deepy/data/tools/AskUserQuestion.md +10 -0
- deepy/data/tools/WebFetch.md +9 -0
- deepy/data/tools/WebSearch.md +9 -0
- deepy/data/tools/__init__.py +1 -0
- deepy/data/tools/bash.md +7 -0
- deepy/data/tools/edit.md +13 -0
- deepy/data/tools/modify.md +17 -0
- deepy/data/tools/read.md +8 -0
- deepy/data/tools/write.md +12 -0
- deepy/errors.py +63 -0
- deepy/llm/__init__.py +13 -0
- deepy/llm/agent.py +31 -0
- deepy/llm/context.py +109 -0
- deepy/llm/events.py +187 -0
- deepy/llm/model_capabilities.py +7 -0
- deepy/llm/provider.py +81 -0
- deepy/llm/replay.py +120 -0
- deepy/llm/runner.py +412 -0
- deepy/llm/thinking.py +30 -0
- deepy/prompts/__init__.py +6 -0
- deepy/prompts/compact.py +100 -0
- deepy/prompts/rules.py +24 -0
- deepy/prompts/runtime_context.py +98 -0
- deepy/prompts/system.py +72 -0
- deepy/prompts/tool_docs.py +21 -0
- deepy/sessions/__init__.py +17 -0
- deepy/sessions/jsonl.py +306 -0
- deepy/sessions/manager.py +202 -0
- deepy/skills.py +202 -0
- deepy/status.py +65 -0
- deepy/tools/__init__.py +6 -0
- deepy/tools/agents.py +343 -0
- deepy/tools/builtin.py +2113 -0
- deepy/tools/file_state.py +85 -0
- deepy/tools/result.py +54 -0
- deepy/tools/shell_utils.py +83 -0
- deepy/ui/__init__.py +5 -0
- deepy/ui/app.py +118 -0
- deepy/ui/ask_user_question.py +182 -0
- deepy/ui/exit_summary.py +142 -0
- deepy/ui/loading_text.py +87 -0
- deepy/ui/markdown.py +152 -0
- deepy/ui/message_view.py +546 -0
- deepy/ui/prompt_buffer.py +176 -0
- deepy/ui/prompt_input.py +286 -0
- deepy/ui/session_list.py +140 -0
- deepy/ui/session_picker.py +179 -0
- deepy/ui/slash_commands.py +67 -0
- deepy/ui/styles.py +21 -0
- deepy/ui/terminal.py +959 -0
- deepy/ui/thinking_state.py +29 -0
- deepy/ui/welcome.py +195 -0
- deepy/update_check.py +195 -0
- deepy/usage.py +192 -0
- deepy/utils/__init__.py +15 -0
- deepy/utils/debug_logger.py +62 -0
- deepy/utils/error_logger.py +107 -0
- deepy/utils/json.py +29 -0
- deepy/utils/notify.py +66 -0
- deepy_cli-0.1.1.dist-info/METADATA +205 -0
- deepy_cli-0.1.1.dist-info/RECORD +69 -0
- deepy_cli-0.1.1.dist-info/WHEEL +4 -0
- deepy_cli-0.1.1.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import platform
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
IGNORED_TOP_LEVEL_ENTRIES = {
|
|
11
|
+
".git",
|
|
12
|
+
".mypy_cache",
|
|
13
|
+
".pytest_cache",
|
|
14
|
+
".ruff_cache",
|
|
15
|
+
".venv",
|
|
16
|
+
"__pycache__",
|
|
17
|
+
"build",
|
|
18
|
+
"dist",
|
|
19
|
+
"wheels",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def build_runtime_context(project_root: Path, *, include_git_dirty: bool = True) -> str:
|
|
24
|
+
lines = [f"Project root: {project_root}"]
|
|
25
|
+
lines.append(f"Current working directory: {Path.cwd()}")
|
|
26
|
+
lines.append(f"Home directory: {Path.home()}")
|
|
27
|
+
lines.append(f"System: {platform.platform()}")
|
|
28
|
+
lines.append(f"Shell: {_shell_info()}")
|
|
29
|
+
lines.append(f"Python: {_python_info()}")
|
|
30
|
+
node = _command_output(project_root, ["node", "--version"])
|
|
31
|
+
lines.append(f"Node: {node or 'missing'}")
|
|
32
|
+
lines.append("Tool availability:")
|
|
33
|
+
lines.extend(f"- {tool}: {_tool_info(tool, project_root)}" for tool in ("rg", "jq", "ast-grep"))
|
|
34
|
+
branch = _git_output(project_root, ["git", "branch", "--show-current"])
|
|
35
|
+
if branch:
|
|
36
|
+
lines.append(f"Git branch: {branch}")
|
|
37
|
+
if include_git_dirty:
|
|
38
|
+
status = _git_output(project_root, ["git", "status", "--short"])
|
|
39
|
+
lines.append(f"Git dirty: {'yes' if status else 'no'}")
|
|
40
|
+
top_level = _top_level_entries(project_root)
|
|
41
|
+
if top_level:
|
|
42
|
+
lines.append("Top-level entries:")
|
|
43
|
+
lines.extend(f"- {entry}" for entry in top_level)
|
|
44
|
+
return "\n".join(lines)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _shell_info() -> str:
|
|
48
|
+
shell = os.environ.get("SHELL") or os.environ.get("COMSPEC") or ""
|
|
49
|
+
return shell or "unknown"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _python_info() -> str:
|
|
53
|
+
version = ".".join(str(part) for part in sys.version_info[:3])
|
|
54
|
+
return f"{sys.executable} ({version})"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _tool_info(tool: str, cwd: Path) -> str:
|
|
58
|
+
path = shutil.which(tool)
|
|
59
|
+
if path is None:
|
|
60
|
+
return "missing"
|
|
61
|
+
version = _command_output(cwd, [path, "--version"])
|
|
62
|
+
first_line = version.splitlines()[0] if version else ""
|
|
63
|
+
return f"{path}" + (f" ({first_line})" if first_line else "")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _command_output(project_root: Path, args: list[str]) -> str:
|
|
67
|
+
try:
|
|
68
|
+
completed = subprocess.run(
|
|
69
|
+
args,
|
|
70
|
+
cwd=project_root,
|
|
71
|
+
text=True,
|
|
72
|
+
capture_output=True,
|
|
73
|
+
timeout=2,
|
|
74
|
+
)
|
|
75
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
76
|
+
return ""
|
|
77
|
+
if completed.returncode != 0:
|
|
78
|
+
return ""
|
|
79
|
+
return completed.stdout.strip()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _git_output(project_root: Path, args: list[str]) -> str:
|
|
83
|
+
return _command_output(project_root, args)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _top_level_entries(project_root: Path, limit: int = 30) -> list[str]:
|
|
87
|
+
try:
|
|
88
|
+
entries = sorted(project_root.iterdir(), key=lambda item: (not item.is_dir(), item.name.lower()))
|
|
89
|
+
except OSError:
|
|
90
|
+
return []
|
|
91
|
+
names: list[str] = []
|
|
92
|
+
for entry in entries:
|
|
93
|
+
if entry.name in IGNORED_TOP_LEVEL_ENTRIES:
|
|
94
|
+
continue
|
|
95
|
+
names.append(f"{entry.name}/" if entry.is_dir() else entry.name)
|
|
96
|
+
if len(names) >= limit:
|
|
97
|
+
break
|
|
98
|
+
return names
|
deepy/prompts/system.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from deepy.config import Settings
|
|
6
|
+
from deepy.skills import (
|
|
7
|
+
SkillInfo,
|
|
8
|
+
discover_skills,
|
|
9
|
+
format_loaded_skills_for_prompt,
|
|
10
|
+
format_skills_for_prompt,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
from .rules import AGENT_DRIFT_GUARD, load_project_rules
|
|
14
|
+
from .runtime_context import build_runtime_context
|
|
15
|
+
from .tool_docs import load_tool_docs
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def build_system_prompt(
|
|
19
|
+
project_root: Path,
|
|
20
|
+
settings: Settings,
|
|
21
|
+
*,
|
|
22
|
+
project_rules: str | None = None,
|
|
23
|
+
skills: list[SkillInfo] | None = None,
|
|
24
|
+
loaded_skills: list[SkillInfo] | None = None,
|
|
25
|
+
runtime_context: str | None = None,
|
|
26
|
+
) -> str:
|
|
27
|
+
resolved_project_rules = (
|
|
28
|
+
load_project_rules(project_root) if project_rules is None else project_rules.strip()
|
|
29
|
+
)
|
|
30
|
+
resolved_skills = discover_skills(project_root) if skills is None else skills
|
|
31
|
+
project_rules_block = resolved_project_rules or "No project rules found."
|
|
32
|
+
skills_block = format_skills_for_prompt(resolved_skills)
|
|
33
|
+
loaded_skills_block = format_loaded_skills_for_prompt(loaded_skills or [])
|
|
34
|
+
runtime_context_block = runtime_context or build_runtime_context(
|
|
35
|
+
project_root,
|
|
36
|
+
include_git_dirty=False,
|
|
37
|
+
)
|
|
38
|
+
tool_docs_block = load_tool_docs()
|
|
39
|
+
# Keep stable instructions before project/runtime blocks so DeepSeek can reuse
|
|
40
|
+
# a longer request prefix through its context cache.
|
|
41
|
+
return f"""You are Deepy, a terminal coding agent in the user's project.
|
|
42
|
+
|
|
43
|
+
Core rules:
|
|
44
|
+
- Work in the repo with tools: inspect, modify, test, verify.
|
|
45
|
+
- Preserve user changes. Prefer small, verifiable edits.
|
|
46
|
+
- Read before changing existing files.
|
|
47
|
+
- Use `modify` for file changes: `content` only creates new files; existing files use `old_string`/`new_string`.
|
|
48
|
+
- After project generators create scaffold files, read and edit the generated block instead of replacing the file.
|
|
49
|
+
- Ask only when blocked by missing intent or required approval.
|
|
50
|
+
|
|
51
|
+
Tool protocol:
|
|
52
|
+
Tool results are JSON strings: ok, name, output, error, metadata, awaitUserResponse.
|
|
53
|
+
|
|
54
|
+
Tool documentation:
|
|
55
|
+
{tool_docs_block}
|
|
56
|
+
|
|
57
|
+
Default skill:
|
|
58
|
+
{AGENT_DRIFT_GUARD}
|
|
59
|
+
|
|
60
|
+
Project rules:
|
|
61
|
+
{project_rules_block}
|
|
62
|
+
|
|
63
|
+
Available skills:
|
|
64
|
+
{skills_block}
|
|
65
|
+
|
|
66
|
+
Loaded skills:
|
|
67
|
+
{loaded_skills_block}
|
|
68
|
+
|
|
69
|
+
Runtime context:
|
|
70
|
+
Runtime: root={project_root}; model={settings.model.name}; thinking={settings.model.thinking_enabled}; reasoning={settings.model.reasoning_effort}
|
|
71
|
+
{runtime_context_block}
|
|
72
|
+
"""
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from importlib import resources
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
TOOL_DOC_FILES = (
|
|
7
|
+
"bash.md",
|
|
8
|
+
"read.md",
|
|
9
|
+
"modify.md",
|
|
10
|
+
"AskUserQuestion.md",
|
|
11
|
+
"WebSearch.md",
|
|
12
|
+
"WebFetch.md",
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load_tool_docs() -> str:
|
|
17
|
+
docs = resources.files("deepy.data.tools")
|
|
18
|
+
sections: list[str] = []
|
|
19
|
+
for filename in TOOL_DOC_FILES:
|
|
20
|
+
sections.append(docs.joinpath(filename).read_text(encoding="utf-8").strip())
|
|
21
|
+
return "\n\n".join(sections)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .jsonl import (
|
|
4
|
+
DeepyJsonlSession,
|
|
5
|
+
SessionEntry,
|
|
6
|
+
list_session_entries,
|
|
7
|
+
project_code,
|
|
8
|
+
project_sessions_dir,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"DeepyJsonlSession",
|
|
13
|
+
"SessionEntry",
|
|
14
|
+
"list_session_entries",
|
|
15
|
+
"project_code",
|
|
16
|
+
"project_sessions_dir",
|
|
17
|
+
]
|
deepy/sessions/jsonl.py
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
import uuid
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from deepy.llm.context import estimate_tokens_for_item
|
|
10
|
+
from deepy.llm.replay import sanitize_sdk_items_for_replay
|
|
11
|
+
from deepy.usage import TokenUsage, merge_usage, normalize_usage
|
|
12
|
+
from deepy.utils import json as json_utils
|
|
13
|
+
|
|
14
|
+
SESSION_INDEX_VERSION = 2
|
|
15
|
+
MAX_SESSION_INDEX_ENTRIES = 50
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class SessionEntry:
|
|
20
|
+
id: str
|
|
21
|
+
path: str
|
|
22
|
+
active_tokens: int
|
|
23
|
+
created_at: int
|
|
24
|
+
updated_at: int
|
|
25
|
+
processes: dict[str, dict[str, str]] | None = None
|
|
26
|
+
usage: dict[str, Any] | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def project_code(project_root: Path) -> str:
|
|
30
|
+
text = str(project_root.resolve())
|
|
31
|
+
return text.replace("/", "-").replace("\\", "-").replace(":", "")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def project_sessions_dir(project_root: Path, deepy_home: Path | None = None) -> Path:
|
|
35
|
+
home = deepy_home or Path.home() / ".deepy"
|
|
36
|
+
return home / "projects" / project_code(project_root)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _now_ms() -> int:
|
|
40
|
+
return int(time.time() * 1000)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _role_from_item(item: dict[str, Any]) -> str:
|
|
44
|
+
role = item.get("role")
|
|
45
|
+
if isinstance(role, str) and role:
|
|
46
|
+
return role
|
|
47
|
+
item_type = item.get("type")
|
|
48
|
+
return item_type if isinstance(item_type, str) and item_type else "unknown"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _content_from_item(item: dict[str, Any]) -> Any:
|
|
52
|
+
if "content" in item:
|
|
53
|
+
return item["content"]
|
|
54
|
+
if "output" in item:
|
|
55
|
+
return item["output"]
|
|
56
|
+
return ""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class DeepyJsonlSession:
|
|
61
|
+
session_id: str
|
|
62
|
+
path: Path
|
|
63
|
+
session_settings: object | None = None
|
|
64
|
+
_loaded_items: list[dict[str, Any]] | None = field(default=None, init=False, repr=False)
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def create(
|
|
68
|
+
cls,
|
|
69
|
+
project_root: Path,
|
|
70
|
+
*,
|
|
71
|
+
deepy_home: Path | None = None,
|
|
72
|
+
session_id: str | None = None,
|
|
73
|
+
) -> "DeepyJsonlSession":
|
|
74
|
+
sid = session_id or uuid.uuid4().hex
|
|
75
|
+
sessions_dir = project_sessions_dir(project_root, deepy_home)
|
|
76
|
+
return cls(session_id=sid, path=sessions_dir / f"{sid}.jsonl")
|
|
77
|
+
|
|
78
|
+
@classmethod
|
|
79
|
+
def open(
|
|
80
|
+
cls,
|
|
81
|
+
project_root: Path,
|
|
82
|
+
session_id: str,
|
|
83
|
+
*,
|
|
84
|
+
deepy_home: Path | None = None,
|
|
85
|
+
) -> "DeepyJsonlSession":
|
|
86
|
+
sessions_dir = project_sessions_dir(project_root, deepy_home)
|
|
87
|
+
return cls(session_id=session_id, path=sessions_dir / f"{session_id}.jsonl")
|
|
88
|
+
|
|
89
|
+
async def get_items(self, limit: int | None = None) -> list[dict[str, Any]]:
|
|
90
|
+
items = self._load_items()
|
|
91
|
+
if limit is not None:
|
|
92
|
+
if limit <= 0:
|
|
93
|
+
return []
|
|
94
|
+
return items[-limit:]
|
|
95
|
+
return list(items)
|
|
96
|
+
|
|
97
|
+
async def add_items(self, items: list[dict[str, Any]]) -> None:
|
|
98
|
+
if not items:
|
|
99
|
+
return
|
|
100
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
101
|
+
with self.path.open("a", encoding="utf-8") as fh:
|
|
102
|
+
for item in items:
|
|
103
|
+
record = self._record_from_sdk_item(item)
|
|
104
|
+
fh.write(json_utils.dumps(record) + "\n")
|
|
105
|
+
if self._loaded_items is not None:
|
|
106
|
+
self._loaded_items = sanitize_sdk_items_for_replay(
|
|
107
|
+
[*self._loaded_items, *(dict(item) for item in items)]
|
|
108
|
+
)
|
|
109
|
+
self._touch_index(active_tokens=self._estimate_active_tokens())
|
|
110
|
+
|
|
111
|
+
async def pop_item(self) -> dict[str, Any] | None:
|
|
112
|
+
records = self._load_records()
|
|
113
|
+
if not records:
|
|
114
|
+
return None
|
|
115
|
+
popped = records.pop()
|
|
116
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
117
|
+
with self.path.open("w", encoding="utf-8") as fh:
|
|
118
|
+
for record in records:
|
|
119
|
+
fh.write(json_utils.dumps(record) + "\n")
|
|
120
|
+
self._loaded_items = [item for item in (_sdk_item_from_record(record) for record in records) if item]
|
|
121
|
+
self._touch_index(active_tokens=self._estimate_active_tokens(records))
|
|
122
|
+
return _sdk_item_from_record(popped)
|
|
123
|
+
|
|
124
|
+
async def clear_session(self) -> None:
|
|
125
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
126
|
+
self.path.write_text("", encoding="utf-8")
|
|
127
|
+
self._loaded_items = []
|
|
128
|
+
self._touch_index(active_tokens=0)
|
|
129
|
+
|
|
130
|
+
def record_usage(self, usage: TokenUsage | dict[str, Any] | None) -> None:
|
|
131
|
+
normalized = usage if isinstance(usage, TokenUsage) else normalize_usage(usage)
|
|
132
|
+
if not normalized.known:
|
|
133
|
+
return
|
|
134
|
+
previous = _entry_for_session(self.path.parent / "sessions-index.json", self.session_id)
|
|
135
|
+
accumulated = merge_usage(previous.get("usage") if previous else None, normalized)
|
|
136
|
+
self._touch_index(usage=accumulated.to_dict())
|
|
137
|
+
|
|
138
|
+
def _record_from_sdk_item(self, item: dict[str, Any]) -> dict[str, Any]:
|
|
139
|
+
now = _now_ms()
|
|
140
|
+
return {
|
|
141
|
+
"id": uuid.uuid4().hex,
|
|
142
|
+
"session_id": self.session_id,
|
|
143
|
+
"role": _role_from_item(item),
|
|
144
|
+
"content": _content_from_item(item),
|
|
145
|
+
"created_at": now,
|
|
146
|
+
"meta": {"sdk_item": item},
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
def _load_records(self) -> list[dict[str, Any]]:
|
|
150
|
+
if not self.path.exists():
|
|
151
|
+
return []
|
|
152
|
+
records: list[dict[str, Any]] = []
|
|
153
|
+
with self.path.open("r", encoding="utf-8") as fh:
|
|
154
|
+
for line in fh:
|
|
155
|
+
line = line.strip()
|
|
156
|
+
if not line:
|
|
157
|
+
continue
|
|
158
|
+
try:
|
|
159
|
+
parsed = json_utils.loads(line)
|
|
160
|
+
except Exception:
|
|
161
|
+
continue
|
|
162
|
+
if isinstance(parsed, dict):
|
|
163
|
+
records.append(parsed)
|
|
164
|
+
return records
|
|
165
|
+
|
|
166
|
+
def _load_items(self) -> list[dict[str, Any]]:
|
|
167
|
+
if self._loaded_items is None:
|
|
168
|
+
loaded_items = [
|
|
169
|
+
item
|
|
170
|
+
for item in (_sdk_item_from_record(record) for record in self._load_records())
|
|
171
|
+
if item is not None
|
|
172
|
+
]
|
|
173
|
+
self._loaded_items = sanitize_sdk_items_for_replay(loaded_items)
|
|
174
|
+
return list(self._loaded_items)
|
|
175
|
+
|
|
176
|
+
def _estimate_active_tokens(self, records: list[dict[str, Any]] | None = None) -> int:
|
|
177
|
+
source = records if records is not None else self._load_records()
|
|
178
|
+
return sum(_estimate_record_tokens(record) for record in source)
|
|
179
|
+
|
|
180
|
+
def _touch_index(
|
|
181
|
+
self,
|
|
182
|
+
*,
|
|
183
|
+
active_tokens: int | None = None,
|
|
184
|
+
usage: dict[str, Any] | None = None,
|
|
185
|
+
) -> None:
|
|
186
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
187
|
+
index_path = self.path.parent / "sessions-index.json"
|
|
188
|
+
now = _now_ms()
|
|
189
|
+
raw = _read_index(index_path)
|
|
190
|
+
sessions = _index_sessions(raw)
|
|
191
|
+
previous = next((entry for entry in sessions if entry.get("id") == self.session_id), {})
|
|
192
|
+
sessions = [entry for entry in sessions if entry.get("id") != self.session_id]
|
|
193
|
+
sessions.insert(
|
|
194
|
+
0,
|
|
195
|
+
{
|
|
196
|
+
"id": self.session_id,
|
|
197
|
+
"path": self.path.name,
|
|
198
|
+
"activeTokens": active_tokens
|
|
199
|
+
if active_tokens is not None
|
|
200
|
+
else _coerce_int(previous.get("activeTokens"), 0),
|
|
201
|
+
"createdAt": _coerce_int(previous.get("createdAt"), now),
|
|
202
|
+
"updatedAt": now,
|
|
203
|
+
**({"usage": usage} if usage is not None else {}),
|
|
204
|
+
**(
|
|
205
|
+
{"usage": previous["usage"]}
|
|
206
|
+
if usage is None and isinstance(previous.get("usage"), dict)
|
|
207
|
+
else {}
|
|
208
|
+
),
|
|
209
|
+
**({"processes": previous["processes"]} if "processes" in previous else {}),
|
|
210
|
+
},
|
|
211
|
+
)
|
|
212
|
+
payload = {
|
|
213
|
+
"version": SESSION_INDEX_VERSION,
|
|
214
|
+
"sessions": sessions[:MAX_SESSION_INDEX_ENTRIES],
|
|
215
|
+
}
|
|
216
|
+
index_path.write_text(json_utils.dumps_pretty(payload) + "\n", encoding="utf-8")
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def list_session_entries(project_root: Path, deepy_home: Path | None = None) -> list[SessionEntry]:
|
|
220
|
+
index_path = project_sessions_dir(project_root, deepy_home) / "sessions-index.json"
|
|
221
|
+
if not index_path.is_file():
|
|
222
|
+
return []
|
|
223
|
+
sessions = _index_sessions(_read_index(index_path))
|
|
224
|
+
entries: list[SessionEntry] = []
|
|
225
|
+
for item in sessions:
|
|
226
|
+
session_id = item.get("id")
|
|
227
|
+
path = item.get("path")
|
|
228
|
+
if not isinstance(session_id, str) or not session_id:
|
|
229
|
+
continue
|
|
230
|
+
if not isinstance(path, str) or not path:
|
|
231
|
+
path = f"{session_id}.jsonl"
|
|
232
|
+
usage = item.get("usage")
|
|
233
|
+
entries.append(
|
|
234
|
+
SessionEntry(
|
|
235
|
+
id=session_id,
|
|
236
|
+
path=path,
|
|
237
|
+
active_tokens=_coerce_int(item.get("activeTokens"), 0),
|
|
238
|
+
created_at=_coerce_int(item.get("createdAt"), 0),
|
|
239
|
+
updated_at=_coerce_int(item.get("updatedAt"), 0),
|
|
240
|
+
processes=_normalize_processes(item.get("processes")),
|
|
241
|
+
usage=usage if isinstance(usage, dict) else None,
|
|
242
|
+
)
|
|
243
|
+
)
|
|
244
|
+
return entries
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _read_index(index_path: Path) -> dict[str, Any]:
|
|
248
|
+
if not index_path.exists():
|
|
249
|
+
return {"version": SESSION_INDEX_VERSION, "sessions": []}
|
|
250
|
+
try:
|
|
251
|
+
raw = json_utils.loads(index_path.read_text(encoding="utf-8") or "{}")
|
|
252
|
+
except Exception:
|
|
253
|
+
return {"version": SESSION_INDEX_VERSION, "sessions": []}
|
|
254
|
+
return raw if isinstance(raw, dict) else {"version": SESSION_INDEX_VERSION, "sessions": []}
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _index_sessions(raw: dict[str, Any]) -> list[dict[str, Any]]:
|
|
258
|
+
sessions = raw.get("sessions")
|
|
259
|
+
if not isinstance(sessions, list):
|
|
260
|
+
return []
|
|
261
|
+
return [item for item in sessions if isinstance(item, dict)]
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _entry_for_session(index_path: Path, session_id: str) -> dict[str, Any] | None:
|
|
265
|
+
return next(
|
|
266
|
+
(entry for entry in _index_sessions(_read_index(index_path)) if entry.get("id") == session_id),
|
|
267
|
+
None,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _sdk_item_from_record(record: dict[str, Any]) -> dict[str, Any] | None:
|
|
272
|
+
meta = record.get("meta")
|
|
273
|
+
if not isinstance(meta, dict):
|
|
274
|
+
return None
|
|
275
|
+
item = meta.get("sdk_item")
|
|
276
|
+
return dict(item) if isinstance(item, dict) else None
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def _coerce_int(value: Any, default: int) -> int:
|
|
280
|
+
if isinstance(value, bool):
|
|
281
|
+
return default
|
|
282
|
+
return value if isinstance(value, int) else default
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _normalize_processes(value: Any) -> dict[str, dict[str, str]] | None:
|
|
286
|
+
if not isinstance(value, dict):
|
|
287
|
+
return None
|
|
288
|
+
processes: dict[str, dict[str, str]] = {}
|
|
289
|
+
for pid, entry in value.items():
|
|
290
|
+
if not isinstance(pid, str) or not pid:
|
|
291
|
+
continue
|
|
292
|
+
if isinstance(entry, dict):
|
|
293
|
+
start_time = entry.get("startTime")
|
|
294
|
+
command = entry.get("command")
|
|
295
|
+
processes[pid] = {
|
|
296
|
+
"startTime": start_time if isinstance(start_time, str) else "",
|
|
297
|
+
"command": command if isinstance(command, str) else "Running process...",
|
|
298
|
+
}
|
|
299
|
+
return processes or None
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _estimate_record_tokens(record: dict[str, Any]) -> int:
|
|
303
|
+
item = _sdk_item_from_record(record)
|
|
304
|
+
if item is not None:
|
|
305
|
+
return estimate_tokens_for_item(item)
|
|
306
|
+
return estimate_tokens_for_item(record.get("content", ""))
|