mainai 0.1.0__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.
- mainai-0.1.0/.gitignore +10 -0
- mainai-0.1.0/.python-version +1 -0
- mainai-0.1.0/PKG-INFO +60 -0
- mainai-0.1.0/README.md +53 -0
- mainai-0.1.0/mainai.md +156 -0
- mainai-0.1.0/pyproject.toml +22 -0
- mainai-0.1.0/src/mainai/__init__.py +1 -0
- mainai-0.1.0/src/mainai/cli.py +148 -0
- mainai-0.1.0/src/mainai/llm.py +112 -0
- mainai-0.1.0/src/mainai/llm_picker.py +92 -0
- mainai-0.1.0/src/mainai/models.py +35 -0
- mainai-0.1.0/src/mainai/picker.py +80 -0
- mainai-0.1.0/src/mainai/readers/__init__.py +25 -0
- mainai-0.1.0/src/mainai/readers/claude_code.py +141 -0
- mainai-0.1.0/src/mainai/readers/codex.py +144 -0
- mainai-0.1.0/tests/test_claude_code_reader.py +108 -0
- mainai-0.1.0/tests/test_llm_picker.py +66 -0
- mainai-0.1.0/uv.lock +79 -0
mainai-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.12
|
mainai-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: mainai
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Pick the right coding agent to continue a project with, across Claude Code, Codex, and Grok sessions.
|
|
5
|
+
Requires-Python: >=3.12
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
|
|
8
|
+
# mainai
|
|
9
|
+
|
|
10
|
+
A CLI that figures out which coding agent (Claude Code, Codex, Grok, ...)
|
|
11
|
+
last did real work in a project folder, and hands off cleanly to the one you
|
|
12
|
+
pick next.
|
|
13
|
+
|
|
14
|
+
## The problem
|
|
15
|
+
|
|
16
|
+
Working the same project folders with several coding agents means that
|
|
17
|
+
coming back months later, it's unclear which agent did the most recent real
|
|
18
|
+
work, and what the other agents did in the meantime.
|
|
19
|
+
|
|
20
|
+
## What it does
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
$ mainai .
|
|
24
|
+
Found 6 sessions in ~/Dropbox/inflammatix/projects/endotypes
|
|
25
|
+
codex 2026-09-25 "fix the endotype clustering..." <- latest work
|
|
26
|
+
claude 2026-09-18 "add the GSE cohort loader..."
|
|
27
|
+
...
|
|
28
|
+
Continue with: Codex (its edits match the current files)
|
|
29
|
+
Wrote HANDOFF.md (summaries of the 4 Claude sessions)
|
|
30
|
+
Launching: codex "Read HANDOFF.md, then continue where we left off."
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
1. Reads every agent's stored conversations, keeps the ones for the current
|
|
34
|
+
folder.
|
|
35
|
+
2. Decides which agent to continue with, based on which session's edits
|
|
36
|
+
match the files actually on disk (not just the newest timestamp).
|
|
37
|
+
3. Summarizes the relevant parts of the *other* agents' sessions into
|
|
38
|
+
`HANDOFF.md`.
|
|
39
|
+
4. Starts the chosen agent, pointed at `HANDOFF.md`.
|
|
40
|
+
|
|
41
|
+
`mainai` with no arguments prints help and does nothing, so a stray bare
|
|
42
|
+
invocation never spends LLM usage. Flags: `--list` (sessions + pick only, no
|
|
43
|
+
LLM, no write), `--agent NAME` (override the pick), `--no-launch` (write
|
|
44
|
+
`HANDOFF.md` but don't start the agent).
|
|
45
|
+
|
|
46
|
+
See `mainai.md` for the full design notes (reader architecture, per-agent log
|
|
47
|
+
formats, picker logic, summarizer, scope/build order).
|
|
48
|
+
|
|
49
|
+
## Status
|
|
50
|
+
|
|
51
|
+
Not yet built. Design only.
|
|
52
|
+
|
|
53
|
+
## Development
|
|
54
|
+
|
|
55
|
+
uv-managed project.
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
uv sync
|
|
59
|
+
uv run mainai --list .
|
|
60
|
+
```
|
mainai-0.1.0/README.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# mainai
|
|
2
|
+
|
|
3
|
+
A CLI that figures out which coding agent (Claude Code, Codex, Grok, ...)
|
|
4
|
+
last did real work in a project folder, and hands off cleanly to the one you
|
|
5
|
+
pick next.
|
|
6
|
+
|
|
7
|
+
## The problem
|
|
8
|
+
|
|
9
|
+
Working the same project folders with several coding agents means that
|
|
10
|
+
coming back months later, it's unclear which agent did the most recent real
|
|
11
|
+
work, and what the other agents did in the meantime.
|
|
12
|
+
|
|
13
|
+
## What it does
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
$ mainai .
|
|
17
|
+
Found 6 sessions in ~/Dropbox/inflammatix/projects/endotypes
|
|
18
|
+
codex 2026-09-25 "fix the endotype clustering..." <- latest work
|
|
19
|
+
claude 2026-09-18 "add the GSE cohort loader..."
|
|
20
|
+
...
|
|
21
|
+
Continue with: Codex (its edits match the current files)
|
|
22
|
+
Wrote HANDOFF.md (summaries of the 4 Claude sessions)
|
|
23
|
+
Launching: codex "Read HANDOFF.md, then continue where we left off."
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
1. Reads every agent's stored conversations, keeps the ones for the current
|
|
27
|
+
folder.
|
|
28
|
+
2. Decides which agent to continue with, based on which session's edits
|
|
29
|
+
match the files actually on disk (not just the newest timestamp).
|
|
30
|
+
3. Summarizes the relevant parts of the *other* agents' sessions into
|
|
31
|
+
`HANDOFF.md`.
|
|
32
|
+
4. Starts the chosen agent, pointed at `HANDOFF.md`.
|
|
33
|
+
|
|
34
|
+
`mainai` with no arguments prints help and does nothing, so a stray bare
|
|
35
|
+
invocation never spends LLM usage. Flags: `--list` (sessions + pick only, no
|
|
36
|
+
LLM, no write), `--agent NAME` (override the pick), `--no-launch` (write
|
|
37
|
+
`HANDOFF.md` but don't start the agent).
|
|
38
|
+
|
|
39
|
+
See `mainai.md` for the full design notes (reader architecture, per-agent log
|
|
40
|
+
formats, picker logic, summarizer, scope/build order).
|
|
41
|
+
|
|
42
|
+
## Status
|
|
43
|
+
|
|
44
|
+
Not yet built. Design only.
|
|
45
|
+
|
|
46
|
+
## Development
|
|
47
|
+
|
|
48
|
+
uv-managed project.
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
uv sync
|
|
52
|
+
uv run mainai --list .
|
|
53
|
+
```
|
mainai-0.1.0/mainai.md
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# MainAI (mainai) - project brief
|
|
2
|
+
|
|
3
|
+
Handoff notes from a Claude Code session in ~/Dropbox/ai/ai (2026-10-04/05).
|
|
4
|
+
Nothing has been built yet; this file is the starting point.
|
|
5
|
+
|
|
6
|
+
## The problem
|
|
7
|
+
|
|
8
|
+
LB works in the same folders with several coding agents (Claude Code, Codex,
|
|
9
|
+
Grok, maybe Gemini later). Coming back to a project months later, it is
|
|
10
|
+
unclear which agent did the most recent real work, and what the other agents
|
|
11
|
+
did in the meantime.
|
|
12
|
+
|
|
13
|
+
## What mainai does
|
|
14
|
+
|
|
15
|
+
One CLI command, run on a project folder:
|
|
16
|
+
|
|
17
|
+
1. Read every agent's stored conversations, keep the ones for that folder.
|
|
18
|
+
2. Decide which agent to continue with.
|
|
19
|
+
3. Summarize the relevant parts of the *other* agents' sessions into
|
|
20
|
+
HANDOFF.md.
|
|
21
|
+
4. Start the chosen agent, already pointed at HANDOFF.md.
|
|
22
|
+
|
|
23
|
+
## User interaction (decided)
|
|
24
|
+
|
|
25
|
+
A single command. Remembering two commands was called a showstopper.
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
$ mainai .
|
|
29
|
+
Found 6 sessions in ~/Dropbox/inflammatix/projects/endotypes
|
|
30
|
+
codex 2026-09-25 "fix the endotype clustering..." <- latest work
|
|
31
|
+
claude 2026-09-18 "add the GSE cohort loader..."
|
|
32
|
+
...
|
|
33
|
+
Continue with: Codex (its edits match the current files)
|
|
34
|
+
Wrote HANDOFF.md (summaries of the 4 Claude sessions)
|
|
35
|
+
Launching: codex "Read HANDOFF.md, then continue where we left off."
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
- `mainai` with no arguments prints help and does nothing. The folder
|
|
39
|
+
argument (`.`) is the "go" signal, so a stray bare `mainai` never spends
|
|
40
|
+
LLM usage.
|
|
41
|
+
- Optional flags:
|
|
42
|
+
- `--list`: show sessions and the pick only; no LLM call, writes nothing
|
|
43
|
+
- `--agent NAME`: override the pick
|
|
44
|
+
- `--no-launch`: write HANDOFF.md but do not start the agent
|
|
45
|
+
- Launching works because `claude`, `codex` and `grok` all accept a starting
|
|
46
|
+
prompt on the command line.
|
|
47
|
+
|
|
48
|
+
## Design
|
|
49
|
+
|
|
50
|
+
1. **One reader per agent.** Each converts its own log format into a common
|
|
51
|
+
record: agent, session id, folder (cwd), start/end time, turns (role,
|
|
52
|
+
text), files touched, commands run. A new agent = one new reader.
|
|
53
|
+
2. **Filter by folder.** Match the session's cwd to the target folder or a
|
|
54
|
+
subfolder.
|
|
55
|
+
3. **Pick the agent.** Raw "latest message" can mislead (a trivial side
|
|
56
|
+
question can be the newest message). The stronger signal is file evidence:
|
|
57
|
+
compare the files each session wrote with what is on disk now. The session
|
|
58
|
+
whose edits match the current files did the latest real work. Use git
|
|
59
|
+
history too when the folder is a repo. Print the pick and the reasons.
|
|
60
|
+
4. **Summarize the others.** First extract without an LLM (user prompts, final
|
|
61
|
+
assistant answers, files changed, decisions, errors), then have an LLM
|
|
62
|
+
condense that into HANDOFF.md with sections: goal, done, decisions + why,
|
|
63
|
+
open items, gotchas, key files. Tag each item with agent and date.
|
|
64
|
+
5. **Hand off.** Launch the chosen agent with a prompt to read HANDOFF.md.
|
|
65
|
+
|
|
66
|
+
## Summarizer: `claude -p` (recommended)
|
|
67
|
+
|
|
68
|
+
Claude Code's headless mode: give it a prompt, it prints the answer and exits.
|
|
69
|
+
It uses the existing Claude login and plan (no API key, no extra
|
|
70
|
+
dependencies). The planned call (flags exist in Claude Code 2.1.289; verify
|
|
71
|
+
the exact combination when building):
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
claude -p "<summarize instructions>" --model sonnet --output-format json \
|
|
75
|
+
--no-session-persistence --tools ""
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
- `--no-session-persistence` is essential: without it, each summarizing run
|
|
79
|
+
saves its own transcript into ~/.claude/projects, and mainai would then
|
|
80
|
+
read and summarize its own runs.
|
|
81
|
+
- `--tools ""` (or a minimal list): the summarizer only needs the text given.
|
|
82
|
+
- Keep the summarizer behind a small interface so the Anthropic API,
|
|
83
|
+
`codex exec` or `grok -p` can be swapped in with a flag.
|
|
84
|
+
- Privacy: the transcripts (including Codex/Grok ones) go to Anthropic.
|
|
85
|
+
|
|
86
|
+
The API alternative needs a key, bills per token separately and adds a
|
|
87
|
+
dependency, but gives full control (caching, batching). Not worth it for a
|
|
88
|
+
personal tool.
|
|
89
|
+
|
|
90
|
+
## Where each agent stores sessions (verified on disk)
|
|
91
|
+
|
|
92
|
+
Locations differ per machine. Two machines so far:
|
|
93
|
+
|
|
94
|
+
| Agent | beograd (laptop) | ljubljana |
|
|
95
|
+
|---|---|---|
|
|
96
|
+
| Claude Code | ~/.claude/projects/<path-slug>/<session>.jsonl | same |
|
|
97
|
+
| Codex | ~/snap/codex/<rev>/sessions/YYYY/MM/DD/rollout-*.jsonl (snap; check data survives snap refreshes) | ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl (48 files, 2025-09 to 2026-09) |
|
|
98
|
+
| Grok | not installed | ~/.grok/sessions/<url-encoded cwd>/<session-id>/ (grok 1.0.44) |
|
|
99
|
+
| Gemini | not installed | not installed |
|
|
100
|
+
|
|
101
|
+
Format notes:
|
|
102
|
+
|
|
103
|
+
- **Claude Code**: JSONL, one event per line. `cwd`, `timestamp`,
|
|
104
|
+
`sessionId`, `gitBranch` on each user/assistant line. Path slug = cwd with
|
|
105
|
+
`/` replaced by `-`. Also a `sessions-index.json` per project dir.
|
|
106
|
+
- **Codex**: JSONL. First line `type: session_meta` with `payload.cwd`,
|
|
107
|
+
`payload.id`, `payload.timestamp`. Then `response_item` (message,
|
|
108
|
+
function_call, function_call_output, reasoning), `event_msg` (user_message,
|
|
109
|
+
agent_message, token_count), `turn_context`. Also ~/.codex/history.jsonl
|
|
110
|
+
and ~/.codex/session_index.jsonl on ljubljana.
|
|
111
|
+
- **Grok**: the folder name is the URL-encoded cwd (e.g.
|
|
112
|
+
`%2Fhome%2Fljubomir%2FDropbox%2Fai%2Fcvic`), which makes folder filtering
|
|
113
|
+
easy. Per session: `chat_history.jsonl` (messages; first line is the system
|
|
114
|
+
prompt), `events.jsonl` (ts, type, session_id, model_id), `summary.json`,
|
|
115
|
+
`rewind_points.jsonl`, `updates.jsonl`. Per folder: `prompt_history.jsonl`.
|
|
116
|
+
Also ~/.grok/sessions/session_search.sqlite. Not parsed in detail yet.
|
|
117
|
+
|
|
118
|
+
All formats are undocumented and change between versions; readers must be
|
|
119
|
+
tolerant (skip unknown line types, never crash on one bad line).
|
|
120
|
+
|
|
121
|
+
## Retention: Claude Code deletes old transcripts
|
|
122
|
+
|
|
123
|
+
Claude Code deletes transcripts after 30 days by default
|
|
124
|
+
(`cleanupPeriodDays`). On beograd only 5 transcripts were left (oldest
|
|
125
|
+
2026-09-15). So for 6-month-old work, Claude's side may simply be gone.
|
|
126
|
+
|
|
127
|
+
- beograd: `"cleanupPeriodDays": 36500` set in ~/.claude/settings.json on
|
|
128
|
+
2026-10-04.
|
|
129
|
+
- ljubljana: set on 2026-10-05 (9 transcripts left, oldest 2026-09-04).
|
|
130
|
+
|
|
131
|
+
mainai should warn when a folder has no or few Claude sessions and the
|
|
132
|
+
setting is at its default.
|
|
133
|
+
|
|
134
|
+
## Scope
|
|
135
|
+
|
|
136
|
+
- v1: Claude Code + Codex readers. Grok reader next (its data is on
|
|
137
|
+
ljubljana). Gemini when installed.
|
|
138
|
+
- Build order: readers + session listing (`mainai . --list`) first, no LLM,
|
|
139
|
+
to check it finds the right sessions; then the picker; then the summarizer
|
|
140
|
+
and launch.
|
|
141
|
+
|
|
142
|
+
## GitHub release (discussed, not decided)
|
|
143
|
+
|
|
144
|
+
Plausibly worth releasing: multi-agent users are a growing group, and
|
|
145
|
+
cross-agent handoff is the distinguishing feature; per-agent readers invite
|
|
146
|
+
contributions. The cost is maintenance: undocumented log formats break
|
|
147
|
+
readers on agent upgrades. Plan: use it personally for a few weeks first,
|
|
148
|
+
search GitHub for prior art, then decide (or publish "as-is").
|
|
149
|
+
|
|
150
|
+
## Conventions
|
|
151
|
+
|
|
152
|
+
- Python, uv-managed venv (no system pip installs). Executable entry point
|
|
153
|
+
with a shebang and argparse help that shows option defaults.
|
|
154
|
+
- Never write `__pycache__` (check the global CLAUDE.md on the machine in
|
|
155
|
+
use; the shebang convention differs between beograd and ljubljana).
|
|
156
|
+
- Markdown/text files plain ASCII (LB edits in Emacs).
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "mainai"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Pick the right coding agent to continue a project with, across Claude Code, Codex, and Grok sessions."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.12"
|
|
7
|
+
dependencies = []
|
|
8
|
+
|
|
9
|
+
[project.scripts]
|
|
10
|
+
mainai = "mainai.cli:main"
|
|
11
|
+
|
|
12
|
+
[build-system]
|
|
13
|
+
requires = ["hatchling"]
|
|
14
|
+
build-backend = "hatchling.build"
|
|
15
|
+
|
|
16
|
+
[tool.hatch.build.targets.wheel]
|
|
17
|
+
packages = ["src/mainai"]
|
|
18
|
+
|
|
19
|
+
[dependency-groups]
|
|
20
|
+
dev = [
|
|
21
|
+
"pytest>=9.1.1",
|
|
22
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""mainai -- pick which coding agent to continue a project with."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from mainai import __version__, llm
|
|
10
|
+
from mainai.llm_picker import llm_pick
|
|
11
|
+
from mainai.picker import Pick, heuristic_pick
|
|
12
|
+
from mainai.readers import all_sessions_for
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
16
|
+
parser = argparse.ArgumentParser(
|
|
17
|
+
prog="mainai",
|
|
18
|
+
description=(
|
|
19
|
+
"Find which coding agent (Claude Code, Codex, ...) last did real "
|
|
20
|
+
"work in a project folder, and show which one to continue with. "
|
|
21
|
+
"Run with no arguments to process the current directory."
|
|
22
|
+
),
|
|
23
|
+
)
|
|
24
|
+
parser.add_argument(
|
|
25
|
+
"folder",
|
|
26
|
+
nargs="?",
|
|
27
|
+
default=".",
|
|
28
|
+
help="project folder to inspect (default: current directory)",
|
|
29
|
+
)
|
|
30
|
+
parser.add_argument(
|
|
31
|
+
"--list",
|
|
32
|
+
action="store_true",
|
|
33
|
+
help=(
|
|
34
|
+
"show the sessions found and a quick file-evidence guess, nothing "
|
|
35
|
+
"else (no file written, no LLM call)"
|
|
36
|
+
),
|
|
37
|
+
)
|
|
38
|
+
parser.add_argument(
|
|
39
|
+
"--agent",
|
|
40
|
+
metavar="NAME",
|
|
41
|
+
help="override the pick, e.g. --agent codex (no LLM call)",
|
|
42
|
+
)
|
|
43
|
+
parser.add_argument(
|
|
44
|
+
"--handoff",
|
|
45
|
+
action="store_true",
|
|
46
|
+
help="also summarize the other agents' sessions into HANDOFF.md (uses an LLM)",
|
|
47
|
+
)
|
|
48
|
+
parser.add_argument(
|
|
49
|
+
"-y",
|
|
50
|
+
"--yes",
|
|
51
|
+
action="store_true",
|
|
52
|
+
help=(
|
|
53
|
+
"don't ask for confirmation before an LLM call that would bill "
|
|
54
|
+
"ANTHROPIC_API_KEY directly (no claude.ai subscription login found)"
|
|
55
|
+
),
|
|
56
|
+
)
|
|
57
|
+
parser.add_argument(
|
|
58
|
+
"--version",
|
|
59
|
+
action="version",
|
|
60
|
+
version=f"%(prog)s {__version__}",
|
|
61
|
+
)
|
|
62
|
+
return parser
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _confirm_api_key_billing(yes: bool) -> bool:
|
|
66
|
+
print(
|
|
67
|
+
"mainai: no claude.ai subscription login found; the LLM pick would bill "
|
|
68
|
+
"ANTHROPIC_API_KEY directly (pay-per-token), not a subscription.",
|
|
69
|
+
file=sys.stderr,
|
|
70
|
+
)
|
|
71
|
+
if yes:
|
|
72
|
+
return True
|
|
73
|
+
if not sys.stdin.isatty():
|
|
74
|
+
print(
|
|
75
|
+
"mainai: not an interactive terminal; re-run with --yes to proceed anyway, "
|
|
76
|
+
"or use --list / --agent to avoid the LLM call.",
|
|
77
|
+
file=sys.stderr,
|
|
78
|
+
)
|
|
79
|
+
return False
|
|
80
|
+
reply = input("Proceed and bill ANTHROPIC_API_KEY for this call? [y/N] ").strip().lower()
|
|
81
|
+
return reply in ("y", "yes")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def main(argv: list[str] | None = None) -> int:
|
|
85
|
+
parser = build_parser()
|
|
86
|
+
args = parser.parse_args(argv)
|
|
87
|
+
|
|
88
|
+
folder = Path(args.folder).expanduser()
|
|
89
|
+
if not folder.is_dir():
|
|
90
|
+
print(f"mainai: {folder} is not a directory", file=sys.stderr)
|
|
91
|
+
return 1
|
|
92
|
+
|
|
93
|
+
sessions = all_sessions_for(folder)
|
|
94
|
+
|
|
95
|
+
if not sessions:
|
|
96
|
+
print(f"No sessions found for {folder.resolve()}")
|
|
97
|
+
return 0
|
|
98
|
+
|
|
99
|
+
if args.agent:
|
|
100
|
+
chosen = next((s for s in sessions if s.agent == args.agent), None)
|
|
101
|
+
if chosen is None:
|
|
102
|
+
print(f"mainai: no session found for agent '{args.agent}'", file=sys.stderr)
|
|
103
|
+
return 1
|
|
104
|
+
result = Pick(session=chosen, reasons=[f"overridden with --agent {args.agent}"])
|
|
105
|
+
elif args.list:
|
|
106
|
+
result = heuristic_pick(sessions, folder)
|
|
107
|
+
else:
|
|
108
|
+
auth_mode = llm.determine_auth_mode()
|
|
109
|
+
if auth_mode == llm.AuthMode.UNAVAILABLE:
|
|
110
|
+
print(
|
|
111
|
+
"mainai: no usable `claude` auth found (no claude.ai login, no "
|
|
112
|
+
"ANTHROPIC_API_KEY). Run `claude login`, or use --list / --agent "
|
|
113
|
+
"to avoid the LLM call.",
|
|
114
|
+
file=sys.stderr,
|
|
115
|
+
)
|
|
116
|
+
return 1
|
|
117
|
+
if auth_mode == llm.AuthMode.API_KEY and not _confirm_api_key_billing(args.yes):
|
|
118
|
+
print("mainai: aborted.", file=sys.stderr)
|
|
119
|
+
return 1
|
|
120
|
+
result = llm_pick(sessions, folder)
|
|
121
|
+
|
|
122
|
+
chosen = result.session
|
|
123
|
+
|
|
124
|
+
print(f"Found {len(sessions)} session(s) in {folder.resolve()}")
|
|
125
|
+
for session in sessions:
|
|
126
|
+
marker = " <- latest work" if session is chosen else ""
|
|
127
|
+
print(f' {session.agent:<8} {session.end.date()} "{session.summary}"{marker}')
|
|
128
|
+
|
|
129
|
+
if args.list:
|
|
130
|
+
return 0
|
|
131
|
+
|
|
132
|
+
if chosen is None:
|
|
133
|
+
return 0
|
|
134
|
+
|
|
135
|
+
print()
|
|
136
|
+
print(f"Continue with: {chosen.agent} ({'; '.join(result.reasons)})")
|
|
137
|
+
print(f" Advice: cd {chosen.cwd} && {chosen.agent}")
|
|
138
|
+
|
|
139
|
+
if args.handoff:
|
|
140
|
+
print()
|
|
141
|
+
print("mainai: --handoff (LLM summarizer) is not implemented yet.", file=sys.stderr)
|
|
142
|
+
return 1
|
|
143
|
+
|
|
144
|
+
return 0
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
if __name__ == "__main__":
|
|
148
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Thin wrapper around calling an LLM in headless/non-interactive mode.
|
|
2
|
+
|
|
3
|
+
Default backend is `claude -p` (Claude Code headless mode): no separate API
|
|
4
|
+
key needed when the user has a claude.ai login, since it draws on that
|
|
5
|
+
login's included usage. Kept behind this small interface so `codex exec` or
|
|
6
|
+
`grok -p` can be swapped in later (see mainai.md).
|
|
7
|
+
|
|
8
|
+
Many developers keep ANTHROPIC_API_KEY set for their own API projects, and
|
|
9
|
+
`claude -p` prefers that key over a claude.ai login when both are present.
|
|
10
|
+
mainai must not silently bill someone's personal API key for a background
|
|
11
|
+
utility call, so every call here strips ANTHROPIC_API_KEY from the
|
|
12
|
+
subprocess environment whenever a claude.ai login is available
|
|
13
|
+
(`determine_auth_mode` -> AuthMode.SUBSCRIPTION). When no claude.ai login
|
|
14
|
+
exists, the caller (mainai.cli) must get explicit confirmation before a call
|
|
15
|
+
that will bill the API key.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import shutil
|
|
23
|
+
import subprocess
|
|
24
|
+
from enum import Enum
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class LLMError(RuntimeError):
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class AuthMode(Enum):
|
|
32
|
+
SUBSCRIPTION = "subscription" # claude.ai login available; API key (if any) is stripped
|
|
33
|
+
API_KEY = "api_key" # no claude.ai login; a call would bill ANTHROPIC_API_KEY
|
|
34
|
+
UNAVAILABLE = "unavailable" # claude CLI missing, or no usable auth at all
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def determine_auth_mode() -> AuthMode:
|
|
38
|
+
if shutil.which("claude") is None:
|
|
39
|
+
return AuthMode.UNAVAILABLE
|
|
40
|
+
|
|
41
|
+
env = dict(os.environ)
|
|
42
|
+
env.pop("ANTHROPIC_API_KEY", None)
|
|
43
|
+
status: dict = {}
|
|
44
|
+
try:
|
|
45
|
+
result = subprocess.run(
|
|
46
|
+
["claude", "auth", "status"],
|
|
47
|
+
capture_output=True,
|
|
48
|
+
text=True,
|
|
49
|
+
timeout=15,
|
|
50
|
+
env=env,
|
|
51
|
+
)
|
|
52
|
+
status = json.loads(result.stdout)
|
|
53
|
+
except (subprocess.SubprocessError, OSError, json.JSONDecodeError):
|
|
54
|
+
status = {}
|
|
55
|
+
|
|
56
|
+
if status.get("loggedIn") and status.get("authMethod") == "claude.ai":
|
|
57
|
+
return AuthMode.SUBSCRIPTION
|
|
58
|
+
if os.environ.get("ANTHROPIC_API_KEY"):
|
|
59
|
+
return AuthMode.API_KEY
|
|
60
|
+
return AuthMode.UNAVAILABLE
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def call(prompt: str, *, model: str = "sonnet", timeout: int = 180) -> str:
|
|
64
|
+
"""Run `prompt` through `claude -p` and return the text result.
|
|
65
|
+
|
|
66
|
+
Raises LLMError on anything that keeps the caller from getting a usable
|
|
67
|
+
answer: missing CLI, process failure, timeout, or unparseable output.
|
|
68
|
+
"""
|
|
69
|
+
if shutil.which("claude") is None:
|
|
70
|
+
raise LLMError("the `claude` CLI is not on PATH")
|
|
71
|
+
|
|
72
|
+
env = dict(os.environ)
|
|
73
|
+
if determine_auth_mode() == AuthMode.SUBSCRIPTION:
|
|
74
|
+
env.pop("ANTHROPIC_API_KEY", None)
|
|
75
|
+
|
|
76
|
+
try:
|
|
77
|
+
result = subprocess.run(
|
|
78
|
+
[
|
|
79
|
+
"claude",
|
|
80
|
+
"-p",
|
|
81
|
+
prompt,
|
|
82
|
+
"--model",
|
|
83
|
+
model,
|
|
84
|
+
"--output-format",
|
|
85
|
+
"json",
|
|
86
|
+
"--no-session-persistence",
|
|
87
|
+
"--tools",
|
|
88
|
+
"",
|
|
89
|
+
],
|
|
90
|
+
capture_output=True,
|
|
91
|
+
text=True,
|
|
92
|
+
timeout=timeout,
|
|
93
|
+
env=env,
|
|
94
|
+
)
|
|
95
|
+
except subprocess.SubprocessError as exc:
|
|
96
|
+
raise LLMError(f"failed to run claude -p: {exc}") from exc
|
|
97
|
+
|
|
98
|
+
if result.returncode != 0:
|
|
99
|
+
raise LLMError(f"claude -p exited {result.returncode}: {result.stderr.strip()}")
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
payload = json.loads(result.stdout)
|
|
103
|
+
except json.JSONDecodeError as exc:
|
|
104
|
+
raise LLMError(f"could not parse claude -p output as JSON: {exc}") from exc
|
|
105
|
+
|
|
106
|
+
if payload.get("is_error"):
|
|
107
|
+
raise LLMError(f"claude -p reported an error: {payload.get('result')}")
|
|
108
|
+
|
|
109
|
+
text = payload.get("result")
|
|
110
|
+
if not text:
|
|
111
|
+
raise LLMError("claude -p returned no text result")
|
|
112
|
+
return text
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""LLM-based picker: this is mainai's default decision maker.
|
|
2
|
+
|
|
3
|
+
Instead of only the file-evidence heuristic in `picker.py`, this asks an LLM
|
|
4
|
+
to actually read each session's content -- the summary/title, a capped
|
|
5
|
+
excerpt of turns, the files it touched that still exist on disk, and the
|
|
6
|
+
commands it ran -- and recommend which agent to continue with, in its own
|
|
7
|
+
words. Falls back to `picker.heuristic_pick` when the LLM call fails (CLI
|
|
8
|
+
missing, timeout, bad output) or there's only one session to begin with.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from mainai import llm
|
|
17
|
+
from mainai.models import Session
|
|
18
|
+
from mainai.picker import Pick, heuristic_pick
|
|
19
|
+
|
|
20
|
+
MAX_TURN_CHARS = 400
|
|
21
|
+
MAX_TURNS_PER_SESSION = 6
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def llm_pick(sessions: list[Session], folder: Path) -> Pick:
|
|
25
|
+
if not sessions:
|
|
26
|
+
return Pick(session=None, reasons=["no sessions found for this folder"])
|
|
27
|
+
if len(sessions) == 1:
|
|
28
|
+
return Pick(session=sessions[0], reasons=["only one session found"])
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
raw = llm.call(_build_prompt(sessions, folder))
|
|
32
|
+
data = json.loads(raw)
|
|
33
|
+
agent = data["agent"]
|
|
34
|
+
reason = str(data.get("reason", "")).strip()
|
|
35
|
+
except (llm.LLMError, json.JSONDecodeError, KeyError, TypeError) as exc:
|
|
36
|
+
fallback = heuristic_pick(sessions, folder)
|
|
37
|
+
fallback.reasons.insert(
|
|
38
|
+
0, f"LLM pick unavailable ({exc}); used file-evidence heuristic instead"
|
|
39
|
+
)
|
|
40
|
+
return fallback
|
|
41
|
+
|
|
42
|
+
matches = [s for s in sessions if s.agent == agent]
|
|
43
|
+
if not matches:
|
|
44
|
+
fallback = heuristic_pick(sessions, folder)
|
|
45
|
+
fallback.reasons.insert(
|
|
46
|
+
0,
|
|
47
|
+
f"LLM named unrecognized agent '{agent}'; used file-evidence heuristic instead",
|
|
48
|
+
)
|
|
49
|
+
return fallback
|
|
50
|
+
|
|
51
|
+
chosen = max(matches, key=lambda s: s.end)
|
|
52
|
+
return Pick(session=chosen, reasons=[reason] if reason else ["LLM pick"])
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _session_excerpt(session: Session) -> dict:
|
|
56
|
+
existing_files = sorted(f for f in session.files_touched if Path(f).exists())
|
|
57
|
+
turns = [
|
|
58
|
+
f"{turn.role}: {turn.text[:MAX_TURN_CHARS]}"
|
|
59
|
+
for turn in session.turns[:MAX_TURNS_PER_SESSION]
|
|
60
|
+
if turn.text.strip()
|
|
61
|
+
]
|
|
62
|
+
return {
|
|
63
|
+
"agent": session.agent,
|
|
64
|
+
"start": session.start.isoformat(),
|
|
65
|
+
"end": session.end.isoformat(),
|
|
66
|
+
"title": session.title,
|
|
67
|
+
"files_touched_still_on_disk": existing_files,
|
|
68
|
+
"commands_run": session.commands_run[:10],
|
|
69
|
+
"turns_excerpt": turns,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _build_prompt(sessions: list[Session], folder: Path) -> str:
|
|
74
|
+
data = [_session_excerpt(s) for s in sessions]
|
|
75
|
+
agents = sorted({s.agent for s in sessions})
|
|
76
|
+
return (
|
|
77
|
+
"A developer worked on the same project folder using several different "
|
|
78
|
+
"coding agents (Claude Code, Codex, Grok, ...), at different times. Below "
|
|
79
|
+
"are summaries of each agent's most recent session in this folder. Decide "
|
|
80
|
+
"which session represents the most recent REAL work worth continuing from. "
|
|
81
|
+
"A session's newest message timestamp alone can be misleading -- e.g. a "
|
|
82
|
+
"trivial side question can be the newest message in an otherwise stale "
|
|
83
|
+
"session. Weigh the actual conversation content, the files it touched that "
|
|
84
|
+
"still exist on disk, and the commands it ran.\n\n"
|
|
85
|
+
f"Project folder: {folder}\n"
|
|
86
|
+
f"Candidate agents: {', '.join(agents)}\n\n"
|
|
87
|
+
"Sessions (JSON):\n"
|
|
88
|
+
f"{json.dumps(data, indent=2)}\n\n"
|
|
89
|
+
"Reply with ONLY a JSON object and nothing else, in this exact shape:\n"
|
|
90
|
+
'{"agent": "<one of the candidate agents above>", '
|
|
91
|
+
'"reason": "<one or two sentence explanation a developer would find useful>"}'
|
|
92
|
+
)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Common record types every per-agent reader converts its log format into."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class Turn:
|
|
11
|
+
role: str # "user" | "assistant"
|
|
12
|
+
text: str
|
|
13
|
+
timestamp: datetime | None = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Session:
|
|
18
|
+
agent: str # "claude", "codex", "grok", ...
|
|
19
|
+
session_id: str
|
|
20
|
+
cwd: str
|
|
21
|
+
start: datetime
|
|
22
|
+
end: datetime
|
|
23
|
+
title: str | None = None
|
|
24
|
+
turns: list[Turn] = field(default_factory=list)
|
|
25
|
+
files_touched: set[str] = field(default_factory=set)
|
|
26
|
+
commands_run: list[str] = field(default_factory=list)
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def summary(self) -> str:
|
|
30
|
+
if self.title:
|
|
31
|
+
return self.title
|
|
32
|
+
for turn in self.turns:
|
|
33
|
+
if turn.role == "user" and turn.text.strip():
|
|
34
|
+
return turn.text.strip().splitlines()[0][:80]
|
|
35
|
+
return "(no prompt text found)"
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Heuristic, LLM-free picker: file evidence + git history only.
|
|
2
|
+
|
|
3
|
+
This is no longer mainai's default decision maker -- see `llm_picker` for
|
|
4
|
+
that, which asks an LLM to read the actual session content. This module is
|
|
5
|
+
kept as the free, instant path for `--list`, and as the fallback when the
|
|
6
|
+
LLM call fails or isn't available.
|
|
7
|
+
|
|
8
|
+
Raw "latest message" can mislead -- a trivial side question can be the
|
|
9
|
+
newest turn. The stronger signal is file evidence: does a file the session
|
|
10
|
+
touched still exist on disk, matching what the session wrote. Git history is
|
|
11
|
+
used as a secondary cross-check when the folder is a repo.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import subprocess
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from datetime import datetime
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from mainai.models import Session
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Pick:
|
|
26
|
+
session: Session | None
|
|
27
|
+
reasons: list[str]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def heuristic_pick(sessions: list[Session], folder: Path) -> Pick:
|
|
31
|
+
if not sessions:
|
|
32
|
+
return Pick(session=None, reasons=["no sessions found for this folder"])
|
|
33
|
+
|
|
34
|
+
folder = folder.resolve()
|
|
35
|
+
latest_commit_ts = _latest_git_commit_timestamp(folder)
|
|
36
|
+
|
|
37
|
+
scored = []
|
|
38
|
+
for session in sessions:
|
|
39
|
+
existing = {f for f in session.files_touched if Path(f).exists()}
|
|
40
|
+
scored.append((session, bool(existing), len(existing)))
|
|
41
|
+
|
|
42
|
+
scored.sort(key=lambda item: (item[1], item[2], item[0].end), reverse=True)
|
|
43
|
+
best_session, has_evidence, existing_count = scored[0]
|
|
44
|
+
|
|
45
|
+
reasons = []
|
|
46
|
+
if has_evidence:
|
|
47
|
+
reasons.append(f"{existing_count} file(s) it touched still exist on disk")
|
|
48
|
+
else:
|
|
49
|
+
reasons.append("no session has file evidence on disk; picked by latest activity")
|
|
50
|
+
reasons.append(f"most recent turn {best_session.end.isoformat()}")
|
|
51
|
+
|
|
52
|
+
if latest_commit_ts is not None:
|
|
53
|
+
if best_session.start <= latest_commit_ts <= best_session.end:
|
|
54
|
+
reasons.append("its time range contains the latest git commit")
|
|
55
|
+
else:
|
|
56
|
+
reasons.append(f"latest git commit was at {latest_commit_ts.isoformat()}")
|
|
57
|
+
|
|
58
|
+
return Pick(session=best_session, reasons=reasons)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _latest_git_commit_timestamp(folder: Path) -> datetime | None:
|
|
62
|
+
if not (folder / ".git").exists():
|
|
63
|
+
return None
|
|
64
|
+
try:
|
|
65
|
+
result = subprocess.run(
|
|
66
|
+
["git", "-C", str(folder), "log", "-1", "--format=%cI"],
|
|
67
|
+
capture_output=True,
|
|
68
|
+
text=True,
|
|
69
|
+
timeout=5,
|
|
70
|
+
check=True,
|
|
71
|
+
)
|
|
72
|
+
except (subprocess.SubprocessError, OSError):
|
|
73
|
+
return None
|
|
74
|
+
value = result.stdout.strip()
|
|
75
|
+
if not value:
|
|
76
|
+
return None
|
|
77
|
+
try:
|
|
78
|
+
return datetime.fromisoformat(value)
|
|
79
|
+
except ValueError:
|
|
80
|
+
return None
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""One reader per coding agent. Each exposes `sessions_for(folder)` and returns
|
|
2
|
+
a list of `mainai.models.Session`, filtered to sessions whose cwd is the given
|
|
3
|
+
folder or a subfolder of it. Readers must never crash on a malformed or
|
|
4
|
+
unrecognized log line -- skip it and move on.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from mainai.models import Session
|
|
12
|
+
from mainai.readers import claude_code, codex
|
|
13
|
+
|
|
14
|
+
READERS = {
|
|
15
|
+
"claude": claude_code.sessions_for,
|
|
16
|
+
"codex": codex.sessions_for,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def all_sessions_for(folder: Path) -> list[Session]:
|
|
21
|
+
sessions: list[Session] = []
|
|
22
|
+
for reader in READERS.values():
|
|
23
|
+
sessions.extend(reader(folder))
|
|
24
|
+
sessions.sort(key=lambda s: s.end, reverse=True)
|
|
25
|
+
return sessions
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Reader for Claude Code's own transcripts.
|
|
2
|
+
|
|
3
|
+
Format: JSONL under ~/.claude/projects/<cwd-slug>/<session-id>.jsonl, one
|
|
4
|
+
event per line. `cwd`, `timestamp`, `sessionId` appear on most event types.
|
|
5
|
+
The format is undocumented and has grown new event types across versions
|
|
6
|
+
(mode, permission-mode, file-history-snapshot, attachment, ai-title, ...);
|
|
7
|
+
this reader only looks at the few types it understands and skips the rest.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from mainai.models import Session, Turn
|
|
17
|
+
|
|
18
|
+
PROJECTS_DIR = Path.home() / ".claude" / "projects"
|
|
19
|
+
|
|
20
|
+
EDIT_TOOLS = {"Edit", "Write", "NotebookEdit", "MultiEdit"}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def sessions_for(folder: Path) -> list[Session]:
|
|
24
|
+
folder = folder.resolve()
|
|
25
|
+
if not PROJECTS_DIR.is_dir():
|
|
26
|
+
return []
|
|
27
|
+
|
|
28
|
+
sessions = []
|
|
29
|
+
for project_dir in PROJECTS_DIR.iterdir():
|
|
30
|
+
if not project_dir.is_dir():
|
|
31
|
+
continue
|
|
32
|
+
for jsonl_path in project_dir.glob("*.jsonl"):
|
|
33
|
+
session = _read_session(jsonl_path, folder)
|
|
34
|
+
if session is not None:
|
|
35
|
+
sessions.append(session)
|
|
36
|
+
return sessions
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _read_session(path: Path, folder: Path) -> Session | None:
|
|
40
|
+
session_id = path.stem
|
|
41
|
+
cwd: str | None = None
|
|
42
|
+
title: str | None = None
|
|
43
|
+
start: datetime | None = None
|
|
44
|
+
end: datetime | None = None
|
|
45
|
+
turns: list[Turn] = []
|
|
46
|
+
files_touched: set[str] = set()
|
|
47
|
+
commands_run: list[str] = []
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
51
|
+
except OSError:
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
for line in lines:
|
|
55
|
+
line = line.strip()
|
|
56
|
+
if not line:
|
|
57
|
+
continue
|
|
58
|
+
try:
|
|
59
|
+
event = json.loads(line)
|
|
60
|
+
except json.JSONDecodeError:
|
|
61
|
+
continue
|
|
62
|
+
if not isinstance(event, dict):
|
|
63
|
+
continue
|
|
64
|
+
|
|
65
|
+
event_cwd = event.get("cwd")
|
|
66
|
+
if event_cwd:
|
|
67
|
+
cwd = event_cwd
|
|
68
|
+
|
|
69
|
+
ts = _parse_timestamp(event.get("timestamp"))
|
|
70
|
+
if ts is not None:
|
|
71
|
+
if start is None or ts < start:
|
|
72
|
+
start = ts
|
|
73
|
+
if end is None or ts > end:
|
|
74
|
+
end = ts
|
|
75
|
+
|
|
76
|
+
event_type = event.get("type")
|
|
77
|
+
|
|
78
|
+
if event_type == "ai-title":
|
|
79
|
+
title = event.get("aiTitle") or title
|
|
80
|
+
continue
|
|
81
|
+
|
|
82
|
+
if event_type not in ("user", "assistant"):
|
|
83
|
+
continue
|
|
84
|
+
|
|
85
|
+
message = event.get("message")
|
|
86
|
+
if not isinstance(message, dict):
|
|
87
|
+
continue
|
|
88
|
+
role = message.get("role", event_type)
|
|
89
|
+
content = message.get("content")
|
|
90
|
+
|
|
91
|
+
if isinstance(content, str):
|
|
92
|
+
if not event.get("isMeta") and content.strip():
|
|
93
|
+
turns.append(Turn(role=role, text=content, timestamp=ts))
|
|
94
|
+
elif isinstance(content, list):
|
|
95
|
+
for block in content:
|
|
96
|
+
if not isinstance(block, dict):
|
|
97
|
+
continue
|
|
98
|
+
block_type = block.get("type")
|
|
99
|
+
if block_type == "text" and not event.get("isMeta"):
|
|
100
|
+
text = block.get("text", "")
|
|
101
|
+
if text.strip():
|
|
102
|
+
turns.append(Turn(role=role, text=text, timestamp=ts))
|
|
103
|
+
elif block_type == "tool_use":
|
|
104
|
+
name = block.get("name")
|
|
105
|
+
tool_input = block.get("input") or {}
|
|
106
|
+
if name in EDIT_TOOLS:
|
|
107
|
+
file_path = tool_input.get("file_path")
|
|
108
|
+
if file_path:
|
|
109
|
+
files_touched.add(file_path)
|
|
110
|
+
elif name == "Bash":
|
|
111
|
+
command = tool_input.get("command")
|
|
112
|
+
if command:
|
|
113
|
+
commands_run.append(command)
|
|
114
|
+
|
|
115
|
+
if cwd is None or start is None or end is None:
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
cwd_path = Path(cwd).resolve()
|
|
119
|
+
if cwd_path != folder and folder not in cwd_path.parents:
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
return Session(
|
|
123
|
+
agent="claude",
|
|
124
|
+
session_id=session_id,
|
|
125
|
+
cwd=cwd,
|
|
126
|
+
start=start,
|
|
127
|
+
end=end,
|
|
128
|
+
title=title,
|
|
129
|
+
turns=turns,
|
|
130
|
+
files_touched=files_touched,
|
|
131
|
+
commands_run=commands_run,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _parse_timestamp(value: object) -> datetime | None:
|
|
136
|
+
if not isinstance(value, str) or not value:
|
|
137
|
+
return None
|
|
138
|
+
try:
|
|
139
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
140
|
+
except ValueError:
|
|
141
|
+
return None
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Reader for Codex CLI session logs.
|
|
2
|
+
|
|
3
|
+
Format: JSONL under ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl (or, when
|
|
4
|
+
Codex is installed via snap, ~/snap/codex/<rev>/sessions/YYYY/MM/DD/). First
|
|
5
|
+
line is `type: session_meta` with `payload.cwd`, `payload.id`,
|
|
6
|
+
`payload.timestamp`. Then `response_item` (message, function_call,
|
|
7
|
+
function_call_output, reasoning), `event_msg`, `turn_context`. Undocumented
|
|
8
|
+
and version-dependent; unrecognized line types and payload shapes are
|
|
9
|
+
skipped.
|
|
10
|
+
|
|
11
|
+
Codex has no dedicated "edit file" tool call in the sessions seen so far --
|
|
12
|
+
file edits happen via shell commands run through `exec_command` -- so
|
|
13
|
+
`files_touched` is left empty here; only `commands_run` is populated.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
from datetime import datetime
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from mainai.models import Session, Turn
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _session_bases() -> list[Path]:
|
|
26
|
+
bases = [Path.home() / ".codex" / "sessions"]
|
|
27
|
+
snap_root = Path.home() / "snap" / "codex"
|
|
28
|
+
if snap_root.is_dir():
|
|
29
|
+
for rev_dir in snap_root.iterdir():
|
|
30
|
+
candidate = rev_dir / "sessions"
|
|
31
|
+
if candidate.is_dir():
|
|
32
|
+
bases.append(candidate)
|
|
33
|
+
return bases
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def sessions_for(folder: Path) -> list[Session]:
|
|
37
|
+
folder = folder.resolve()
|
|
38
|
+
sessions = []
|
|
39
|
+
for base in _session_bases():
|
|
40
|
+
if not base.is_dir():
|
|
41
|
+
continue
|
|
42
|
+
for jsonl_path in base.glob("*/*/*/rollout-*.jsonl"):
|
|
43
|
+
session = _read_session(jsonl_path, folder)
|
|
44
|
+
if session is not None:
|
|
45
|
+
sessions.append(session)
|
|
46
|
+
return sessions
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _read_session(path: Path, folder: Path) -> Session | None:
|
|
50
|
+
session_id: str | None = None
|
|
51
|
+
cwd: str | None = None
|
|
52
|
+
start: datetime | None = None
|
|
53
|
+
end: datetime | None = None
|
|
54
|
+
turns: list[Turn] = []
|
|
55
|
+
commands_run: list[str] = []
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
lines = path.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
59
|
+
except OSError:
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
for line in lines:
|
|
63
|
+
line = line.strip()
|
|
64
|
+
if not line:
|
|
65
|
+
continue
|
|
66
|
+
try:
|
|
67
|
+
event = json.loads(line)
|
|
68
|
+
except json.JSONDecodeError:
|
|
69
|
+
continue
|
|
70
|
+
if not isinstance(event, dict):
|
|
71
|
+
continue
|
|
72
|
+
|
|
73
|
+
ts = _parse_timestamp(event.get("timestamp"))
|
|
74
|
+
if ts is not None:
|
|
75
|
+
if start is None or ts < start:
|
|
76
|
+
start = ts
|
|
77
|
+
if end is None or ts > end:
|
|
78
|
+
end = ts
|
|
79
|
+
|
|
80
|
+
event_type = event.get("type")
|
|
81
|
+
payload = event.get("payload")
|
|
82
|
+
if not isinstance(payload, dict):
|
|
83
|
+
continue
|
|
84
|
+
|
|
85
|
+
if event_type == "session_meta":
|
|
86
|
+
session_id = payload.get("id") or payload.get("session_id") or session_id
|
|
87
|
+
cwd = payload.get("cwd") or cwd
|
|
88
|
+
continue
|
|
89
|
+
|
|
90
|
+
if event_type != "response_item":
|
|
91
|
+
continue
|
|
92
|
+
|
|
93
|
+
item_type = payload.get("type")
|
|
94
|
+
|
|
95
|
+
if item_type == "message":
|
|
96
|
+
role = payload.get("role")
|
|
97
|
+
if role not in ("user", "assistant"):
|
|
98
|
+
continue
|
|
99
|
+
content = payload.get("content")
|
|
100
|
+
if not isinstance(content, list):
|
|
101
|
+
continue
|
|
102
|
+
text = "".join(
|
|
103
|
+
block.get("text", "")
|
|
104
|
+
for block in content
|
|
105
|
+
if isinstance(block, dict)
|
|
106
|
+
and block.get("type") in ("input_text", "output_text", "text")
|
|
107
|
+
).strip()
|
|
108
|
+
if not text or text.startswith("<environment_context>"):
|
|
109
|
+
continue
|
|
110
|
+
turns.append(Turn(role=role, text=text, timestamp=ts))
|
|
111
|
+
elif item_type == "function_call" and payload.get("name") == "exec_command":
|
|
112
|
+
try:
|
|
113
|
+
arguments = json.loads(payload.get("arguments") or "{}")
|
|
114
|
+
except json.JSONDecodeError:
|
|
115
|
+
arguments = {}
|
|
116
|
+
command = arguments.get("cmd")
|
|
117
|
+
if command:
|
|
118
|
+
commands_run.append(command)
|
|
119
|
+
|
|
120
|
+
if session_id is None or cwd is None or start is None or end is None:
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
cwd_path = Path(cwd).resolve()
|
|
124
|
+
if cwd_path != folder and folder not in cwd_path.parents:
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
return Session(
|
|
128
|
+
agent="codex",
|
|
129
|
+
session_id=session_id,
|
|
130
|
+
cwd=cwd,
|
|
131
|
+
start=start,
|
|
132
|
+
end=end,
|
|
133
|
+
turns=turns,
|
|
134
|
+
commands_run=commands_run,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _parse_timestamp(value: object) -> datetime | None:
|
|
139
|
+
if not isinstance(value, str) or not value:
|
|
140
|
+
return None
|
|
141
|
+
try:
|
|
142
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
143
|
+
except ValueError:
|
|
144
|
+
return None
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from mainai.readers import claude_code
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _write_jsonl(path: Path, events: list[dict]) -> None:
|
|
8
|
+
path.write_text("\n".join(json.dumps(e) for e in events), encoding="utf-8")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_reads_session_matching_folder(tmp_path, monkeypatch):
|
|
12
|
+
project_dir = tmp_path / "projects" / "-home-user-proj"
|
|
13
|
+
project_dir.mkdir(parents=True)
|
|
14
|
+
target = tmp_path / "home" / "user" / "proj"
|
|
15
|
+
target.mkdir(parents=True)
|
|
16
|
+
|
|
17
|
+
events = [
|
|
18
|
+
{
|
|
19
|
+
"type": "user",
|
|
20
|
+
"message": {"role": "user", "content": "fix the bug"},
|
|
21
|
+
"timestamp": "2026-01-01T00:00:00Z",
|
|
22
|
+
"cwd": str(target),
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
"type": "assistant",
|
|
26
|
+
"message": {
|
|
27
|
+
"role": "assistant",
|
|
28
|
+
"content": [
|
|
29
|
+
{"type": "text", "text": "done"},
|
|
30
|
+
{
|
|
31
|
+
"type": "tool_use",
|
|
32
|
+
"name": "Edit",
|
|
33
|
+
"input": {"file_path": str(target / "a.py")},
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
"timestamp": "2026-01-01T00:05:00Z",
|
|
38
|
+
"cwd": str(target),
|
|
39
|
+
},
|
|
40
|
+
{"type": "ai-title", "aiTitle": "Fix the bug"},
|
|
41
|
+
]
|
|
42
|
+
_write_jsonl(project_dir / "session1.jsonl", events)
|
|
43
|
+
|
|
44
|
+
monkeypatch.setattr(claude_code, "PROJECTS_DIR", tmp_path / "projects")
|
|
45
|
+
|
|
46
|
+
sessions = claude_code.sessions_for(target)
|
|
47
|
+
|
|
48
|
+
assert len(sessions) == 1
|
|
49
|
+
session = sessions[0]
|
|
50
|
+
assert session.agent == "claude"
|
|
51
|
+
assert session.title == "Fix the bug"
|
|
52
|
+
assert str(target / "a.py") in session.files_touched
|
|
53
|
+
assert any(t.text == "fix the bug" for t in session.turns)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_skips_malformed_lines_without_crashing(tmp_path, monkeypatch):
|
|
57
|
+
project_dir = tmp_path / "projects" / "-home-user-proj"
|
|
58
|
+
project_dir.mkdir(parents=True)
|
|
59
|
+
target = tmp_path / "home" / "user" / "proj"
|
|
60
|
+
target.mkdir(parents=True)
|
|
61
|
+
|
|
62
|
+
path = project_dir / "session2.jsonl"
|
|
63
|
+
path.write_text(
|
|
64
|
+
"not json at all\n"
|
|
65
|
+
+ json.dumps({"type": "unknown-future-type", "stuff": 1})
|
|
66
|
+
+ "\n"
|
|
67
|
+
+ json.dumps(
|
|
68
|
+
{
|
|
69
|
+
"type": "user",
|
|
70
|
+
"message": {"role": "user", "content": "hello"},
|
|
71
|
+
"timestamp": "2026-01-01T00:00:00Z",
|
|
72
|
+
"cwd": str(target),
|
|
73
|
+
}
|
|
74
|
+
),
|
|
75
|
+
encoding="utf-8",
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
monkeypatch.setattr(claude_code, "PROJECTS_DIR", tmp_path / "projects")
|
|
79
|
+
|
|
80
|
+
sessions = claude_code.sessions_for(target)
|
|
81
|
+
|
|
82
|
+
assert len(sessions) == 1
|
|
83
|
+
assert sessions[0].turns[0].text == "hello"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_excludes_sessions_outside_folder(tmp_path, monkeypatch):
|
|
87
|
+
project_dir = tmp_path / "projects" / "-other"
|
|
88
|
+
project_dir.mkdir(parents=True)
|
|
89
|
+
other = tmp_path / "other"
|
|
90
|
+
other.mkdir()
|
|
91
|
+
target = tmp_path / "home" / "user" / "proj"
|
|
92
|
+
target.mkdir(parents=True)
|
|
93
|
+
|
|
94
|
+
_write_jsonl(
|
|
95
|
+
project_dir / "session3.jsonl",
|
|
96
|
+
[
|
|
97
|
+
{
|
|
98
|
+
"type": "user",
|
|
99
|
+
"message": {"role": "user", "content": "unrelated"},
|
|
100
|
+
"timestamp": "2026-01-01T00:00:00Z",
|
|
101
|
+
"cwd": str(other),
|
|
102
|
+
}
|
|
103
|
+
],
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
monkeypatch.setattr(claude_code, "PROJECTS_DIR", tmp_path / "projects")
|
|
107
|
+
|
|
108
|
+
assert claude_code.sessions_for(target) == []
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from datetime import datetime, timezone
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from mainai import llm, llm_picker
|
|
7
|
+
from mainai.models import Session
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _session(agent: str, end_hour: int) -> Session:
|
|
11
|
+
return Session(
|
|
12
|
+
agent=agent,
|
|
13
|
+
session_id=f"{agent}-1",
|
|
14
|
+
cwd="/tmp/proj",
|
|
15
|
+
start=datetime(2026, 1, 1, 0, tzinfo=timezone.utc),
|
|
16
|
+
end=datetime(2026, 1, 1, end_hour, tzinfo=timezone.utc),
|
|
17
|
+
title=f"{agent} session",
|
|
18
|
+
turns=[],
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_single_session_skips_llm(monkeypatch):
|
|
23
|
+
def fail(*a, **k):
|
|
24
|
+
raise AssertionError("llm.call should not be invoked for a single session")
|
|
25
|
+
|
|
26
|
+
monkeypatch.setattr(llm, "call", fail)
|
|
27
|
+
sessions = [_session("claude", 1)]
|
|
28
|
+
result = llm_picker.llm_pick(sessions, Path("/tmp/proj"))
|
|
29
|
+
assert result.session is sessions[0]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_falls_back_to_heuristic_on_llm_error(monkeypatch):
|
|
33
|
+
def raise_error(*a, **k):
|
|
34
|
+
raise llm.LLMError("claude not found")
|
|
35
|
+
|
|
36
|
+
monkeypatch.setattr(llm, "call", raise_error)
|
|
37
|
+
sessions = [_session("claude", 1), _session("codex", 2)]
|
|
38
|
+
result = llm_picker.llm_pick(sessions, Path("/tmp/proj"))
|
|
39
|
+
assert result.session is not None
|
|
40
|
+
assert "LLM pick unavailable" in result.reasons[0]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_falls_back_on_unparseable_response(monkeypatch):
|
|
44
|
+
monkeypatch.setattr(llm, "call", lambda *a, **k: "not json")
|
|
45
|
+
sessions = [_session("claude", 1), _session("codex", 2)]
|
|
46
|
+
result = llm_picker.llm_pick(sessions, Path("/tmp/proj"))
|
|
47
|
+
assert result.session is not None
|
|
48
|
+
assert "LLM pick unavailable" in result.reasons[0]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_uses_llm_choice(monkeypatch):
|
|
52
|
+
monkeypatch.setattr(
|
|
53
|
+
llm, "call", lambda *a, **k: '{"agent": "codex", "reason": "it did the real work"}'
|
|
54
|
+
)
|
|
55
|
+
sessions = [_session("claude", 1), _session("codex", 2)]
|
|
56
|
+
result = llm_picker.llm_pick(sessions, Path("/tmp/proj"))
|
|
57
|
+
assert result.session.agent == "codex"
|
|
58
|
+
assert result.reasons == ["it did the real work"]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_falls_back_on_unrecognized_agent(monkeypatch):
|
|
62
|
+
monkeypatch.setattr(llm, "call", lambda *a, **k: '{"agent": "grok", "reason": "x"}')
|
|
63
|
+
sessions = [_session("claude", 1), _session("codex", 2)]
|
|
64
|
+
result = llm_picker.llm_pick(sessions, Path("/tmp/proj"))
|
|
65
|
+
assert result.session is not None
|
|
66
|
+
assert "unrecognized agent" in result.reasons[0]
|
mainai-0.1.0/uv.lock
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
version = 1
|
|
2
|
+
revision = 3
|
|
3
|
+
requires-python = ">=3.12"
|
|
4
|
+
|
|
5
|
+
[[package]]
|
|
6
|
+
name = "colorama"
|
|
7
|
+
version = "0.4.6"
|
|
8
|
+
source = { registry = "https://pypi.org/simple" }
|
|
9
|
+
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
|
10
|
+
wheels = [
|
|
11
|
+
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[[package]]
|
|
15
|
+
name = "iniconfig"
|
|
16
|
+
version = "2.3.0"
|
|
17
|
+
source = { registry = "https://pypi.org/simple" }
|
|
18
|
+
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
|
19
|
+
wheels = [
|
|
20
|
+
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[[package]]
|
|
24
|
+
name = "mainai"
|
|
25
|
+
version = "0.1.0"
|
|
26
|
+
source = { editable = "." }
|
|
27
|
+
|
|
28
|
+
[package.dev-dependencies]
|
|
29
|
+
dev = [
|
|
30
|
+
{ name = "pytest" },
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[package.metadata]
|
|
34
|
+
|
|
35
|
+
[package.metadata.requires-dev]
|
|
36
|
+
dev = [{ name = "pytest", specifier = ">=9.1.1" }]
|
|
37
|
+
|
|
38
|
+
[[package]]
|
|
39
|
+
name = "packaging"
|
|
40
|
+
version = "26.3"
|
|
41
|
+
source = { registry = "https://pypi.org/simple" }
|
|
42
|
+
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
|
|
43
|
+
wheels = [
|
|
44
|
+
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
[[package]]
|
|
48
|
+
name = "pluggy"
|
|
49
|
+
version = "1.6.0"
|
|
50
|
+
source = { registry = "https://pypi.org/simple" }
|
|
51
|
+
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
|
52
|
+
wheels = [
|
|
53
|
+
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
[[package]]
|
|
57
|
+
name = "pygments"
|
|
58
|
+
version = "2.21.0"
|
|
59
|
+
source = { registry = "https://pypi.org/simple" }
|
|
60
|
+
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
|
|
61
|
+
wheels = [
|
|
62
|
+
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
[[package]]
|
|
66
|
+
name = "pytest"
|
|
67
|
+
version = "9.1.1"
|
|
68
|
+
source = { registry = "https://pypi.org/simple" }
|
|
69
|
+
dependencies = [
|
|
70
|
+
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
|
71
|
+
{ name = "iniconfig" },
|
|
72
|
+
{ name = "packaging" },
|
|
73
|
+
{ name = "pluggy" },
|
|
74
|
+
{ name = "pygments" },
|
|
75
|
+
]
|
|
76
|
+
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
|
|
77
|
+
wheels = [
|
|
78
|
+
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
|
79
|
+
]
|