omega-code 0.4.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.
- omega/__init__.py +0 -0
- omega/__main__.py +589 -0
- omega/artifacts.py +151 -0
- omega/checkpoint.py +246 -0
- omega/compact.py +106 -0
- omega/config.py +285 -0
- omega/eval/__init__.py +3 -0
- omega/eval/cli.py +127 -0
- omega/eval/examples/plan-version-flag.yaml +11 -0
- omega/eval/examples/relative-age-negative-delta.yaml +14 -0
- omega/eval/examples/version-flag.yaml +10 -0
- omega/eval/manifest.py +129 -0
- omega/eval/prices.py +29 -0
- omega/eval/report.py +135 -0
- omega/eval/runner.py +199 -0
- omega/eval/tasks.py +97 -0
- omega/events.py +145 -0
- omega/export.py +80 -0
- omega/gitlog.py +229 -0
- omega/hooks.py +63 -0
- omega/instructions.py +103 -0
- omega/integrations.py +284 -0
- omega/keys.py +173 -0
- omega/llm.py +442 -0
- omega/loop.py +510 -0
- omega/mcp.py +490 -0
- omega/memory/__init__.py +5 -0
- omega/memory/consolidate.py +103 -0
- omega/memory/curate.py +69 -0
- omega/memory/store.py +321 -0
- omega/memory/tools.py +175 -0
- omega/migrate.py +40 -0
- omega/onboarding.py +242 -0
- omega/permissions.py +137 -0
- omega/secrets.py +173 -0
- omega/server/__init__.py +7 -0
- omega/server/__main__.py +18 -0
- omega/server/app.py +71 -0
- omega/server/auth.py +73 -0
- omega/server/manager.py +287 -0
- omega/server/models.py +123 -0
- omega/server/tasks_api.py +311 -0
- omega/server/terminals.py +245 -0
- omega/server/worker.py +186 -0
- omega/session.py +209 -0
- omega/setup.html +281 -0
- omega/setup_server.py +452 -0
- omega/skills.py +158 -0
- omega/subagent.py +98 -0
- omega/tasks.py +195 -0
- omega/tools.py +590 -0
- omega/trace.py +156 -0
- omega/trajectory.py +146 -0
- omega/ui/__init__.py +0 -0
- omega/ui/composer.py +140 -0
- omega/ui/format.py +708 -0
- omega/ui/plain.py +141 -0
- omega/ui/tui/__init__.py +9 -0
- omega/ui/tui/app.py +958 -0
- omega/ui/tui/history.py +50 -0
- omega/ui/tui/modals.py +292 -0
- omega/ui/tui/onboarding.py +367 -0
- omega/ui/tui/prefs.py +25 -0
- omega/ui/tui/sidebar.py +510 -0
- omega/ui/tui/status.py +115 -0
- omega/ui/tui/theme.py +91 -0
- omega/ui/tui/transcript.py +783 -0
- omega/verify.py +133 -0
- omega_code-0.4.0.dist-info/METADATA +479 -0
- omega_code-0.4.0.dist-info/RECORD +73 -0
- omega_code-0.4.0.dist-info/WHEEL +4 -0
- omega_code-0.4.0.dist-info/entry_points.txt +2 -0
- omega_code-0.4.0.dist-info/licenses/LICENSE +21 -0
omega/trace.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Per-session JSONL event trace: every `events.Event` a session emits is
|
|
2
|
+
appended as one line to `~/.omega/sessions/<id>/trace.jsonl`, independent of
|
|
3
|
+
what either UI chooses to render -- so `omega trace <id>` can replay a
|
|
4
|
+
session's full timeline (including events a UI drops on the floor) after the
|
|
5
|
+
fact, and price it against `omega.eval.prices`."""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import asdict, is_dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from . import events, session
|
|
15
|
+
|
|
16
|
+
_TOOL_TYPES = {"ToolStart", "ToolEnd"}
|
|
17
|
+
|
|
18
|
+
_GLYPH = {
|
|
19
|
+
"ToolStart": "●", "ToolEnd": "└", "SubagentSpawned": "●", "SubagentDone": "✓",
|
|
20
|
+
"Compacted": "⏺", "MemoryWrite": "◆", "MemoryConsolidated": "◆", "Error": "✗",
|
|
21
|
+
"Done": "●", "Fallback": "⇄", "ModelUsed": "◇", "Usage": "◇", "Phase": "·",
|
|
22
|
+
"Checkpoint": "⎘", "Verified": "✓", "JobStarted": "⟳", "JobFinished": "✓",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def trace_path(session_id: str) -> Path:
|
|
27
|
+
return session.DIR / session_id / "trace.jsonl"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def append(session_id: str, ev: events.Event, turn: int) -> None:
|
|
31
|
+
"""Fire-and-forget: observability must never be able to break a turn, so
|
|
32
|
+
any failure here (disk full, permissions, an event with no dataclass
|
|
33
|
+
fields) is swallowed rather than raised."""
|
|
34
|
+
try:
|
|
35
|
+
if not is_dataclass(ev):
|
|
36
|
+
return
|
|
37
|
+
path = trace_path(session_id)
|
|
38
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
line = {"t": time.time(), "turn": turn, "type": type(ev).__name__, **asdict(ev)}
|
|
40
|
+
with path.open("a") as f:
|
|
41
|
+
f.write(json.dumps(line) + "\n")
|
|
42
|
+
except Exception:
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _read(session_id: str) -> list[dict[str, Any]]:
|
|
47
|
+
"""Tolerates a truncated trailing line the same way `session._read_jsonl`
|
|
48
|
+
does -- appends are not fsynced, so a crash mid-write leaves one."""
|
|
49
|
+
path = trace_path(session_id)
|
|
50
|
+
if not path.exists():
|
|
51
|
+
return []
|
|
52
|
+
rows: list[dict[str, Any]] = []
|
|
53
|
+
for line in path.read_text().splitlines():
|
|
54
|
+
line = line.strip()
|
|
55
|
+
if not line:
|
|
56
|
+
continue
|
|
57
|
+
try:
|
|
58
|
+
rows.append(json.loads(line))
|
|
59
|
+
except json.JSONDecodeError:
|
|
60
|
+
break
|
|
61
|
+
return rows
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _summary(row: dict[str, Any]) -> str:
|
|
65
|
+
t = row.get("type", "")
|
|
66
|
+
if t == "ToolStart":
|
|
67
|
+
return f"{row.get('name', '')} {row.get('args_preview', '')}"
|
|
68
|
+
if t == "ToolEnd":
|
|
69
|
+
dur = float(row.get("duration_s") or 0.0)
|
|
70
|
+
return f"{row.get('name', '')} {row.get('outcome', '')} ({dur:.2f}s)".strip()
|
|
71
|
+
if t == "Done":
|
|
72
|
+
return " ".join(str(row.get("text", "")).split())[:80]
|
|
73
|
+
if t == "Error":
|
|
74
|
+
message = str(row.get("message", ""))
|
|
75
|
+
return message.splitlines()[0] if message else ""
|
|
76
|
+
if t == "SubagentSpawned":
|
|
77
|
+
return f"subagent({row.get('tier', '')}) {row.get('task_preview', '')}"
|
|
78
|
+
if t == "SubagentDone":
|
|
79
|
+
return f"subagent {row.get('subagent_id', '')} done"
|
|
80
|
+
if t == "Compacted":
|
|
81
|
+
return str(row.get("note", ""))
|
|
82
|
+
if t == "MemoryWrite":
|
|
83
|
+
return f"{row.get('type', '')} '{row.get('title', '')}' ({row.get('scope', '')})"
|
|
84
|
+
if t == "MemoryConsolidated":
|
|
85
|
+
return str(row.get("summary", ""))
|
|
86
|
+
if t == "ModelUsed":
|
|
87
|
+
alias = row.get("alias") or ""
|
|
88
|
+
return f"{alias} {row.get('model', '')}".strip()
|
|
89
|
+
if t == "Usage":
|
|
90
|
+
return f"{row.get('prompt_tokens', 0)} in / {row.get('completion_tokens', 0)} out"
|
|
91
|
+
if t == "Fallback":
|
|
92
|
+
return f"{row.get('from_model', '')} -> {row.get('to_model', '')}: {row.get('reason', '')}"
|
|
93
|
+
if t == "Phase":
|
|
94
|
+
return str(row.get("state", ""))
|
|
95
|
+
if t == "Checkpoint":
|
|
96
|
+
return f"checkpoint {row.get('id', '')}"
|
|
97
|
+
if t == "Verified":
|
|
98
|
+
return str(row.get("results_summary", ""))
|
|
99
|
+
if t == "JobStarted":
|
|
100
|
+
return f"{row.get('id', '')} {row.get('command', '')}"
|
|
101
|
+
if t == "JobFinished":
|
|
102
|
+
return f"{row.get('id', '')} exit {row.get('exit_code', '')}"
|
|
103
|
+
return ""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _turn_totals(rows: list[dict[str, Any]]) -> dict[int, tuple[int, int, float | None]]:
|
|
107
|
+
"""`turn -> (tokens_in, tokens_out, cost_usd)`, summed from `Usage` rows
|
|
108
|
+
and priced against whichever alias the last `ModelUsed` row announced --
|
|
109
|
+
`cost_usd` is None once that model is absent from `eval.prices.PRICES`."""
|
|
110
|
+
from .eval import prices
|
|
111
|
+
|
|
112
|
+
totals: dict[int, tuple[int, int, float | None]] = {}
|
|
113
|
+
alias: str | None = None
|
|
114
|
+
for row in rows:
|
|
115
|
+
if row.get("type") == "ModelUsed":
|
|
116
|
+
alias = row.get("alias")
|
|
117
|
+
elif row.get("type") == "Usage":
|
|
118
|
+
turn = int(row.get("turn", 0))
|
|
119
|
+
prev_in, prev_out, _ = totals.get(turn, (0, 0, None))
|
|
120
|
+
tin = prev_in + int(row.get("prompt_tokens", 0))
|
|
121
|
+
tout = prev_out + int(row.get("completion_tokens", 0))
|
|
122
|
+
cost = prices.estimate_cost(alias, tin, tout) if alias else None
|
|
123
|
+
totals[turn] = (tin, tout, cost)
|
|
124
|
+
return totals
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def render_timeline(session_id: str, *, tools_only: bool = False, raw_json: bool = False) -> str:
|
|
128
|
+
rows = _read(session_id)
|
|
129
|
+
if raw_json:
|
|
130
|
+
return "\n".join(json.dumps(r) for r in rows)
|
|
131
|
+
if not rows:
|
|
132
|
+
return f"no trace for session {session_id}"
|
|
133
|
+
|
|
134
|
+
totals = _turn_totals(rows)
|
|
135
|
+
view = [r for r in rows if r.get("type") in _TOOL_TYPES] if tools_only else rows
|
|
136
|
+
t0 = float(rows[0].get("t") or 0.0)
|
|
137
|
+
|
|
138
|
+
lines = []
|
|
139
|
+
for row in view:
|
|
140
|
+
t = str(row.get("type", ""))
|
|
141
|
+
if t == "TextDelta":
|
|
142
|
+
continue
|
|
143
|
+
offset = float(row.get("t") or t0) - t0
|
|
144
|
+
turn = row.get("turn", 0)
|
|
145
|
+
glyph = _GLYPH.get(t, "·")
|
|
146
|
+
lines.append(f"+{offset:7.2f}s turn {turn:<3} {glyph} {t:<18}{_summary(row)}")
|
|
147
|
+
|
|
148
|
+
if totals:
|
|
149
|
+
lines.append("")
|
|
150
|
+
lines.append("per-turn totals:")
|
|
151
|
+
for turn in sorted(totals):
|
|
152
|
+
tin, tout, cost = totals[turn]
|
|
153
|
+
cost_text = f"${cost:.4f}" if cost is not None else "unknown"
|
|
154
|
+
lines.append(f" turn {turn}: {tin} in / {tout} out tokens · {cost_text}")
|
|
155
|
+
|
|
156
|
+
return "\n".join(lines)
|
omega/trajectory.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""The trajectory ledger: a deterministic, non-LLM record of every tool call
|
|
2
|
+
made this session, derived by walking `history` rather than kept as separate
|
|
3
|
+
state -- that way it survives session resume for free and can never drift
|
|
4
|
+
from what actually happened.
|
|
5
|
+
|
|
6
|
+
Two consumers:
|
|
7
|
+
- `render()` -- the live "## Recent actions" block `loop.run_turn` injects
|
|
8
|
+
into the volatile half of the system prompt every round.
|
|
9
|
+
- `compaction_lines()` -- the full-fidelity, uncapped-per-field lines
|
|
10
|
+
`compact.py` folds into its deterministic ledger for a dropped range.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from . import tools
|
|
19
|
+
from .session import Message
|
|
20
|
+
from .ui import format
|
|
21
|
+
|
|
22
|
+
MAX_ENTRIES = 50
|
|
23
|
+
ACTION_LINE_CAP = 25
|
|
24
|
+
BLOCK_MAX_CHARS = 2000
|
|
25
|
+
ARGS_DIGEST_CHARS = 64
|
|
26
|
+
SUMMARY_CHARS = 120
|
|
27
|
+
|
|
28
|
+
_DEDUPE_NOTE = ("If an identical call already appears above, do not re-run it "
|
|
29
|
+
"-- call fetch_result on its recorded artifact instead.")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class Entry:
|
|
34
|
+
tool: str
|
|
35
|
+
args_digest: str
|
|
36
|
+
summary: str
|
|
37
|
+
artifact_id: str | None
|
|
38
|
+
call_id: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class _Raw:
|
|
43
|
+
tool: str
|
|
44
|
+
args: dict[str, Any]
|
|
45
|
+
result: str
|
|
46
|
+
artifact_id: str | None
|
|
47
|
+
call_id: str
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _flatten(text: str) -> str:
|
|
51
|
+
return " ".join(text.split())
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _cap(text: str, limit: int) -> str:
|
|
55
|
+
text = _flatten(text)
|
|
56
|
+
return text if len(text) <= limit else text[:limit - 1] + "…"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _describe_args(name: str, args: dict[str, Any]) -> str:
|
|
60
|
+
try:
|
|
61
|
+
described = format.describe_call(name, args)
|
|
62
|
+
except Exception:
|
|
63
|
+
described = ""
|
|
64
|
+
if described.startswith(name):
|
|
65
|
+
described = described[len(name):].strip()
|
|
66
|
+
if not described:
|
|
67
|
+
described = json.dumps(args, default=str)
|
|
68
|
+
return _flatten(described)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _tool_calls_by_id(history: list[Message]) -> dict[str, tuple[str, str]]:
|
|
72
|
+
out: dict[str, tuple[str, str]] = {}
|
|
73
|
+
for m in history:
|
|
74
|
+
if m.get("role") != "assistant":
|
|
75
|
+
continue
|
|
76
|
+
for tc in m.get("tool_calls") or []:
|
|
77
|
+
fn = tc.get("function") or {}
|
|
78
|
+
out[tc["id"]] = (fn.get("name", ""), fn.get("arguments", "{}"))
|
|
79
|
+
return out
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _raw_entries(history: list[Message]) -> list[_Raw]:
|
|
83
|
+
calls = _tool_calls_by_id(history)
|
|
84
|
+
out: list[_Raw] = []
|
|
85
|
+
for m in history:
|
|
86
|
+
if m.get("role") != "tool":
|
|
87
|
+
continue
|
|
88
|
+
call_id = str(m.get("tool_call_id") or "")
|
|
89
|
+
name, args_json = calls.get(call_id, ("", "{}"))
|
|
90
|
+
if not name:
|
|
91
|
+
continue
|
|
92
|
+
try:
|
|
93
|
+
args = dict(json.loads(args_json or "{}"))
|
|
94
|
+
except json.JSONDecodeError:
|
|
95
|
+
args = {}
|
|
96
|
+
text = str(m.get("content") or "")
|
|
97
|
+
_offloaded, artifact_id = tools.offload_info(text)
|
|
98
|
+
out.append(_Raw(tool=name, args=args, result=text,
|
|
99
|
+
artifact_id=artifact_id, call_id=call_id))
|
|
100
|
+
return out
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def build(history: list[Message]) -> list[Entry]:
|
|
104
|
+
"""The last `MAX_ENTRIES` tool calls this session, oldest dropped."""
|
|
105
|
+
raws = _raw_entries(history)[-MAX_ENTRIES:]
|
|
106
|
+
return [Entry(tool=r.tool,
|
|
107
|
+
args_digest=_cap(_describe_args(r.tool, r.args), ARGS_DIGEST_CHARS),
|
|
108
|
+
summary=_cap(r.result, SUMMARY_CHARS),
|
|
109
|
+
artifact_id=r.artifact_id, call_id=r.call_id)
|
|
110
|
+
for r in raws]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _line(tool: str, args_text: str, summary_text: str, artifact_id: str | None) -> str:
|
|
114
|
+
suffix = f" [artifact {artifact_id}]" if artifact_id else ""
|
|
115
|
+
return f"{tool}({args_text}) → {summary_text}{suffix}"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def render(history: list[Message]) -> str:
|
|
119
|
+
"""`## Recent actions` block for the volatile half of the system prompt.
|
|
120
|
+
Empty string when there is nothing to show yet."""
|
|
121
|
+
entries = build(history)[-ACTION_LINE_CAP:]
|
|
122
|
+
if not entries:
|
|
123
|
+
return ""
|
|
124
|
+
header = "## Recent actions\n"
|
|
125
|
+
footer = "\n\n" + _DEDUPE_NOTE
|
|
126
|
+
lines = [f"- {_line(e.tool, e.args_digest, e.summary, e.artifact_id)}" for e in entries]
|
|
127
|
+
# Oldest-first drop keeps the most recent (most relevant) actions when the
|
|
128
|
+
# block would otherwise blow the char budget.
|
|
129
|
+
while lines and len(header) + len("\n".join(lines)) + len(footer) > BLOCK_MAX_CHARS:
|
|
130
|
+
lines.pop(0)
|
|
131
|
+
if not lines:
|
|
132
|
+
return ""
|
|
133
|
+
return header + "\n".join(lines) + footer
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def compaction_lines(history: list[Message], entry_char_cap: int) -> list[str]:
|
|
137
|
+
"""Every tool call in `history` (typically the dropped range of a
|
|
138
|
+
compaction), one deterministic line each -- no LLM involved, so it cannot
|
|
139
|
+
hallucinate. Unlike `render()`, nothing here is capped to the live
|
|
140
|
+
ledger's tight per-field limits; only the whole line is capped."""
|
|
141
|
+
lines = []
|
|
142
|
+
for r in _raw_entries(history):
|
|
143
|
+
args_text = _describe_args(r.tool, r.args)
|
|
144
|
+
line = _line(r.tool, args_text, _flatten(r.result), r.artifact_id)
|
|
145
|
+
lines.append(_cap(line, entry_char_cap) if len(line) > entry_char_cap else line)
|
|
146
|
+
return lines
|
omega/ui/__init__.py
ADDED
|
File without changes
|
omega/ui/composer.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Shared composer helpers for the TUI (and any other Python front-end).
|
|
2
|
+
|
|
3
|
+
Mirrors the Swift `ComposerPrompt` helpers: `@mention` parsing, workspace
|
|
4
|
+
file ranking, and flattening dropped paths into the text-only prompt the
|
|
5
|
+
daemon accepts.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
_TOKEN = re.compile(r"[A-Za-z0-9._\-++/\\]")
|
|
15
|
+
_SKIP_DIRS = {
|
|
16
|
+
".git", ".hg", ".svn",
|
|
17
|
+
"node_modules", ".build", "DerivedData", ".swiftpm",
|
|
18
|
+
"dist", "build", "Pods", "Carthage",
|
|
19
|
+
".venv", "venv", "__pycache__", ".mypy_cache", ".pytest_cache",
|
|
20
|
+
".next", ".nuxt", "target", ".omega", ".idea", ".vscode",
|
|
21
|
+
"xcuserdata",
|
|
22
|
+
}
|
|
23
|
+
_MAX_FILES = 8_000
|
|
24
|
+
_MAX_DEPTH = 10
|
|
25
|
+
_IMAGE_EXT = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".tif", ".tiff", ".heic", ".bmp"}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class MentionQuery:
|
|
30
|
+
query: str
|
|
31
|
+
start: int # character offset of '@'
|
|
32
|
+
end: int # character offset of the cursor (exclusive of the token)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def mention_query(text: str, cursor: int) -> MentionQuery | None:
|
|
36
|
+
"""If `cursor` sits inside an `@token` at a word boundary, return it."""
|
|
37
|
+
if not text:
|
|
38
|
+
return None
|
|
39
|
+
cursor = max(0, min(cursor, len(text)))
|
|
40
|
+
start = cursor
|
|
41
|
+
while start > 0 and _TOKEN.match(text[start - 1]):
|
|
42
|
+
start -= 1
|
|
43
|
+
if start == 0 or text[start - 1] != "@":
|
|
44
|
+
return None
|
|
45
|
+
at = start - 1
|
|
46
|
+
if at > 0 and not text[at - 1].isspace():
|
|
47
|
+
return None
|
|
48
|
+
return MentionQuery(query=text[start:cursor], start=at, end=cursor)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def rank_files(files: list[str], query: str, limit: int = 12) -> list[str]:
|
|
52
|
+
q = query.strip().lower()
|
|
53
|
+
if not q:
|
|
54
|
+
return sorted(files, key=lambda p: (p.count("/"), p.lower()))[:limit]
|
|
55
|
+
|
|
56
|
+
hits: list[tuple[int, int, str, str]] = []
|
|
57
|
+
for path in files:
|
|
58
|
+
filename = path.rsplit("/", 1)[-1]
|
|
59
|
+
path_l, file_l = path.lower(), filename.lower()
|
|
60
|
+
if file_l == q:
|
|
61
|
+
score = 0
|
|
62
|
+
elif file_l.startswith(q):
|
|
63
|
+
score = 1
|
|
64
|
+
elif path_l.startswith(q):
|
|
65
|
+
score = 2
|
|
66
|
+
elif q in file_l:
|
|
67
|
+
score = 3
|
|
68
|
+
elif q in path_l:
|
|
69
|
+
score = 4
|
|
70
|
+
else:
|
|
71
|
+
continue
|
|
72
|
+
hits.append((score, len(filename), file_l, path))
|
|
73
|
+
hits.sort()
|
|
74
|
+
return [h[3] for h in hits[:limit]]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def insert_mention(text: str, cursor: int, path: str) -> tuple[str, int]:
|
|
78
|
+
"""Replace the active `@token` (or append) with `` `path` ``. Returns (text, cursor)."""
|
|
79
|
+
q = mention_query(text, cursor)
|
|
80
|
+
replacement = f"`{path}` "
|
|
81
|
+
if q is None:
|
|
82
|
+
new = text + replacement
|
|
83
|
+
return new, len(new)
|
|
84
|
+
new = text[:q.start] + replacement + text[q.end:]
|
|
85
|
+
return new, q.start + len(replacement)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def build_prompt(text: str, attachment_paths: list[str]) -> str:
|
|
89
|
+
trimmed = text.strip()
|
|
90
|
+
paths = [p.strip() for p in attachment_paths if p.strip()]
|
|
91
|
+
if not paths:
|
|
92
|
+
return trimmed
|
|
93
|
+
lines: list[str] = []
|
|
94
|
+
if trimmed:
|
|
95
|
+
lines.append(trimmed)
|
|
96
|
+
lines.append("")
|
|
97
|
+
lines.append("Attached:")
|
|
98
|
+
lines.extend(f"- {p}" for p in paths)
|
|
99
|
+
return "\n".join(lines)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def display_path(path: str, workspace_root: str | None) -> str:
|
|
103
|
+
if not workspace_root:
|
|
104
|
+
return path
|
|
105
|
+
root = os.path.realpath(workspace_root)
|
|
106
|
+
file = os.path.realpath(path)
|
|
107
|
+
if file == root:
|
|
108
|
+
return "."
|
|
109
|
+
prefix = root if root.endswith(os.sep) else root + os.sep
|
|
110
|
+
if file.startswith(prefix):
|
|
111
|
+
return file[len(prefix):]
|
|
112
|
+
return path
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def is_image_path(path: str) -> bool:
|
|
116
|
+
return Path(path).suffix.lower() in _IMAGE_EXT
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def list_workspace_files(root: str) -> list[str]:
|
|
120
|
+
root_path = Path(root).expanduser().resolve()
|
|
121
|
+
if not root_path.is_dir():
|
|
122
|
+
return []
|
|
123
|
+
files: list[str] = []
|
|
124
|
+
root_s = str(root_path)
|
|
125
|
+
prefix = root_s if root_s.endswith(os.sep) else root_s + os.sep
|
|
126
|
+
for dirpath, dirnames, filenames in os.walk(root_path):
|
|
127
|
+
rel_dir = dirpath[len(prefix):] if dirpath.startswith(prefix) else ""
|
|
128
|
+
depth = 0 if not rel_dir else rel_dir.count(os.sep) + 1
|
|
129
|
+
if depth > _MAX_DEPTH:
|
|
130
|
+
dirnames[:] = []
|
|
131
|
+
continue
|
|
132
|
+
dirnames[:] = [d for d in dirnames if d not in _SKIP_DIRS and not d.startswith(".")]
|
|
133
|
+
for name in filenames:
|
|
134
|
+
if name.startswith("."):
|
|
135
|
+
continue
|
|
136
|
+
rel = name if not rel_dir else f"{rel_dir}/{name}".replace("\\", "/")
|
|
137
|
+
files.append(rel)
|
|
138
|
+
if len(files) >= _MAX_FILES:
|
|
139
|
+
return files
|
|
140
|
+
return files
|