localcode 0.4.0__py3-none-macosx_13_0_arm64.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 (173) hide show
  1. localcode/__init__.py +11 -0
  2. localcode/__main__.py +5 -0
  3. localcode/_subproc_env.py +54 -0
  4. localcode/agent/__init__.py +177 -0
  5. localcode/agent/app_tasks.py +157 -0
  6. localcode/agent/constants.py +275 -0
  7. localcode/agent/context.py +1152 -0
  8. localcode/agent/goal.py +244 -0
  9. localcode/agent/helpers.py +1036 -0
  10. localcode/agent/hollow_module.py +230 -0
  11. localcode/agent/hooks.py +414 -0
  12. localcode/agent/loop.py +2855 -0
  13. localcode/agent/plateau.py +297 -0
  14. localcode/agent/project_check_gate.py +91 -0
  15. localcode/agent/prompt_context.py +264 -0
  16. localcode/agent/prompts.py +368 -0
  17. localcode/agent/reasoning_loop.py +79 -0
  18. localcode/agent/recovery.py +606 -0
  19. localcode/agent/recovery_controller.py +25 -0
  20. localcode/agent/round_policy.py +23 -0
  21. localcode/agent/sections.py +241 -0
  22. localcode/agent/state_machine.py +100 -0
  23. localcode/agent/streaming.py +324 -0
  24. localcode/agent/tool_execution.py +368 -0
  25. localcode/agent/tool_orchestration.py +54 -0
  26. localcode/agent/turn_finalization.py +135 -0
  27. localcode/app.py +1595 -0
  28. localcode/approvals.py +17 -0
  29. localcode/auto_compact.py +176 -0
  30. localcode/autonomy.py +103 -0
  31. localcode/bin/__init__.py +0 -0
  32. localcode/bin/llama-server +0 -0
  33. localcode/bin/localcode-ui +4 -0
  34. localcode/bootstrap.py +1191 -0
  35. localcode/cache.py +194 -0
  36. localcode/checkpoint.py +375 -0
  37. localcode/compact.py +73 -0
  38. localcode/compaction.py +341 -0
  39. localcode/composer.py +72 -0
  40. localcode/config.py +521 -0
  41. localcode/context.py +161 -0
  42. localcode/display.py +58 -0
  43. localcode/embeddings.py +480 -0
  44. localcode/entrypoint.py +536 -0
  45. localcode/errors.py +350 -0
  46. localcode/events.py +409 -0
  47. localcode/evidence.py +102 -0
  48. localcode/execution_policy.py +48 -0
  49. localcode/features.py +308 -0
  50. localcode/formatting.py +91 -0
  51. localcode/headless_json.py +265 -0
  52. localcode/health.py +288 -0
  53. localcode/hf_quants.py +203 -0
  54. localcode/history.py +594 -0
  55. localcode/indexer.py +144 -0
  56. localcode/injection_defense.py +204 -0
  57. localcode/launcher.py +373 -0
  58. localcode/logging_utils.py +28 -0
  59. localcode/lsp.py +153 -0
  60. localcode/mcp/__init__.py +88 -0
  61. localcode/mcp/_bridge.py +61 -0
  62. localcode/mcp/_config.py +33 -0
  63. localcode/mcp/_transports.py +128 -0
  64. localcode/mcp/client.py +395 -0
  65. localcode/memory_guard.py +291 -0
  66. localcode/model_config.py +288 -0
  67. localcode/model_delete.py +444 -0
  68. localcode/model_families.py +279 -0
  69. localcode/models.py +236 -0
  70. localcode/models_catalog.py +901 -0
  71. localcode/notebook.py +123 -0
  72. localcode/output.py +403 -0
  73. localcode/patching.py +65 -0
  74. localcode/paths.py +303 -0
  75. localcode/performance.py +623 -0
  76. localcode/permissions.py +79 -0
  77. localcode/permissions_v2.py +310 -0
  78. localcode/plans.py +198 -0
  79. localcode/process_registry.py +271 -0
  80. localcode/protocol/__init__.py +56 -0
  81. localcode/protocol/events.py +116 -0
  82. localcode/protocol/jsonl.py +153 -0
  83. localcode/protocol/outcomes.py +231 -0
  84. localcode/reasoning_capabilities.py +59 -0
  85. localcode/recommendations.py +87 -0
  86. localcode/recovery.py +134 -0
  87. localcode/redaction.py +164 -0
  88. localcode/runtime.py +2331 -0
  89. localcode/server_manager.py +1069 -0
  90. localcode/session.py +296 -0
  91. localcode/shell.py +83 -0
  92. localcode/skills/debug.md +21 -0
  93. localcode/skills/edit-verified.md +30 -0
  94. localcode/skills/explain.md +19 -0
  95. localcode/skills/finish-verified.md +11 -0
  96. localcode/skills/git-commit-safely.md +30 -0
  97. localcode/skills/locate.md +21 -0
  98. localcode/skills/plan-task.md +51 -0
  99. localcode/skills/review.md +22 -0
  100. localcode/skills/run-tests.md +24 -0
  101. localcode/skills.py +728 -0
  102. localcode/snapshots.py +195 -0
  103. localcode/sounds.py +50 -0
  104. localcode/telemetry.py +273 -0
  105. localcode/theme.py +137 -0
  106. localcode/thermal.py +160 -0
  107. localcode/thinking.py +114 -0
  108. localcode/tool_router.py +246 -0
  109. localcode/toolkit.py +1241 -0
  110. localcode/tools/__init__.py +400 -0
  111. localcode/tools/agent.py +259 -0
  112. localcode/tools/append_file.py +55 -0
  113. localcode/tools/background_process.py +115 -0
  114. localcode/tools/base.py +168 -0
  115. localcode/tools/bash.py +1160 -0
  116. localcode/tools/code_navigation.py +97 -0
  117. localcode/tools/edit_diff.py +80 -0
  118. localcode/tools/edit_file.py +477 -0
  119. localcode/tools/facts.py +149 -0
  120. localcode/tools/glob_tool.py +60 -0
  121. localcode/tools/grep.py +65 -0
  122. localcode/tools/inspect_symbol.py +266 -0
  123. localcode/tools/launch_app.py +93 -0
  124. localcode/tools/list_files.py +42 -0
  125. localcode/tools/multi_edit.py +167 -0
  126. localcode/tools/plan_mode.py +86 -0
  127. localcode/tools/project_check.py +685 -0
  128. localcode/tools/read_file.py +261 -0
  129. localcode/tools/read_state.py +223 -0
  130. localcode/tools/skill_tool.py +41 -0
  131. localcode/tools/syntax_check.py +216 -0
  132. localcode/tools/todo_write.py +200 -0
  133. localcode/tools/tool_call_repair.py +157 -0
  134. localcode/tools/web_fetch.py +182 -0
  135. localcode/tools/web_search.py +45 -0
  136. localcode/tools/write_file.py +281 -0
  137. localcode/tui/__init__.py +1 -0
  138. localcode/tui/app.py +485 -0
  139. localcode/tui/bridge.py +78 -0
  140. localcode/tui/clipboard_image.py +153 -0
  141. localcode/tui/paste_collapse.py +95 -0
  142. localcode/tui/screens/__init__.py +0 -0
  143. localcode/tui/screens/chat.py +5052 -0
  144. localcode/tui/screens/confirm.py +123 -0
  145. localcode/tui/screens/mode_picker.py +79 -0
  146. localcode/tui/screens/model_picker.py +962 -0
  147. localcode/tui/screens/setup.py +891 -0
  148. localcode/tui/styles/__init__.py +0 -0
  149. localcode/tui/styles/app.tcss +197 -0
  150. localcode/tui/widgets/__init__.py +0 -0
  151. localcode/tui/widgets/approval.py +2 -0
  152. localcode/tui/widgets/chat_log.py +2063 -0
  153. localcode/tui/widgets/messages/__init__.py +21 -0
  154. localcode/tui/widgets/messages/diff.py +287 -0
  155. localcode/tui/widgets/voice_visualizer.py +122 -0
  156. localcode/turn_diff.py +180 -0
  157. localcode/ui/FORK_COMMIT +1 -0
  158. localcode/ui/__init__.py +58 -0
  159. localcode/ui/launch.py +204 -0
  160. localcode/ui/picker_cli.py +165 -0
  161. localcode/ui/plugin/localcode.ts +626 -0
  162. localcode/ui/ports.py +42 -0
  163. localcode/ui/server_cmd.py +89 -0
  164. localcode/ui/supervisor.py +736 -0
  165. localcode/undo.py +121 -0
  166. localcode/verification.py +87 -0
  167. localcode/voice.py +948 -0
  168. localcode-0.4.0.dist-info/METADATA +160 -0
  169. localcode-0.4.0.dist-info/RECORD +173 -0
  170. localcode-0.4.0.dist-info/WHEEL +5 -0
  171. localcode-0.4.0.dist-info/entry_points.txt +3 -0
  172. localcode-0.4.0.dist-info/licenses/LICENSE +201 -0
  173. localcode-0.4.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,55 @@
1
+ """append_file - append content to a text file, creating it when needed."""
2
+ from __future__ import annotations
3
+
4
+ from .base import ToolContext
5
+ from .write_file import _detect_stub_code
6
+
7
+
8
+ SCHEMA = {
9
+ "type": "function",
10
+ "function": {
11
+ "name": "append_file",
12
+ "description": (
13
+ "Append content to a text file, creating the file and parent "
14
+ "directories if needed. Use this for section-by-section file "
15
+ "creation or when adding a later section without an edit anchor."
16
+ ),
17
+ "parameters": {
18
+ "type": "object",
19
+ "properties": {
20
+ "path": {"type": "string"},
21
+ "content": {
22
+ "type": "string",
23
+ "description": "Content to append to the end of the file",
24
+ },
25
+ },
26
+ "required": ["path", "content"],
27
+ },
28
+ },
29
+ }
30
+
31
+
32
+ def execute(ctx: ToolContext, args: dict) -> str:
33
+ if "path" not in args:
34
+ return "Error: 'path' argument is required for append_file."
35
+ if "content" not in args:
36
+ return "Error: 'content' argument is required for append_file."
37
+
38
+ rel_path = str(args["path"])
39
+ path = ctx.resolve_write_path(rel_path)
40
+ if path.is_dir():
41
+ return f"Error: append_file cannot append to directory: {rel_path}"
42
+ content = str(args.get("content", ""))
43
+ stub = _detect_stub_code(content, rel_path)
44
+ if stub:
45
+ return stub
46
+
47
+ created = not path.exists()
48
+ path.parent.mkdir(parents=True, exist_ok=True)
49
+ existing = "" if created else path.read_text(errors="replace")
50
+ separator = "" if not existing or existing.endswith("\n") or content.startswith("\n") else "\n"
51
+ path.write_text(existing + separator + content)
52
+ appended_lines = content.count("\n") + (1 if content else 0)
53
+ total_lines = (existing + separator + content).count("\n") + 1
54
+ verb = "Created" if created else "Appended"
55
+ return f"{verb} {rel_path}; added {appended_lines} lines ({total_lines} total lines)"
@@ -0,0 +1,115 @@
1
+ """Explicit lifecycle tool for supervised background commands."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import signal
6
+ import subprocess
7
+ import time
8
+ from pathlib import Path
9
+
10
+ from .base import ToolContext
11
+ from .._subproc_env import clean_env
12
+ from ..process_registry import (
13
+ ProcessRecord,
14
+ find_record,
15
+ load_records,
16
+ mark_spawned,
17
+ record_process,
18
+ refresh_record,
19
+ save_records,
20
+ spawned_this_session,
21
+ )
22
+
23
+ SCHEMA = {
24
+ "type": "function",
25
+ "function": {
26
+ "name": "background_process",
27
+ "description": "Start, poll, list, or stop a supervised background command. Returns a stable process ID and incremental output.",
28
+ "parameters": {
29
+ "type": "object",
30
+ "properties": {
31
+ "action": {"type": "string", "enum": ["start", "poll", "list", "stop"]},
32
+ "command": {"type": "string", "description": "Required for start."},
33
+ "process_id": {"type": "string", "description": "Required for poll/stop."},
34
+ "offset": {"type": "integer", "description": "Byte offset for incremental poll output.", "default": 0},
35
+ "owner": {"type": "string", "description": "Logical owner label.", "default": "agent"},
36
+ },
37
+ "required": ["action"],
38
+ },
39
+ },
40
+ }
41
+
42
+ def _status(record: ProcessRecord) -> str:
43
+ return "stopped" if record.stopped_at else "running"
44
+
45
+ def execute(ctx: ToolContext, args: dict) -> str:
46
+ action = str(args.get("action", ""))
47
+ if action == "list":
48
+ records = load_records(ctx.repo)
49
+ if not records:
50
+ return "No supervised background processes."
51
+ return "\n".join(f"{r.process_id} {_status(refresh_record(ctx.repo, r.process_id) or r)} owner={r.owner} pid={r.pid} command={r.command}" for r in records[-50:])
52
+ process_id = str(args.get("process_id", ""))
53
+ if action in {"poll", "stop"} and not process_id:
54
+ return f"Error: process_id is required for {action}."
55
+ if action == "start":
56
+ command = str(args.get("command", "")).strip()
57
+ if not command:
58
+ return "Error: command is required for start."
59
+ log_dir = ctx.repo / ".localcode" / "process-logs"
60
+ log_dir.mkdir(parents=True, exist_ok=True)
61
+ provisional = ProcessRecord(pid=0, pgid=0, port=0, url="", cwd=str(ctx.repo), kind="background", command=command, log_path="", verified=False, started_at=time.time(), owner=str(args.get("owner", "agent")))
62
+ log_path = log_dir / f"{provisional.process_id}.log"
63
+ stream = log_path.open("ab", buffering=0)
64
+ try:
65
+ proc = subprocess.Popen(command, cwd=ctx.repo, shell=True, executable="/bin/sh", stdout=stream, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL, env=clean_env(), start_new_session=True)
66
+ finally:
67
+ stream.close()
68
+ mark_spawned(proc.pid)
69
+ provisional.pid = proc.pid
70
+ provisional.pgid = proc.pid
71
+ provisional.log_path = str(log_path)
72
+ record_process(ctx.repo, provisional)
73
+ return f"Started {provisional.process_id}: status=running pid={proc.pid} owner={provisional.owner} output_offset=0"
74
+ record = refresh_record(ctx.repo, process_id)
75
+ if record is None:
76
+ return f"Error: unknown process_id: {process_id}"
77
+ if action == "stop":
78
+ if not record.stopped_at:
79
+ target = record.pgid or record.pid
80
+ # Registry records are durable on-disk state. Refuse to signal
81
+ # any pid this LocalCode process did not spawn — a crafted or
82
+ # stale record must never drive killpg at an arbitrary pid.
83
+ if target <= 1 or not (spawned_this_session(record.pid) or spawned_this_session(target)):
84
+ return (
85
+ f"Error: refusing to stop {process_id}: its pid was not "
86
+ "started by this session (stop it manually if needed)."
87
+ )
88
+ try:
89
+ os.killpg(target, signal.SIGTERM)
90
+ except ProcessLookupError:
91
+ pass
92
+ except OSError as exc:
93
+ return f"Error: could not stop {process_id}: {exc}"
94
+ records = load_records(ctx.repo)
95
+ for item in records:
96
+ if item.process_id == process_id:
97
+ item.stopped_at = time.time()
98
+ save_records(ctx.repo, records)
99
+ return f"Stopped {process_id}."
100
+ if action == "poll":
101
+ offset = max(0, int(args.get("offset", 0)))
102
+ data = b""
103
+ try:
104
+ with Path(record.log_path).open("rb") as handle:
105
+ handle.seek(offset)
106
+ data = handle.read(64_000)
107
+ next_offset = handle.tell()
108
+ except OSError:
109
+ next_offset = offset
110
+ output = data.decode(errors="replace")
111
+ return f"id={process_id} status={_status(record)} pid={record.pid} next_offset={next_offset}\n{output}".rstrip()
112
+ return f"Error: unsupported action: {action}"
113
+
114
+ def is_concurrency_safe(args: dict) -> bool:
115
+ return str(args.get("action")) in {"poll", "list"}
@@ -0,0 +1,168 @@
1
+ """Shared types for tool modules.
2
+
3
+ Each tool lives in its own file under src/localcode/tools/ and exports two
4
+ symbols:
5
+
6
+ SCHEMA: dict — the OpenAI-compatible function schema handed to the
7
+ model. See TOOL_SCHEMAS usage in src/localcode/agent.py.
8
+
9
+ execute: callable (ctx: ToolContext, args: dict) -> str — invoked
10
+ when the model picks this tool. Returns the result string
11
+ that's fed back as the `tool` message.
12
+
13
+ Per-tool files + co-located schema+exec is the minimal-agent / terminal coding tools Agent
14
+ SDK convention. Makes adding a tool a one-file change, keeps description
15
+ next to the code that implements it, and enables per-tool unit tests.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass, field
20
+ from pathlib import Path
21
+ from typing import TYPE_CHECKING, Protocol
22
+
23
+ if TYPE_CHECKING:
24
+ from ..app import LocalCodeApp
25
+ from ..output import OutputManager
26
+
27
+
28
+ @dataclass
29
+ class ToolContext:
30
+ """Everything a tool might need to do its job.
31
+
32
+ Keep this deliberately small — if a tool can be done with just
33
+ `repo` (Path) and `args` (dict), prefer that. Reach for `app` or
34
+ `out` only when you need the full agent state or the TUI output
35
+ manager.
36
+ """
37
+ app: "LocalCodeApp"
38
+ out: "OutputManager"
39
+
40
+ @property
41
+ def repo(self) -> Path:
42
+ return self.app.repo_root
43
+
44
+ @property
45
+ def check_root(self) -> Path:
46
+ """Where project checks (ruff/tsc/...) may scan — see app.check_root."""
47
+ return getattr(self.app, "check_root", None) or self.app.repo_root
48
+
49
+ def resolve_path(self, raw: str) -> Path:
50
+ """Resolve a tool `path` argument, healing corrupted repo prefixes.
51
+
52
+ Small quantized models corrupt long absolute paths when they copy
53
+ them between calls. Observed live (2026-07-13): the model turned the
54
+ workdir `…/localcode-evals/20260713-024502/tempconv` into
55
+ `…/localcode-evals/2026-0713-0245-02/tempconv` — hyphens
56
+ hallucinated into the timestamp — and every subsequent write landed
57
+ in a phantom tree while the real project stayed empty. Same class of
58
+ fix as fuzzy old_string matching in edit_file: if an absolute path
59
+ is NOT under repo_root but its leading components are a near-match
60
+ of repo_root's (difflib ratio ≥ 0.8 per component), remap that
61
+ prefix onto the real repo_root. Anything else passes through
62
+ untouched — reads intentionally have full filesystem access.
63
+
64
+ NOTE: this method does NOT contain the path. Write tools must call
65
+ `resolve_write_path()` instead, which applies the same healing and
66
+ then refuses anything that lands outside the project root.
67
+ """
68
+ p = self.repo / raw
69
+ try:
70
+ root = self.repo.resolve()
71
+ rp = p.resolve()
72
+ if root == rp or root in rp.parents:
73
+ return p # already inside the project — nothing to heal
74
+ root_parts = root.parts
75
+ path_parts = rp.parts
76
+ if len(path_parts) <= len(root_parts):
77
+ return p
78
+ import difflib
79
+ for a, b in zip(root_parts, path_parts):
80
+ if a == b:
81
+ continue
82
+ if difflib.SequenceMatcher(None, a, b).ratio() < 0.8:
83
+ return p # genuinely different location — respect it
84
+ healed = root.joinpath(*path_parts[len(root_parts):])
85
+ try:
86
+ from ..events import emit as _emit
87
+ _emit("path_remap", raw=str(raw)[:200], healed=str(healed)[:200])
88
+ except Exception:
89
+ pass
90
+ return healed
91
+ except Exception:
92
+ return p
93
+
94
+ def resolve_write_path(self, raw: str) -> Path:
95
+ """Resolve a WRITE target: heal first, then enforce containment.
96
+
97
+ Healing runs exactly as it does for reads (small quants mangle long
98
+ absolute paths, and a mangled path that near-matches repo_root is
99
+ remapped onto it). Containment is applied to the healed result, so
100
+ the heal feature keeps working while `/Users/victim/.zshrc`,
101
+ `../../../etc/hosts`, and a repo-internal symlink pointing outside
102
+ all raise `PathContainmentError`.
103
+
104
+ Escape hatches, in order:
105
+ * the agent's notebook scratch dir (sanctioned, may live outside
106
+ the project root), and
107
+ * paths the USER has explicitly approved this session, recorded on
108
+ `app._approved_write_paths` — approval is a human decision, so an
109
+ approved path is never re-blocked here.
110
+ """
111
+ from ..paths import PathContainmentError, contain_write_path
112
+
113
+ healed = self.resolve_path(raw)
114
+
115
+ approved = getattr(self.app, "_approved_write_paths", None)
116
+ if approved:
117
+ try:
118
+ if str(Path(healed).resolve()) in {str(Path(a).resolve()) for a in approved}:
119
+ return Path(healed).resolve()
120
+ except Exception:
121
+ pass
122
+
123
+ try:
124
+ return contain_write_path(healed, self.repo)
125
+ except PathContainmentError:
126
+ # Outside the project — the notebook scratch dir is the one
127
+ # sanctioned exception. Checked lazily so the common (contained)
128
+ # case never touches the notebook module.
129
+ try:
130
+ from ..notebook import is_within_notebook
131
+ if is_within_notebook(Path(healed)):
132
+ return Path(healed).resolve()
133
+ except Exception:
134
+ pass
135
+ try:
136
+ from ..events import emit as _emit
137
+ _emit("write_containment_block", raw=str(raw)[:200],
138
+ resolved=str(healed)[:200])
139
+ except Exception:
140
+ pass
141
+ raise
142
+
143
+
144
+ @dataclass
145
+ class ToolResult:
146
+ """Internal typed tool result.
147
+
148
+ Tools may continue returning plain strings. The dispatcher normalizes
149
+ both forms to this type so the agent loop can reason over facts while
150
+ preserving the exact text sent back to the model/UI.
151
+ """
152
+ text: str
153
+ ok: bool = True
154
+ facts: dict = field(default_factory=dict)
155
+
156
+ def __str__(self) -> str:
157
+ return self.text
158
+
159
+
160
+ class ToolModule(Protocol):
161
+ """Structural type every tool module satisfies."""
162
+ SCHEMA: dict
163
+
164
+ @staticmethod
165
+ def execute(ctx: ToolContext, args: dict) -> str: ... # noqa: E704
166
+
167
+ @staticmethod
168
+ def is_concurrency_safe(args: dict) -> bool: ... # noqa: E704