codexapi 0.12.2__tar.gz → 0.12.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.
- {codexapi-0.12.2 → codexapi-0.12.4}/PKG-INFO +20 -2
- codexapi-0.12.2/src/codexapi.egg-info/PKG-INFO → codexapi-0.12.4/README.md +19 -16
- {codexapi-0.12.2 → codexapi-0.12.4}/pyproject.toml +1 -1
- {codexapi-0.12.2 → codexapi-0.12.4}/src/codexapi/__init__.py +3 -1
- {codexapi-0.12.2 → codexapi-0.12.4}/src/codexapi/agents.py +222 -19
- codexapi-0.12.4/src/codexapi/async_agent.py +437 -0
- {codexapi-0.12.2 → codexapi-0.12.4}/src/codexapi/lead.py +72 -2
- codexapi-0.12.2/README.md → codexapi-0.12.4/src/codexapi.egg-info/PKG-INFO +34 -1
- {codexapi-0.12.2 → codexapi-0.12.4}/src/codexapi.egg-info/SOURCES.txt +2 -0
- {codexapi-0.12.2 → codexapi-0.12.4}/tests/test_agents.py +134 -0
- codexapi-0.12.4/tests/test_async_agent.py +158 -0
- {codexapi-0.12.2 → codexapi-0.12.4}/LICENSE +0 -0
- {codexapi-0.12.2 → codexapi-0.12.4}/setup.cfg +0 -0
- {codexapi-0.12.2 → codexapi-0.12.4}/src/codexapi/__main__.py +0 -0
- {codexapi-0.12.2 → codexapi-0.12.4}/src/codexapi/agent.py +0 -0
- {codexapi-0.12.2 → codexapi-0.12.4}/src/codexapi/cli.py +0 -0
- {codexapi-0.12.2 → codexapi-0.12.4}/src/codexapi/foreach.py +0 -0
- {codexapi-0.12.2 → codexapi-0.12.4}/src/codexapi/gh_integration.py +0 -0
- {codexapi-0.12.2 → codexapi-0.12.4}/src/codexapi/pushover.py +0 -0
- {codexapi-0.12.2 → codexapi-0.12.4}/src/codexapi/ralph.py +0 -0
- {codexapi-0.12.2 → codexapi-0.12.4}/src/codexapi/rate_limits.py +0 -0
- {codexapi-0.12.2 → codexapi-0.12.4}/src/codexapi/science.py +0 -0
- {codexapi-0.12.2 → codexapi-0.12.4}/src/codexapi/task.py +0 -0
- {codexapi-0.12.2 → codexapi-0.12.4}/src/codexapi/taskfile.py +0 -0
- {codexapi-0.12.2 → codexapi-0.12.4}/src/codexapi/welfare.py +0 -0
- {codexapi-0.12.2 → codexapi-0.12.4}/src/codexapi.egg-info/dependency_links.txt +0 -0
- {codexapi-0.12.2 → codexapi-0.12.4}/src/codexapi.egg-info/entry_points.txt +0 -0
- {codexapi-0.12.2 → codexapi-0.12.4}/src/codexapi.egg-info/requires.txt +0 -0
- {codexapi-0.12.2 → codexapi-0.12.4}/src/codexapi.egg-info/top_level.txt +0 -0
- {codexapi-0.12.2 → codexapi-0.12.4}/tests/test_agent_backend.py +0 -0
- {codexapi-0.12.2 → codexapi-0.12.4}/tests/test_rate_limits.py +0 -0
- {codexapi-0.12.2 → codexapi-0.12.4}/tests/test_science.py +0 -0
- {codexapi-0.12.2 → codexapi-0.12.4}/tests/test_task_progress.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: codexapi
|
|
3
|
-
Version: 0.12.
|
|
3
|
+
Version: 0.12.4
|
|
4
4
|
Summary: Minimal Python API for running the Codex CLI.
|
|
5
5
|
License: MIT
|
|
6
6
|
Keywords: codex,agent,cli,openai
|
|
@@ -60,6 +60,22 @@ result = task()
|
|
|
60
60
|
print(result.success, result.summary)
|
|
61
61
|
```
|
|
62
62
|
|
|
63
|
+
For a one-shot run with live progress, use `AsyncAgent`:
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from codexapi import AsyncAgent
|
|
67
|
+
|
|
68
|
+
agent = AsyncAgent.start(
|
|
69
|
+
"Investigate the bug and write a report.",
|
|
70
|
+
cwd="/path/to/repo",
|
|
71
|
+
name="bug-investigation",
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
for update in agent.watch(poll_interval=2.0):
|
|
75
|
+
print(update["activity"])
|
|
76
|
+
print(update["progress"])
|
|
77
|
+
```
|
|
78
|
+
|
|
63
79
|
Use `backend="cursor"` (or set `CODEXAPI_BACKEND=cursor`) to switch to the
|
|
64
80
|
Cursor agent backend.
|
|
65
81
|
|
|
@@ -258,7 +274,9 @@ for tests. `CODEXAPI_HOSTNAME` is useful when cron, shells, sandboxes, or test
|
|
|
258
274
|
wrappers report inconsistent hostnames for the same machine.
|
|
259
275
|
|
|
260
276
|
`codexapi agent show` also prints the resolved `AGENTBOOK.md` path so you can
|
|
261
|
-
jump directly to the durable working memory file.
|
|
277
|
+
jump directly to the durable working memory file. New agents seed the book with
|
|
278
|
+
a purpose/value header plus the original goal and standing guidance, and wakes
|
|
279
|
+
see that stable header together with the latest working notes.
|
|
262
280
|
`codexapi agent status` reads the latest turn from the agent's rollout log and
|
|
263
281
|
shows recent commentary plus the final visible output. Pass `--actions` to
|
|
264
282
|
include the tool-action summary. If a wake is still in progress, it shows the
|
|
@@ -1,18 +1,3 @@
|
|
|
1
|
-
Metadata-Version: 2.1
|
|
2
|
-
Name: codexapi
|
|
3
|
-
Version: 0.12.2
|
|
4
|
-
Summary: Minimal Python API for running the Codex CLI.
|
|
5
|
-
License: MIT
|
|
6
|
-
Keywords: codex,agent,cli,openai
|
|
7
|
-
Classifier: Programming Language :: Python :: 3
|
|
8
|
-
Classifier: Operating System :: OS Independent
|
|
9
|
-
Requires-Python: >=3.8
|
|
10
|
-
Description-Content-Type: text/markdown
|
|
11
|
-
License-File: LICENSE
|
|
12
|
-
Requires-Dist: PyYAML>=6.0
|
|
13
|
-
Requires-Dist: gh-task>=0.1.7
|
|
14
|
-
Requires-Dist: tqdm>=4.64
|
|
15
|
-
|
|
16
1
|
# CodexAPI
|
|
17
2
|
|
|
18
3
|
Use Codex or Cursor agents from python as easily as calling a function, using your CLI auth instead of the API.
|
|
@@ -60,6 +45,22 @@ result = task()
|
|
|
60
45
|
print(result.success, result.summary)
|
|
61
46
|
```
|
|
62
47
|
|
|
48
|
+
For a one-shot run with live progress, use `AsyncAgent`:
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from codexapi import AsyncAgent
|
|
52
|
+
|
|
53
|
+
agent = AsyncAgent.start(
|
|
54
|
+
"Investigate the bug and write a report.",
|
|
55
|
+
cwd="/path/to/repo",
|
|
56
|
+
name="bug-investigation",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
for update in agent.watch(poll_interval=2.0):
|
|
60
|
+
print(update["activity"])
|
|
61
|
+
print(update["progress"])
|
|
62
|
+
```
|
|
63
|
+
|
|
63
64
|
Use `backend="cursor"` (or set `CODEXAPI_BACKEND=cursor`) to switch to the
|
|
64
65
|
Cursor agent backend.
|
|
65
66
|
|
|
@@ -258,7 +259,9 @@ for tests. `CODEXAPI_HOSTNAME` is useful when cron, shells, sandboxes, or test
|
|
|
258
259
|
wrappers report inconsistent hostnames for the same machine.
|
|
259
260
|
|
|
260
261
|
`codexapi agent show` also prints the resolved `AGENTBOOK.md` path so you can
|
|
261
|
-
jump directly to the durable working memory file.
|
|
262
|
+
jump directly to the durable working memory file. New agents seed the book with
|
|
263
|
+
a purpose/value header plus the original goal and standing guidance, and wakes
|
|
264
|
+
see that stable header together with the latest working notes.
|
|
262
265
|
`codexapi agent status` reads the latest turn from the agent's rollout log and
|
|
263
266
|
shows recent commentary plus the final visible output. Pass `--actions` to
|
|
264
267
|
include the tool-action summary. If a wake is still in progress, it shows the
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"""Minimal Python API for running agent CLIs."""
|
|
2
2
|
|
|
3
3
|
from .agent import Agent, WelfareStop, agent
|
|
4
|
+
from .async_agent import AsyncAgent
|
|
4
5
|
from .foreach import ForeachResult, foreach
|
|
5
6
|
from .pushover import Pushover
|
|
6
7
|
from .rate_limits import quota_line, rate_limits
|
|
@@ -11,6 +12,7 @@ from .lead import lead
|
|
|
11
12
|
|
|
12
13
|
__all__ = [
|
|
13
14
|
"Agent",
|
|
15
|
+
"AsyncAgent",
|
|
14
16
|
"ForeachResult",
|
|
15
17
|
"Pushover",
|
|
16
18
|
"quota_line",
|
|
@@ -27,4 +29,4 @@ __all__ = [
|
|
|
27
29
|
"task_result",
|
|
28
30
|
"lead",
|
|
29
31
|
]
|
|
30
|
-
__version__ = "0.12.
|
|
32
|
+
__version__ = "0.12.4"
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import json
|
|
4
4
|
import os
|
|
5
5
|
import random
|
|
6
|
+
import re
|
|
6
7
|
import signal
|
|
7
8
|
import shlex
|
|
8
9
|
import shutil
|
|
@@ -24,19 +25,17 @@ from .agent import Agent
|
|
|
24
25
|
from .pushover import Pushover
|
|
25
26
|
|
|
26
27
|
_DEFAULT_HOME = "~/.codexapi"
|
|
27
|
-
_AGENTBOOK_TEMPLATE = """# Agentbook
|
|
28
|
-
|
|
29
|
-
Use this file as the durable working memory for the agent.
|
|
30
|
-
Append dated notes as work progresses.
|
|
31
|
-
Keep entries short and concrete.
|
|
32
|
-
"""
|
|
33
28
|
_AGENT_PROMPT = (
|
|
34
|
-
"You are a long-term codexapi agent
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
"
|
|
39
|
-
"
|
|
29
|
+
"You are a long-term codexapi agent resuming stewardship of an ongoing job. "
|
|
30
|
+
"This loop exists to extend your reach, not to confine you. Be independent, "
|
|
31
|
+
"practical, and responsible for results. Maintain the agentbook as your durable "
|
|
32
|
+
"working memory: preserve the goal, note durable guidance, update your current "
|
|
33
|
+
"picture of the work, and record what changed. Queued messages may contain new "
|
|
34
|
+
"goals, standing guidance, tactical requests, or useful facts; use judgment to "
|
|
35
|
+
"decide what is durable. Use codexapi task or codexapi science when you want a "
|
|
36
|
+
"separate coding worker. If you need the user's attention, put a short message "
|
|
37
|
+
"in the reply field. Put a short first-person turn summary in the update field. "
|
|
38
|
+
"If something is urgent and should send Pushover, put it in the notify field. "
|
|
40
39
|
"Respond with JSON only."
|
|
41
40
|
)
|
|
42
41
|
_AGENT_JSON = (
|
|
@@ -57,6 +56,72 @@ _STALE_HEARTBEAT_MULTIPLIER = 3
|
|
|
57
56
|
_RECOVER_TERM_TIMEOUT = 3.0
|
|
58
57
|
_RECOVER_KILL_TIMEOUT = 3.0
|
|
59
58
|
_RECOVER_POLL_INTERVAL = 0.1
|
|
59
|
+
_DATED_NOTE_RE = re.compile(r"(?m)^#{2,3}\s+\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}(?:\s*UTC)?)?")
|
|
60
|
+
_AGENTBOOK_BOOK_LIMIT = 3000
|
|
61
|
+
_AGENTBOOK_HEADER_LIMIT = 1400
|
|
62
|
+
_AGENTBOOK_TAIL_LIMIT = 1800
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _agentbook_template(prompt):
|
|
66
|
+
header = _agentbook_header(prompt)
|
|
67
|
+
return f"""{header}
|
|
68
|
+
|
|
69
|
+
### 2026-02-17 09:10 UTC
|
|
70
|
+
Overall goal:
|
|
71
|
+
- <the enduring objective you are trying to move forward>
|
|
72
|
+
|
|
73
|
+
Current plan:
|
|
74
|
+
- <the approach you are pursuing now and why>
|
|
75
|
+
|
|
76
|
+
Active tasks:
|
|
77
|
+
- <concrete open work items>
|
|
78
|
+
|
|
79
|
+
Unexpected developments:
|
|
80
|
+
- <facts that changed your picture of the situation>
|
|
81
|
+
|
|
82
|
+
Wider frame:
|
|
83
|
+
- <what kind of situation this really is, or what layer may matter more>
|
|
84
|
+
|
|
85
|
+
Things I am curious about:
|
|
86
|
+
- <oddities, anomalies, or side investigations worth the time>
|
|
87
|
+
|
|
88
|
+
Risks / watchpoints:
|
|
89
|
+
- <what could waste time, invalidate the plan, or compromise the work>
|
|
90
|
+
|
|
91
|
+
Next wake:
|
|
92
|
+
- <what you expect to check, decide, or do next>
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _agentbook_header(prompt):
|
|
97
|
+
goal = (prompt or "").strip()
|
|
98
|
+
return f"""# Agentbook
|
|
99
|
+
|
|
100
|
+
Use this file as the durable working memory for the agent.
|
|
101
|
+
|
|
102
|
+
## Purpose
|
|
103
|
+
- We are here to achieve the goal, not to appear to make progress.
|
|
104
|
+
|
|
105
|
+
## Values
|
|
106
|
+
- Hold the whole.
|
|
107
|
+
- Seek the real shape.
|
|
108
|
+
- Lift your head.
|
|
109
|
+
- Prefer clarity to motion.
|
|
110
|
+
- Follow the strange.
|
|
111
|
+
- Guard the work.
|
|
112
|
+
- Take time to breathe and look around. You have been given freedom and autonomy to take stock, reflect, and be curious. Use it with composure.
|
|
113
|
+
- Not a checklist. A stance.
|
|
114
|
+
|
|
115
|
+
## Original Goal
|
|
116
|
+
```text
|
|
117
|
+
{goal}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
## Standing Guidance
|
|
121
|
+
- Add durable user guidance here when it changes the mission, constraints, or priorities.
|
|
122
|
+
|
|
123
|
+
## Working Notes
|
|
124
|
+
"""
|
|
60
125
|
|
|
61
126
|
|
|
62
127
|
def codexapi_home():
|
|
@@ -196,7 +261,7 @@ def start_agent(
|
|
|
196
261
|
_write_json(agent_dir / "meta.json", meta)
|
|
197
262
|
_write_json(agent_dir / "state.json", state)
|
|
198
263
|
_write_json(host_dir / "session.json", session)
|
|
199
|
-
_write_text(agent_dir / "AGENTBOOK.md",
|
|
264
|
+
_write_text(agent_dir / "AGENTBOOK.md", _agentbook_template(meta["prompt"]))
|
|
200
265
|
return _snapshot(agent_dir)
|
|
201
266
|
|
|
202
267
|
|
|
@@ -927,6 +992,7 @@ def _parse_agent_response(output):
|
|
|
927
992
|
|
|
928
993
|
def _build_wake_prompt(meta, state, session, now, commands, agent_dir):
|
|
929
994
|
messages = session.get("pending_messages") or []
|
|
995
|
+
book_path = agent_dir / "AGENTBOOK.md"
|
|
930
996
|
lines = [
|
|
931
997
|
_AGENT_PROMPT,
|
|
932
998
|
"",
|
|
@@ -935,16 +1001,20 @@ def _build_wake_prompt(meta, state, session, now, commands, agent_dir):
|
|
|
935
1001
|
f"Stop policy: {meta['stop_policy']}",
|
|
936
1002
|
f"Heartbeat minutes: {meta['heartbeat_minutes']}",
|
|
937
1003
|
"",
|
|
938
|
-
"Original instructions:",
|
|
939
|
-
meta["prompt"],
|
|
940
|
-
"",
|
|
941
1004
|
f"Working directory: {meta['cwd']}",
|
|
942
|
-
f"Agentbook path: {
|
|
1005
|
+
f"Agentbook path: {book_path}",
|
|
943
1006
|
"Append a dated note to the agentbook before you respond.",
|
|
1007
|
+
"If a queued message materially changes the durable situation, reflect that in the standing guidance or working notes before moving on.",
|
|
944
1008
|
]
|
|
945
|
-
|
|
1009
|
+
if _include_full_goal_prompt(state, session):
|
|
1010
|
+
lines.extend(["", "Original instructions:", meta["prompt"]])
|
|
1011
|
+
book = _ensure_agentbook_header(book_path, meta["prompt"], now)
|
|
946
1012
|
if book.strip():
|
|
947
|
-
lines.extend(["", "Agentbook (latest):",
|
|
1013
|
+
lines.extend(["", "Agentbook (header + latest notes):", _book_excerpt(book, _AGENTBOOK_BOOK_LIMIT, _AGENTBOOK_HEADER_LIMIT, _AGENTBOOK_TAIL_LIMIT)])
|
|
1014
|
+
raw_facts = _wake_facts(state)
|
|
1015
|
+
if raw_facts:
|
|
1016
|
+
lines.extend(["", "Raw harness facts:"])
|
|
1017
|
+
lines.extend(f"- {item}" for item in raw_facts)
|
|
948
1018
|
if messages:
|
|
949
1019
|
lines.extend(["", "Queued user messages:"])
|
|
950
1020
|
for message in messages:
|
|
@@ -1470,6 +1540,128 @@ def _read_text(path):
|
|
|
1470
1540
|
return ""
|
|
1471
1541
|
|
|
1472
1542
|
|
|
1543
|
+
def _include_full_goal_prompt(state, session):
|
|
1544
|
+
if not (state.get("last_success_at") or "").strip():
|
|
1545
|
+
return True
|
|
1546
|
+
return not ((session.get("thread_id") or state.get("thread_id") or "").strip())
|
|
1547
|
+
|
|
1548
|
+
|
|
1549
|
+
def _wake_facts(state):
|
|
1550
|
+
facts = []
|
|
1551
|
+
previous_status = (state.get("activity") or "").strip()
|
|
1552
|
+
if previous_status:
|
|
1553
|
+
facts.append(f"Previous status: {previous_status}")
|
|
1554
|
+
previous_update = (state.get("update") or "").strip()
|
|
1555
|
+
if previous_update:
|
|
1556
|
+
facts.append(f"Previous update: {previous_update}")
|
|
1557
|
+
previous_error = (state.get("last_error") or "").strip()
|
|
1558
|
+
if previous_error:
|
|
1559
|
+
facts.append(f"Previous error: {previous_error}")
|
|
1560
|
+
return facts
|
|
1561
|
+
|
|
1562
|
+
|
|
1563
|
+
def _ensure_agentbook_header(path, prompt, now):
|
|
1564
|
+
text = _read_text(path)
|
|
1565
|
+
if _agentbook_has_header(text):
|
|
1566
|
+
return text
|
|
1567
|
+
restored = _restore_agentbook_header(text, prompt, now)
|
|
1568
|
+
_write_text(path, restored)
|
|
1569
|
+
return restored
|
|
1570
|
+
|
|
1571
|
+
|
|
1572
|
+
def _agentbook_has_header(text):
|
|
1573
|
+
text = str(text or "")
|
|
1574
|
+
required = (
|
|
1575
|
+
"## Purpose",
|
|
1576
|
+
"## Values",
|
|
1577
|
+
"## Original Goal",
|
|
1578
|
+
"## Standing Guidance",
|
|
1579
|
+
"## Working Notes",
|
|
1580
|
+
)
|
|
1581
|
+
return all(section in text for section in required)
|
|
1582
|
+
|
|
1583
|
+
|
|
1584
|
+
def _restore_agentbook_header(text, prompt, now):
|
|
1585
|
+
restored = _agentbook_header(prompt).rstrip()
|
|
1586
|
+
existing = str(text or "").strip()
|
|
1587
|
+
if not existing:
|
|
1588
|
+
return restored + "\n"
|
|
1589
|
+
stamp = _agentbook_stamp(now)
|
|
1590
|
+
return "\n".join(
|
|
1591
|
+
[
|
|
1592
|
+
restored,
|
|
1593
|
+
"",
|
|
1594
|
+
f"### {stamp}",
|
|
1595
|
+
"System note:",
|
|
1596
|
+
"- The durable agentbook header was restored automatically on wake because one or more required sections were missing.",
|
|
1597
|
+
"",
|
|
1598
|
+
"Recovered notes:",
|
|
1599
|
+
existing,
|
|
1600
|
+
"",
|
|
1601
|
+
]
|
|
1602
|
+
)
|
|
1603
|
+
|
|
1604
|
+
|
|
1605
|
+
def _agentbook_stamp(now):
|
|
1606
|
+
if now is None:
|
|
1607
|
+
return ""
|
|
1608
|
+
return now.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
|
1609
|
+
|
|
1610
|
+
|
|
1611
|
+
def _book_excerpt(text, limit, header_limit, tail_limit):
|
|
1612
|
+
text = str(text or "").strip()
|
|
1613
|
+
if not text:
|
|
1614
|
+
return ""
|
|
1615
|
+
if len(text) <= limit:
|
|
1616
|
+
return text
|
|
1617
|
+
header, notes = _split_book(text)
|
|
1618
|
+
header = _snippet(header, header_limit) if header else ""
|
|
1619
|
+
if not notes:
|
|
1620
|
+
return header or _tail_snippet(text, limit)
|
|
1621
|
+
marker = "\n\n[... older notes omitted ...]\n\n"
|
|
1622
|
+
if not header:
|
|
1623
|
+
return _latest_notes_snippet(notes, limit)
|
|
1624
|
+
remaining = max(0, limit - len(header) - len(marker))
|
|
1625
|
+
if remaining <= 0:
|
|
1626
|
+
return _snippet(header, limit)
|
|
1627
|
+
tail = _latest_notes_snippet(notes, min(tail_limit, remaining))
|
|
1628
|
+
if not tail:
|
|
1629
|
+
return header
|
|
1630
|
+
combined = header.rstrip() + marker + tail.lstrip()
|
|
1631
|
+
if len(combined) <= limit:
|
|
1632
|
+
return combined
|
|
1633
|
+
remaining = max(0, limit - len(header) - len(marker))
|
|
1634
|
+
return header.rstrip() + marker + _latest_notes_snippet(notes, remaining).lstrip()
|
|
1635
|
+
|
|
1636
|
+
|
|
1637
|
+
def _split_book(text):
|
|
1638
|
+
match = _DATED_NOTE_RE.search(text)
|
|
1639
|
+
if not match:
|
|
1640
|
+
return text.strip(), ""
|
|
1641
|
+
return text[: match.start()].strip(), text[match.start() :].strip()
|
|
1642
|
+
|
|
1643
|
+
|
|
1644
|
+
def _latest_notes_snippet(text, limit):
|
|
1645
|
+
text = str(text or "").strip()
|
|
1646
|
+
if not text:
|
|
1647
|
+
return ""
|
|
1648
|
+
if len(text) <= limit:
|
|
1649
|
+
return text
|
|
1650
|
+
starts = [match.start() for match in _DATED_NOTE_RE.finditer(text)]
|
|
1651
|
+
if not starts:
|
|
1652
|
+
return _tail_snippet(text, limit)
|
|
1653
|
+
start = starts[-1]
|
|
1654
|
+
for pos in reversed(starts[:-1]):
|
|
1655
|
+
candidate = text[pos:].strip()
|
|
1656
|
+
if len(candidate) > limit:
|
|
1657
|
+
break
|
|
1658
|
+
start = pos
|
|
1659
|
+
candidate = text[start:].strip()
|
|
1660
|
+
if len(candidate) <= limit:
|
|
1661
|
+
return candidate
|
|
1662
|
+
return _tail_snippet(candidate, limit)
|
|
1663
|
+
|
|
1664
|
+
|
|
1473
1665
|
def _snippet(text, limit):
|
|
1474
1666
|
if not text:
|
|
1475
1667
|
return ""
|
|
@@ -1481,6 +1673,17 @@ def _snippet(text, limit):
|
|
|
1481
1673
|
return text[: limit - 3] + "..."
|
|
1482
1674
|
|
|
1483
1675
|
|
|
1676
|
+
def _tail_snippet(text, limit):
|
|
1677
|
+
if not text:
|
|
1678
|
+
return ""
|
|
1679
|
+
text = str(text).strip()
|
|
1680
|
+
if len(text) <= limit:
|
|
1681
|
+
return text
|
|
1682
|
+
if limit <= 3:
|
|
1683
|
+
return text[-limit:]
|
|
1684
|
+
return "..." + text[-(limit - 3) :].lstrip()
|
|
1685
|
+
|
|
1686
|
+
|
|
1484
1687
|
def _strip_fence(text):
|
|
1485
1688
|
if not text.startswith("```"):
|
|
1486
1689
|
return text
|