pal-code 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.
- pal/__init__.py +29 -0
- pal/__main__.py +3 -0
- pal/access_manager/__init__.py +16 -0
- pal/access_manager/components/__init__.py +14 -0
- pal/access_manager/components/access_control.py +41 -0
- pal/access_manager/components/base.py +39 -0
- pal/access_manager/components/intervention.py +31 -0
- pal/access_manager/components/privilege_registry.py +30 -0
- pal/access_manager/pipeline.py +68 -0
- pal/access_manager/utils/__init__.py +2 -0
- pal/agent_constraints/__init__.py +3 -0
- pal/agent_constraints/components/AGENT_OUTPUT.md +13 -0
- pal/agent_constraints/components/__init__.py +3 -0
- pal/agent_constraints/components/constraints.py +22 -0
- pal/agent_constraints/pipeline.py +22 -0
- pal/agent_constraints/utils/__init__.py +2 -0
- pal/agents/__init__.py +12 -0
- pal/agents/coding.py +14 -0
- pal/agents/docs.py +15 -0
- pal/chat/__init__.py +4 -0
- pal/chat/demo.py +231 -0
- pal/chat/providers.py +23 -0
- pal/chat/service.py +591 -0
- pal/chat/services/__init__.py +26 -0
- pal/chat/services/base.py +142 -0
- pal/chat/services/data.py +142 -0
- pal/chat/services/media.py +123 -0
- pal/chat/services/web.py +60 -0
- pal/chat/sessions.py +108 -0
- pal/chat/tools.py +446 -0
- pal/chat/uploads.py +81 -0
- pal/cli.py +502 -0
- pal/compression/__init__.py +12 -0
- pal/compression/components/__init__.py +64 -0
- pal/compression/components/adverbs.py +20 -0
- pal/compression/components/hedges.py +54 -0
- pal/compression/components/interjections.py +20 -0
- pal/compression/components/minifier.py +56 -0
- pal/compression/components/output_compressor.py +13 -0
- pal/compression/components/pleonasms.py +54 -0
- pal/compression/components/punctuations.py +36 -0
- pal/compression/components/stopwords.py +62 -0
- pal/compression/history.py +346 -0
- pal/compression/pipeline.py +58 -0
- pal/compression/utils/__init__.py +30 -0
- pal/compression/utils/negations.py +18 -0
- pal/compression/utils/nltk_setup.py +20 -0
- pal/compression/utils/pronouns.py +27 -0
- pal/compression/utils/proselint_setup.py +9 -0
- pal/compression/utils/spacy_setup.py +22 -0
- pal/config/default.yaml +163 -0
- pal/config.py +504 -0
- pal/context_manager/__init__.py +7 -0
- pal/context_manager/chat_context.py +117 -0
- pal/context_manager/components/__init__.py +5 -0
- pal/context_manager/components/assembler.py +75 -0
- pal/context_manager/components/context_cache.py +45 -0
- pal/context_manager/components/kv_bridge.py +16 -0
- pal/context_manager/components/pager.py +48 -0
- pal/context_manager/components/simple_context_manager.py +56 -0
- pal/context_manager/components/turns.py +88 -0
- pal/context_manager/pipeline.py +27 -0
- pal/context_manager/utils/__init__.py +18 -0
- pal/context_manager/utils/hashing.py +5 -0
- pal/context_manager/utils/relevance.py +59 -0
- pal/context_manager/utils/storage.py +37 -0
- pal/documents/__init__.py +4 -0
- pal/documents/components/__init__.py +0 -0
- pal/documents/components/chunker.py +79 -0
- pal/documents/components/index.py +155 -0
- pal/documents/utils/__init__.py +1 -0
- pal/errors.py +40 -0
- pal/input_converters/__init__.py +8 -0
- pal/input_converters/components/__init__.py +0 -0
- pal/input_converters/components/base.py +38 -0
- pal/input_converters/components/pandoc_converter.py +87 -0
- pal/input_converters/components/pdf_converter.py +46 -0
- pal/input_converters/components/pptx_converter.py +52 -0
- pal/input_converters/components/xlsx_converter.py +38 -0
- pal/input_converters/pipeline.py +72 -0
- pal/input_converters/utils/__init__.py +1 -0
- pal/input_converters/workspace.py +56 -0
- pal/input_savers/INPUT_RULES.md +2 -0
- pal/input_savers/__init__.py +5 -0
- pal/input_savers/components/__init__.py +0 -0
- pal/input_savers/components/base.py +40 -0
- pal/input_savers/components/methods.py +41 -0
- pal/input_savers/components/verifier.py +39 -0
- pal/input_savers/pipeline.py +154 -0
- pal/input_savers/utils/__init__.py +0 -0
- pal/input_savers/utils/guards.py +94 -0
- pal/knowledge_graph/__init__.py +10 -0
- pal/knowledge_graph/components/__init__.py +0 -0
- pal/knowledge_graph/components/base.py +50 -0
- pal/knowledge_graph/components/builder.py +54 -0
- pal/knowledge_graph/components/retriever.py +100 -0
- pal/knowledge_graph/components/workspace.py +116 -0
- pal/knowledge_graph/pipeline.py +215 -0
- pal/knowledge_graph/utils/__init__.py +1 -0
- pal/latency_tracker/__init__.py +3 -0
- pal/latency_tracker/pipeline.py +86 -0
- pal/llm_scheduler/__init__.py +11 -0
- pal/llm_scheduler/api.py +137 -0
- pal/llm_scheduler/code.py +28 -0
- pal/llm_scheduler/core.py +92 -0
- pal/llm_scheduler/local.py +215 -0
- pal/llm_scheduler/pipeline.py +42 -0
- pal/llm_scheduler/types.py +92 -0
- pal/llm_wrapper/__init__.py +34 -0
- pal/llm_wrapper/adapter.py +316 -0
- pal/llm_wrapper/components/__init__.py +23 -0
- pal/llm_wrapper/components/adapter_types.py +90 -0
- pal/llm_wrapper/components/base.py +195 -0
- pal/llm_wrapper/components/features.py +104 -0
- pal/llm_wrapper/components/litellm_wrapper.py +215 -0
- pal/llm_wrapper/components/openrouter_wrapper.py +114 -0
- pal/llm_wrapper/modes.py +73 -0
- pal/llm_wrapper/pipeline.py +61 -0
- pal/llm_wrapper/utils/__init__.py +4 -0
- pal/llm_wrapper/utils/registry.py +49 -0
- pal/llm_wrapper/utils/token_counting.py +10 -0
- pal/local_llm/__init__.py +5 -0
- pal/local_llm/components/__init__.py +0 -0
- pal/local_llm/components/huggingface_local.py +217 -0
- pal/local_llm/components/local_llm.py +60 -0
- pal/logging_setup.py +18 -0
- pal/memory/__init__.py +7 -0
- pal/memory/components/__init__.py +6 -0
- pal/memory/components/lru_k.py +56 -0
- pal/memory/components/manager.py +98 -0
- pal/memory/components/note.py +54 -0
- pal/memory/components/storage_manager.py +60 -0
- pal/memory/pipeline.py +122 -0
- pal/memory/utils/__init__.py +3 -0
- pal/memory/utils/keyword_overlap.py +3 -0
- pal/memory_tracker/__init__.py +3 -0
- pal/memory_tracker/components/__init__.py +0 -0
- pal/memory_tracker/components/process.py +56 -0
- pal/memory_tracker/pipeline.py +44 -0
- pal/metrics/__init__.py +4 -0
- pal/metrics/components/__init__.py +4 -0
- pal/metrics/components/collector.py +140 -0
- pal/metrics/components/exporter.py +28 -0
- pal/metrics/pipeline.py +49 -0
- pal/metrics/utils/__init__.py +2 -0
- pal/notes_manager/__init__.py +5 -0
- pal/notes_manager/components/__init__.py +0 -0
- pal/notes_manager/components/privacy.py +14 -0
- pal/notes_manager/components/recall.py +31 -0
- pal/notes_manager/components/tidy.py +28 -0
- pal/notes_manager/pipeline.py +138 -0
- pal/orchestrator/__init__.py +26 -0
- pal/orchestrator/components/__init__.py +26 -0
- pal/orchestrator/components/agent.py +105 -0
- pal/orchestrator/components/cgroup_manager.py +209 -0
- pal/orchestrator/components/context_interrupt.py +220 -0
- pal/orchestrator/components/limits.py +9 -0
- pal/orchestrator/components/scheduler.py +69 -0
- pal/orchestrator/components/syscall_scheduler.py +200 -0
- pal/orchestrator/pipeline.py +228 -0
- pal/orchestrator/utils/__init__.py +4 -0
- pal/orchestrator/utils/device.py +164 -0
- pal/orchestrator/utils/protocol.py +32 -0
- pal/os_kernel/__init__.py +5 -0
- pal/os_kernel/filesystem.py +80 -0
- pal/os_kernel/network.py +146 -0
- pal/os_kernel/pipeline.py +175 -0
- pal/os_kernel/sysinfo.py +36 -0
- pal/paths.py +59 -0
- pal/pipeline.py +177 -0
- pal/py.typed +0 -0
- pal/redact.py +19 -0
- pal/sdk/__init__.py +33 -0
- pal/sdk/adapters.py +43 -0
- pal/sdk/agent.py +99 -0
- pal/sdk/components/__init__.py +14 -0
- pal/sdk/components/autogen_adapter.py +65 -0
- pal/sdk/components/autogen_client.py +98 -0
- pal/sdk/components/framework_adapter_base.py +69 -0
- pal/sdk/components/metagpt_adapter.py +46 -0
- pal/sdk/components/open_interpreter_adapter.py +47 -0
- pal/sdk/components/tool_catalog.py +44 -0
- pal/sdk/os_api.py +84 -0
- pal/sdk/patterns.py +129 -0
- pal/sdk/pipeline.py +240 -0
- pal/sdk/queries.py +68 -0
- pal/sdk/savers.py +88 -0
- pal/sdk/utils/__init__.py +2 -0
- pal/storage_manager/__init__.py +17 -0
- pal/storage_manager/components/__init__.py +19 -0
- pal/storage_manager/components/base.py +50 -0
- pal/storage_manager/components/local_file_backend.py +84 -0
- pal/storage_manager/components/storage_queue.py +91 -0
- pal/storage_manager/components/vector_store_backend.py +122 -0
- pal/storage_manager/pipeline.py +153 -0
- pal/storage_manager/utils/__init__.py +4 -0
- pal/storage_manager/utils/memory_adapter.py +60 -0
- pal/storage_manager/utils/sdk.py +93 -0
- pal/studio/__init__.py +0 -0
- pal/studio/builder.py +166 -0
- pal/studio/catalog.py +90 -0
- pal/studio/checks.py +129 -0
- pal/studio/clients.py +6 -0
- pal/studio/editor.py +137 -0
- pal/studio/files.py +6 -0
- pal/studio/models.py +6 -0
- pal/studio/plan.py +149 -0
- pal/studio/planner.py +205 -0
- pal/studio/policy.py +69 -0
- pal/studio/routes.py +254 -0
- pal/studio/runs.py +210 -0
- pal/task_router/__init__.py +11 -0
- pal/task_router/components/__init__.py +0 -0
- pal/task_router/components/answerer.py +37 -0
- pal/task_router/components/base.py +34 -0
- pal/task_router/components/classifier.py +40 -0
- pal/task_router/components/gates.py +23 -0
- pal/task_router/pipeline.py +61 -0
- pal/task_router/utils/__init__.py +1 -0
- pal/terminal/__init__.py +1 -0
- pal/terminal/backend.py +141 -0
- pal/terminal/bench.py +173 -0
- pal/terminal/cli.py +826 -0
- pal/terminal/clients.py +170 -0
- pal/terminal/commands.py +401 -0
- pal/terminal/decide.py +242 -0
- pal/terminal/files.py +55 -0
- pal/terminal/flow_cli.py +439 -0
- pal/terminal/hardware.py +199 -0
- pal/terminal/history.py +88 -0
- pal/terminal/keys.py +83 -0
- pal/terminal/local_models.py +261 -0
- pal/terminal/logo.py +117 -0
- pal/terminal/models.py +99 -0
- pal/terminal/policy.py +54 -0
- pal/terminal/project.py +413 -0
- pal/terminal/review.py +65 -0
- pal/terminal/security.py +239 -0
- pal/terminal/session.py +503 -0
- pal/terminal/setup.py +354 -0
- pal/terminal/syntax.py +98 -0
- pal/terminal/theme.py +37 -0
- pal/terminal/ui.py +647 -0
- pal/terminal/voice.py +137 -0
- pal/terminal/workflow.py +624 -0
- pal/token_tracker/__init__.py +3 -0
- pal/token_tracker/pipeline.py +54 -0
- pal/tokens.py +21 -0
- pal/tool_catalog.py +107 -0
- pal/tool_manager/__init__.py +17 -0
- pal/tool_manager/builtin.py +82 -0
- pal/tool_manager/components/__init__.py +19 -0
- pal/tool_manager/components/base.py +53 -0
- pal/tool_manager/components/conflict_resolver.py +27 -0
- pal/tool_manager/components/param_validator.py +41 -0
- pal/tool_manager/components/tool_call_queue.py +111 -0
- pal/tool_manager/components/tool_registry.py +60 -0
- pal/tool_manager/pipeline.py +80 -0
- pal/tool_manager/utils/__init__.py +3 -0
- pal/web/__init__.py +3 -0
- pal/web/app.py +276 -0
- pal/web/static/editor.js +219 -0
- pal/web/static/fonts/LICENSE-Inter.txt +3 -0
- pal/web/static/fonts/inter-400.woff2 +0 -0
- pal/web/static/fonts/inter-500.woff2 +0 -0
- pal/web/static/fonts/inter-600.woff2 +0 -0
- pal/web/static/fonts/inter-700.woff2 +0 -0
- pal/web/static/inter.css +5 -0
- pal/web/static/pal-logo-256.png +0 -0
- pal/web/static/pal-logo-32.png +0 -0
- pal/web/static/pal-logo-64.png +0 -0
- pal/web/static/studio.css +306 -0
- pal/web/static/studio.js +533 -0
- pal/web/static/voice.js +76 -0
- pal/web/templates/index.html +368 -0
- pal/web/templates/studio.html +120 -0
- pal_code-0.1.0.dist-info/METADATA +379 -0
- pal_code-0.1.0.dist-info/RECORD +282 -0
- pal_code-0.1.0.dist-info/WHEEL +5 -0
- pal_code-0.1.0.dist-info/entry_points.txt +2 -0
- pal_code-0.1.0.dist-info/licenses/LICENSE +21 -0
- pal_code-0.1.0.dist-info/top_level.txt +1 -0
pal/studio/catalog.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""What the bot can choose from: build roles, tools, and access permissions.
|
|
2
|
+
|
|
3
|
+
The bot proposes; the person decides. Every item here has a plain description so the page can show what was chosen and why.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
from pal import tool_catalog
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class Role:
|
|
15
|
+
id: str
|
|
16
|
+
name: str
|
|
17
|
+
description: str
|
|
18
|
+
prompt: str # what this role is told it is, kept short: it is sent with every request
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
ROLES: dict[str, Role] = {r.id: r for r in (
|
|
22
|
+
Role("designer", "Designer", "Plans the look and layout: pages, styling, user-interface structure.",
|
|
23
|
+
"You are the designer. You decide the layout and write the styles and markup. Keep it clean, readable and responsive."),
|
|
24
|
+
Role("coder", "Coder", "Writes the working code: the logic and the main files.",
|
|
25
|
+
"You are the coder. You write complete, working code. No placeholders, no TODOs, no explanations inside code."),
|
|
26
|
+
Role("tester", "Tester", "Writes tests and checks that the pieces fit together.",
|
|
27
|
+
"You are the tester. You write small, clear tests for what exists and point out what does not fit together."),
|
|
28
|
+
Role("docs", "Docs writer", "Writes the README and usage instructions.",
|
|
29
|
+
"You are the documentation writer. You write a short README: what it is, how to run it, one example."),
|
|
30
|
+
Role("reviewer", "Reviewer", "Reads everything that was written and fixes mistakes.",
|
|
31
|
+
"You are the reviewer. You read all the files so far, find mistakes and mismatches, and give corrected files."),
|
|
32
|
+
)}
|
|
33
|
+
ROLE_ORDER = tuple(ROLES)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class Permission:
|
|
38
|
+
id: str
|
|
39
|
+
title: str
|
|
40
|
+
description: str
|
|
41
|
+
default: bool
|
|
42
|
+
risky: bool = False # only the person can switch it on: the bot may suggest it but never enables it
|
|
43
|
+
locked: bool = False # cannot be changed in this version
|
|
44
|
+
why_locked: str = ""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
PERMISSIONS: tuple[Permission, ...] = (
|
|
48
|
+
Permission("write_project", "Save files in the project folder", "The build writes the files it creates into its own project folder. Off: a dry run that shows the files but saves nothing.", True),
|
|
49
|
+
Permission("modify_own_files", "Change files it already wrote", "Later steps (the reviewer, fixes) may rewrite a file an earlier step created in this build.", True),
|
|
50
|
+
Permission("read_references", "Read the reference files you attach", "The bot may read documents you attach as reference (a brief, a design doc). They stay on this machine unless you use an API model.", True),
|
|
51
|
+
Permission("network_access", "Go online", "Tools that reach the internet (Wikipedia, arXiv, web fetch, search). Off by default: it sends queries off this machine.", False, risky=True),
|
|
52
|
+
Permission("run_code", "Run the code it writes", "Running generated code needs a proper sandbox, which this version does not have. The build checks files for syntax mistakes instead.", False, locked=True,
|
|
53
|
+
why_locked="needs a sandbox (not in this version)"),
|
|
54
|
+
Permission("outside_project", "Touch files outside the project folder", "Never. Every path must stay inside the project folder.", False, locked=True, why_locked="never allowed"),
|
|
55
|
+
Permission("delete_files", "Delete files", "Never. The build only creates and rewrites files.", False, locked=True, why_locked="never allowed"),
|
|
56
|
+
)
|
|
57
|
+
PERMISSION_BY_ID = {p.id: p for p in PERMISSIONS}
|
|
58
|
+
DEFAULT_PERMISSIONS = {p.id: p.default for p in PERMISSIONS}
|
|
59
|
+
|
|
60
|
+
# Tools the builder's own file handling replaces, or that need a loaded project: not offered as choices.
|
|
61
|
+
_NOT_OFFERED = frozenset({"save_file", "read_file", "write_file", "list_files", "file_to_markdown", "build_code_graph", "query_code_graph", "read_source", "find_usages",
|
|
62
|
+
"search_source", "list_project_files", "system_info", "word_count"})
|
|
63
|
+
DOCUMENT_TOOLS = frozenset({"search_documents", "read_document", "document_outline"})
|
|
64
|
+
NETWORK_TOOLS = frozenset({"wikipedia", "arxiv_search", "http_get", "google_search", "wolfram_alpha", "currency_convert", "moon_phase", "words_api",
|
|
65
|
+
"image_caption", "visual_question_answering", "text_to_image", "text_to_audio", "speech_to_text"})
|
|
66
|
+
OFFERED_TOOLS = tuple(e.name for e in tool_catalog.CATALOG if e.name not in _NOT_OFFERED)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def tool_needs(name: str) -> str | None:
|
|
70
|
+
"""The permission a tool needs: going online, or reading the files you attached."""
|
|
71
|
+
if name in NETWORK_TOOLS:
|
|
72
|
+
return "network_access"
|
|
73
|
+
if name in DOCUMENT_TOOLS:
|
|
74
|
+
return "read_references"
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def catalog(tools_cfg, env: Mapping[str, str]) -> dict:
|
|
79
|
+
"""Everything the page can offer, as plain data. `ready` is false for a tool that has to be switched on in the settings first."""
|
|
80
|
+
tools = []
|
|
81
|
+
for name in OFFERED_TOOLS:
|
|
82
|
+
e = tool_catalog.BY_NAME[name]
|
|
83
|
+
st = tool_catalog.state(e, tools_cfg, env)
|
|
84
|
+
tools.append({"name": name, "title": e.title, "group": e.group, "summary": e.summary, "needs": tool_needs(name), "state": st,
|
|
85
|
+
"ready": st in ("", "ready"), "why_not": "" if st in ("", "ready") else (f"{st}: set it in the settings" if st != "off" else "off in the settings (tools.services / tools.network)")})
|
|
86
|
+
return {
|
|
87
|
+
"roles": [{"id": r.id, "name": r.name, "description": r.description} for r in ROLES.values()],
|
|
88
|
+
"tools": tools,
|
|
89
|
+
"permissions": [{"id": p.id, "title": p.title, "description": p.description, "default": p.default, "risky": p.risky, "locked": p.locked, "why_locked": p.why_locked} for p in PERMISSIONS],
|
|
90
|
+
}
|
pal/studio/checks.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Static checks on the files a build wrote. Nothing is run: a file is read, never executed.
|
|
2
|
+
|
|
3
|
+
These catch the mistakes small models make most (a half-closed tag, a missing brace, a page that links to a file that was never written). A problem is
|
|
4
|
+
reported in plain words so it can be sent back to the model once for a fix.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import ast
|
|
9
|
+
import json
|
|
10
|
+
import re
|
|
11
|
+
from html.parser import HTMLParser
|
|
12
|
+
from pathlib import PurePosixPath
|
|
13
|
+
|
|
14
|
+
_VOID = frozenset({"area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"})
|
|
15
|
+
_TRACKED = frozenset({"html", "head", "body", "script", "style", "div", "section", "nav", "main", "header", "footer", "form", "table", "ul", "ol", "button"})
|
|
16
|
+
_PAIRS = {"{": "}", "(": ")", "[": "]"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _Html(HTMLParser):
|
|
20
|
+
def __init__(self):
|
|
21
|
+
super().__init__(convert_charrefs=True)
|
|
22
|
+
self.stack: list[str] = []
|
|
23
|
+
self.problems: list[str] = []
|
|
24
|
+
self.refs: list[str] = []
|
|
25
|
+
|
|
26
|
+
def handle_starttag(self, tag, attrs):
|
|
27
|
+
a = dict(attrs)
|
|
28
|
+
for key in ("src", "href"):
|
|
29
|
+
v = a.get(key)
|
|
30
|
+
if v and tag in ("script", "link", "img", "source") and not re.match(r"^([a-z][a-z0-9+.-]*:|//|#|data:)", v, re.I):
|
|
31
|
+
self.refs.append(v.split("#")[0].split("?")[0])
|
|
32
|
+
if tag in _TRACKED:
|
|
33
|
+
self.stack.append(tag)
|
|
34
|
+
|
|
35
|
+
def handle_startendtag(self, tag, attrs):
|
|
36
|
+
self.handle_starttag(tag, attrs)
|
|
37
|
+
if tag in _TRACKED and self.stack and self.stack[-1] == tag:
|
|
38
|
+
self.stack.pop()
|
|
39
|
+
|
|
40
|
+
def handle_endtag(self, tag):
|
|
41
|
+
if tag in _VOID or tag not in _TRACKED:
|
|
42
|
+
return
|
|
43
|
+
if tag in self.stack:
|
|
44
|
+
while self.stack and self.stack[-1] != tag:
|
|
45
|
+
self.problems.append(f"<{self.stack.pop()}> is never closed")
|
|
46
|
+
self.stack.pop()
|
|
47
|
+
else:
|
|
48
|
+
self.problems.append(f"</{tag}> closes something that was never opened")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _balance(code: str, comments: bool = True) -> list[str]:
|
|
52
|
+
"""Brackets that do not pair up, ignoring strings and comments (a lightweight scan, not a parser)."""
|
|
53
|
+
stack: list[str] = []
|
|
54
|
+
i, n = 0, len(code)
|
|
55
|
+
while i < n:
|
|
56
|
+
c = code[i]
|
|
57
|
+
if comments and code.startswith("//", i):
|
|
58
|
+
i = code.find("\n", i)
|
|
59
|
+
i = n if i < 0 else i
|
|
60
|
+
continue
|
|
61
|
+
if comments and code.startswith("/*", i):
|
|
62
|
+
j = code.find("*/", i + 2)
|
|
63
|
+
i = n if j < 0 else j + 2
|
|
64
|
+
continue
|
|
65
|
+
if c in "\"'`":
|
|
66
|
+
j = i + 1
|
|
67
|
+
while j < n and code[j] != c:
|
|
68
|
+
j += 2 if code[j] == "\\" else 1
|
|
69
|
+
if c != "`" and j < n and code[j] == "\n":
|
|
70
|
+
break
|
|
71
|
+
i = j + 1
|
|
72
|
+
continue
|
|
73
|
+
if c in _PAIRS:
|
|
74
|
+
stack.append(c)
|
|
75
|
+
elif c in _PAIRS.values():
|
|
76
|
+
if not stack or _PAIRS[stack[-1]] != c:
|
|
77
|
+
return [f"a '{c}' has no matching opener"]
|
|
78
|
+
stack.pop()
|
|
79
|
+
i += 1
|
|
80
|
+
return [f"a '{stack[-1]}' is never closed"] if stack else []
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def check_file(path: str, content: str) -> list[str]:
|
|
84
|
+
ext = PurePosixPath(path).suffix.lower()
|
|
85
|
+
if ext == ".py":
|
|
86
|
+
try:
|
|
87
|
+
ast.parse(content)
|
|
88
|
+
except SyntaxError as e:
|
|
89
|
+
return [f"syntax error on line {e.lineno}: {e.msg}"]
|
|
90
|
+
elif ext == ".json":
|
|
91
|
+
try:
|
|
92
|
+
json.loads(content)
|
|
93
|
+
except ValueError as e:
|
|
94
|
+
return [f"not valid JSON: {e}"]
|
|
95
|
+
elif ext in (".html", ".htm"):
|
|
96
|
+
p = _Html()
|
|
97
|
+
p.feed(content)
|
|
98
|
+
p.close()
|
|
99
|
+
return p.problems[:3] + [f"<{t}> is never closed" for t in p.stack[:3]]
|
|
100
|
+
elif ext in (".js", ".mjs", ".ts", ".tsx", ".jsx", ".css"):
|
|
101
|
+
return _balance(content, comments=True)
|
|
102
|
+
return []
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def check_project(files: dict[str, str], only: set[str] | None = None, links: bool = True) -> dict[str, list[str]]:
|
|
106
|
+
"""Problems by file. `only` limits the single-file checks to files that just changed. `links` also checks that every file a page links to was
|
|
107
|
+
written: leave it off in the middle of a build, because a page may link to a file a later step is about to write."""
|
|
108
|
+
out: dict[str, list[str]] = {}
|
|
109
|
+
lower = {p.lower() for p in files}
|
|
110
|
+
for path, content in files.items():
|
|
111
|
+
problems = check_file(path, content) if only is None or path in only else []
|
|
112
|
+
if links and PurePosixPath(path).suffix.lower() in (".html", ".htm"):
|
|
113
|
+
p = _Html()
|
|
114
|
+
p.feed(content)
|
|
115
|
+
base = PurePosixPath(path).parent
|
|
116
|
+
for ref in p.refs:
|
|
117
|
+
target = (base / ref).as_posix() if str(base) != "." else ref
|
|
118
|
+
parts: list[str] = []
|
|
119
|
+
for seg in target.split("/"):
|
|
120
|
+
if seg == "..":
|
|
121
|
+
if parts:
|
|
122
|
+
parts.pop()
|
|
123
|
+
elif seg not in ("", "."):
|
|
124
|
+
parts.append(seg)
|
|
125
|
+
if "/".join(parts).lower() not in lower:
|
|
126
|
+
problems.append(f"it refers to {ref}, but no file with that name was written")
|
|
127
|
+
if problems:
|
|
128
|
+
out[path] = problems
|
|
129
|
+
return out
|
pal/studio/clients.py
ADDED
pal/studio/editor.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""The code editor behind the Studio: open a built project, change a file by hand, or ask Pal to change it.
|
|
2
|
+
|
|
3
|
+
Every "ask" goes through the same token-saving client the builds use (PalClient), kept alive for the whole editing session, so the history is paged
|
|
4
|
+
and compressed and the project's other files are indexed once instead of pasted again. Nothing Pal writes is saved until the person accepts it:
|
|
5
|
+
`ask` only proposes (old text, new text, a diff); `save_file` is the one place a file changes, and it applies the same path and size rules as a build.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
import threading
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from pal.errors import UserInputError
|
|
16
|
+
from pal.studio.builder import FORMAT
|
|
17
|
+
from pal.studio.policy import MAX_FILE_BYTES, MAX_FILES, MAX_TOTAL_BYTES, PolicyError, safe_path
|
|
18
|
+
from pal.terminal.files import _FENCE, parse_files
|
|
19
|
+
from pal.terminal.session import diff_lines # noqa: F401 (one diff, shared with the terminal)
|
|
20
|
+
from pal.tokens import estimate_tokens
|
|
21
|
+
|
|
22
|
+
SYSTEM = "You are a careful senior developer editing an existing project. You change only what was asked, and keep every file consistent with the others."
|
|
23
|
+
MAX_DIFF_LINES = 400
|
|
24
|
+
MAX_INSTRUCTION = 4000
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def lookup(files: dict[str, str], path: str) -> str | None:
|
|
28
|
+
"""The stored name of `path`, compared without regard to case (Windows and macOS treat Index.html and index.html as one file)."""
|
|
29
|
+
low = path.lower()
|
|
30
|
+
return next((p for p in files if p.lower() == low), None)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def save_file(state, path: str, content: str) -> dict:
|
|
34
|
+
"""Write one file into a finished build (memory, and the project folder when the build was saved). Raises UserInputError with a plain reason."""
|
|
35
|
+
if state.build is None:
|
|
36
|
+
raise UserInputError("this build has no files yet")
|
|
37
|
+
try:
|
|
38
|
+
clean = safe_path(path)
|
|
39
|
+
except PolicyError as e:
|
|
40
|
+
raise UserInputError(str(e)) from None
|
|
41
|
+
if not isinstance(content, str):
|
|
42
|
+
raise UserInputError("the file's text must be a string")
|
|
43
|
+
size = len(content.encode("utf-8"))
|
|
44
|
+
if size > MAX_FILE_BYTES:
|
|
45
|
+
raise UserInputError(f"too big ({size // 1000} KB; the limit is {MAX_FILE_BYTES // 1000} KB)")
|
|
46
|
+
files = state.build.files
|
|
47
|
+
name = lookup(files, clean) or clean
|
|
48
|
+
if name not in files and len(files) >= MAX_FILES:
|
|
49
|
+
raise UserInputError(f"too many files (the limit is {MAX_FILES})")
|
|
50
|
+
if sum(len(c.encode("utf-8")) for p, c in files.items() if p != name) + size > MAX_TOTAL_BYTES:
|
|
51
|
+
raise UserInputError(f"the project would be over {MAX_TOTAL_BYTES // 1_000_000} MB")
|
|
52
|
+
if state.workspace is not None:
|
|
53
|
+
target = (state.workspace / name).resolve()
|
|
54
|
+
if state.workspace.resolve() not in target.parents:
|
|
55
|
+
raise UserInputError("files must stay inside the project folder")
|
|
56
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
57
|
+
target.write_text(content, encoding="utf-8", newline="")
|
|
58
|
+
files[name] = content
|
|
59
|
+
return {"path": name, "bytes": size, "saved_to_disk": state.workspace is not None}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class EditorSession:
|
|
64
|
+
"""One editing conversation about one build. Holds the token-saving client and the running totals."""
|
|
65
|
+
client: Any
|
|
66
|
+
product: str
|
|
67
|
+
lock: threading.Lock = field(default_factory=threading.Lock)
|
|
68
|
+
sent: dict[str, str] = field(default_factory=dict) # path -> the text Pal's index last received
|
|
69
|
+
history_tokens: int = 0 # what a plain chat would carry along: every earlier request and reply
|
|
70
|
+
asks: int = 0
|
|
71
|
+
pal_tokens: int = 0
|
|
72
|
+
helper_tokens: int = 0
|
|
73
|
+
plain_tokens: int = 0
|
|
74
|
+
|
|
75
|
+
def ask(self, files: dict[str, str], path: str, instruction: str) -> dict:
|
|
76
|
+
if not self.lock.acquire(blocking=False):
|
|
77
|
+
raise UserInputError("Pal is still working on your last request: wait for it to finish")
|
|
78
|
+
try:
|
|
79
|
+
return self._ask(files, path, instruction)
|
|
80
|
+
finally:
|
|
81
|
+
self.lock.release()
|
|
82
|
+
|
|
83
|
+
def _ask(self, files: dict[str, str], path: str, instruction: str) -> dict:
|
|
84
|
+
name = lookup(files, path) if path else None
|
|
85
|
+
others = {p: c for p, c in files.items() if p != name}
|
|
86
|
+
changed = [{"name": p, "text": c, "kind": "document"} for p, c in others.items() if self.sent.get(p) != c]
|
|
87
|
+
listing = "\n".join(f"- {p} ({len(c.encode('utf-8')):,} bytes)" for p, c in sorted(files.items()))
|
|
88
|
+
parts = [f"Project: {self.product}. Files:\n{listing}", "The other files are available to you as reference documents; read them only if the change needs them."]
|
|
89
|
+
if name:
|
|
90
|
+
parts.append(f"The file open in the editor is {name}. Its current content:\n```\n{files[name]}\n```")
|
|
91
|
+
parts += [f"Change requested: {instruction}",
|
|
92
|
+
"Reply with the COMPLETE new content of every file you change (never a fragment, never '...'), and leave other files out. " + FORMAT
|
|
93
|
+
+ " If the request is only a question, answer in a few sentences and write no files."]
|
|
94
|
+
message = "\n\n".join(parts)
|
|
95
|
+
# what a plain chat client would have sent for this same request: the system line, the whole project, every earlier turn, and the request
|
|
96
|
+
plain_prompt = estimate_tokens(SYSTEM) + sum(estimate_tokens(c) for c in files.values()) + estimate_tokens(listing) + self.history_tokens + estimate_tokens(instruction)
|
|
97
|
+
reply = self.client.ask(message, changed or None)
|
|
98
|
+
for p, c in others.items():
|
|
99
|
+
self.sent[p] = c
|
|
100
|
+
self.asks += 1
|
|
101
|
+
self.pal_tokens += reply.prompt_tokens + reply.completion_tokens
|
|
102
|
+
self.helper_tokens += reply.helper_tokens
|
|
103
|
+
self.plain_tokens += plain_prompt + reply.completion_tokens
|
|
104
|
+
self.history_tokens += estimate_tokens(instruction) + estimate_tokens(reply.text)
|
|
105
|
+
|
|
106
|
+
parsed, _unnamed = parse_files(reply.text, default_name=name or "")
|
|
107
|
+
proposals, refused = [], []
|
|
108
|
+
for pf in parsed:
|
|
109
|
+
try:
|
|
110
|
+
clean = safe_path(pf.path)
|
|
111
|
+
except PolicyError as e:
|
|
112
|
+
refused.append({"path": pf.path[:80], "reason": str(e)})
|
|
113
|
+
continue
|
|
114
|
+
if len(pf.content.encode("utf-8")) > MAX_FILE_BYTES:
|
|
115
|
+
refused.append({"path": clean, "reason": f"too big (the limit is {MAX_FILE_BYTES // 1000} KB)"})
|
|
116
|
+
continue
|
|
117
|
+
existing = lookup(files, clean)
|
|
118
|
+
old = files[existing] if existing else ""
|
|
119
|
+
if existing and old == pf.content:
|
|
120
|
+
continue
|
|
121
|
+
proposals.append({"path": existing or clean, "new": not existing, "old": old, "content": pf.content, "diff": diff_lines(old, pf.content)})
|
|
122
|
+
note = " ".join(m.group(1).strip() for m in re.finditer(r"^NOTE:\s*(.+)$", reply.text, re.M))
|
|
123
|
+
talk = re.sub(r"^#+\s*FILE:.*$|^NOTE:.*$", "", _FENCE.sub("", reply.text), flags=re.M | re.I).strip()
|
|
124
|
+
return {"files": proposals, "refused": refused, "note": note, "message": talk[:1500],
|
|
125
|
+
"usage": {"prompt_tokens": reply.prompt_tokens, "completion_tokens": reply.completion_tokens, "helper_tokens": reply.helper_tokens, "seconds": round(reply.seconds, 1),
|
|
126
|
+
"plain_estimate": plain_prompt + reply.completion_tokens},
|
|
127
|
+
"session": {"asks": self.asks, "pal_tokens": self.pal_tokens, "helper_tokens": self.helper_tokens, "plain_estimate": self.plain_tokens,
|
|
128
|
+
"saved_pct": round(100.0 * (self.plain_tokens - self.pal_tokens) / self.plain_tokens, 1) if self.plain_tokens else 0.0}}
|
|
129
|
+
|
|
130
|
+
def close(self) -> None:
|
|
131
|
+
close = getattr(self.client, "close", None)
|
|
132
|
+
if close:
|
|
133
|
+
close()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def new_session_dir(root: Path, run_id: str, arm: str) -> Path:
|
|
137
|
+
return root / run_id / f"{arm}-edit-home"
|
pal/studio/files.py
ADDED
pal/studio/models.py
ADDED
pal/studio/plan.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""The plan: what the bot proposes and what the person edits. One normaliser enforces every rule, whoever changed the plan.
|
|
2
|
+
|
|
3
|
+
The rules that matter:
|
|
4
|
+
* a risky permission (going online) can be on only if the PERSON switched it on (`user_set`): the bot may suggest it, never enable it;
|
|
5
|
+
* a locked permission (running code, touching files outside the project, deleting) is always off;
|
|
6
|
+
* a tool that needs a permission is dropped when that permission is off, and the plan says so;
|
|
7
|
+
* unknown roles, tools and permissions are dropped, so a model's invention can never become an action.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from collections.abc import Iterable, Mapping
|
|
13
|
+
|
|
14
|
+
from pal.llm_wrapper.modes import PROVIDER_KEYS, mode_for
|
|
15
|
+
from pal.studio.catalog import DEFAULT_PERMISSIONS, OFFERED_TOOLS, PERMISSION_BY_ID, ROLE_ORDER, ROLES, tool_needs
|
|
16
|
+
|
|
17
|
+
KINDS = ("web_app", "cli_tool", "library", "script", "document", "other")
|
|
18
|
+
MAX_STEPS, MAX_ROLES = 8, 5
|
|
19
|
+
_FOLDER = re.compile(r"[^a-z0-9]+")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
_DEVICE = frozenset({"con", "prn", "aux", "nul", *(f"com{i}" for i in range(1, 10)), *(f"lpt{i}" for i in range(1, 10))})
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def slug(name: str) -> str:
|
|
26
|
+
"""A safe folder name for a product. A name that is a Windows device name (con, nul, aux ...) would break there, so it gets a prefix."""
|
|
27
|
+
out = _FOLDER.sub("-", name.lower()).strip("-")[:40] or "project"
|
|
28
|
+
return f"p-{out}" if out in _DEVICE else out
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _mapping(value) -> Mapping:
|
|
32
|
+
return value if isinstance(value, Mapping) else {}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _text(value, limit: int) -> str:
|
|
36
|
+
return " ".join(str(value or "").split())[:limit]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def default_steps(roles: list[str], summary: str | None = "") -> list[dict]:
|
|
40
|
+
task = {"designer": "Design the layout and write the styles and markup.", "coder": "Write the working code and the main files.",
|
|
41
|
+
"tester": "Write tests for what exists.", "docs": "Write the README: what it is, how to run it, one example.",
|
|
42
|
+
"reviewer": "Review every file written so far and give corrected versions of any with mistakes."}
|
|
43
|
+
return [{"role": r, "task": task[r]} for r in ROLE_ORDER if r in roles]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def normalize(raw: Mapping | None, *, installed: Iterable[str] = (), recommended: str | None = None, user_set: Iterable[str] = (),
|
|
47
|
+
ready_tools: Iterable[str] | None = None) -> tuple[dict, list[str]]:
|
|
48
|
+
"""Any proposed plan -> (a valid plan, the notes about what had to change). `installed` are the local models that exist; `ready_tools` the
|
|
49
|
+
tools that can be used right now (None: every offered tool)."""
|
|
50
|
+
raw = dict(raw or {})
|
|
51
|
+
notes: list[str] = []
|
|
52
|
+
user_set = set(user_set)
|
|
53
|
+
installed = list(installed)
|
|
54
|
+
ready = set(OFFERED_TOOLS if ready_tools is None else ready_tools)
|
|
55
|
+
|
|
56
|
+
prod = _mapping(raw.get("product"))
|
|
57
|
+
kind = prod.get("kind") if prod.get("kind") in KINDS else "web_app"
|
|
58
|
+
name = _text(prod.get("name"), 60) or "My project"
|
|
59
|
+
product = {"name": name, "summary": _text(prod.get("summary"), 400), "kind": kind, "folder": slug(name)}
|
|
60
|
+
|
|
61
|
+
roles = []
|
|
62
|
+
for r in raw.get("roles") or []:
|
|
63
|
+
if isinstance(r, str) and r in ROLES and r not in roles:
|
|
64
|
+
roles.append(r)
|
|
65
|
+
elif isinstance(r, str) and r not in ROLES:
|
|
66
|
+
notes.append(f"dropped an agent that does not exist: {_text(r, 30)}")
|
|
67
|
+
roles = [r for r in ROLE_ORDER if r in roles][:MAX_ROLES]
|
|
68
|
+
if "coder" not in roles:
|
|
69
|
+
roles.insert(0, "coder")
|
|
70
|
+
notes.append("added the Coder: a build needs someone to write the code")
|
|
71
|
+
roles = [r for r in ROLE_ORDER if r in roles]
|
|
72
|
+
|
|
73
|
+
perms = dict(DEFAULT_PERMISSIONS)
|
|
74
|
+
given = _mapping(raw.get("permissions"))
|
|
75
|
+
for pid, want in given.items():
|
|
76
|
+
p = PERMISSION_BY_ID.get(pid)
|
|
77
|
+
if p is None:
|
|
78
|
+
notes.append(f"dropped a permission that does not exist: {_text(pid, 30)}")
|
|
79
|
+
continue
|
|
80
|
+
if p.locked:
|
|
81
|
+
if want:
|
|
82
|
+
notes.append(f"{p.title} stays off: {p.why_locked}")
|
|
83
|
+
continue
|
|
84
|
+
if want and p.risky and pid not in user_set:
|
|
85
|
+
notes.append(f"{p.title} stays off: only you can switch it on (use the switch in Access)")
|
|
86
|
+
continue
|
|
87
|
+
perms[pid] = bool(want)
|
|
88
|
+
|
|
89
|
+
tools = []
|
|
90
|
+
for t in raw.get("tools") or []:
|
|
91
|
+
if not isinstance(t, str) or t in tools:
|
|
92
|
+
continue
|
|
93
|
+
if t not in OFFERED_TOOLS:
|
|
94
|
+
notes.append(f"dropped a tool that is not available to builds: {_text(t, 30)}")
|
|
95
|
+
continue
|
|
96
|
+
need = tool_needs(t)
|
|
97
|
+
if need and not perms.get(need):
|
|
98
|
+
notes.append(f"left out {t}: it needs '{PERMISSION_BY_ID[need].title}', which is off")
|
|
99
|
+
continue
|
|
100
|
+
if t not in ready:
|
|
101
|
+
notes.append(f"left out {t}: it is not switched on in the settings")
|
|
102
|
+
continue
|
|
103
|
+
tools.append(t)
|
|
104
|
+
|
|
105
|
+
m = _mapping(raw.get("model"))
|
|
106
|
+
if m.get("mode") == "api" and m.get("provider") in PROVIDER_KEYS and _text(m.get("model"), 120):
|
|
107
|
+
model = {"mode": "api", "provider": m["provider"], "model": _text(m["model"], 120), "api_mode": mode_for(m["provider"])}
|
|
108
|
+
else:
|
|
109
|
+
pick = m.get("model") if m.get("mode") == "local" and m.get("model") in installed else (recommended or (installed[0] if installed else ""))
|
|
110
|
+
model = {"mode": "local", "model": pick or "", "provider": "ollama", "api_mode": ""}
|
|
111
|
+
if m.get("mode") == "local" and m.get("model") and m.get("model") not in installed and installed:
|
|
112
|
+
notes.append(f"{_text(m['model'], 40)} is not installed: using {pick}")
|
|
113
|
+
|
|
114
|
+
steps = []
|
|
115
|
+
for s in raw.get("steps") or []:
|
|
116
|
+
if isinstance(s, Mapping) and s.get("role") in roles and _text(s.get("task"), 300):
|
|
117
|
+
steps.append({"role": s["role"], "task": _text(s["task"], 300)})
|
|
118
|
+
steps = steps[:MAX_STEPS] or default_steps(roles, product["summary"])
|
|
119
|
+
|
|
120
|
+
reasons = {}
|
|
121
|
+
for key, why in _mapping(raw.get("reasons")).items():
|
|
122
|
+
kind_, _, ident = str(key).partition(":")
|
|
123
|
+
if (kind_ == "role" and ident in roles) or (kind_ == "tool" and ident in tools) or (kind_ == "permission" and ident in PERMISSION_BY_ID) or key in ("model", "metrics"):
|
|
124
|
+
reasons[str(key)] = _text(why, 200)
|
|
125
|
+
|
|
126
|
+
return {"product": product, "roles": roles, "tools": tools, "permissions": perms, "model": model, "steps": steps, "reasons": reasons,
|
|
127
|
+
"metrics": bool(raw.get("metrics"))}, notes
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def diff(old: Mapping, new: Mapping) -> list[str]:
|
|
131
|
+
"""What changed between two plans, in words the person can read."""
|
|
132
|
+
out: list[str] = []
|
|
133
|
+
for key, label in (("roles", "agent"), ("tools", "tool")):
|
|
134
|
+
a, b = set(old.get(key, [])), set(new.get(key, []))
|
|
135
|
+
out += [f"added {label}: {ROLES[x].name if key == 'roles' and x in ROLES else x}" for x in new.get(key, []) if x not in a]
|
|
136
|
+
out += [f"removed {label}: {ROLES[x].name if key == 'roles' and x in ROLES else x}" for x in old.get(key, []) if x not in b]
|
|
137
|
+
for pid, now in new.get("permissions", {}).items():
|
|
138
|
+
if old.get("permissions", {}).get(pid) != now:
|
|
139
|
+
out.append(f"{'turned on' if now else 'turned off'}: {PERMISSION_BY_ID[pid].title.lower()}")
|
|
140
|
+
if old.get("model") != new.get("model"):
|
|
141
|
+
nm = new["model"]
|
|
142
|
+
out.append(f"model: {nm['model'] or 'none'} ({'API, ' + nm['provider'] if nm['mode'] == 'api' else 'local'})")
|
|
143
|
+
if old.get("steps") != new.get("steps"):
|
|
144
|
+
out.append(f"steps changed ({len(new.get('steps', []))} now)")
|
|
145
|
+
if old.get("product") != new.get("product"):
|
|
146
|
+
out.append("product description changed")
|
|
147
|
+
if bool(old.get("metrics")) != bool(new.get("metrics")):
|
|
148
|
+
out.append("build twice to compare: " + ("on" if new.get("metrics") else "off"))
|
|
149
|
+
return out
|