pcli-agent 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. pcli/__init__.py +1 -0
  2. pcli/__main__.py +4 -0
  3. pcli/agent/__init__.py +0 -0
  4. pcli/agent/activity.py +116 -0
  5. pcli/agent/compaction.py +205 -0
  6. pcli/agent/context_pruning.py +88 -0
  7. pcli/agent/headless.py +209 -0
  8. pcli/agent/loop.py +442 -0
  9. pcli/agent/prompt.py +371 -0
  10. pcli/agent/runtime.py +240 -0
  11. pcli/browser/__init__.py +0 -0
  12. pcli/browser/session.py +135 -0
  13. pcli/cli.py +757 -0
  14. pcli/config/__init__.py +0 -0
  15. pcli/config/paths.py +95 -0
  16. pcli/config/settings.py +435 -0
  17. pcli/cost/__init__.py +0 -0
  18. pcli/cost/context.py +275 -0
  19. pcli/cost/context_detect.py +183 -0
  20. pcli/cost/pricing_table.py +141 -0
  21. pcli/cost/tracker.py +126 -0
  22. pcli/llm/__init__.py +0 -0
  23. pcli/llm/client.py +285 -0
  24. pcli/llm/errors.py +37 -0
  25. pcli/llm/models.py +100 -0
  26. pcli/llm/streaming.py +108 -0
  27. pcli/memory/__init__.py +0 -0
  28. pcli/memory/extraction.py +106 -0
  29. pcli/memory/models.py +103 -0
  30. pcli/memory/store.py +88 -0
  31. pcli/permissions/__init__.py +0 -0
  32. pcli/permissions/guardrails.py +219 -0
  33. pcli/permissions/manager.py +215 -0
  34. pcli/permissions/policy.py +70 -0
  35. pcli/sandbox/__init__.py +0 -0
  36. pcli/sandbox/base.py +50 -0
  37. pcli/sandbox/docker_backend.py +107 -0
  38. pcli/sandbox/limits.py +63 -0
  39. pcli/sandbox/null_backend.py +92 -0
  40. pcli/sandbox/selector.py +75 -0
  41. pcli/sandbox/subprocess_backend.py +376 -0
  42. pcli/scheduler/__init__.py +0 -0
  43. pcli/scheduler/daemon.py +194 -0
  44. pcli/scheduler/models.py +97 -0
  45. pcli/scheduler/runner.py +84 -0
  46. pcli/scheduler/store.py +75 -0
  47. pcli/scheduler/triggers.py +84 -0
  48. pcli/session/__init__.py +0 -0
  49. pcli/session/audit.py +122 -0
  50. pcli/session/directory_check.py +28 -0
  51. pcli/session/export.py +57 -0
  52. pcli/session/importer.py +92 -0
  53. pcli/session/models.py +168 -0
  54. pcli/session/store.py +127 -0
  55. pcli/telegram/__init__.py +0 -0
  56. pcli/telegram/bot.py +266 -0
  57. pcli/telegram/daemon.py +1197 -0
  58. pcli/telegram/permissions.py +131 -0
  59. pcli/telegram/sender.py +58 -0
  60. pcli/tools/__init__.py +0 -0
  61. pcli/tools/_nested_agent.py +204 -0
  62. pcli/tools/agent_tools.py +264 -0
  63. pcli/tools/agent_tools_store.py +69 -0
  64. pcli/tools/artifacts.py +47 -0
  65. pcli/tools/base.py +185 -0
  66. pcli/tools/builtin/__init__.py +0 -0
  67. pcli/tools/builtin/agent_tool_register_tool.py +100 -0
  68. pcli/tools/builtin/artifact_tool.py +212 -0
  69. pcli/tools/builtin/ask_tool.py +77 -0
  70. pcli/tools/builtin/browser_tool.py +253 -0
  71. pcli/tools/builtin/decision_tool.py +73 -0
  72. pcli/tools/builtin/describe_tool.py +389 -0
  73. pcli/tools/builtin/diff_tools.py +225 -0
  74. pcli/tools/builtin/fs_tools.py +371 -0
  75. pcli/tools/builtin/grep_tool.py +88 -0
  76. pcli/tools/builtin/memory_tool.py +108 -0
  77. pcli/tools/builtin/network_tools.py +107 -0
  78. pcli/tools/builtin/pip_tool.py +106 -0
  79. pcli/tools/builtin/shell_tool.py +240 -0
  80. pcli/tools/builtin/subagent_tool.py +146 -0
  81. pcli/tools/builtin/todo_tool.py +122 -0
  82. pcli/tools/builtin/toolbox_register_tool.py +76 -0
  83. pcli/tools/builtin/web_tools.py +322 -0
  84. pcli/tools/pydiscovery/__init__.py +0 -0
  85. pcli/tools/pydiscovery/cache.py +51 -0
  86. pcli/tools/pydiscovery/index.py +48 -0
  87. pcli/tools/pydiscovery/invoke.py +181 -0
  88. pcli/tools/pydiscovery/search.py +117 -0
  89. pcli/tools/registry.py +138 -0
  90. pcli/tools/toolbox/__init__.py +0 -0
  91. pcli/tools/toolbox/introspect.py +48 -0
  92. pcli/tools/toolbox/manager.py +336 -0
  93. pcli/tools/toolbox/plugin_base.py +51 -0
  94. pcli/tools/toolbox/plugins/__init__.py +6 -0
  95. pcli/tools/toolbox/plugins/httpd.py +99 -0
  96. pcli/tools/toolbox/plugins/kafka.py +162 -0
  97. pcli/tools/toolbox/plugins/kubectl.py +211 -0
  98. pcli/tools/toolbox/plugins/sge.py +146 -0
  99. pcli/tools/toolbox/store.py +65 -0
  100. pcli/tools/toolbox/synthesize.py +100 -0
  101. pcli/tui/__init__.py +0 -0
  102. pcli/tui/app.py +37 -0
  103. pcli/tui/screens/__init__.py +0 -0
  104. pcli/tui/screens/ask_question_modal.py +54 -0
  105. pcli/tui/screens/chat.py +2070 -0
  106. pcli/tui/screens/confirm_modal.py +39 -0
  107. pcli/tui/screens/models.py +43 -0
  108. pcli/tui/screens/permission_modal.py +71 -0
  109. pcli/tui/screens/sessions.py +162 -0
  110. pcli/tui/screens/subagent_activity_modal.py +71 -0
  111. pcli/tui/shell_passthrough.py +56 -0
  112. pcli/tui/styles/pcli.tcss +241 -0
  113. pcli/tui/themes.py +84 -0
  114. pcli/tui/widgets/__init__.py +0 -0
  115. pcli/tui/widgets/chat_input.py +240 -0
  116. pcli/tui/widgets/command_suggestions.py +33 -0
  117. pcli/tui/widgets/message_view.py +328 -0
  118. pcli/tui/widgets/paste_input.py +99 -0
  119. pcli/tui/widgets/paste_marker.py +69 -0
  120. pcli/tui/widgets/status_bar.py +133 -0
  121. pcli/tui/widgets/status_pane.py +58 -0
  122. pcli/util/__init__.py +0 -0
  123. pcli/util/ids.py +15 -0
  124. pcli/util/logging.py +18 -0
  125. pcli/util/text.py +10 -0
  126. pcli_agent-0.1.0.dist-info/METADATA +259 -0
  127. pcli_agent-0.1.0.dist-info/RECORD +130 -0
  128. pcli_agent-0.1.0.dist-info/WHEEL +4 -0
  129. pcli_agent-0.1.0.dist-info/entry_points.txt +2 -0
  130. pcli_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,371 @@
1
+ """In-process filesystem tools (no sandbox needed — plain Python I/O), still
2
+ guardrail-gated on the `path` argument."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from pathlib import Path
7
+
8
+ from pcli.tools.base import ToolContext, ToolResult, ToolSpec
9
+
10
+ _MAX_READ_BYTES = 512_000
11
+
12
+
13
+ def resolve_path(raw_path: str, ctx: ToolContext) -> Path:
14
+ """Resolves a tool-supplied path against the working directory —
15
+ shared with network_tools.py, since download_file needs the exact
16
+ same relative-vs-absolute handling as every other path-taking tool."""
17
+ path = Path(raw_path).expanduser()
18
+ if not path.is_absolute():
19
+ path = ctx.cwd / path
20
+ return path.resolve()
21
+
22
+
23
+ def not_a_directory_result(resolved: Path) -> ToolResult:
24
+ """Shared "this path isn't usable as a directory" error for list_dir/
25
+ glob_search/grep (grep_tool.py imports this too) - distinguishes two
26
+ genuinely different situations that a flat "Not a directory" message
27
+ used to collapse into one: the path doesn't exist at all (wrong name/
28
+ location - point at how to locate it) vs. it exists but is a file (the
29
+ caller likely meant to target that file directly, not search/list a
30
+ directory - point at read_file instead)."""
31
+ if not resolved.exists():
32
+ return ToolResult(
33
+ output=f"{resolved} does not exist.\n"
34
+ "[pcli] Suggestion: check the path — list_dir its parent directory to see "
35
+ "what's actually there, or glob_search for the name if you're not sure "
36
+ "exactly where it is.",
37
+ is_error=True,
38
+ )
39
+ return ToolResult(
40
+ output=f"{resolved} is a file, not a directory.\n"
41
+ "[pcli] Suggestion: use read_file to read it directly instead.",
42
+ is_error=True,
43
+ )
44
+
45
+
46
+ async def _read_file(arguments: dict, ctx: ToolContext) -> ToolResult:
47
+ resolved = resolve_path(arguments["path"], ctx)
48
+ if not resolved.exists():
49
+ return ToolResult(
50
+ output=f"File not found: {resolved}\n"
51
+ "[pcli] Suggestion: if you're not sure of the exact path, use list_dir on its "
52
+ "parent directory or glob_search to locate it.",
53
+ is_error=True,
54
+ )
55
+ if not resolved.is_file():
56
+ return ToolResult(
57
+ output=f"Not a file: {resolved} is a directory.\n"
58
+ "[pcli] Suggestion: use list_dir to see what's inside it instead.",
59
+ is_error=True,
60
+ )
61
+ data = resolved.read_bytes()
62
+ text = data[:_MAX_READ_BYTES].decode(errors="replace")
63
+ if len(data) > _MAX_READ_BYTES:
64
+ text += "\n[...truncated...]"
65
+ return ToolResult(output=text)
66
+
67
+
68
+ READ_FILE = ToolSpec(
69
+ name="read_file",
70
+ description="Read the contents of a text file.",
71
+ parameters={
72
+ "type": "object",
73
+ "properties": {
74
+ "path": {
75
+ "type": "string",
76
+ "description": "Path to the file, relative to the working directory or absolute.",
77
+ }
78
+ },
79
+ "required": ["path"],
80
+ },
81
+ handler=_read_file,
82
+ needs_permission=False,
83
+ guardrail_path_arg="path",
84
+ plan_mode_safe=True,
85
+ read_only=True,
86
+ )
87
+
88
+
89
+ async def _write_file(arguments: dict, ctx: ToolContext) -> ToolResult:
90
+ resolved = resolve_path(arguments["path"], ctx)
91
+ content = arguments.get("content", "")
92
+ resolved.parent.mkdir(parents=True, exist_ok=True)
93
+ resolved.write_text(content, encoding="utf-8")
94
+ return ToolResult(output=f"Wrote {len(content)} bytes to {resolved}")
95
+
96
+
97
+ WRITE_FILE = ToolSpec(
98
+ name="write_file",
99
+ description="Write (overwrite) a text file with the given content, creating parent "
100
+ "directories as needed.",
101
+ parameters={
102
+ "type": "object",
103
+ "properties": {
104
+ "path": {"type": "string"},
105
+ "content": {"type": "string"},
106
+ },
107
+ "required": ["path", "content"],
108
+ },
109
+ handler=_write_file,
110
+ needs_permission=True,
111
+ risk_description="Writes/overwrites a file on disk.",
112
+ guardrail_path_arg="path",
113
+ read_only=False,
114
+ )
115
+
116
+
117
+ def _split_keepends(content: str) -> list[str]:
118
+ """splitlines(keepends=True) — used by insert/delete mode so a file's
119
+ existing line-ending style (or a missing trailing newline on the last
120
+ line) round-trips exactly, instead of normalizing to "\\n" everywhere."""
121
+ return content.splitlines(keepends=True)
122
+
123
+
124
+ async def _edit_file(arguments: dict, ctx: ToolContext) -> ToolResult:
125
+ resolved = resolve_path(arguments["path"], ctx)
126
+ if not resolved.is_file():
127
+ return ToolResult(
128
+ output=f"{resolved} doesn't exist — use write_file to create it.", is_error=True
129
+ )
130
+
131
+ old_string = arguments.get("old_string")
132
+ new_string = arguments.get("new_string")
133
+ insert_after_line = arguments.get("insert_after_line")
134
+ delete_start_line = arguments.get("delete_start_line")
135
+ delete_end_line = arguments.get("delete_end_line")
136
+
137
+ modes_given = sum(
138
+ (
139
+ old_string is not None,
140
+ insert_after_line is not None,
141
+ delete_start_line is not None or delete_end_line is not None,
142
+ )
143
+ )
144
+ if modes_given == 0:
145
+ return ToolResult(
146
+ output="Provide exactly one editing mode: old_string (+ new_string) to replace, "
147
+ "insert_after_line (+ new_string) to insert, or delete_start_line + "
148
+ "delete_end_line to delete lines.",
149
+ is_error=True,
150
+ )
151
+ if modes_given > 1:
152
+ return ToolResult(
153
+ output="Provide exactly one editing mode at a time — old_string, "
154
+ "insert_after_line, and delete_start_line/delete_end_line are mutually "
155
+ "exclusive, not combinable in one call.",
156
+ is_error=True,
157
+ )
158
+
159
+ content = resolved.read_text(encoding="utf-8")
160
+
161
+ if old_string is not None:
162
+ if new_string is None:
163
+ return ToolResult(output="new_string is required alongside old_string.", is_error=True)
164
+ if old_string == new_string:
165
+ return ToolResult(
166
+ output="old_string and new_string are identical — this call would change "
167
+ "nothing.\n[pcli] Suggestion: to insert new text without removing anything, "
168
+ "use insert_after_line + new_string instead (e.g. insert_after_line=12, "
169
+ "new_string='the new line(s) to add') — don't repeat the same anchor text as "
170
+ "both old_string and new_string expecting it to insert around it.",
171
+ is_error=True,
172
+ )
173
+ replace_all = bool(arguments.get("replace_all", False))
174
+ count = content.count(old_string)
175
+ if count == 0:
176
+ return ToolResult(
177
+ output="old_string not found in file.\n"
178
+ "[pcli] Suggestion: old_string must match the file's current content exactly, "
179
+ "including whitespace — the file may also have changed since you last saw it "
180
+ "(e.g. an earlier edit_file/write_file call already changed this part). "
181
+ "read_file to see its current content before retrying.",
182
+ is_error=True,
183
+ )
184
+ if count > 1 and not replace_all:
185
+ return ToolResult(
186
+ output=f"old_string is not unique ({count} occurrences) — include more "
187
+ "surrounding context to make it match exactly once, or pass "
188
+ "replace_all=true to replace every occurrence.",
189
+ is_error=True,
190
+ )
191
+ new_content = content.replace(old_string, new_string, -1 if replace_all else 1)
192
+ resolved.write_text(new_content, encoding="utf-8")
193
+ label = f"{count} replacement(s)" if replace_all else "1 replacement"
194
+ return ToolResult(output=f"Edited {resolved} ({label}).")
195
+
196
+ lines = _split_keepends(content)
197
+ total_lines = len(lines)
198
+
199
+ if insert_after_line is not None:
200
+ if not new_string:
201
+ return ToolResult(
202
+ output="new_string (the text to insert) is required alongside "
203
+ "insert_after_line.",
204
+ is_error=True,
205
+ )
206
+ if not isinstance(insert_after_line, int) or insert_after_line < 0 or insert_after_line > total_lines:
207
+ return ToolResult(
208
+ output=f"insert_after_line must be an integer between 0 (start of file) and "
209
+ f"{total_lines} (end of file) — the file currently has {total_lines} line(s).",
210
+ is_error=True,
211
+ )
212
+ # The line being inserted after must end with a newline, or the
213
+ # inserted text would be glued onto its end instead of starting a
214
+ # new line — only relevant for a file whose last line has no
215
+ # trailing newline, and only when inserting after that exact line.
216
+ if insert_after_line > 0 and not lines[insert_after_line - 1].endswith("\n"):
217
+ lines[insert_after_line - 1] += "\n"
218
+ insert_text = new_string if new_string.endswith("\n") else new_string + "\n"
219
+ lines.insert(insert_after_line, insert_text)
220
+ resolved.write_text("".join(lines), encoding="utf-8")
221
+ where = "the start of the file" if insert_after_line == 0 else f"line {insert_after_line}"
222
+ return ToolResult(output=f"Inserted new text after {where} in {resolved}.")
223
+
224
+ # delete mode
225
+ if delete_start_line is None or delete_end_line is None:
226
+ return ToolResult(
227
+ output="delete_start_line and delete_end_line are both required together.",
228
+ is_error=True,
229
+ )
230
+ if (
231
+ not isinstance(delete_start_line, int)
232
+ or not isinstance(delete_end_line, int)
233
+ or delete_start_line < 1
234
+ or delete_end_line < delete_start_line
235
+ or delete_end_line > total_lines
236
+ ):
237
+ return ToolResult(
238
+ output=f"Invalid line range {delete_start_line}-{delete_end_line} — the file has "
239
+ f"{total_lines} line(s); delete_start_line must be >= 1 and <= delete_end_line, "
240
+ f"and delete_end_line must be <= {total_lines}.",
241
+ is_error=True,
242
+ )
243
+ removed = delete_end_line - delete_start_line + 1
244
+ del lines[delete_start_line - 1 : delete_end_line]
245
+ resolved.write_text("".join(lines), encoding="utf-8")
246
+ return ToolResult(
247
+ output=f"Deleted line(s) {delete_start_line}-{delete_end_line} ({removed} line(s)) "
248
+ f"from {resolved}."
249
+ )
250
+
251
+
252
+ EDIT_FILE = ToolSpec(
253
+ name="edit_file",
254
+ description="Edit an existing file using exactly one of three modes. (1) Replace — "
255
+ "old_string + new_string: replaces an exact, unique block of text (old_string must match "
256
+ "the file's current content verbatim, including whitespace — read_file first if unsure; "
257
+ "include enough surrounding context to make it unambiguous, or pass replace_all=true to "
258
+ "replace every occurrence instead of requiring uniqueness). (2) Insert — "
259
+ "insert_after_line + new_string: inserts new text after a given 1-indexed line "
260
+ "(0 = start of file) without removing anything — use this to add a new section/line, "
261
+ "never the replace mode with new_string equal to old_string. (3) Delete — "
262
+ "delete_start_line + delete_end_line: removes an inclusive range of 1-indexed lines. "
263
+ "Line numbers shift after any edit — re-check with read_file or grep -n before a second "
264
+ "line-based edit to the same file. Use write_file instead for a new file or a genuine "
265
+ "full rewrite.",
266
+ parameters={
267
+ "type": "object",
268
+ "properties": {
269
+ "path": {"type": "string"},
270
+ "old_string": {
271
+ "type": "string",
272
+ "description": "Replace mode: exact text to replace, matching the file's "
273
+ "current content verbatim. Do not set this equal to new_string to try to "
274
+ "'insert' text around it — use insert_after_line for that instead.",
275
+ },
276
+ "new_string": {
277
+ "type": "string",
278
+ "description": "Replace mode: the replacement text. Insert mode: the new "
279
+ "text to insert.",
280
+ },
281
+ "replace_all": {
282
+ "type": "boolean",
283
+ "description": "Replace mode only: replace every occurrence of old_string "
284
+ "instead of requiring it to match exactly once (default false).",
285
+ },
286
+ "insert_after_line": {
287
+ "type": "integer",
288
+ "description": "Insert mode: 1-indexed line number to insert new_string "
289
+ "after (0 = insert at the very start of the file, before line 1).",
290
+ },
291
+ "delete_start_line": {
292
+ "type": "integer",
293
+ "description": "Delete mode: first 1-indexed line to delete (inclusive). "
294
+ "Required together with delete_end_line.",
295
+ },
296
+ "delete_end_line": {
297
+ "type": "integer",
298
+ "description": "Delete mode: last 1-indexed line to delete (inclusive). "
299
+ "Required together with delete_start_line.",
300
+ },
301
+ },
302
+ "required": ["path"],
303
+ },
304
+ handler=_edit_file,
305
+ needs_permission=True,
306
+ risk_description="Edits a file on disk.",
307
+ guardrail_path_arg="path",
308
+ read_only=False,
309
+ )
310
+
311
+
312
+ async def _list_dir(arguments: dict, ctx: ToolContext) -> ToolResult:
313
+ resolved = resolve_path(arguments.get("path", "."), ctx)
314
+ if not resolved.is_dir():
315
+ return not_a_directory_result(resolved)
316
+ entries = sorted(resolved.iterdir(), key=lambda p: p.name)
317
+ lines = [f"{'d' if e.is_dir() else 'f'} {e.name}" for e in entries]
318
+ return ToolResult(output="\n".join(lines) or "(empty directory)")
319
+
320
+
321
+ LIST_DIR = ToolSpec(
322
+ name="list_dir",
323
+ description="List the contents of a directory.",
324
+ parameters={
325
+ "type": "object",
326
+ "properties": {
327
+ "path": {"type": "string", "description": "Directory path (default: working directory)."}
328
+ },
329
+ },
330
+ handler=_list_dir,
331
+ needs_permission=False,
332
+ guardrail_path_arg="path",
333
+ plan_mode_safe=True,
334
+ read_only=True,
335
+ )
336
+
337
+
338
+ async def _glob_search(arguments: dict, ctx: ToolContext) -> ToolResult:
339
+ resolved_base = resolve_path(arguments.get("path", "."), ctx)
340
+ pattern = arguments["pattern"]
341
+ if not resolved_base.is_dir():
342
+ return not_a_directory_result(resolved_base)
343
+ matches = sorted(
344
+ str(p.relative_to(resolved_base)) for p in resolved_base.glob(pattern) if p.is_file()
345
+ )
346
+ if not matches:
347
+ return ToolResult(output="No matches.")
348
+ truncated = matches[:500]
349
+ text = "\n".join(truncated)
350
+ if len(matches) > 500:
351
+ text += f"\n[...{len(matches) - 500} more matches not shown...]"
352
+ return ToolResult(output=text)
353
+
354
+
355
+ GLOB_SEARCH = ToolSpec(
356
+ name="glob_search",
357
+ description="Find files under a directory matching a glob pattern, e.g. '**/*.py'.",
358
+ parameters={
359
+ "type": "object",
360
+ "properties": {
361
+ "pattern": {"type": "string"},
362
+ "path": {"type": "string", "description": "Base directory (default: working directory)."},
363
+ },
364
+ "required": ["pattern"],
365
+ },
366
+ handler=_glob_search,
367
+ needs_permission=False,
368
+ guardrail_path_arg="path",
369
+ plan_mode_safe=True,
370
+ read_only=True,
371
+ )
@@ -0,0 +1,88 @@
1
+ """In-process regex search across text files under a directory."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+
8
+ from pcli.tools.base import ToolContext, ToolResult, ToolSpec
9
+ from pcli.tools.builtin.fs_tools import not_a_directory_result
10
+
11
+ _MAX_MATCHES = 200
12
+ _MAX_FILES_SCANNED = 5000
13
+
14
+
15
+ def _resolve(raw_path: str, ctx: ToolContext) -> Path:
16
+ path = Path(raw_path).expanduser()
17
+ if not path.is_absolute():
18
+ path = ctx.cwd / path
19
+ return path.resolve()
20
+
21
+
22
+ async def _grep(arguments: dict, ctx: ToolContext) -> ToolResult:
23
+ resolved_base = _resolve(arguments.get("path", "."), ctx)
24
+ glob_pattern = arguments.get("glob", "**/*")
25
+
26
+ try:
27
+ regex = re.compile(arguments["pattern"])
28
+ except re.error as exc:
29
+ return ToolResult(
30
+ output=f"Invalid regex: {exc}\n"
31
+ "[pcli] Suggestion: if you don't need regex features, escape the special "
32
+ "character(s) or search for a plain substring instead.",
33
+ is_error=True,
34
+ )
35
+
36
+ if not resolved_base.is_dir():
37
+ return not_a_directory_result(resolved_base)
38
+
39
+ matches: list[str] = []
40
+ scanned = 0
41
+ for file_path in resolved_base.glob(glob_pattern):
42
+ if not file_path.is_file():
43
+ continue
44
+ scanned += 1
45
+ if scanned > _MAX_FILES_SCANNED:
46
+ break
47
+ try:
48
+ text = file_path.read_text(encoding="utf-8", errors="ignore")
49
+ except OSError:
50
+ continue
51
+ for line_number, line in enumerate(text.splitlines(), start=1):
52
+ if regex.search(line):
53
+ rel = file_path.relative_to(resolved_base)
54
+ matches.append(f"{rel}:{line_number}: {line.strip()[:200]}")
55
+ if len(matches) >= _MAX_MATCHES:
56
+ break
57
+ if len(matches) >= _MAX_MATCHES:
58
+ break
59
+
60
+ if not matches:
61
+ return ToolResult(output="No matches.")
62
+ text = "\n".join(matches)
63
+ if len(matches) >= _MAX_MATCHES:
64
+ text += "\n[...match limit reached, results may be incomplete...]"
65
+ return ToolResult(output=text)
66
+
67
+
68
+ GREP = ToolSpec(
69
+ name="grep",
70
+ description="Search for a regex pattern across text files under a directory.",
71
+ parameters={
72
+ "type": "object",
73
+ "properties": {
74
+ "pattern": {"type": "string", "description": "Regular expression to search for."},
75
+ "path": {"type": "string", "description": "Base directory (default: working directory)."},
76
+ "glob": {
77
+ "type": "string",
78
+ "description": "Glob to select which files to scan (default: '**/*').",
79
+ },
80
+ },
81
+ "required": ["pattern"],
82
+ },
83
+ handler=_grep,
84
+ needs_permission=False,
85
+ guardrail_path_arg="path",
86
+ plan_mode_safe=True,
87
+ read_only=True,
88
+ )
@@ -0,0 +1,108 @@
1
+ """remember: lets the LLM persist a fact, in one of two scopes it must pick
2
+ explicitly (see REMEMBER's own description below for the exact instructions
3
+ given for telling them apart). The global categories (profile/preference/
4
+ style/common_ask) write to the global, cross-session memory store (memory/
5
+ store.py) - unlike record_decision (decision_tool.py), which logs to the
6
+ current session only, this is meant to still be true, and useful, in a
7
+ completely different session and project. category='local' is the
8
+ deliberate escape hatch for everything else: project/task-specific content
9
+ that should not survive past this conversation - _remember below intercepts
10
+ it and never calls add_entry, so nothing is written anywhere. Two callers:
11
+ the main agent, immediately whenever the user explicitly asks to be
12
+ remembered; and memory/extraction.py's own small tool-only agent, which
13
+ calls it after reviewing a chunk of conversation for anything durable
14
+ worth keeping (and, now, anything merely project-specific worth noting as
15
+ local instead of silently dropping it or miscategorizing it as global)."""
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import cast, get_args
20
+
21
+ from pcli.memory.models import MemoryCategory, MemorySource
22
+ from pcli.memory.store import add_entry
23
+ from pcli.tools.base import ToolContext, ToolResult, ToolSpec
24
+
25
+ _VALID_CATEGORIES = set(get_args(MemoryCategory))
26
+
27
+
28
+ async def _remember(arguments: dict, ctx: ToolContext) -> ToolResult:
29
+ content = (arguments.get("content") or "").strip()
30
+ category = arguments.get("category")
31
+ if not content or category not in _VALID_CATEGORIES:
32
+ return ToolResult(
33
+ output=f"Both 'content' and a valid 'category' ({', '.join(sorted(_VALID_CATEGORIES))}) "
34
+ "are required.",
35
+ is_error=True,
36
+ )
37
+
38
+ if category == "local":
39
+ # Deliberately not persisted anywhere - see MemoryCategory's own
40
+ # docstring. This category exists so the model has a correct place
41
+ # to put something project/task-specific instead of discarding it
42
+ # silently or miscategorizing it as one of the global ones below
43
+ # (confirmed in practice: a real derived entry once got filed under
44
+ # "preference" even though it was specific to one project's test
45
+ # script - exactly the failure this branch exists to avoid).
46
+ return ToolResult(output=f"Noted (local to this session, not saved globally): {content}")
47
+
48
+ source: MemorySource = "explicit" if arguments.get("source") == "explicit" else "derived"
49
+ entry = add_entry(
50
+ content,
51
+ category=cast(MemoryCategory, category),
52
+ source=source,
53
+ max_entries=ctx.memory_max_entries,
54
+ )
55
+ return ToolResult(output=f"Remembered ({entry.category}): {entry.content}")
56
+
57
+
58
+ REMEMBER = ToolSpec(
59
+ name="remember",
60
+ description="Persist a fact to pcli's memory. Two categorically different scopes, pick "
61
+ "carefully:\n\n"
62
+ "GLOBAL (profile/preference/style/common_ask) - saved to disk, injected into the system "
63
+ "prompt of every future session, in every project, forever (until forgotten). Use these "
64
+ "ONLY for something that is true of the USER as a person, independent of whatever you "
65
+ "happen to be working on right now - it must still make complete sense if read cold in a "
66
+ "totally unrelated project with no shared context. Test before calling: would this "
67
+ "sentence confuse someone working on a different codebase next week? If yes, it is not "
68
+ "global.\n\n"
69
+ "LOCAL - NOT saved anywhere, not visible again after this turn. Use this for anything "
70
+ "that is specific to the current project, repo, file, bug, or task - project/package/"
71
+ "file names, the specifics of a bug you're mid-investigation on, a decision that only "
72
+ "matters for this codebase. This is the correct, intentional choice for most of what "
73
+ "comes up in a normal session - it is not a lesser option.\n\n"
74
+ "Use GLOBAL immediately whenever the user explicitly asks to be remembered (\"remember "
75
+ "that I use tabs\", \"don't suggest X again\") - that is always about the user, not the "
76
+ "project. When in doubt between the two, choose LOCAL: the cost of wrongly going global "
77
+ "is a stale, confusing fact bleeding into unrelated future work; the cost of wrongly "
78
+ "going local is just not remembering something that turns out to have been durable.",
79
+ parameters={
80
+ "type": "object",
81
+ "properties": {
82
+ "content": {
83
+ "type": "string",
84
+ "description": "The fact to remember, stated plainly in one or two sentences.",
85
+ },
86
+ "category": {
87
+ "type": "string",
88
+ "enum": sorted(_VALID_CATEGORIES),
89
+ "description": "GLOBAL scope - profile = nature of work/role, preference = a "
90
+ "recurring technical/workflow choice, style = how they like responses/"
91
+ "conversation, common_ask = a recurring task pattern. LOCAL scope - local = "
92
+ "specific to the current project/task only; never persisted, never resurfaces.",
93
+ },
94
+ "source": {
95
+ "type": "string",
96
+ "enum": ["explicit", "derived"],
97
+ "description": "'explicit' if the user directly asked you to remember this "
98
+ "(protects it from automatic eviction later); omit or use 'derived' otherwise. "
99
+ "Meaningless for category='local', which is never persisted regardless.",
100
+ },
101
+ },
102
+ "required": ["content", "category"],
103
+ },
104
+ handler=_remember,
105
+ needs_permission=False,
106
+ plan_mode_safe=True,
107
+ read_only=False,
108
+ )
@@ -0,0 +1,107 @@
1
+ """Pythonic network tools — structured, cross-platform alternatives to
2
+ shelling out to curl/wget/Invoke-WebRequest, which differ across shells and
3
+ platforms and aren't uniformly installed (confirmed via a real debugged
4
+ session: a model repeatedly retrying a bash heredoc that fails outright on
5
+ a Windows/cmd.exe shell). Built on httpx (already a pcli dependency, used
6
+ throughout llm/client.py), not requests, to avoid adding a new one.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import httpx
12
+
13
+ from pcli.tools.base import ToolContext, ToolResult, ToolSpec
14
+ from pcli.tools.builtin.fs_tools import resolve_path
15
+
16
+ _DEFAULT_DOWNLOAD_TIMEOUT_S = 30.0
17
+ _MAX_DOWNLOAD_BYTES = 500_000_000 # 500 MB safety cap, mirrors other tools' internal size caps
18
+
19
+
20
+ class _DownloadTooLarge(Exception):
21
+ pass
22
+
23
+
24
+ async def _download_file(arguments: dict, ctx: ToolContext) -> ToolResult:
25
+ url = arguments["url"]
26
+ resolved = resolve_path(arguments["path"], ctx)
27
+ timeout_s = arguments.get("timeout_s", _DEFAULT_DOWNLOAD_TIMEOUT_S)
28
+ resolved.parent.mkdir(parents=True, exist_ok=True)
29
+
30
+ total = 0
31
+ try:
32
+ async with (
33
+ httpx.AsyncClient(follow_redirects=True, timeout=timeout_s) as client,
34
+ client.stream("GET", url) as response,
35
+ ):
36
+ if response.status_code >= 400:
37
+ return ToolResult(
38
+ output=f"Download failed: HTTP {response.status_code} for {url}\n"
39
+ "[pcli] Suggestion: double-check the URL is correct and still reachable "
40
+ "(e.g. a dataset/release URL may have moved or been renamed) — retrying "
41
+ "the identical URL won't fix a 404/403/etc., re-verify the source first.",
42
+ is_error=True,
43
+ )
44
+ with resolved.open("wb") as f:
45
+ async for chunk in response.aiter_bytes():
46
+ total += len(chunk)
47
+ if total > _MAX_DOWNLOAD_BYTES:
48
+ raise _DownloadTooLarge
49
+ f.write(chunk)
50
+ except _DownloadTooLarge:
51
+ resolved.unlink(missing_ok=True)
52
+ return ToolResult(
53
+ output=f"Download exceeded the {_MAX_DOWNLOAD_BYTES:,}-byte safety cap and was "
54
+ "aborted; the partial file was removed.\n"
55
+ "[pcli] Suggestion: if this file is genuinely expected to be this large, use "
56
+ "run_shell_background with a dedicated download command instead.",
57
+ is_error=True,
58
+ )
59
+ except httpx.TimeoutException:
60
+ resolved.unlink(missing_ok=True)
61
+ return ToolResult(
62
+ output=f"Download timed out after {timeout_s:g}s for {url}\n"
63
+ "[pcli] Suggestion: raise timeout_s for a large file or slow connection, or use "
64
+ "run_shell_background if it may take several minutes.",
65
+ is_error=True,
66
+ )
67
+ except httpx.HTTPError as exc:
68
+ resolved.unlink(missing_ok=True)
69
+ return ToolResult(
70
+ output=f"Download failed: {exc}\n"
71
+ "[pcli] Suggestion: check the URL is reachable and correctly formed, and that "
72
+ "network access is actually available from this environment.",
73
+ is_error=True,
74
+ )
75
+
76
+ return ToolResult(output=f"Downloaded {total:,} bytes from {url} to {resolved}")
77
+
78
+
79
+ DOWNLOAD_FILE = ToolSpec(
80
+ name="download_file",
81
+ description="Downloads a file from a URL and saves it to a local path. Prefer this over "
82
+ "run_shell with curl/wget/Invoke-WebRequest: it's one cross-platform tool call with no "
83
+ "shell syntax to get wrong, and reports a clear HTTP status/error instead of a raw stderr "
84
+ "blob you'd have to parse yourself. Follows redirects automatically.",
85
+ parameters={
86
+ "type": "object",
87
+ "properties": {
88
+ "url": {"type": "string", "description": "URL to download from."},
89
+ "path": {
90
+ "type": "string",
91
+ "description": "Destination path to save the downloaded file to, relative to "
92
+ "the working directory or absolute. Parent directories are created as needed.",
93
+ },
94
+ "timeout_s": {
95
+ "type": "number",
96
+ "description": f"Timeout in seconds for the download (default "
97
+ f"{_DEFAULT_DOWNLOAD_TIMEOUT_S:g}).",
98
+ },
99
+ },
100
+ "required": ["url", "path"],
101
+ },
102
+ handler=_download_file,
103
+ needs_permission=True,
104
+ risk_description="Downloads content from a URL and writes it to disk.",
105
+ guardrail_path_arg="path",
106
+ read_only=False,
107
+ )