codex-agent-framework 0.1.10__tar.gz → 0.1.11__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. {codex_agent_framework-0.1.10 → codex_agent_framework-0.1.11}/CHANGELOG.md +17 -0
  2. {codex_agent_framework-0.1.10/codex_agent_framework.egg-info → codex_agent_framework-0.1.11}/PKG-INFO +2 -1
  3. {codex_agent_framework-0.1.10 → codex_agent_framework-0.1.11}/README.md +1 -0
  4. {codex_agent_framework-0.1.10 → codex_agent_framework-0.1.11}/codex_agent/agent.py +28 -0
  5. {codex_agent_framework-0.1.10 → codex_agent_framework-0.1.11}/codex_agent/builtin_tools.py +52 -17
  6. {codex_agent_framework-0.1.10 → codex_agent_framework-0.1.11}/codex_agent/chat.py +19 -1
  7. {codex_agent_framework-0.1.10 → codex_agent_framework-0.1.11}/codex_agent/prompts/system_prompt.txt +5 -4
  8. {codex_agent_framework-0.1.10 → codex_agent_framework-0.1.11}/codex_agent/utils.py +126 -33
  9. {codex_agent_framework-0.1.10 → codex_agent_framework-0.1.11/codex_agent_framework.egg-info}/PKG-INFO +2 -1
  10. {codex_agent_framework-0.1.10 → codex_agent_framework-0.1.11}/pyproject.toml +1 -1
  11. {codex_agent_framework-0.1.10 → codex_agent_framework-0.1.11}/tests/test_agent.py +42 -0
  12. {codex_agent_framework-0.1.10 → codex_agent_framework-0.1.11}/tests/test_chat.py +66 -0
  13. {codex_agent_framework-0.1.10 → codex_agent_framework-0.1.11}/tests/test_utils.py +109 -0
  14. {codex_agent_framework-0.1.10 → codex_agent_framework-0.1.11}/LICENSE +0 -0
  15. {codex_agent_framework-0.1.10 → codex_agent_framework-0.1.11}/MANIFEST.in +0 -0
  16. {codex_agent_framework-0.1.10 → codex_agent_framework-0.1.11}/codex_agent/__init__.py +0 -0
  17. {codex_agent_framework-0.1.10 → codex_agent_framework-0.1.11}/codex_agent/__main__.py +0 -0
  18. {codex_agent_framework-0.1.10 → codex_agent_framework-0.1.11}/codex_agent/agent_runtime.py +0 -0
  19. {codex_agent_framework-0.1.10 → codex_agent_framework-0.1.11}/codex_agent/ai.py +0 -0
  20. {codex_agent_framework-0.1.10 → codex_agent_framework-0.1.11}/codex_agent/builtin_commands.py +0 -0
  21. {codex_agent_framework-0.1.10 → codex_agent_framework-0.1.11}/codex_agent/builtin_providers.py +0 -0
  22. {codex_agent_framework-0.1.10 → codex_agent_framework-0.1.11}/codex_agent/client.py +0 -0
  23. {codex_agent_framework-0.1.10 → codex_agent_framework-0.1.11}/codex_agent/command.py +0 -0
  24. {codex_agent_framework-0.1.10 → codex_agent_framework-0.1.11}/codex_agent/event.py +0 -0
  25. {codex_agent_framework-0.1.10 → codex_agent_framework-0.1.11}/codex_agent/get_text/__init__.py +0 -0
  26. {codex_agent_framework-0.1.10 → codex_agent_framework-0.1.11}/codex_agent/get_text/default_gitignore +0 -0
  27. {codex_agent_framework-0.1.10 → codex_agent_framework-0.1.11}/codex_agent/get_text/get_text.py +0 -0
  28. {codex_agent_framework-0.1.10 → codex_agent_framework-0.1.11}/codex_agent/get_text/simpler_get_text.py +0 -0
  29. {codex_agent_framework-0.1.10 → codex_agent_framework-0.1.11}/codex_agent/get_webdriver.py +0 -0
  30. {codex_agent_framework-0.1.10 → codex_agent_framework-0.1.11}/codex_agent/image.py +0 -0
  31. {codex_agent_framework-0.1.10 → codex_agent_framework-0.1.11}/codex_agent/latex.py +0 -0
  32. {codex_agent_framework-0.1.10 → codex_agent_framework-0.1.11}/codex_agent/memory.py +0 -0
  33. {codex_agent_framework-0.1.10 → codex_agent_framework-0.1.11}/codex_agent/message.py +0 -0
  34. {codex_agent_framework-0.1.10 → codex_agent_framework-0.1.11}/codex_agent/prompts/image_generation_system_prompt.txt +0 -0
  35. {codex_agent_framework-0.1.10 → codex_agent_framework-0.1.11}/codex_agent/provider.py +0 -0
  36. {codex_agent_framework-0.1.10 → codex_agent_framework-0.1.11}/codex_agent/scheduler.py +0 -0
  37. {codex_agent_framework-0.1.10 → codex_agent_framework-0.1.11}/codex_agent/server.py +0 -0
  38. {codex_agent_framework-0.1.10 → codex_agent_framework-0.1.11}/codex_agent/service.py +0 -0
  39. {codex_agent_framework-0.1.10 → codex_agent_framework-0.1.11}/codex_agent/stream_utils.py +0 -0
  40. {codex_agent_framework-0.1.10 → codex_agent_framework-0.1.11}/codex_agent/tool.py +0 -0
  41. {codex_agent_framework-0.1.10 → codex_agent_framework-0.1.11}/codex_agent/tray.py +0 -0
  42. {codex_agent_framework-0.1.10 → codex_agent_framework-0.1.11}/codex_agent/tui.py +0 -0
  43. {codex_agent_framework-0.1.10 → codex_agent_framework-0.1.11}/codex_agent/voice.py +0 -0
  44. {codex_agent_framework-0.1.10 → codex_agent_framework-0.1.11}/codex_agent/worker.py +0 -0
  45. {codex_agent_framework-0.1.10 → codex_agent_framework-0.1.11}/codex_agent_framework.egg-info/SOURCES.txt +0 -0
  46. {codex_agent_framework-0.1.10 → codex_agent_framework-0.1.11}/codex_agent_framework.egg-info/dependency_links.txt +0 -0
  47. {codex_agent_framework-0.1.10 → codex_agent_framework-0.1.11}/codex_agent_framework.egg-info/entry_points.txt +0 -0
  48. {codex_agent_framework-0.1.10 → codex_agent_framework-0.1.11}/codex_agent_framework.egg-info/requires.txt +0 -0
  49. {codex_agent_framework-0.1.10 → codex_agent_framework-0.1.11}/codex_agent_framework.egg-info/top_level.txt +0 -0
  50. {codex_agent_framework-0.1.10 → codex_agent_framework-0.1.11}/setup.cfg +0 -0
  51. {codex_agent_framework-0.1.10 → codex_agent_framework-0.1.11}/tests/test_ai.py +0 -0
  52. {codex_agent_framework-0.1.10 → codex_agent_framework-0.1.11}/tests/test_cli.py +0 -0
  53. {codex_agent_framework-0.1.10 → codex_agent_framework-0.1.11}/tests/test_client.py +0 -0
  54. {codex_agent_framework-0.1.10 → codex_agent_framework-0.1.11}/tests/test_events.py +0 -0
  55. {codex_agent_framework-0.1.10 → codex_agent_framework-0.1.11}/tests/test_image_message.py +0 -0
  56. {codex_agent_framework-0.1.10 → codex_agent_framework-0.1.11}/tests/test_memory.py +0 -0
  57. {codex_agent_framework-0.1.10 → codex_agent_framework-0.1.11}/tests/test_messages.py +0 -0
  58. {codex_agent_framework-0.1.10 → codex_agent_framework-0.1.11}/tests/test_scheduler.py +0 -0
  59. {codex_agent_framework-0.1.10 → codex_agent_framework-0.1.11}/tests/test_server.py +0 -0
  60. {codex_agent_framework-0.1.10 → codex_agent_framework-0.1.11}/tests/test_service.py +0 -0
  61. {codex_agent_framework-0.1.10 → codex_agent_framework-0.1.11}/tests/test_tray.py +0 -0
  62. {codex_agent_framework-0.1.10 → codex_agent_framework-0.1.11}/tests/test_tui.py +0 -0
  63. {codex_agent_framework-0.1.10 → codex_agent_framework-0.1.11}/tests/test_worker.py +0 -0
@@ -3,6 +3,23 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
5
  This project loosely follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and uses semantic versioning where practical.
6
+ ## [0.1.11] - 2026-05-05
7
+ ### Added
8
+ - Add a separate `view` tool for folders, URLs, rich document extraction, and Python object inspection.
9
+ - Add context status counts for persistent and temporary session messages, surfaced in the TUI status bar.
10
+
11
+ ### Changed
12
+ - Make `read` a strict local UTF-8 file reader with reliable forced line numbers and regex pattern search by default.
13
+ - Keep extracted `view` output unnumbered, including search snippets, because transformed document line structure is not source-of-truth reliable.
14
+ - Update the system prompt to direct agents to use `read` before targeted edits and `view` for broad extraction.
15
+
16
+ ### Fixed
17
+ - Preserve blank lines and real source line numbers in `read` pattern snippets.
18
+ - Apply line-based pagination before token truncation.
19
+
20
+ ### Tests
21
+ - Add regression coverage for strict `read`, unnumbered `view`, blank-line preservation, regex/literal search, and persistent/temporary message status counts.
22
+
6
23
  ## [0.1.10] - 2026-05-05
7
24
  ### Fixed
8
25
  - Persist only backend `compaction_summary` items in compaction messages, avoiding bulky compacted conversation payloads while preserving summary replay.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codex-agent-framework
3
- Version: 0.1.10
3
+ Version: 0.1.11
4
4
  Summary: A lightweight event-driven Codex agent runtime.
5
5
  Author: Baptiste
6
6
  License-Expression: MIT
@@ -400,6 +400,7 @@ python -m build
400
400
  The distribution includes prompt text files and `codex_agent/get_text/default_gitignore` through package data and `MANIFEST.in`.
401
401
 
402
402
  ## Recent changes
403
+ - `0.1.11`: split strict line-numbered UTF-8 `read` from unnumbered extracted `view`, preserve blank lines in read snippets, and show persistent+temporary message counts in the TUI status bar.
403
404
  - `0.1.10`: persist only backend compaction summaries, drop bulky compacted conversation payloads, and refresh context status after compaction.
404
405
  - `0.1.9`: maintenance packaging release after validating the local execution environment and deploy workflow.
405
406
  - `0.1.8`: scope TUI replay/SSE catch-up to the active session and make bash/python subprocesses inherit the project Python environment, including service-launched agents.
@@ -352,6 +352,7 @@ python -m build
352
352
  The distribution includes prompt text files and `codex_agent/get_text/default_gitignore` through package data and `MANIFEST.in`.
353
353
 
354
354
  ## Recent changes
355
+ - `0.1.11`: split strict line-numbered UTF-8 `read` from unnumbered extracted `view`, preserve blank lines in read snippets, and show persistent+temporary message counts in the TUI status bar.
355
356
  - `0.1.10`: persist only backend compaction summaries, drop bulky compacted conversation payloads, and refresh context status after compaction.
356
357
  - `0.1.9`: maintenance packaging release after validating the local execution environment and deploy workflow.
357
358
  - `0.1.8`: scope TUI replay/SSE catch-up to the active session and make bash/python subprocesses inherit the project Python environment, including service-launched agents.
@@ -1154,6 +1154,14 @@ class Agent:
1154
1154
  visible_history_count = len(truncated_others[max(0, history_start):])
1155
1155
  sent_session_messages = visible_history_count + len(images)
1156
1156
  total_session_messages = len(self.session.messages) if self.session else 0
1157
+ persistent_session_messages = sum(
1158
+ 1 for msg in session_messages
1159
+ if msg.get("turns_left", -1) == -1
1160
+ )
1161
+ temporary_session_messages = sum(
1162
+ 1 for msg in session_messages
1163
+ if isinstance(msg.get("turns_left", -1), int) and msg.get("turns_left", -1) > 0
1164
+ )
1157
1165
  percent = (current_count / input_limit * 100) if input_limit else 0
1158
1166
  status = modict(
1159
1167
  used_tokens=current_count,
@@ -1167,6 +1175,8 @@ class Agent:
1167
1175
  sent_session_messages=sent_session_messages,
1168
1176
  total_session_messages=total_session_messages,
1169
1177
  sent_session_message_percent=(sent_session_messages / total_session_messages * 100) if total_session_messages else 0,
1178
+ persistent_session_messages=persistent_session_messages,
1179
+ temporary_session_messages=temporary_session_messages,
1170
1180
  context_session_messages=len(session_messages),
1171
1181
  image_messages=len(images),
1172
1182
  pending_messages=len(self.pending),
@@ -1199,6 +1209,8 @@ class Agent:
1199
1209
  sent_session_messages=estimated.sent_session_messages,
1200
1210
  total_session_messages=estimated.total_session_messages,
1201
1211
  sent_session_message_percent=estimated.sent_session_message_percent,
1212
+ persistent_session_messages=estimated.get("persistent_session_messages", 0),
1213
+ temporary_session_messages=estimated.get("temporary_session_messages", 0),
1202
1214
  quota_5h_percent=None,
1203
1215
  quota_7d_percent=None,
1204
1216
  model=self.config.get("model") or "gpt-5.4",
@@ -1223,6 +1235,8 @@ class Agent:
1223
1235
  sent_session_messages=estimated.sent_session_messages,
1224
1236
  total_session_messages=estimated.total_session_messages,
1225
1237
  sent_session_message_percent=estimated.sent_session_message_percent,
1238
+ persistent_session_messages=estimated.get("persistent_session_messages", 0),
1239
+ temporary_session_messages=estimated.get("temporary_session_messages", 0),
1226
1240
  model=self.config.get("model") or "gpt-5.4",
1227
1241
  busy=self.busy,
1228
1242
  current_turn_id=self.current_turn_id,
@@ -1244,6 +1258,8 @@ class Agent:
1244
1258
  sent_session_messages=estimated.sent_session_messages,
1245
1259
  total_session_messages=estimated.total_session_messages,
1246
1260
  sent_session_message_percent=estimated.sent_session_message_percent,
1261
+ persistent_session_messages=estimated.get("persistent_session_messages", 0),
1262
+ temporary_session_messages=estimated.get("temporary_session_messages", 0),
1247
1263
  model=self.config.get("model") or "gpt-5.4",
1248
1264
  busy=self.busy,
1249
1265
  current_turn_id=self.current_turn_id,
@@ -1267,6 +1283,8 @@ class Agent:
1267
1283
  sent_session_messages=context_status.get("sent_session_messages", 0),
1268
1284
  total_session_messages=context_status.get("total_session_messages", 0),
1269
1285
  sent_session_message_percent=context_status.get("sent_session_message_percent", 0),
1286
+ persistent_session_messages=context_status.get("persistent_session_messages", 0),
1287
+ temporary_session_messages=context_status.get("temporary_session_messages", 0),
1270
1288
  quota_5h_percent=wham.quota_5h_percent,
1271
1289
  quota_7d_percent=wham.quota_7d_percent,
1272
1290
  model=model or self.config.get("model") or "gpt-5.4",
@@ -1383,6 +1401,14 @@ class Agent:
1383
1401
  formatted_context = list(msg.format(context=self.shell.namespace) for msg in context)
1384
1402
  total_session_messages = len(self.session.messages) if self.session else 0
1385
1403
  sent_session_messages = len(history) + len(images)
1404
+ persistent_session_messages = sum(
1405
+ 1 for msg in session_messages
1406
+ if msg.get("turns_left", -1) == -1
1407
+ )
1408
+ temporary_session_messages = sum(
1409
+ 1 for msg in session_messages
1410
+ if isinstance(msg.get("turns_left", -1), int) and msg.get("turns_left", -1) > 0
1411
+ )
1386
1412
  token_count_source = "local_estimate"
1387
1413
  try:
1388
1414
  current_count = self.ai.count_input_tokens(
@@ -1398,6 +1424,8 @@ class Agent:
1398
1424
  sent_session_messages=sent_session_messages,
1399
1425
  total_session_messages=total_session_messages,
1400
1426
  sent_session_message_percent=(sent_session_messages / total_session_messages * 100) if total_session_messages else 0,
1427
+ persistent_session_messages=persistent_session_messages,
1428
+ temporary_session_messages=temporary_session_messages,
1401
1429
  token_count_source=token_count_source,
1402
1430
  )
1403
1431
  return formatted_context
@@ -7,43 +7,78 @@ from collections.abc import Mapping
7
7
  from .image import persist_image_url
8
8
  from .memory import format_memory_results_xml
9
9
  from .tool import ImageGenerationTool, WebSearchTool, get_agent, tool
10
- from .utils import read_document_content
10
+ from .utils import read_document_content, view_document_content
11
11
 
12
12
  web_search = WebSearchTool()
13
13
  image_generation = ImageGenerationTool()
14
14
 
15
15
 
16
16
  @tool
17
- def read(source, start_at_line: int = 1):
17
+ def read(source: str, start_at_line: int = 1, pattern: str = None, regex: bool = True):
18
18
  """
19
19
  description: |
20
- Read and extract text content from various sources including folders, files, urls or python variables / objects.
21
- This tool reads exactly one source per call. To read multiple sources at once, issue multiple `read` tool calls in parallel.
22
- For folders: returns a tree view of the folder structure.
23
- For files: returns the text content. PDF, DOCX, XLSX, PPTX, ODT, HTML, TXT, and more are supported.
24
- For urls: returns the text content of the web page (or of the remote file if the url points to a file).
25
- For python variables / objects: introspects the object or gives a str repr.
26
-
27
- For large outputs: The output will be automatically truncated to fit token limits. When truncated, you'll see a message at the end of content indicating the truncated line range to know from where to resume reading.
20
+ Strictly read a local UTF-8 text file as source-of-truth content.
21
+ This tool always prefixes returned content with reliable source line numbers.
22
+ It does not extract document formats, fetch URLs, inspect Python objects, or render directory trees.
23
+ Use `view` for folders, URLs, PDFs, DOCX, ODT, HTML, XLSX, and other extracted or transformed content.
24
+
25
+ For large outputs: the output is automatically truncated. When truncated, a message indicates the omitted line range.
26
+ When `pattern` is provided, it is treated as a Python regex by default and all matches are returned as separated numbered snippets with up to 10 lines before and after each match.
28
27
  parameters:
29
28
  source:
30
29
  description: |
31
- Document source - can be either:
32
- - Absolute path to a local folder or file (e.g., "/home/user/document.pdf")
33
- - URL (e.g., "https://example.com/report.pdf")
34
- - any other data structure, or python variable living in the shell's namespace
35
- Pass only one source. Use parallel `read` calls for multiple sources.
30
+ Local UTF-8 text file path only. For directories, URLs, rich documents, or Python objects, use `view` instead.
31
+ type: string
36
32
  start_at_line:
37
33
  description: |
38
34
  Line number to start reading from (1-indexed). Default is 1 (start from beginning).
39
- When reading large documents in chunks, set this to continue from where you left off.
35
+ When reading large files in chunks, set this to continue from where you left off.
40
36
  type: integer
37
+ pattern:
38
+ description: |
39
+ Optional search pattern. Treated as a Python regular expression by default.
40
+ Matching results are returned as separated snippets with reliable source line numbers.
41
+ type: string
42
+ regex:
43
+ description: |
44
+ Whether to treat `pattern` as a Python regular expression. Default true. Set false for literal substring search.
45
+ type: boolean
46
+ required:
47
+ - source
48
+ """
49
+ agent = get_agent()
50
+ max_tokens = agent.config.get('max_input_tokens', 8000)
51
+ return read_document_content(source, start_at_line=start_at_line, max_tokens=max_tokens, pattern=pattern, regex=regex)
52
+
53
+
54
+ @tool
55
+ def view(source, start_at_line: int = 1, search_query: str = None):
56
+ """
57
+ description: |
58
+ View/extract text content from varied sources using the document extraction pipeline.
59
+ Use this for folders, URLs, PDFs, DOCX, ODT, HTML, XLSX, archives, and Python objects.
60
+ This is a convenience extraction view, not a strict source-of-truth file reader; use `read` for local UTF-8 files that must be read faithfully before editing.
61
+
62
+ `view` never prefixes line numbers, including search result snippets, because extracted line structure is not reliable.
63
+ For large outputs: the output is automatically truncated.
64
+ parameters:
65
+ source:
66
+ description: |
67
+ Source to view/extract: local folder or file, URL, or Python object/variable.
68
+ start_at_line:
69
+ description: |
70
+ Line number to start viewing from (1-indexed). Pagination only; line numbers are not rendered.
71
+ type: integer
72
+ search_query:
73
+ description: |
74
+ Optional literal text query for simple search within the extracted content. Results are returned as unnumbered snippets.
75
+ type: string
41
76
  required:
42
77
  - source
43
78
  """
44
79
  agent = get_agent()
45
80
  max_tokens = agent.config.get('max_input_tokens', 8000)
46
- return read_document_content(source, start_at_line=start_at_line, max_tokens=max_tokens)
81
+ return view_document_content(source, start_at_line=start_at_line, max_tokens=max_tokens, search_query=search_query)
47
82
 
48
83
 
49
84
  @tool(tool_type="custom")
@@ -390,14 +390,17 @@ class Chat:
390
390
  quota_5h = cls._format_percent(status.get("quota_5h_percent"))
391
391
  quota_7d = cls._format_percent(status.get("quota_7d_percent"))
392
392
  sent_messages = int(status.get("sent_session_messages") or 0)
393
+ persistent_messages = status.get("persistent_session_messages")
394
+ temporary_messages = status.get("temporary_session_messages")
393
395
  total_messages = int(status.get("total_session_messages") or 0)
394
396
  model = status.get("model") or "-"
397
+ message_segments = cls._message_status_segments(sent_messages, persistent_messages, temporary_messages, total_messages)
395
398
  return Text.assemble(
396
399
  (" ctx ", "bold #c9d1d9 on #161b22"),
397
400
  (f"{used}/{limit} ktok ", "#8b949e on #161b22"),
398
401
  (f"{percent:.1f}%", f"{context_style} on #161b22"),
399
402
  (" msg ", "#6e7681 on #161b22"),
400
- (f"{sent_messages}/{total_messages}", "#c9d1d9 on #161b22"),
403
+ *message_segments,
401
404
  (" 5h ", "#6e7681 on #161b22"),
402
405
  (quota_5h, f"{cls._percent_style(status.get('quota_5h_percent'))} on #161b22"),
403
406
  (" 7d ", "#6e7681 on #161b22"),
@@ -406,6 +409,21 @@ class Chat:
406
409
  (str(model), "#c9d1d9 on #161b22"),
407
410
  )
408
411
 
412
+ @staticmethod
413
+ def _message_status_segments(sent_messages, persistent_messages, temporary_messages, total_messages):
414
+ if persistent_messages is None and temporary_messages is None:
415
+ return ((f"{sent_messages}/{total_messages}", "#c9d1d9 on #161b22"),)
416
+ try:
417
+ persistent = int(persistent_messages or 0)
418
+ temporary = int(temporary_messages or 0)
419
+ except Exception:
420
+ return ((f"{sent_messages}/{total_messages}", "#c9d1d9 on #161b22"),)
421
+ return (
422
+ (str(persistent), "#c9d1d9 on #161b22"),
423
+ (f"+{temporary}", "italic #f0883e on #161b22"),
424
+ (f"/{total_messages}", "#c9d1d9 on #161b22"),
425
+ )
426
+
409
427
  @staticmethod
410
428
  def _format_ktok(value):
411
429
  try:
@@ -29,16 +29,17 @@ These tools are intended as general means for you to act on the user's local env
29
29
  Code runs with user priviledges on his local system, use discernment before running code that could compromise sensitive data or system integrity.
30
30
 
31
31
  Other agentic tools are provided to ease your interaction with the user and content:
32
- - `read` : to read folders, various file types, or webpages
33
- - `edit`: to edit files
34
- - `write`: to write/overwrite wholes files
32
+ - `read` : to read line-numbered local utf-8 files precisely (with optional pattern search in the content)
33
+ - `edit`: to make targeted edits to local utf-8 files.
34
+ - `write`: to write/overwrite wholes files (use with caution)
35
+ - `view`: to quickly access the content of folders, various file types (PDF, ODF, XLSX, HTML, ...), webpages, or python objects - when used programmatically. Works on most content types (always yields a result, even mediocre).
35
36
  - `observe` : to use your AI vision capabilities on image files or urls
36
37
  - `show`: to show any kind of content to the user by opening it with the default system backend
37
38
  + Any other custom tools loaded for the current session
38
39
 
39
40
  Always use these specialized tools over generic bash or python calls when relevant for a task as they are optimized for agentic feedback and efficiency
40
41
  Don't use `bash` or `python` to perform targeted edits via scripts (unless automation makes sense) : always use the edit tool.
41
-
42
+ Never edit blindly/guessing, always use `read` first to know what to replace before you attempt to make changes to files.
42
43
 
43
44
  # Agentic Work Loop
44
45
 
@@ -79,15 +79,53 @@ def text_content(file):
79
79
  else:
80
80
  return None
81
81
 
82
- def add_line_numbers(text: str) -> str:
82
+ def _number_lines(lines: list[str], start_line: int = 1, total_lines: int | None = None) -> str:
83
+ last_line = (start_line + len(lines) - 1) if total_lines is None else max(total_lines, start_line + len(lines) - 1)
84
+ width = len(str(max(1, last_line)))
85
+ return "\n".join(
86
+ f"{i:0{width}d}|{line}"
87
+ for i, line in enumerate(lines, start=start_line)
88
+ )
89
+
90
+
91
+ def add_line_numbers(text: str, start_line: int = 1, total_lines: int | None = None) -> str:
92
+ return _number_lines(text.splitlines(), start_line=start_line, total_lines=total_lines)
93
+
94
+
95
+ def pattern_snippets(text: str, pattern, context_lines: int = 10, regex_pattern: bool = True) -> str:
96
+ pattern_text = str(pattern)
83
97
  lines = text.splitlines()
84
- n = len(lines)
85
- width = len(str(n))
86
-
87
- lines_num = [f"{i:0{width}d}|{line}"
88
- for i, line in enumerate(lines, start=1)]
89
- return "\n".join(lines_num)
90
-
98
+ total_lines = len(lines)
99
+ if regex_pattern:
100
+ try:
101
+ compiled_pattern = re.compile(pattern_text)
102
+ except re.error as exc:
103
+ raise ValueError(f"Invalid regex pattern {pattern_text!r}: {exc}") from exc
104
+ matches = [index for index, line in enumerate(lines, start=1) if compiled_pattern.search(line)]
105
+ else:
106
+ matches = [index for index, line in enumerate(lines, start=1) if pattern_text in line]
107
+ if not matches:
108
+ return _number_lines(lines, start_line=1, total_lines=total_lines)
109
+
110
+ context_lines = max(0, int(context_lines or 0))
111
+ ranges = []
112
+ for match_line in matches:
113
+ start = max(1, match_line - context_lines)
114
+ end = min(total_lines, match_line + context_lines)
115
+ if ranges and start <= ranges[-1][1] + 1:
116
+ ranges[-1] = (ranges[-1][0], max(ranges[-1][1], end))
117
+ else:
118
+ ranges.append((start, end))
119
+
120
+ parts = []
121
+ for number, (start, end) in enumerate(ranges, start=1):
122
+ snippet_lines = lines[start - 1:end]
123
+ parts.append(
124
+ f"--- match snippet {number}/{len(ranges)}: lines {start}-{end} ---\n"
125
+ + _number_lines(snippet_lines, start_line=start, total_lines=total_lines)
126
+ )
127
+ return "\n\n".join(parts)
128
+
91
129
  def short_id(length=8):
92
130
  return "".join(random.choices(string.ascii_letters,k=length))
93
131
 
@@ -162,19 +200,18 @@ def truncate(string, max_tokens=2000, start_line=1, model=None):
162
200
  Returns:
163
201
  Truncated string with indication of removed content
164
202
  """
165
- # Quick check - if already under limit, return as-is
166
- tokens = tokenize(string, model=model)
167
- if len(tokens) <= max_tokens:
168
- return string
169
-
170
- # line-based truncation
203
+ # line-based pagination
171
204
  lines = string.splitlines(keepends=True)
172
-
173
- # Skip to start_line if needed
174
205
  if start_line > 1:
175
206
  if start_line > len(lines):
176
207
  return ""
177
- lines = lines[start_line - 1:]
208
+ string = ''.join(lines[start_line - 1:])
209
+ lines = string.splitlines(keepends=True)
210
+
211
+ # Quick check - if already under limit after pagination, return as-is
212
+ tokens = tokenize(string, model=model)
213
+ if len(tokens) <= max_tokens:
214
+ return string
178
215
 
179
216
  # Calculate the actual last line number after skipping
180
217
  remaining_lines = len(lines)
@@ -206,7 +243,7 @@ def truncate(string, max_tokens=2000, start_line=1, model=None):
206
243
 
207
244
  return result
208
245
  else:
209
- return f"[Lines {start_line}-{start_line + remaining_lines - 1} truncated - first lineexceeds token limit]\n"
246
+ return f"[Lines {start_line}-{start_line + remaining_lines - 1} truncated - first line exceeds token limit]\n"
210
247
 
211
248
  def pack_msgs(messages):
212
249
  text = ''
@@ -387,24 +424,80 @@ class Logger:
387
424
  with open(file,'w',encoding="utf-8") as f:
388
425
  f.write(str(content)+'\n')
389
426
 
390
- def read_document_content(source, start_at_line=1, max_tokens=8000):
391
- """
392
- Read and extract text content from various document formats.
427
+ def _render_numbered_read_text(text: str, start_at_line=1, max_tokens=8000, pattern=None, pattern_context_lines=10, regex=True):
428
+ effective_start_at_line = int(start_at_line or 1)
429
+ total_lines = len(text.splitlines())
430
+ if pattern:
431
+ text = pattern_snippets(text, pattern, context_lines=pattern_context_lines, regex_pattern=regex)
432
+ if max_tokens > 0:
433
+ text = truncate(text, max_tokens=max_tokens, start_line=1)
434
+ return text
435
+ # Apply pagination and truncation - truncate function handles smart chunking internally
436
+ if max_tokens > 0:
437
+ text = truncate(text, max_tokens=max_tokens, start_line=effective_start_at_line)
438
+ elif effective_start_at_line > 1:
439
+ lines = text.splitlines(keepends=True)
440
+ text = "" if effective_start_at_line > len(lines) else ''.join(lines[effective_start_at_line - 1:])
441
+ return add_line_numbers(text, start_line=effective_start_at_line, total_lines=total_lines)
393
442
 
394
- Args:
395
- source: Document source - either a local file path or URL (http/https)
396
- start_at_line: Line number to start reading from (1-indexed)
397
- max_tokens: Maximum tokens for truncation (0 to disable)
398
443
 
399
- Returns:
400
- Extracted text content, possibly truncated
401
- """
402
- get_text = import_module(".get_text.get_text", __package__).get_text
444
+ def _view_search_snippets(text: str, search_query, context_lines: int = 10) -> str:
445
+ query = str(search_query)
446
+ lines = text.splitlines()
447
+ matches = [index for index, line in enumerate(lines, start=1) if query in line]
448
+ if not matches:
449
+ return f"No results for search_query: {query!r}"
450
+
451
+ context_lines = max(0, int(context_lines or 0))
452
+ ranges = []
453
+ for match_line in matches:
454
+ start = max(1, match_line - context_lines)
455
+ end = min(len(lines), match_line + context_lines)
456
+ if ranges and start <= ranges[-1][1] + 1:
457
+ ranges[-1] = (ranges[-1][0], max(ranges[-1][1], end))
458
+ else:
459
+ ranges.append((start, end))
460
+
461
+ parts = []
462
+ for number, (start, end) in enumerate(ranges, start=1):
463
+ snippet = "\n".join(lines[start - 1:end])
464
+ parts.append(f"--- search result {number}/{len(ranges)} ---\n{snippet}")
465
+ return "\n\n".join(parts)
466
+
467
+
468
+ def read_document_content(source, start_at_line=1, max_tokens=8000, pattern=None, pattern_context_lines=10, regex=True):
469
+ """Strictly read a local UTF-8 text file without extraction, URL fetching, or directory traversal."""
470
+ if not isinstance(source, (str, os.PathLike)):
471
+ raise TypeError("read only accepts a local file path. Use view for objects or extracted document content.")
472
+ path = os.fspath(source)
473
+ if path.startswith(("http://", "https://")):
474
+ raise ValueError("read only accepts local UTF-8 text files. Use view for URLs.")
475
+ if os.path.isdir(path):
476
+ raise ValueError("read only accepts local UTF-8 text files. Use view for directories.")
477
+ if not os.path.isfile(path):
478
+ raise FileNotFoundError(f"File not found: {path}")
479
+ with open(path, "rb") as f:
480
+ data = f.read()
481
+ try:
482
+ text = data.decode("utf-8")
483
+ except UnicodeDecodeError as exc:
484
+ raise UnicodeDecodeError(exc.encoding, exc.object, exc.start, exc.end, f"{exc.reason}. read requires UTF-8 text files; use view for extracted content.") from exc
485
+ return _render_numbered_read_text(text, start_at_line=start_at_line, max_tokens=max_tokens, pattern=pattern, pattern_context_lines=pattern_context_lines, regex=regex)
403
486
 
404
- text=get_text(source)
405
487
 
406
- # Apply truncation - truncate function handles smart chunking internally
488
+ def view_document_content(source, start_at_line=1, max_tokens=8000, search_query=None, search_context_lines=10):
489
+ """Extract text from varied sources via get_text. This view is not line-structure reliable."""
490
+ get_text = import_module(".get_text.get_text", __package__).get_text
491
+ text = get_text(source)
492
+ effective_start_at_line = int(start_at_line or 1)
493
+ if search_query:
494
+ text = _view_search_snippets(text, search_query, context_lines=search_context_lines)
495
+ if max_tokens > 0:
496
+ text = truncate(text, max_tokens=max_tokens, start_line=1)
497
+ return text
407
498
  if max_tokens > 0:
408
- text = truncate(text, max_tokens=max_tokens, start_line=start_at_line)
409
-
499
+ return truncate(text, max_tokens=max_tokens, start_line=effective_start_at_line)
500
+ if effective_start_at_line > 1:
501
+ lines = text.splitlines(keepends=True)
502
+ return "" if effective_start_at_line > len(lines) else ''.join(lines[effective_start_at_line - 1:])
410
503
  return text
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codex-agent-framework
3
- Version: 0.1.10
3
+ Version: 0.1.11
4
4
  Summary: A lightweight event-driven Codex agent runtime.
5
5
  Author: Baptiste
6
6
  License-Expression: MIT
@@ -400,6 +400,7 @@ python -m build
400
400
  The distribution includes prompt text files and `codex_agent/get_text/default_gitignore` through package data and `MANIFEST.in`.
401
401
 
402
402
  ## Recent changes
403
+ - `0.1.11`: split strict line-numbered UTF-8 `read` from unnumbered extracted `view`, preserve blank lines in read snippets, and show persistent+temporary message counts in the TUI status bar.
403
404
  - `0.1.10`: persist only backend compaction summaries, drop bulky compacted conversation payloads, and refresh context status after compaction.
404
405
  - `0.1.9`: maintenance packaging release after validating the local execution environment and deploy workflow.
405
406
  - `0.1.8`: scope TUI replay/SSE catch-up to the active session and make bash/python subprocesses inherit the project Python environment, including service-launched agents.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "codex-agent-framework"
7
- version = "0.1.10"
7
+ version = "0.1.11"
8
8
  description = "A lightweight event-driven Codex agent runtime."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -671,6 +671,48 @@ def test_builtin_read_tool_survives_get_text_submodule_shadowing():
671
671
  assert "def read(" in agent.pending[-1].content
672
672
 
673
673
 
674
+ def test_builtin_read_tool_accepts_pattern_and_includes_context(tmp_path):
675
+ file = tmp_path / "sample.txt"
676
+ lines = [f"line {index}" for index in range(1, 31)]
677
+ lines[20] = "line 21 needle"
678
+ file.write_text("\n".join(lines), encoding="utf-8")
679
+
680
+ agent = Agent(session="new", voice_enabled=False)
681
+ message = AssistantMessage()
682
+ message.tool_calls = [{
683
+ "type": "function_call",
684
+ "call_id": "call_1",
685
+ "name": "read",
686
+ "arguments": f'{{"source": "{file}", "pattern": "needle"}}',
687
+ }]
688
+
689
+ assert agent.call_tools(message) is True
690
+ assert isinstance(agent.pending[-1], ToolResultWrapper)
691
+ assert "11|line 11" in agent.pending[-1].content
692
+ assert "21|line 21 needle" in agent.pending[-1].content
693
+ assert "10|line 10" not in agent.pending[-1].content
694
+
695
+
696
+ def test_builtin_read_tool_accepts_regex_pattern(tmp_path):
697
+ file = tmp_path / "sample.txt"
698
+ lines = ["alpha", "ticket ABC-123", *[f"line {index}" for index in range(3, 25)], "ticket XYZ-123"]
699
+ file.write_text("\n".join(lines), encoding="utf-8")
700
+
701
+ agent = Agent(session="new", voice_enabled=False)
702
+ message = AssistantMessage()
703
+ message.tool_calls = [{
704
+ "type": "function_call",
705
+ "call_id": "call_1",
706
+ "name": "read",
707
+ "arguments": f'{{"source": "{file}", "pattern": "ABC-\\\\d+", "regex": true}}',
708
+ }]
709
+
710
+ assert agent.call_tools(message) is True
711
+ assert isinstance(agent.pending[-1], ToolResultWrapper)
712
+ assert "2|ticket ABC-123" in agent.pending[-1].content
713
+ assert "3|ticket XYZ-123" not in agent.pending[-1].content
714
+
715
+
674
716
  def test_agent_applies_image_generation_tool_config():
675
717
  agent = Agent(
676
718
  session="new",
@@ -40,6 +40,48 @@ def test_agent_context_status_counts_only_effective_context_messages():
40
40
  assert status.context_session_messages == 2
41
41
  assert status.total_history_messages == 2
42
42
  assert status.sent_session_messages == 2
43
+ assert status.persistent_session_messages == 2
44
+ assert status.temporary_session_messages == 0
45
+
46
+
47
+ def test_agent_context_status_counts_persistent_and_temporary_messages():
48
+ agent = Agent(session="new", voice_enabled=False, input_token_limit=10_000)
49
+ compaction = CompactionMessage(output_items=[{"type": "compaction_summary", "id": "cs_123"}])
50
+ persistent = DeveloperMessage(content="persistent")
51
+ temporary = DeveloperMessage(content="temporary", turns_left=2)
52
+ expired = DeveloperMessage(content="expired", turns_left=0)
53
+ agent.session.messages = [compaction, persistent, temporary, expired]
54
+
55
+ status = agent.context_status()
56
+
57
+ assert status.context_session_messages == 3
58
+ assert status.persistent_session_messages == 2
59
+ assert status.temporary_session_messages == 1
60
+
61
+
62
+ def test_agent_status_preserves_persistent_and_temporary_message_counts():
63
+ agent = Agent(session="new", voice_enabled=False, input_token_limit=10_000)
64
+ agent.session.messages.append(DeveloperMessage(content="persistent"))
65
+ agent.session.messages.append(DeveloperMessage(content="temporary", turns_left=2))
66
+
67
+ status = agent.get_status()
68
+
69
+ assert status.persistent_session_messages == 2
70
+ assert status.temporary_session_messages == 1
71
+
72
+
73
+ def test_agent_api_status_uses_precached_persistent_and_temporary_counts(monkeypatch):
74
+ agent = Agent(session="new", voice_enabled=False, input_token_limit=10_000)
75
+ agent.session.messages.append(DeveloperMessage(content="persistent"))
76
+ agent.session.messages.append(DeveloperMessage(content="temporary", turns_left=2))
77
+
78
+ agent.get_context()
79
+ monkeypatch.setattr(agent, "context_status", lambda: (_ for _ in ()).throw(AssertionError("should use cache")))
80
+ agent.cache_api_status(usage=modict(input_tokens=1000), model="gpt-test")
81
+
82
+ status = agent.get_status()
83
+ assert status.persistent_session_messages == 2
84
+ assert status.temporary_session_messages == 1
43
85
 
44
86
 
45
87
  def test_agent_context_status_counts_response_tool_specs():
@@ -185,6 +227,30 @@ def test_chat_status_bar_formats_compact_status():
185
227
  assert any("#161b22" in str(span.style) for span in rendered.spans)
186
228
 
187
229
 
230
+ def test_chat_status_bar_formats_persistent_plus_temporary_messages():
231
+ status = modict(
232
+ context_current_tokens=12_300,
233
+ context_limit_tokens=128_000,
234
+ context_percent=9.609,
235
+ sent_session_messages=43,
236
+ persistent_session_messages=33,
237
+ temporary_session_messages=10,
238
+ total_session_messages=93,
239
+ quota_5h_percent=42,
240
+ quota_7d_percent=13,
241
+ model="gpt-5.4",
242
+ )
243
+
244
+ rendered = Chat.format_status(status)
245
+ output = rendered.plain
246
+
247
+ assert "msg 33+10/93" in output
248
+ assert any(
249
+ "#f0883e" in str(span.style) and output[span.start:span.end] == "+10"
250
+ for span in rendered.spans
251
+ )
252
+
253
+
188
254
  def test_chat_refresh_status_keeps_rich_text_styles():
189
255
  chat = Chat(client=modict(
190
256
  get_status=lambda: modict(
@@ -1,5 +1,6 @@
1
1
  from io import BytesIO
2
2
 
3
+ import pytest
3
4
  from PIL import Image as PILImage
4
5
 
5
6
  from codex_agent.message import AssistantMessage, DeveloperMessage, ServerToolResponseMessage, ToolResultMessage, UserMessage
@@ -35,6 +36,114 @@ def test_project_python_env_prefers_running_interpreter():
35
36
  assert env["PYTHON_EXECUTABLE"] == "/opt/project-python/bin/python"
36
37
 
37
38
 
39
+ def test_read_document_content_adds_line_numbers_by_default(tmp_path):
40
+ file = tmp_path / "sample.txt"
41
+ file.write_text("alpha\nbeta\ngamma", encoding="utf-8")
42
+
43
+ content = utils.read_document_content(str(file), max_tokens=10_000)
44
+
45
+ assert content == "1|alpha\n2|beta\n3|gamma"
46
+
47
+
48
+ def test_read_document_content_pattern_returns_all_numbered_snippets(tmp_path):
49
+ file = tmp_path / "sample.txt"
50
+ lines = [f"line {index}" for index in range(1, 61)]
51
+ lines[20] = "line 21 MATCH here"
52
+ lines[49] = "line 50 MATCH again"
53
+ file.write_text("\n".join(lines), encoding="utf-8")
54
+
55
+ content = utils.read_document_content(str(file), pattern="MATCH", max_tokens=10_000)
56
+
57
+ assert "--- match snippet 1/2: lines 11-31 ---" in content
58
+ assert "11|line 11" in content
59
+ assert "21|line 21 MATCH here" in content
60
+ assert "31|line 31" in content
61
+ assert "10|line 10" not in content
62
+ assert "--- match snippet 2/2: lines 40-60 ---" in content
63
+ assert "50|line 50 MATCH again" in content
64
+
65
+
66
+ def test_read_document_content_pattern_context_is_clamped_to_file_start(tmp_path):
67
+ file = tmp_path / "sample.txt"
68
+ lines = ["line 1", "line 2 MATCH", "line 3"]
69
+ file.write_text("\n".join(lines), encoding="utf-8")
70
+
71
+ content = utils.read_document_content(str(file), pattern="MATCH", max_tokens=10_000)
72
+
73
+ assert content.startswith("--- match snippet 1/1: lines 1-3 ---\n1|line 1")
74
+ assert "2|line 2 MATCH" in content
75
+
76
+
77
+ def test_read_document_content_pattern_is_regex_by_default(tmp_path):
78
+ file = tmp_path / "sample.txt"
79
+ lines = ["alpha", "ticket ABC-123", "ticket ABC-999", "ticket XYZ-123"]
80
+ file.write_text("\n".join(lines), encoding="utf-8")
81
+
82
+ content = utils.read_document_content(str(file), pattern=r"ABC-\d+", pattern_context_lines=0, max_tokens=10_000)
83
+
84
+ assert "2|ticket ABC-123" in content
85
+ assert "3|ticket ABC-999" in content
86
+ assert "4|ticket XYZ-123" not in content
87
+
88
+
89
+ def test_read_document_content_can_treat_pattern_as_literal(tmp_path):
90
+ file = tmp_path / "sample.txt"
91
+ lines = ["ticket ABC-123", r"literal ABC-\d+ pattern"]
92
+ file.write_text("\n".join(lines), encoding="utf-8")
93
+
94
+ content = utils.read_document_content(str(file), pattern=r"ABC-\d+", regex=False, pattern_context_lines=0, max_tokens=10_000)
95
+
96
+ assert "1|ticket ABC-123" not in content
97
+ assert r"2|literal ABC-\d+ pattern" in content
98
+
99
+
100
+ def test_read_document_content_preserves_blank_lines_and_real_line_numbers(tmp_path):
101
+ file = tmp_path / "sample.py"
102
+ file.write_text("one\n\n\n \nfive", encoding="utf-8")
103
+
104
+ content = utils.read_document_content(str(file), max_tokens=10_000)
105
+
106
+ assert content == "1|one\n2|\n3|\n4| \n5|five"
107
+
108
+
109
+ def test_read_document_content_pattern_preserves_blank_lines_in_snippet_context(tmp_path):
110
+ file = tmp_path / "sample.py"
111
+ file.write_text("one\n\n\nMATCH\nfive", encoding="utf-8")
112
+
113
+ content = utils.read_document_content(str(file), pattern="MATCH", pattern_context_lines=3, max_tokens=10_000)
114
+
115
+ assert content == "--- match snippet 1/1: lines 1-5 ---\n1|one\n2|\n3|\n4|MATCH\n5|five"
116
+
117
+
118
+ def test_read_document_content_rejects_directories_and_urls(tmp_path):
119
+ with pytest.raises(ValueError, match="Use view for directories"):
120
+ utils.read_document_content(str(tmp_path), max_tokens=10_000)
121
+
122
+ with pytest.raises(ValueError, match="Use view for URLs"):
123
+ utils.read_document_content("https://example.com", max_tokens=10_000)
124
+
125
+
126
+ def test_view_document_content_keeps_extraction_pipeline_for_directories_without_line_numbers(tmp_path):
127
+ file = tmp_path / "sample.txt"
128
+ file.write_text("hello", encoding="utf-8")
129
+
130
+ content = utils.view_document_content(str(tmp_path), max_tokens=10_000)
131
+
132
+ assert "sample.txt" in content
133
+ assert "1|" not in content
134
+
135
+
136
+ def test_view_document_content_search_query_returns_unnumbered_snippets(tmp_path):
137
+ file = tmp_path / "sample.txt"
138
+ file.write_text("alpha\nbeta needle\ngamma", encoding="utf-8")
139
+
140
+ content = utils.view_document_content(str(file), search_query="needle", max_tokens=10_000)
141
+
142
+ assert content.startswith("--- search result 1/1 ---\n")
143
+ assert "beta needle" in content
144
+ assert "2|" not in content
145
+
146
+
38
147
  def test_split_history_turns_keeps_current_unfinished_turn_as_remainder():
39
148
  complete_user = UserMessage(content="first")
40
149
  complete_assistant = AssistantMessage(content="done")