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.
- pcli/__init__.py +1 -0
- pcli/__main__.py +4 -0
- pcli/agent/__init__.py +0 -0
- pcli/agent/activity.py +116 -0
- pcli/agent/compaction.py +205 -0
- pcli/agent/context_pruning.py +88 -0
- pcli/agent/headless.py +209 -0
- pcli/agent/loop.py +442 -0
- pcli/agent/prompt.py +371 -0
- pcli/agent/runtime.py +240 -0
- pcli/browser/__init__.py +0 -0
- pcli/browser/session.py +135 -0
- pcli/cli.py +757 -0
- pcli/config/__init__.py +0 -0
- pcli/config/paths.py +95 -0
- pcli/config/settings.py +435 -0
- pcli/cost/__init__.py +0 -0
- pcli/cost/context.py +275 -0
- pcli/cost/context_detect.py +183 -0
- pcli/cost/pricing_table.py +141 -0
- pcli/cost/tracker.py +126 -0
- pcli/llm/__init__.py +0 -0
- pcli/llm/client.py +285 -0
- pcli/llm/errors.py +37 -0
- pcli/llm/models.py +100 -0
- pcli/llm/streaming.py +108 -0
- pcli/memory/__init__.py +0 -0
- pcli/memory/extraction.py +106 -0
- pcli/memory/models.py +103 -0
- pcli/memory/store.py +88 -0
- pcli/permissions/__init__.py +0 -0
- pcli/permissions/guardrails.py +219 -0
- pcli/permissions/manager.py +215 -0
- pcli/permissions/policy.py +70 -0
- pcli/sandbox/__init__.py +0 -0
- pcli/sandbox/base.py +50 -0
- pcli/sandbox/docker_backend.py +107 -0
- pcli/sandbox/limits.py +63 -0
- pcli/sandbox/null_backend.py +92 -0
- pcli/sandbox/selector.py +75 -0
- pcli/sandbox/subprocess_backend.py +376 -0
- pcli/scheduler/__init__.py +0 -0
- pcli/scheduler/daemon.py +194 -0
- pcli/scheduler/models.py +97 -0
- pcli/scheduler/runner.py +84 -0
- pcli/scheduler/store.py +75 -0
- pcli/scheduler/triggers.py +84 -0
- pcli/session/__init__.py +0 -0
- pcli/session/audit.py +122 -0
- pcli/session/directory_check.py +28 -0
- pcli/session/export.py +57 -0
- pcli/session/importer.py +92 -0
- pcli/session/models.py +168 -0
- pcli/session/store.py +127 -0
- pcli/telegram/__init__.py +0 -0
- pcli/telegram/bot.py +266 -0
- pcli/telegram/daemon.py +1197 -0
- pcli/telegram/permissions.py +131 -0
- pcli/telegram/sender.py +58 -0
- pcli/tools/__init__.py +0 -0
- pcli/tools/_nested_agent.py +204 -0
- pcli/tools/agent_tools.py +264 -0
- pcli/tools/agent_tools_store.py +69 -0
- pcli/tools/artifacts.py +47 -0
- pcli/tools/base.py +185 -0
- pcli/tools/builtin/__init__.py +0 -0
- pcli/tools/builtin/agent_tool_register_tool.py +100 -0
- pcli/tools/builtin/artifact_tool.py +212 -0
- pcli/tools/builtin/ask_tool.py +77 -0
- pcli/tools/builtin/browser_tool.py +253 -0
- pcli/tools/builtin/decision_tool.py +73 -0
- pcli/tools/builtin/describe_tool.py +389 -0
- pcli/tools/builtin/diff_tools.py +225 -0
- pcli/tools/builtin/fs_tools.py +371 -0
- pcli/tools/builtin/grep_tool.py +88 -0
- pcli/tools/builtin/memory_tool.py +108 -0
- pcli/tools/builtin/network_tools.py +107 -0
- pcli/tools/builtin/pip_tool.py +106 -0
- pcli/tools/builtin/shell_tool.py +240 -0
- pcli/tools/builtin/subagent_tool.py +146 -0
- pcli/tools/builtin/todo_tool.py +122 -0
- pcli/tools/builtin/toolbox_register_tool.py +76 -0
- pcli/tools/builtin/web_tools.py +322 -0
- pcli/tools/pydiscovery/__init__.py +0 -0
- pcli/tools/pydiscovery/cache.py +51 -0
- pcli/tools/pydiscovery/index.py +48 -0
- pcli/tools/pydiscovery/invoke.py +181 -0
- pcli/tools/pydiscovery/search.py +117 -0
- pcli/tools/registry.py +138 -0
- pcli/tools/toolbox/__init__.py +0 -0
- pcli/tools/toolbox/introspect.py +48 -0
- pcli/tools/toolbox/manager.py +336 -0
- pcli/tools/toolbox/plugin_base.py +51 -0
- pcli/tools/toolbox/plugins/__init__.py +6 -0
- pcli/tools/toolbox/plugins/httpd.py +99 -0
- pcli/tools/toolbox/plugins/kafka.py +162 -0
- pcli/tools/toolbox/plugins/kubectl.py +211 -0
- pcli/tools/toolbox/plugins/sge.py +146 -0
- pcli/tools/toolbox/store.py +65 -0
- pcli/tools/toolbox/synthesize.py +100 -0
- pcli/tui/__init__.py +0 -0
- pcli/tui/app.py +37 -0
- pcli/tui/screens/__init__.py +0 -0
- pcli/tui/screens/ask_question_modal.py +54 -0
- pcli/tui/screens/chat.py +2070 -0
- pcli/tui/screens/confirm_modal.py +39 -0
- pcli/tui/screens/models.py +43 -0
- pcli/tui/screens/permission_modal.py +71 -0
- pcli/tui/screens/sessions.py +162 -0
- pcli/tui/screens/subagent_activity_modal.py +71 -0
- pcli/tui/shell_passthrough.py +56 -0
- pcli/tui/styles/pcli.tcss +241 -0
- pcli/tui/themes.py +84 -0
- pcli/tui/widgets/__init__.py +0 -0
- pcli/tui/widgets/chat_input.py +240 -0
- pcli/tui/widgets/command_suggestions.py +33 -0
- pcli/tui/widgets/message_view.py +328 -0
- pcli/tui/widgets/paste_input.py +99 -0
- pcli/tui/widgets/paste_marker.py +69 -0
- pcli/tui/widgets/status_bar.py +133 -0
- pcli/tui/widgets/status_pane.py +58 -0
- pcli/util/__init__.py +0 -0
- pcli/util/ids.py +15 -0
- pcli/util/logging.py +18 -0
- pcli/util/text.py +10 -0
- pcli_agent-0.1.0.dist-info/METADATA +259 -0
- pcli_agent-0.1.0.dist-info/RECORD +130 -0
- pcli_agent-0.1.0.dist-info/WHEEL +4 -0
- pcli_agent-0.1.0.dist-info/entry_points.txt +2 -0
- pcli_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Autonomous memory extraction: after a compaction pass archives a chunk of
|
|
2
|
+
old conversation away (agent/compaction.py's maybe_compact), review that
|
|
3
|
+
transcript - plus what's already known - and classify anything worth
|
|
4
|
+
noting into one of remember's two scopes (tools/builtin/memory_tool.py) -
|
|
5
|
+
global (persisted, cross-session, cross-project) or local (not persisted
|
|
6
|
+
at all - a correctly-labeled dead end for project-specific content, not a
|
|
7
|
+
lesser fallback). Runs as a small, tool-only AgentLoop scoped to just that
|
|
8
|
+
one tool - no sandbox, shell, or file access needed or wanted here, unlike
|
|
9
|
+
a real subagent (tools/_nested_agent.py).
|
|
10
|
+
|
|
11
|
+
The explicit local category exists because leaving this as an implicit
|
|
12
|
+
binary "durable enough to remember, or not" judgment demonstrably failed in
|
|
13
|
+
practice: a real derived entry once got filed under the global 'preference'
|
|
14
|
+
category even though its content (a specific package's import error in one
|
|
15
|
+
project's test script) was clearly project-specific, not durable. Giving
|
|
16
|
+
the model a concrete, named place for "worth noting but not global" - the
|
|
17
|
+
same kind of discrete choice it already makes among the four global
|
|
18
|
+
categories - is the fix: classifying into a parallel category is a more
|
|
19
|
+
reliable task for an LLM than making an abstract counterfactual judgment
|
|
20
|
+
("would this matter in some hypothetical future project?") inline while
|
|
21
|
+
also deciding whether to act at all.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from dataclasses import replace
|
|
27
|
+
|
|
28
|
+
from pcli.agent.loop import AgentLoop
|
|
29
|
+
from pcli.cost.tracker import cost_budget_reason
|
|
30
|
+
from pcli.llm.models import ChatMessage, Usage, UsageEvent
|
|
31
|
+
from pcli.memory.models import render_memory_section
|
|
32
|
+
from pcli.memory.store import read_memory
|
|
33
|
+
from pcli.tools.base import ToolContext
|
|
34
|
+
from pcli.tools.builtin.memory_tool import REMEMBER
|
|
35
|
+
from pcli.tools.registry import ToolRegistry
|
|
36
|
+
|
|
37
|
+
_EXTRACTION_SYSTEM_PROMPT = (
|
|
38
|
+
"You review a chunk of an in-progress coding-agent conversation and classify anything "
|
|
39
|
+
"worth noting into exactly one of two scopes. Be strict - most reviews should add zero "
|
|
40
|
+
"or one fact, not several.\n\n"
|
|
41
|
+
"GLOBAL - call remember(content, category) with category='profile' (their role or "
|
|
42
|
+
"domain), 'preference' (a recurring technical/workflow choice), 'style' (how they like "
|
|
43
|
+
"responses/conversation), or 'common_ask' (a task they repeatedly ask for across "
|
|
44
|
+
"different projects) ONLY for a fact that is true of the USER as a person, independent "
|
|
45
|
+
"of whatever project this conversation happens to be about. Before using one of these "
|
|
46
|
+
"four categories, check: would this sentence still make complete sense, with no "
|
|
47
|
+
"confusion, read cold at the start of a totally unrelated project with none of this "
|
|
48
|
+
"conversation's context? If no, it is not global - do not file it under one of these "
|
|
49
|
+
"four categories just because it seems important right now.\n\n"
|
|
50
|
+
"LOCAL - call remember(content, category='local') for anything that seems worth noting "
|
|
51
|
+
"but is specific to the current project, repo, file, or task: project/package names, "
|
|
52
|
+
"the specifics of a bug under investigation, a decision that only matters for this "
|
|
53
|
+
"codebase. This is the correct, intentional choice for most of what comes up in a "
|
|
54
|
+
"normal conversation - not a lesser fallback. Nothing saved this way is ever persisted "
|
|
55
|
+
"or shown again; it exists so you have somewhere correct to put project-specific "
|
|
56
|
+
"content instead of discarding it silently or stretching it to fit a global category.\n\n"
|
|
57
|
+
"If genuinely nothing in this chunk is worth noting even locally, make no calls at all."
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
_MAX_EXTRACTION_ITERATIONS = 5
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def extract_memory(transcript: str, ctx: ToolContext) -> list[Usage]:
|
|
64
|
+
"""Reviews `transcript` (typically the plain-text rendering of the turns
|
|
65
|
+
a compaction pass just archived - see chat.py's _run_compaction) via a
|
|
66
|
+
tool-only AgentLoop scoped to REMEMBER, and returns the LLM usage
|
|
67
|
+
incurred so the caller can fold it into cost tracking. Never raises for
|
|
68
|
+
a "found nothing worth remembering" outcome - only for a real gateway
|
|
69
|
+
failure, which the caller is expected to catch (mirrors maybe_compact's
|
|
70
|
+
own GatewayError-can-propagate contract; ctx.gateway_client must already
|
|
71
|
+
be set, same precondition _run_compaction already checks before calling
|
|
72
|
+
maybe_compact in the first place)."""
|
|
73
|
+
assert ctx.gateway_client is not None
|
|
74
|
+
|
|
75
|
+
memory_registry = ToolRegistry()
|
|
76
|
+
memory_registry.register(REMEMBER)
|
|
77
|
+
extraction_ctx = replace(ctx, tool_registry=memory_registry)
|
|
78
|
+
|
|
79
|
+
known = render_memory_section(read_memory().entries) or "(nothing remembered yet)"
|
|
80
|
+
sub_loop = AgentLoop(
|
|
81
|
+
ctx.gateway_client,
|
|
82
|
+
model=ctx.model,
|
|
83
|
+
tool_registry=memory_registry,
|
|
84
|
+
permission_manager=ctx.permission_manager,
|
|
85
|
+
tool_context_factory=lambda: extraction_ctx,
|
|
86
|
+
max_tool_iterations=_MAX_EXTRACTION_ITERATIONS,
|
|
87
|
+
max_response_tokens=ctx.max_response_tokens,
|
|
88
|
+
temperature=ctx.temperature,
|
|
89
|
+
)
|
|
90
|
+
messages = [
|
|
91
|
+
ChatMessage(
|
|
92
|
+
role="system", content=f"{_EXTRACTION_SYSTEM_PROMPT}\n\nAlready known:\n{known}"
|
|
93
|
+
),
|
|
94
|
+
ChatMessage(role="user", content=transcript),
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
def _budget_check() -> str | None:
|
|
98
|
+
if ctx.session is None:
|
|
99
|
+
return None
|
|
100
|
+
return cost_budget_reason(ctx.session, ctx.max_session_cost_usd)
|
|
101
|
+
|
|
102
|
+
usages: list[Usage] = []
|
|
103
|
+
async for event in sub_loop.run_turn(messages, budget_check=_budget_check):
|
|
104
|
+
if isinstance(event, UsageEvent):
|
|
105
|
+
usages.append(event.usage)
|
|
106
|
+
return usages
|
pcli/memory/models.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Global, cross-session user-memory data model: what pcli has learned about
|
|
2
|
+
the user (nature of work, preferences, conversation style, recurring task
|
|
3
|
+
patterns) that should carry across every session, not just the one it was
|
|
4
|
+
learned in - see memory/store.py for persistence and memory/extraction.py
|
|
5
|
+
for how derived entries get added on top of the remember tool's explicit
|
|
6
|
+
ones (tools/builtin/memory_tool.py)."""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from datetime import UTC, datetime
|
|
11
|
+
from typing import Literal
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, Field
|
|
14
|
+
|
|
15
|
+
from pcli.util.ids import new_id
|
|
16
|
+
|
|
17
|
+
MemoryCategory = Literal["profile", "preference", "style", "common_ask", "local"]
|
|
18
|
+
"""The first four are global - persisted to memory/store.py's cross-session,
|
|
19
|
+
cross-project store and injected into every future session's system prompt
|
|
20
|
+
(render_memory_section below). "local" is not one of those: it exists purely
|
|
21
|
+
so the LLM has an explicit, correctly-labeled place to put something
|
|
22
|
+
project/task-specific instead of being forced to either discard it or
|
|
23
|
+
miscategorize it as one of the global ones (the actual failure mode this
|
|
24
|
+
was added to fix - see tools/builtin/memory_tool.py's REMEMBER description
|
|
25
|
+
and memory/extraction.py's system prompt for the instructions that lean on
|
|
26
|
+
this distinction). A "local"-categorized MemoryEntry is never constructed in
|
|
27
|
+
practice - the remember tool's handler intercepts that category and returns
|
|
28
|
+
without calling add_entry - but it's kept as part of this same enum rather
|
|
29
|
+
than a second parallel type, since the tool's valid-category set is derived
|
|
30
|
+
directly from this one."""
|
|
31
|
+
MemorySource = Literal["explicit", "derived"]
|
|
32
|
+
|
|
33
|
+
_CATEGORY_ORDER: tuple[MemoryCategory, ...] = ("profile", "preference", "style", "common_ask")
|
|
34
|
+
_CATEGORY_LABELS: dict[MemoryCategory, str] = {
|
|
35
|
+
"profile": "Nature of work",
|
|
36
|
+
"preference": "Preferences",
|
|
37
|
+
"style": "Conversation style",
|
|
38
|
+
"common_ask": "Common asks",
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _utcnow() -> datetime:
|
|
43
|
+
return datetime.now(UTC)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class MemoryEntry(BaseModel):
|
|
47
|
+
id: str = Field(default_factory=lambda: new_id("mem_"))
|
|
48
|
+
category: MemoryCategory
|
|
49
|
+
content: str
|
|
50
|
+
source: MemorySource
|
|
51
|
+
created_at: datetime = Field(default_factory=_utcnow)
|
|
52
|
+
updated_at: datetime = Field(default_factory=_utcnow)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class MemoryStore(BaseModel):
|
|
56
|
+
entries: list[MemoryEntry] = Field(default_factory=list)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def render_memory_section(entries: list[MemoryEntry]) -> str:
|
|
60
|
+
"""Plain-prose system-prompt section, grouped by category in a fixed
|
|
61
|
+
order - empty string (so build_system_prompt's extra_sections gets
|
|
62
|
+
nothing to inject) if there's nothing stored yet, so a fresh install's
|
|
63
|
+
prompt is unaffected."""
|
|
64
|
+
if not entries:
|
|
65
|
+
return ""
|
|
66
|
+
lines = [
|
|
67
|
+
"# What you know about this user",
|
|
68
|
+
(
|
|
69
|
+
"Derived from past sessions (or told to you directly). Treat it as background, not "
|
|
70
|
+
"instruction — still follow whatever the user actually says in this conversation "
|
|
71
|
+
"over anything here if the two ever conflict."
|
|
72
|
+
),
|
|
73
|
+
]
|
|
74
|
+
for category in _CATEGORY_ORDER:
|
|
75
|
+
matching = [e for e in entries if e.category == category]
|
|
76
|
+
if not matching:
|
|
77
|
+
continue
|
|
78
|
+
lines.append(f"\n{_CATEGORY_LABELS[category]}:")
|
|
79
|
+
lines.extend(f"- {entry.content}" for entry in matching)
|
|
80
|
+
return "\n".join(lines)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def render_memory_list(entries: list[MemoryEntry]) -> str:
|
|
84
|
+
"""Human-facing listing for the /memory command - unlike
|
|
85
|
+
render_memory_section (fed to the model), this includes each entry's
|
|
86
|
+
short id (last 4 chars, matching sessions.py's SessionListScreen
|
|
87
|
+
convention) so /memory forget <id> has something to target. Real
|
|
88
|
+
Markdown (blank line between category blocks, "- " bullets) - the TUI
|
|
89
|
+
always renders system messages through rich.markdown.Markdown, which
|
|
90
|
+
collapses plain "\\n"-joined lines into a single run-on paragraph."""
|
|
91
|
+
if not entries:
|
|
92
|
+
return "No memory entries yet."
|
|
93
|
+
blocks = []
|
|
94
|
+
for category in _CATEGORY_ORDER:
|
|
95
|
+
matching = [e for e in entries if e.category == category]
|
|
96
|
+
if not matching:
|
|
97
|
+
continue
|
|
98
|
+
lines = [f"**{_CATEGORY_LABELS[category]}:**"]
|
|
99
|
+
for entry in matching:
|
|
100
|
+
marker = " *(explicit)*" if entry.source == "explicit" else ""
|
|
101
|
+
lines.append(f"- `{entry.id[-4:]}` {entry.content}{marker}")
|
|
102
|
+
blocks.append("\n".join(lines))
|
|
103
|
+
return "\n\n".join(blocks)
|
pcli/memory/store.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""On-disk persistence for the global user-memory store (memory/models.py) -
|
|
2
|
+
mirrors tools/agent_tools_store.py's shape (a single JSON file under
|
|
3
|
+
data_dir(), atomic-written, loaded once and mutated in place) rather than
|
|
4
|
+
session/store.py's per-session-directory layout, since this is one global
|
|
5
|
+
file shared across every session, not per-session state."""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from datetime import UTC, datetime
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from pcli.config.paths import memory_file
|
|
14
|
+
from pcli.memory.models import MemoryCategory, MemoryEntry, MemorySource, MemoryStore
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _atomic_write(path: Path, content: str) -> None:
|
|
18
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
19
|
+
tmp_path = path.with_suffix(path.suffix + ".tmp")
|
|
20
|
+
tmp_path.write_text(content, encoding="utf-8")
|
|
21
|
+
os.replace(tmp_path, path)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def read_memory() -> MemoryStore:
|
|
25
|
+
path = memory_file()
|
|
26
|
+
if not path.exists():
|
|
27
|
+
return MemoryStore()
|
|
28
|
+
return MemoryStore.model_validate_json(path.read_text(encoding="utf-8"))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def write_memory(store: MemoryStore) -> None:
|
|
32
|
+
_atomic_write(memory_file(), store.model_dump_json(indent=2))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def add_entry(
|
|
36
|
+
content: str,
|
|
37
|
+
*,
|
|
38
|
+
category: MemoryCategory,
|
|
39
|
+
source: MemorySource,
|
|
40
|
+
max_entries: int,
|
|
41
|
+
) -> MemoryEntry:
|
|
42
|
+
"""Appends a new entry, or - a deliberately simple, predictable check,
|
|
43
|
+
not fuzzy matching - refreshes an existing entry in the same category
|
|
44
|
+
whose content matches case-insensitively, rather than growing a pile of
|
|
45
|
+
near-duplicates every time the same fact gets re-derived. Evicts the
|
|
46
|
+
oldest source='derived' entry first (never 'explicit' - the user asked
|
|
47
|
+
for that one directly) once adding would exceed max_entries."""
|
|
48
|
+
store = read_memory()
|
|
49
|
+
content = content.strip()
|
|
50
|
+
|
|
51
|
+
for existing in store.entries:
|
|
52
|
+
if existing.category == category and existing.content.strip().lower() == content.lower():
|
|
53
|
+
existing.updated_at = datetime.now(UTC)
|
|
54
|
+
if source == "explicit":
|
|
55
|
+
existing.source = "explicit"
|
|
56
|
+
write_memory(store)
|
|
57
|
+
return existing
|
|
58
|
+
|
|
59
|
+
entry = MemoryEntry(category=category, content=content, source=source)
|
|
60
|
+
store.entries.append(entry)
|
|
61
|
+
|
|
62
|
+
while len(store.entries) > max_entries:
|
|
63
|
+
derived_indices = [i for i, e in enumerate(store.entries) if e.source == "derived"]
|
|
64
|
+
if not derived_indices:
|
|
65
|
+
break # every remaining entry is explicit - stop evicting rather than touch those
|
|
66
|
+
oldest = min(derived_indices, key=lambda i: store.entries[i].created_at)
|
|
67
|
+
del store.entries[oldest]
|
|
68
|
+
|
|
69
|
+
write_memory(store)
|
|
70
|
+
return entry
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def remove_entry(entry_id: str) -> bool:
|
|
74
|
+
"""Removes one entry by its full id. Returns whether anything was
|
|
75
|
+
removed - the /memory forget <id> command resolves a short (last-4-char)
|
|
76
|
+
id to a full one before calling this, so callers here always deal in
|
|
77
|
+
full ids, not the abbreviated form users type."""
|
|
78
|
+
store = read_memory()
|
|
79
|
+
before = len(store.entries)
|
|
80
|
+
store.entries = [e for e in store.entries if e.id != entry_id]
|
|
81
|
+
if len(store.entries) == before:
|
|
82
|
+
return False
|
|
83
|
+
write_memory(store)
|
|
84
|
+
return True
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def clear_memory() -> None:
|
|
88
|
+
write_memory(MemoryStore())
|
|
File without changes
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""Hard, non-negotiable, config-driven checks. A guardrail violation is an
|
|
2
|
+
automatic deny — it never reaches the permission-prompt UI. This is separate
|
|
3
|
+
from (and evaluated before) PermissionManager's ask/remember flow; see
|
|
4
|
+
manager.py.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import fnmatch
|
|
10
|
+
import tomllib
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel
|
|
15
|
+
|
|
16
|
+
from pcli.config.paths import guardrails_file
|
|
17
|
+
from pcli.config.settings import _dump_toml
|
|
18
|
+
|
|
19
|
+
DEFAULT_GUARDRAILS_TOML = """\
|
|
20
|
+
# pcli guardrails — hard limits that are never prompted, only enforced.
|
|
21
|
+
# Patterns without '*'/'?' are matched as a case-insensitive substring of the
|
|
22
|
+
# command; patterns with '*'/'?' are matched anywhere in the command via
|
|
23
|
+
# shell-style wildcards. This is a heuristic safety net, not a shell parser —
|
|
24
|
+
# treat it as defense-in-depth alongside the sandbox, not a substitute for it.
|
|
25
|
+
|
|
26
|
+
[shell]
|
|
27
|
+
denylist = [
|
|
28
|
+
"rm -rf /",
|
|
29
|
+
"rm -rf ~",
|
|
30
|
+
"mkfs*",
|
|
31
|
+
":(){ :|:& };:",
|
|
32
|
+
"dd if=/dev/zero",
|
|
33
|
+
"> /dev/sda",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
[fs]
|
|
37
|
+
allowed_roots = ["."]
|
|
38
|
+
deny_paths = ["~/.ssh", "~/.aws", "~/.config/pcli"]
|
|
39
|
+
|
|
40
|
+
[limits]
|
|
41
|
+
max_output_bytes = 2000000
|
|
42
|
+
max_tool_calls_per_turn = 25
|
|
43
|
+
max_tool_calls_per_minute = 60
|
|
44
|
+
max_shell_timeout_s = 300
|
|
45
|
+
max_background_jobs = 5
|
|
46
|
+
|
|
47
|
+
[python]
|
|
48
|
+
# Top-level modules the pydiscovery tools (inspect_python_module, call_python)
|
|
49
|
+
# refuse to import/resolve into — these overlap with the dedicated, reviewed
|
|
50
|
+
# shell/fs tools, so letting the LLM reach them indirectly via arbitrary
|
|
51
|
+
# Python calls would be a redundant and higher-risk escape hatch.
|
|
52
|
+
module_denylist = [
|
|
53
|
+
"os",
|
|
54
|
+
"sys",
|
|
55
|
+
"subprocess",
|
|
56
|
+
"ctypes",
|
|
57
|
+
"shutil",
|
|
58
|
+
"socket",
|
|
59
|
+
"importlib",
|
|
60
|
+
"multiprocessing",
|
|
61
|
+
"threading",
|
|
62
|
+
"pty",
|
|
63
|
+
]
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class GuardrailResult(BaseModel):
|
|
68
|
+
allowed: bool
|
|
69
|
+
reason: str | None = None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _pattern_matches(command: str, pattern: str) -> bool:
|
|
73
|
+
normalized_command = command.strip().lower()
|
|
74
|
+
normalized_pattern = pattern.strip().lower()
|
|
75
|
+
if "*" in normalized_pattern or "?" in normalized_pattern:
|
|
76
|
+
wrapped = normalized_pattern
|
|
77
|
+
if not wrapped.startswith("*"):
|
|
78
|
+
wrapped = f"*{wrapped}"
|
|
79
|
+
if not wrapped.endswith("*"):
|
|
80
|
+
wrapped = f"{wrapped}*"
|
|
81
|
+
return fnmatch.fnmatch(normalized_command, wrapped)
|
|
82
|
+
return normalized_pattern in normalized_command
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class GuardrailsConfig(BaseModel):
|
|
86
|
+
shell_denylist: list[str] = []
|
|
87
|
+
fs_allowed_roots: list[str] = ["."]
|
|
88
|
+
fs_deny_paths: list[str] = []
|
|
89
|
+
max_output_bytes: int = 2_000_000
|
|
90
|
+
max_tool_calls_per_turn: int = 25
|
|
91
|
+
max_tool_calls_per_minute: int = 60
|
|
92
|
+
max_shell_timeout_s: int = 300
|
|
93
|
+
"""Ceiling run_shell's (blocking) timeout_s is clamped to — commands
|
|
94
|
+
that need longer should use run_shell_background instead, which has no
|
|
95
|
+
such cap since it doesn't block the turn."""
|
|
96
|
+
max_background_jobs: int = 5
|
|
97
|
+
"""Concurrent-running cap for run_shell_background; enforced by
|
|
98
|
+
RestrictedSubprocessSandbox.start_background."""
|
|
99
|
+
python_module_denylist: list[str] = []
|
|
100
|
+
|
|
101
|
+
@classmethod
|
|
102
|
+
def load(cls) -> GuardrailsConfig:
|
|
103
|
+
path = guardrails_file()
|
|
104
|
+
if not path.exists():
|
|
105
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
106
|
+
path.write_text(DEFAULT_GUARDRAILS_TOML, encoding="utf-8")
|
|
107
|
+
raw = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
108
|
+
shell = raw.get("shell", {})
|
|
109
|
+
fs = raw.get("fs", {})
|
|
110
|
+
limits = raw.get("limits", {})
|
|
111
|
+
python = raw.get("python", {})
|
|
112
|
+
return cls(
|
|
113
|
+
shell_denylist=shell.get("denylist", []),
|
|
114
|
+
fs_allowed_roots=fs.get("allowed_roots", ["."]),
|
|
115
|
+
fs_deny_paths=fs.get("deny_paths", []),
|
|
116
|
+
max_output_bytes=limits.get("max_output_bytes", 2_000_000),
|
|
117
|
+
max_tool_calls_per_turn=limits.get("max_tool_calls_per_turn", 25),
|
|
118
|
+
max_tool_calls_per_minute=limits.get("max_tool_calls_per_minute", 60),
|
|
119
|
+
max_shell_timeout_s=limits.get("max_shell_timeout_s", 300),
|
|
120
|
+
max_background_jobs=limits.get("max_background_jobs", 5),
|
|
121
|
+
python_module_denylist=python.get("module_denylist", []),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
def evaluate_command(self, command: str) -> GuardrailResult:
|
|
125
|
+
for pattern in self.shell_denylist:
|
|
126
|
+
if _pattern_matches(command, pattern):
|
|
127
|
+
return GuardrailResult(
|
|
128
|
+
allowed=False, reason=f"command matches denylist pattern '{pattern}'"
|
|
129
|
+
)
|
|
130
|
+
return GuardrailResult(allowed=True)
|
|
131
|
+
|
|
132
|
+
def evaluate_python_module(self, qualified_name: str) -> GuardrailResult:
|
|
133
|
+
top_level = qualified_name.split(".")[0]
|
|
134
|
+
if top_level in self.python_module_denylist:
|
|
135
|
+
return GuardrailResult(
|
|
136
|
+
allowed=False,
|
|
137
|
+
reason=f"module '{top_level}' is blocked (use the dedicated shell/fs tools instead)",
|
|
138
|
+
)
|
|
139
|
+
return GuardrailResult(allowed=True)
|
|
140
|
+
|
|
141
|
+
def evaluate_path(self, path: str | Path) -> GuardrailResult:
|
|
142
|
+
resolved = Path(path).expanduser().resolve()
|
|
143
|
+
|
|
144
|
+
for deny_path in self.fs_deny_paths:
|
|
145
|
+
deny_resolved = Path(deny_path).expanduser().resolve()
|
|
146
|
+
if resolved == deny_resolved or _is_relative_to(resolved, deny_resolved):
|
|
147
|
+
return GuardrailResult(
|
|
148
|
+
allowed=False,
|
|
149
|
+
reason=f"path is within denied path '{deny_path}'.\n"
|
|
150
|
+
"[pcli] Suggestion: this is a deliberate guardrails.toml [fs] deny_paths "
|
|
151
|
+
"entry (a hard block on a sensitive location - SSH keys, cloud "
|
|
152
|
+
"credentials, ...), checked before any permission prompt, so it can't be "
|
|
153
|
+
"approved by asking. If this is genuinely blocking legitimate work, that's "
|
|
154
|
+
"a guardrails.toml change for the user to make, not something to route "
|
|
155
|
+
"around",
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
for allowed_root in self.fs_allowed_roots:
|
|
159
|
+
allowed_resolved = Path(allowed_root).expanduser().resolve()
|
|
160
|
+
if resolved == allowed_resolved or _is_relative_to(resolved, allowed_resolved):
|
|
161
|
+
return GuardrailResult(allowed=True)
|
|
162
|
+
|
|
163
|
+
roots_list = ", ".join(
|
|
164
|
+
str(Path(root).expanduser()) for root in self.fs_allowed_roots
|
|
165
|
+
)
|
|
166
|
+
return GuardrailResult(
|
|
167
|
+
allowed=False,
|
|
168
|
+
reason=f"path '{resolved}' is outside all allowed roots ({roots_list}).\n"
|
|
169
|
+
"[pcli] Suggestion: this is a guardrail (guardrails.toml's [fs] allowed_roots), "
|
|
170
|
+
"checked before any permission prompt, so it can't be approved by asking - add "
|
|
171
|
+
"the path (or a parent of it) to allowed_roots to let the agent reach it (in the "
|
|
172
|
+
"TUI: /allowed-roots add <path>)",
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def update_guardrails_limits(**limit_updates: Any) -> None:
|
|
177
|
+
"""Persists the given key/value pairs into guardrails.toml's [limits]
|
|
178
|
+
table, preserving the [shell]/[fs]/[python] tables and any other
|
|
179
|
+
[limits] keys untouched — mirrors config/settings.py's
|
|
180
|
+
update_config_file, but for guardrails.toml's fixed 4-table shape
|
|
181
|
+
instead of config.toml's flatter one.
|
|
182
|
+
|
|
183
|
+
Falsy values (None) are skipped rather than written, matching
|
|
184
|
+
update_config_file's same behavior for optional callers."""
|
|
185
|
+
path = guardrails_file()
|
|
186
|
+
if path.exists():
|
|
187
|
+
raw = dict(tomllib.loads(path.read_text(encoding="utf-8")))
|
|
188
|
+
else:
|
|
189
|
+
raw = dict(tomllib.loads(DEFAULT_GUARDRAILS_TOML))
|
|
190
|
+
limits = dict(raw.get("limits", {}))
|
|
191
|
+
limits.update({key: value for key, value in limit_updates.items() if value is not None})
|
|
192
|
+
raw["limits"] = limits
|
|
193
|
+
path.write_text(_dump_toml(raw), encoding="utf-8")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def update_guardrails_fs_allowed_roots(allowed_roots: list[str]) -> None:
|
|
197
|
+
"""Persists a full replacement allowed_roots list into guardrails.toml's
|
|
198
|
+
[fs] table, preserving deny_paths and every other table untouched -
|
|
199
|
+
same shape as update_guardrails_limits above but for a list-valued
|
|
200
|
+
[fs] key instead of a scalar [limits] one, so the caller (chat.py's
|
|
201
|
+
/allowed-roots add/remove) computes the whole new list itself rather
|
|
202
|
+
than this function doing partial add/remove logic."""
|
|
203
|
+
path = guardrails_file()
|
|
204
|
+
if path.exists():
|
|
205
|
+
raw = dict(tomllib.loads(path.read_text(encoding="utf-8")))
|
|
206
|
+
else:
|
|
207
|
+
raw = dict(tomllib.loads(DEFAULT_GUARDRAILS_TOML))
|
|
208
|
+
fs = dict(raw.get("fs", {}))
|
|
209
|
+
fs["allowed_roots"] = allowed_roots
|
|
210
|
+
raw["fs"] = fs
|
|
211
|
+
path.write_text(_dump_toml(raw), encoding="utf-8")
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _is_relative_to(path: Path, other: Path) -> bool:
|
|
215
|
+
try:
|
|
216
|
+
path.relative_to(other)
|
|
217
|
+
return True
|
|
218
|
+
except ValueError:
|
|
219
|
+
return False
|