abstractcode 0.2.0__py3-none-any.whl → 0.3.1__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.
- abstractcode/__init__.py +1 -1
- abstractcode/cli.py +911 -9
- abstractcode/file_mentions.py +276 -0
- abstractcode/flow_cli.py +1413 -0
- abstractcode/fullscreen_ui.py +2473 -158
- abstractcode/gateway_cli.py +715 -0
- abstractcode/py.typed +1 -0
- abstractcode/react_shell.py +8140 -546
- abstractcode/recall.py +384 -0
- abstractcode/remember.py +184 -0
- abstractcode/terminal_markdown.py +557 -0
- abstractcode/theme.py +244 -0
- abstractcode/workflow_agent.py +1412 -0
- abstractcode/workflow_cli.py +229 -0
- abstractcode-0.3.1.dist-info/METADATA +158 -0
- abstractcode-0.3.1.dist-info/RECORD +21 -0
- {abstractcode-0.2.0.dist-info → abstractcode-0.3.1.dist-info}/WHEEL +1 -1
- abstractcode-0.2.0.dist-info/METADATA +0 -160
- abstractcode-0.2.0.dist-info/RECORD +0 -11
- {abstractcode-0.2.0.dist-info → abstractcode-0.3.1.dist-info}/entry_points.txt +0 -0
- {abstractcode-0.2.0.dist-info → abstractcode-0.3.1.dist-info}/licenses/LICENSE +0 -0
- {abstractcode-0.2.0.dist-info → abstractcode-0.3.1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Iterable, List, Tuple
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
_AT_MENTION_RE = re.compile(r"(^|\s)@([^\s]+)")
|
|
10
|
+
_TRAILING_PUNCT = ".,;:!?)]}>\"'"
|
|
11
|
+
_MOUNT_NAME_RE = re.compile(r"^[a-zA-Z0-9_-]{1,32}$")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def default_workspace_root(*, cwd: Path | None = None) -> Path:
|
|
15
|
+
"""Return the workspace root used by AbstractCode for `@file` mentions.
|
|
16
|
+
|
|
17
|
+
Preference order:
|
|
18
|
+
- `ABSTRACTCODE_WORKSPACE_DIR`
|
|
19
|
+
- `ABSTRACTGATEWAY_WORKSPACE_DIR` (common shared convention)
|
|
20
|
+
- `cwd` (or `Path.cwd()`)
|
|
21
|
+
"""
|
|
22
|
+
raw = os.environ.get("ABSTRACTCODE_WORKSPACE_DIR") or os.environ.get("ABSTRACTGATEWAY_WORKSPACE_DIR")
|
|
23
|
+
if isinstance(raw, str) and raw.strip():
|
|
24
|
+
return Path(raw).expanduser().resolve()
|
|
25
|
+
base = cwd if isinstance(cwd, Path) else Path.cwd()
|
|
26
|
+
return base.resolve()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def extract_at_file_mentions(text: str) -> Tuple[str, List[str]]:
|
|
30
|
+
"""Extract `@file` mentions from a text prompt.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
(cleaned_text, mentions)
|
|
34
|
+
|
|
35
|
+
Notes:
|
|
36
|
+
- Mentions must start at the beginning or be preceded by whitespace.
|
|
37
|
+
- Mentions run until the next whitespace.
|
|
38
|
+
- Common trailing punctuation is stripped from the mention token.
|
|
39
|
+
"""
|
|
40
|
+
raw = str(text or "")
|
|
41
|
+
mentions: List[str] = []
|
|
42
|
+
|
|
43
|
+
def _repl(m: re.Match[str]) -> str:
|
|
44
|
+
tok = str(m.group(2) or "")
|
|
45
|
+
tok = tok.rstrip(_TRAILING_PUNCT).strip()
|
|
46
|
+
if tok:
|
|
47
|
+
mentions.append(tok)
|
|
48
|
+
# Keep the leading whitespace (or empty start-of-string).
|
|
49
|
+
return str(m.group(1) or "")
|
|
50
|
+
|
|
51
|
+
cleaned = _AT_MENTION_RE.sub(_repl, raw)
|
|
52
|
+
cleaned = re.sub(r"\s{2,}", " ", cleaned).strip()
|
|
53
|
+
return cleaned, mentions
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def find_at_file_mentions(text: str) -> List[str]:
|
|
57
|
+
"""Return `@file` mention tokens without mutating the original text."""
|
|
58
|
+
raw = str(text or "")
|
|
59
|
+
out: List[str] = []
|
|
60
|
+
for m in _AT_MENTION_RE.finditer(raw):
|
|
61
|
+
tok = str(m.group(2) or "")
|
|
62
|
+
tok = tok.rstrip(_TRAILING_PUNCT).strip()
|
|
63
|
+
if tok:
|
|
64
|
+
out.append(tok)
|
|
65
|
+
return out
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def parse_workspace_mounts(raw: str) -> dict[str, Path]:
|
|
69
|
+
"""Parse newline-separated `name=/abs/path` mount entries (best-effort)."""
|
|
70
|
+
out: dict[str, Path] = {}
|
|
71
|
+
for ln in str(raw or "").splitlines():
|
|
72
|
+
line = str(ln or "").strip()
|
|
73
|
+
if not line or line.startswith("#"):
|
|
74
|
+
continue
|
|
75
|
+
if "=" not in line:
|
|
76
|
+
continue
|
|
77
|
+
name, path = line.split("=", 1)
|
|
78
|
+
name = name.strip()
|
|
79
|
+
path = path.strip()
|
|
80
|
+
if not name or not _MOUNT_NAME_RE.match(name):
|
|
81
|
+
continue
|
|
82
|
+
if not path:
|
|
83
|
+
continue
|
|
84
|
+
try:
|
|
85
|
+
p = Path(path).expanduser()
|
|
86
|
+
if not p.is_absolute():
|
|
87
|
+
continue
|
|
88
|
+
resolved = p.resolve()
|
|
89
|
+
except Exception:
|
|
90
|
+
continue
|
|
91
|
+
try:
|
|
92
|
+
if not resolved.exists() or not resolved.is_dir():
|
|
93
|
+
continue
|
|
94
|
+
except Exception:
|
|
95
|
+
continue
|
|
96
|
+
out[name] = resolved
|
|
97
|
+
return dict(out)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def default_workspace_mounts() -> dict[str, Path]:
|
|
101
|
+
raw = os.environ.get("ABSTRACTCODE_WORKSPACE_MOUNTS") or os.environ.get("ABSTRACTGATEWAY_WORKSPACE_MOUNTS") or ""
|
|
102
|
+
return parse_workspace_mounts(raw)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _is_safe_relpath(path: str) -> bool:
|
|
106
|
+
p = str(path or "").strip()
|
|
107
|
+
if not p:
|
|
108
|
+
return False
|
|
109
|
+
if p.startswith(("/", "\\")):
|
|
110
|
+
return False
|
|
111
|
+
# Disallow drive-letter absolute paths (Windows-style).
|
|
112
|
+
if len(p) >= 2 and p[1] == ":":
|
|
113
|
+
return False
|
|
114
|
+
return True
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def normalize_relative_path(path: str) -> str:
|
|
118
|
+
p = str(path or "").strip()
|
|
119
|
+
p = p.replace("\\", "/")
|
|
120
|
+
if not _is_safe_relpath(p):
|
|
121
|
+
return ""
|
|
122
|
+
# Collapse a leading "./" for nicer UX and more stable matching.
|
|
123
|
+
if p.startswith("./"):
|
|
124
|
+
p = p[2:]
|
|
125
|
+
return p
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def resolve_workspace_path(
|
|
129
|
+
*,
|
|
130
|
+
raw_path: str,
|
|
131
|
+
workspace_root: Path,
|
|
132
|
+
mounts: dict[str, Path],
|
|
133
|
+
) -> tuple[Path, str, str | None, Path]:
|
|
134
|
+
"""Resolve a virtual path against workspace_root + mounts.
|
|
135
|
+
|
|
136
|
+
Virtual path grammar:
|
|
137
|
+
- Primary root: `docs/readme.md`
|
|
138
|
+
- Mount root: `mount/path/to/file.md` (mount must be in `mounts`)
|
|
139
|
+
|
|
140
|
+
Notes:
|
|
141
|
+
- Mount resolution requires `mount/...` (at least one `/`) to avoid collisions.
|
|
142
|
+
- Absolute paths are allowed only when under workspace_root or a mount root.
|
|
143
|
+
"""
|
|
144
|
+
raw = str(raw_path or "").strip()
|
|
145
|
+
if not raw:
|
|
146
|
+
raise ValueError("Empty path")
|
|
147
|
+
|
|
148
|
+
cleaned = raw.replace("\\", "/")
|
|
149
|
+
p = Path(cleaned).expanduser()
|
|
150
|
+
root = Path(workspace_root).expanduser()
|
|
151
|
+
|
|
152
|
+
if p.is_absolute():
|
|
153
|
+
resolved = p.resolve()
|
|
154
|
+
candidates: list[tuple[int, str | None, Path]] = []
|
|
155
|
+
try:
|
|
156
|
+
resolved.relative_to(root)
|
|
157
|
+
candidates.append((len(str(root)), None, root))
|
|
158
|
+
except Exception:
|
|
159
|
+
pass
|
|
160
|
+
for name, mroot in (mounts or {}).items():
|
|
161
|
+
if not isinstance(mroot, Path):
|
|
162
|
+
continue
|
|
163
|
+
try:
|
|
164
|
+
resolved.relative_to(mroot)
|
|
165
|
+
candidates.append((len(str(mroot)), str(name), mroot))
|
|
166
|
+
except Exception:
|
|
167
|
+
continue
|
|
168
|
+
if not candidates:
|
|
169
|
+
raise ValueError("Path is outside workspace roots")
|
|
170
|
+
candidates.sort(key=lambda x: x[0], reverse=True)
|
|
171
|
+
_len, mount, selected_root = candidates[0]
|
|
172
|
+
rel = resolved.relative_to(selected_root).as_posix()
|
|
173
|
+
virt = f"{mount}/{rel}" if mount and rel else (str(mount) if mount else rel)
|
|
174
|
+
return resolved, virt, mount, selected_root
|
|
175
|
+
|
|
176
|
+
virt_raw = cleaned
|
|
177
|
+
while virt_raw.startswith("./"):
|
|
178
|
+
virt_raw = virt_raw[2:]
|
|
179
|
+
|
|
180
|
+
parts = [seg for seg in virt_raw.split("/") if seg not in ("", ".")]
|
|
181
|
+
mount: str | None = None
|
|
182
|
+
selected_root = root
|
|
183
|
+
rel_part = virt_raw
|
|
184
|
+
if len(parts) >= 2 and parts[0] in (mounts or {}):
|
|
185
|
+
mount = parts[0]
|
|
186
|
+
selected_root = mounts[mount]
|
|
187
|
+
rel_part = "/".join(parts[1:])
|
|
188
|
+
|
|
189
|
+
resolved = (selected_root / Path(rel_part)).resolve()
|
|
190
|
+
try:
|
|
191
|
+
resolved.relative_to(selected_root)
|
|
192
|
+
except Exception:
|
|
193
|
+
raise ValueError("Path escapes workspace root")
|
|
194
|
+
|
|
195
|
+
rel_norm = resolved.relative_to(selected_root).as_posix()
|
|
196
|
+
virt_norm = f"{mount}/{rel_norm}" if mount and rel_norm else (str(mount) if mount else rel_norm)
|
|
197
|
+
return resolved, virt_norm, mount, selected_root
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def list_workspace_files(*, root: Path, ignore, max_files: int = 20000) -> List[str]:
|
|
201
|
+
"""Return a best-effort list of workspace-relative file paths (POSIX style)."""
|
|
202
|
+
import os
|
|
203
|
+
|
|
204
|
+
base = Path(root).resolve()
|
|
205
|
+
out: List[str] = []
|
|
206
|
+
|
|
207
|
+
for dirpath, dirnames, filenames in os.walk(base):
|
|
208
|
+
cur = Path(dirpath)
|
|
209
|
+
|
|
210
|
+
# Prune ignored directories (in-place).
|
|
211
|
+
kept: list[str] = []
|
|
212
|
+
for d in dirnames:
|
|
213
|
+
p = cur / d
|
|
214
|
+
try:
|
|
215
|
+
if ignore is not None and ignore.is_ignored(p, is_dir=True):
|
|
216
|
+
continue
|
|
217
|
+
except Exception:
|
|
218
|
+
pass
|
|
219
|
+
kept.append(d)
|
|
220
|
+
dirnames[:] = kept
|
|
221
|
+
|
|
222
|
+
for fn in filenames:
|
|
223
|
+
p = cur / fn
|
|
224
|
+
try:
|
|
225
|
+
if ignore is not None and ignore.is_ignored(p, is_dir=False):
|
|
226
|
+
continue
|
|
227
|
+
except Exception:
|
|
228
|
+
pass
|
|
229
|
+
try:
|
|
230
|
+
rel = p.relative_to(base).as_posix()
|
|
231
|
+
except Exception:
|
|
232
|
+
continue
|
|
233
|
+
if not rel:
|
|
234
|
+
continue
|
|
235
|
+
out.append(rel)
|
|
236
|
+
if len(out) >= int(max_files):
|
|
237
|
+
return out
|
|
238
|
+
|
|
239
|
+
return out
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def search_workspace_files(files: Iterable[str], query: str, *, limit: int = 25) -> List[str]:
|
|
243
|
+
"""Return ranked file-path candidates for a user query (case-insensitive)."""
|
|
244
|
+
q = str(query or "").strip().lower()
|
|
245
|
+
if not q:
|
|
246
|
+
return []
|
|
247
|
+
|
|
248
|
+
scored: list[tuple[tuple[int, int, int, str], str]] = []
|
|
249
|
+
for path in files:
|
|
250
|
+
p = str(path or "").strip()
|
|
251
|
+
if not p:
|
|
252
|
+
continue
|
|
253
|
+
p_low = p.lower()
|
|
254
|
+
name = p.split("/")[-1].lower()
|
|
255
|
+
|
|
256
|
+
# Scoring: lower tuple sorts first.
|
|
257
|
+
# - 0: basename startswith
|
|
258
|
+
# - 1: path startswith
|
|
259
|
+
# - 2: basename contains
|
|
260
|
+
# - 3: path contains
|
|
261
|
+
if name.startswith(q):
|
|
262
|
+
key = (0, len(p), 0, p_low)
|
|
263
|
+
elif p_low.startswith(q):
|
|
264
|
+
key = (1, len(p), 0, p_low)
|
|
265
|
+
elif q in name:
|
|
266
|
+
key = (2, len(p), name.find(q), p_low)
|
|
267
|
+
elif q in p_low:
|
|
268
|
+
key = (3, len(p), p_low.find(q), p_low)
|
|
269
|
+
else:
|
|
270
|
+
continue
|
|
271
|
+
|
|
272
|
+
scored.append((key, p))
|
|
273
|
+
|
|
274
|
+
scored.sort(key=lambda x: x[0])
|
|
275
|
+
out = [p for _, p in scored]
|
|
276
|
+
return out[: int(limit)]
|