webbee 0.3.52__tar.gz → 0.3.53__tar.gz
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.
- {webbee-0.3.52 → webbee-0.3.53}/CHANGELOG.md +41 -0
- {webbee-0.3.52 → webbee-0.3.53}/PKG-INFO +2 -2
- {webbee-0.3.52 → webbee-0.3.53}/pyproject.toml +1 -1
- webbee-0.3.53/src/webbee/intel/contracts.py +183 -0
- {webbee-0.3.52 → webbee-0.3.53}/src/webbee/intel/indexer.py +6 -1
- {webbee-0.3.52 → webbee-0.3.53}/src/webbee/intel/models.py +3 -0
- {webbee-0.3.52 → webbee-0.3.53}/src/webbee/intel/query.py +47 -0
- {webbee-0.3.52 → webbee-0.3.53}/src/webbee/intel/service.py +20 -0
- {webbee-0.3.52 → webbee-0.3.53}/src/webbee/intel/store.py +4 -2
- webbee-0.3.53/src/webbee/native_files.py +105 -0
- webbee-0.3.53/src/webbee/native_read.py +283 -0
- {webbee-0.3.52 → webbee-0.3.53}/src/webbee/session.py +23 -2
- {webbee-0.3.52 → webbee-0.3.53}/src/webbee/tools.py +38 -3
- webbee-0.3.53/tests/test_intel_contracts.py +101 -0
- {webbee-0.3.52 → webbee-0.3.53}/tests/test_intel_service.py +20 -0
- {webbee-0.3.52 → webbee-0.3.53}/tests/test_marathon.py +2 -2
- webbee-0.3.53/tests/test_native_files.py +208 -0
- {webbee-0.3.52 → webbee-0.3.53}/tests/test_session.py +7 -7
- {webbee-0.3.52 → webbee-0.3.53}/.github/workflows/publish.yml +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/.gitignore +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/LICENSE +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/README.md +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/install.sh +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/src/webbee/__init__.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/src/webbee/account.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/src/webbee/active_sessions.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/src/webbee/banner_art.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/src/webbee/boot.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/src/webbee/checkpoints.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/src/webbee/cli.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/src/webbee/clipboard.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/src/webbee/clipboard_read.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/src/webbee/clipboard_session.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/src/webbee/coding_context.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/src/webbee/commands.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/src/webbee/config.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/src/webbee/consent.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/src/webbee/details.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/src/webbee/events.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/src/webbee/frames.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/src/webbee/home.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/src/webbee/home_view.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/src/webbee/http.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/src/webbee/instance_lock.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/src/webbee/intel/__init__.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/src/webbee/intel/chunker.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/src/webbee/intel/embed.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/src/webbee/intel/graph.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/src/webbee/intel/vectors.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/src/webbee/intel/watch.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/src/webbee/mode_store.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/src/webbee/newtab_mode.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/src/webbee/output_pane.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/src/webbee/queue_panel.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/src/webbee/reflow.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/src/webbee/remote.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/src/webbee/render.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/src/webbee/repl.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/src/webbee/repo.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/src/webbee/selection.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/src/webbee/sessions.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/src/webbee/sizing.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/src/webbee/slots.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/src/webbee/steer.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/src/webbee/stream.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/src/webbee/tab_store.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/src/webbee/tabs.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/src/webbee/thread.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/src/webbee/tier_store.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/src/webbee/todo_panel.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/src/webbee/tokens.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/src/webbee/tui.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/src/webbee/update.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/src/webbee/urlopen.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/src/webbee/wallet.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/src/webbee/worktrees.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/tests/__init__.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/tests/conftest.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/tests/test_account.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/tests/test_active_sessions.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/tests/test_audit_2026_07_25_findings.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/tests/test_checkpoints.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/tests/test_cli.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/tests/test_clipboard.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/tests/test_clipboard_read.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/tests/test_coding_context.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/tests/test_commands.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/tests/test_config.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/tests/test_cpc_contract_stable.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/tests/test_details.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/tests/test_events.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/tests/test_freeze_fix.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/tests/test_home.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/tests/test_home_view.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/tests/test_instance_lock.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/tests/test_intel_chunker.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/tests/test_intel_embed.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/tests/test_intel_graph.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/tests/test_intel_indexer.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/tests/test_intel_no_numpy.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/tests/test_intel_query.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/tests/test_intel_store.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/tests/test_intel_vectors.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/tests/test_intel_watch.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/tests/test_mode_store.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/tests/test_newtab_mode.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/tests/test_packaging.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/tests/test_queue_manage_0337.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/tests/test_reflow.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/tests/test_render.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/tests/test_repl.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/tests/test_repo.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/tests/test_sessions.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/tests/test_sizing.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/tests/test_slots.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/tests/test_steer.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/tests/test_stream.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/tests/test_tab_restore_0337.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/tests/test_tabs.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/tests/test_terminal_ux_0336.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/tests/test_thread.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/tests/test_tier_store.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/tests/test_todo_panel.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/tests/test_tokens.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/tests/test_tools.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/tests/test_tui.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/tests/test_tui_hardening.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/tests/test_update.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/tests/test_update_badge_0340.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/tests/test_urlopen.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/tests/test_version.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/tests/test_version_badge_corner.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/tests/test_wallet.py +0 -0
- {webbee-0.3.52 → webbee-0.3.53}/tests/test_worktrees.py +0 -0
|
@@ -1,5 +1,46 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.53
|
|
4
|
+
|
|
5
|
+
**Documents and images are now readable — natively, on every OS.**
|
|
6
|
+
|
|
7
|
+
`read_file` opened every path as UTF-8. A `.docx`, `.pdf`, `.xlsx` or a
|
|
8
|
+
screenshot therefore did not read *badly* — it raised `UnicodeDecodeError` and
|
|
9
|
+
the agent got **nothing at all**, on macOS, Windows and Linux alike. The
|
|
10
|
+
workaround was a detour through `bash` (`strings`, a converter, an OCR tool)
|
|
11
|
+
that burned a turn and produced garbage.
|
|
12
|
+
|
|
13
|
+
Meanwhile the platform already reads those formats: the document service on
|
|
14
|
+
the API server, reached through the **system File Reader extension**, handles
|
|
15
|
+
pdf/docx/doc/odt/rtf/xlsx/ods/csv/pptx/odp and images (OCR + vision). The
|
|
16
|
+
capability existed; the bridge from a *local* path to it did not.
|
|
17
|
+
|
|
18
|
+
That bridge is what this release adds — and nothing else:
|
|
19
|
+
|
|
20
|
+
* **No parsers on the client.** Extraction stays the service's job, reached
|
|
21
|
+
only through the file-reader extension. Vendoring `pdfplumber` here would
|
|
22
|
+
have created a second, drifting source of truth.
|
|
23
|
+
* **Bytes never enter the model's context.** They ride base64 in the extension
|
|
24
|
+
call — the same door the panel dropzone, Telegram and clipboard paste
|
|
25
|
+
already use — and the brain gets back a compact **text window** plus a
|
|
26
|
+
`file_id` it pages through with `offset`/`limit`. A 300-page PDF costs a
|
|
27
|
+
window, not a fortune in tokens.
|
|
28
|
+
* **Source files are untouched.** They keep the byte-exact text path that
|
|
29
|
+
`edit_file`'s exact-match contract depends on. `.svg` stays text: it is
|
|
30
|
+
markup an agent edits, not a picture to OCR.
|
|
31
|
+
* **Honest degradation.** Offline, no plan, service down, still extracting —
|
|
32
|
+
each is one readable sentence, never a traceback, never a silently empty
|
|
33
|
+
read.
|
|
34
|
+
* **A binary that hides its type** (no extension, an odd one, a mislabelled
|
|
35
|
+
export) is caught on the decode error and routed the same way, instead of
|
|
36
|
+
failing with an error the agent can do nothing about.
|
|
37
|
+
|
|
38
|
+
Two bugs found by reading real files end-to-end rather than trusting the
|
|
39
|
+
schema: the extracted text arrives as `body` (reading only `text` returned a
|
|
40
|
+
confident, completely empty answer), and a not-yet-extracted file answers with
|
|
41
|
+
a human placeholder sentence — treating that as content made the wait exit on
|
|
42
|
+
its first tick and declare a perfectly readable image empty.
|
|
43
|
+
|
|
3
44
|
## 0.3.52
|
|
4
45
|
|
|
5
46
|
Tool-honesty pass. An audit of the local tools against live behaviour found
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Deterministic endpoint/schema evidence extraction.
|
|
2
|
+
|
|
3
|
+
This is deliberately a projection over source snapshots, not a second source
|
|
4
|
+
of truth. Every record carries file+line provenance and is rebuilt from the
|
|
5
|
+
content-addressed repository index. Unsupported syntax is simply absent; no
|
|
6
|
+
LLM inference is stored as fact.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import ast
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import asdict, dataclass
|
|
13
|
+
from typing import Iterable
|
|
14
|
+
|
|
15
|
+
_HTTP_METHODS = frozenset({"get", "post", "put", "patch", "delete", "options", "head"})
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class EndpointEvidence:
|
|
20
|
+
method: str
|
|
21
|
+
route: str
|
|
22
|
+
handler: str
|
|
23
|
+
path: str
|
|
24
|
+
line: int
|
|
25
|
+
schema_refs: tuple[str, ...] = ()
|
|
26
|
+
|
|
27
|
+
def to_dict(self) -> dict:
|
|
28
|
+
return asdict(self)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class SchemaEvidence:
|
|
33
|
+
name: str
|
|
34
|
+
schema_kind: str
|
|
35
|
+
path: str
|
|
36
|
+
line: int
|
|
37
|
+
fields: tuple[str, ...] = ()
|
|
38
|
+
storage_name: str = ""
|
|
39
|
+
|
|
40
|
+
def to_dict(self) -> dict:
|
|
41
|
+
return asdict(self)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _name(node) -> str:
|
|
45
|
+
if isinstance(node, ast.Name):
|
|
46
|
+
return node.id
|
|
47
|
+
if isinstance(node, ast.Attribute):
|
|
48
|
+
left = _name(node.value)
|
|
49
|
+
return f"{left}.{node.attr}" if left else node.attr
|
|
50
|
+
if isinstance(node, ast.Subscript):
|
|
51
|
+
return _name(node.value)
|
|
52
|
+
return ""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _literal_string(node) -> str:
|
|
56
|
+
return node.value if isinstance(node, ast.Constant) and isinstance(node.value, str) else ""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _annotation_names(node) -> set[str]:
|
|
60
|
+
if node is None:
|
|
61
|
+
return set()
|
|
62
|
+
names = set()
|
|
63
|
+
for part in ast.walk(node):
|
|
64
|
+
n = _name(part)
|
|
65
|
+
if n and n.split(".")[-1][:1].isupper():
|
|
66
|
+
names.add(n.split(".")[-1])
|
|
67
|
+
return names
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _python_contracts(path: str, text: str):
|
|
71
|
+
try:
|
|
72
|
+
tree = ast.parse(text)
|
|
73
|
+
except (SyntaxError, ValueError):
|
|
74
|
+
return [], []
|
|
75
|
+
endpoints, schemas = [], []
|
|
76
|
+
for node in ast.walk(tree):
|
|
77
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
78
|
+
for dec in node.decorator_list:
|
|
79
|
+
if not isinstance(dec, ast.Call) or not isinstance(dec.func, ast.Attribute):
|
|
80
|
+
continue
|
|
81
|
+
method = dec.func.attr.lower()
|
|
82
|
+
route = _literal_string(dec.args[0]) if dec.args else ""
|
|
83
|
+
if method not in _HTTP_METHODS or not route.startswith("/"):
|
|
84
|
+
continue
|
|
85
|
+
refs = set()
|
|
86
|
+
for arg in (*node.args.posonlyargs, *node.args.args, *node.args.kwonlyargs):
|
|
87
|
+
refs.update(_annotation_names(arg.annotation))
|
|
88
|
+
refs.update(_annotation_names(node.returns))
|
|
89
|
+
for kw in dec.keywords:
|
|
90
|
+
if kw.arg in {"response_model", "request_model", "model"}:
|
|
91
|
+
refs.update(_annotation_names(kw.value))
|
|
92
|
+
endpoints.append(EndpointEvidence(
|
|
93
|
+
method.upper(), route, node.name, path,
|
|
94
|
+
getattr(dec, "lineno", node.lineno), tuple(sorted(refs))))
|
|
95
|
+
elif isinstance(node, ast.ClassDef):
|
|
96
|
+
bases = {_name(b).split(".")[-1] for b in node.bases}
|
|
97
|
+
is_model = bool(bases & {"BaseModel", "TypedDict", "Schema", "Serializer"})
|
|
98
|
+
is_table = bool(bases & {"Base", "DeclarativeBase", "Model"})
|
|
99
|
+
if not (is_model or is_table):
|
|
100
|
+
continue
|
|
101
|
+
fields, storage = set(), ""
|
|
102
|
+
for stmt in node.body:
|
|
103
|
+
if isinstance(stmt, ast.AnnAssign) and isinstance(stmt.target, ast.Name):
|
|
104
|
+
fields.add(stmt.target.id)
|
|
105
|
+
elif isinstance(stmt, ast.Assign):
|
|
106
|
+
for target in stmt.targets:
|
|
107
|
+
if isinstance(target, ast.Name):
|
|
108
|
+
if target.id == "__tablename__":
|
|
109
|
+
storage = _literal_string(stmt.value)
|
|
110
|
+
elif isinstance(stmt.value, ast.Call):
|
|
111
|
+
call = _name(stmt.value.func).split(".")[-1]
|
|
112
|
+
if call in {"Column", "mapped_column", "Field"}:
|
|
113
|
+
fields.add(target.id)
|
|
114
|
+
schemas.append(SchemaEvidence(
|
|
115
|
+
node.name, "table" if is_table else "model", path, node.lineno,
|
|
116
|
+
tuple(sorted(fields)), storage))
|
|
117
|
+
return endpoints, schemas
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
_TS_ENDPOINT = re.compile(
|
|
121
|
+
r"(?m)\b(?:app|router|server)\s*\.\s*(get|post|put|patch|delete|options|head)"
|
|
122
|
+
r"\s*\(\s*([\"'])(/[^\"']*)\2\s*,\s*([A-Za-z_$][\w$]*)"
|
|
123
|
+
)
|
|
124
|
+
_TS_SCHEMA = re.compile(
|
|
125
|
+
r"(?ms)\b(?:export\s+)?(?:interface|type|class)\s+([A-Za-z_$][\w$]*)"
|
|
126
|
+
r"(?:\s+extends[^\{=]+)?\s*(?:\{|=\s*\{)(.*?)\}"
|
|
127
|
+
)
|
|
128
|
+
_TS_FIELD = re.compile(r"(?m)^\s*([A-Za-z_$][\w$]*)\s*\??\s*:")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _typescript_contracts(path: str, text: str):
|
|
132
|
+
endpoints = [EndpointEvidence(
|
|
133
|
+
m.group(1).upper(), m.group(3), m.group(4), path,
|
|
134
|
+
text.count("\n", 0, m.start()) + 1)
|
|
135
|
+
for m in _TS_ENDPOINT.finditer(text)]
|
|
136
|
+
schemas = []
|
|
137
|
+
for m in _TS_SCHEMA.finditer(text):
|
|
138
|
+
schemas.append(SchemaEvidence(
|
|
139
|
+
m.group(1), "model", path, text.count("\n", 0, m.start()) + 1,
|
|
140
|
+
tuple(sorted(set(_TS_FIELD.findall(m.group(2)))))))
|
|
141
|
+
return endpoints, schemas
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def extract_contracts(path: str, text: str, lang: str):
|
|
145
|
+
if lang == "python":
|
|
146
|
+
return _python_contracts(path, text)
|
|
147
|
+
if lang in {"typescript", "javascript"}:
|
|
148
|
+
return _typescript_contracts(path, text)
|
|
149
|
+
return [], []
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def match_contracts(repos: Iterable[dict]) -> list[dict]:
|
|
153
|
+
"""Build deterministic cross-repo links from exact contract identities.
|
|
154
|
+
|
|
155
|
+
Endpoint route+method and schema names are intentionally exact. Semantic
|
|
156
|
+
guesses belong in retrieval ranking, never in the evidence graph.
|
|
157
|
+
"""
|
|
158
|
+
repos = sorted(repos, key=lambda r: r.get("repo_key", ""))
|
|
159
|
+
out = []
|
|
160
|
+
for i, left in enumerate(repos):
|
|
161
|
+
for right in repos[i + 1:]:
|
|
162
|
+
lk, rk = left.get("repo_key", ""), right.get("repo_key", "")
|
|
163
|
+
le = {(x.method, x.route): x for x in left.get("endpoints", ())}
|
|
164
|
+
re_ = {(x.method, x.route): x for x in right.get("endpoints", ())}
|
|
165
|
+
for key in sorted(le.keys() & re_.keys()):
|
|
166
|
+
out.append({"relation": "endpoint_identity", "source_repo": lk,
|
|
167
|
+
"target_repo": rk, "identity": f"{key[0]} {key[1]}"})
|
|
168
|
+
# A schema contract can be declared as a concrete model/table OR
|
|
169
|
+
# referenced by an endpoint annotation/response_model. Both are
|
|
170
|
+
# deterministic source evidence; including references lets a
|
|
171
|
+
# producer endpoint link to a consumer's shared DTO even when the
|
|
172
|
+
# producer imports that DTO from another package.
|
|
173
|
+
def schema_names(repo):
|
|
174
|
+
names = {x.name for x in repo.get("schemas", ())}
|
|
175
|
+
for endpoint in repo.get("endpoints", ()):
|
|
176
|
+
names.update(endpoint.schema_refs)
|
|
177
|
+
return names
|
|
178
|
+
|
|
179
|
+
for name in sorted(schema_names(left) & schema_names(right)):
|
|
180
|
+
source, target = sorted((lk, rk))
|
|
181
|
+
out.append({"relation": "schema_name_match", "source_repo": source,
|
|
182
|
+
"target_repo": target, "identity": name})
|
|
183
|
+
return out
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
|
+
import hashlib
|
|
2
3
|
import os
|
|
3
4
|
|
|
5
|
+
from webbee.intel.contracts import extract_contracts
|
|
4
6
|
from webbee.intel.models import FileIndex, ProjectIndex, Symbol
|
|
5
7
|
|
|
6
8
|
LANG_BY_EXT = {".py": "python", ".ts": "typescript", ".tsx": "typescript",
|
|
@@ -59,7 +61,10 @@ def _name_of(node, src_bytes: bytes) -> str:
|
|
|
59
61
|
|
|
60
62
|
def parse_file(path: str, text: str) -> FileIndex | None:
|
|
61
63
|
lang = _lang_for(path)
|
|
62
|
-
fi = FileIndex(path=path, lang=lang)
|
|
64
|
+
fi = FileIndex(path=path, lang=lang, content_hash=hashlib.sha256(text.encode("utf-8", "replace")).hexdigest())
|
|
65
|
+
endpoints, schemas = extract_contracts(path, text, lang)
|
|
66
|
+
fi.endpoints = [x.to_dict() for x in endpoints]
|
|
67
|
+
fi.schemas = [x.to_dict() for x in schemas]
|
|
63
68
|
if lang == "other":
|
|
64
69
|
return fi # line-only fallback (no symbols) — never crash
|
|
65
70
|
L = _get_language(lang)
|
|
@@ -19,6 +19,9 @@ class FileIndex:
|
|
|
19
19
|
symbols: list[Symbol] = field(default_factory=list)
|
|
20
20
|
imports: list[str] = field(default_factory=list) # imported module/path strings
|
|
21
21
|
refs: list[str] = field(default_factory=list) # referenced identifier names (for the graph)
|
|
22
|
+
content_hash: str = ""
|
|
23
|
+
endpoints: list[dict] = field(default_factory=list)
|
|
24
|
+
schemas: list[dict] = field(default_factory=list)
|
|
22
25
|
|
|
23
26
|
|
|
24
27
|
@dataclass
|
|
@@ -139,6 +139,53 @@ def search_code(svc, q, k: int = 20, kind=None, path_glob=None) -> dict:
|
|
|
139
139
|
return _env(hits, has_more=len(fused) > k)
|
|
140
140
|
|
|
141
141
|
|
|
142
|
+
def contract_slice(svc, query: str = "", kind: str = "all", k: int = 50) -> dict:
|
|
143
|
+
"""Return bounded, source-proven endpoint/schema evidence for this repo.
|
|
144
|
+
|
|
145
|
+
Matching is deterministic substring search over route/method/handler/schema
|
|
146
|
+
names and fields. Semantic expansion belongs to `search_code`; this tool is
|
|
147
|
+
the exact contract projection used to ground cross-repo reasoning.
|
|
148
|
+
"""
|
|
149
|
+
if svc is None or svc.index is None:
|
|
150
|
+
return {"ok": False, "content": "index not ready"}
|
|
151
|
+
q = (query or "").strip().lower()
|
|
152
|
+
wanted = (kind or "all").strip().lower()
|
|
153
|
+
limit = max(1, min(int(k or 50), 200))
|
|
154
|
+
items = []
|
|
155
|
+
for fi in svc.index.files.values():
|
|
156
|
+
if wanted in {"all", "endpoint"}:
|
|
157
|
+
for endpoint in fi.endpoints:
|
|
158
|
+
hay = " ".join((str(endpoint.get("method", "")),
|
|
159
|
+
str(endpoint.get("route", "")),
|
|
160
|
+
str(endpoint.get("handler", "")),
|
|
161
|
+
" ".join(endpoint.get("schema_refs") or ())))
|
|
162
|
+
if q and q not in hay.lower():
|
|
163
|
+
continue
|
|
164
|
+
route = str(endpoint.get("route") or "")
|
|
165
|
+
method = str(endpoint.get("method") or "")
|
|
166
|
+
items.append({
|
|
167
|
+
"id": f"{svc.repo_key}:endpoint:{method}:{route}:{endpoint.get('path')}:{endpoint.get('line')}",
|
|
168
|
+
"title": f"{method} {route}", "kind": "endpoint",
|
|
169
|
+
"repo_key": svc.repo_key, **endpoint,
|
|
170
|
+
})
|
|
171
|
+
if wanted in {"all", "schema"}:
|
|
172
|
+
for schema in fi.schemas:
|
|
173
|
+
hay = " ".join((str(schema.get("name", "")),
|
|
174
|
+
str(schema.get("schema_kind", "")),
|
|
175
|
+
str(schema.get("storage_name", "")),
|
|
176
|
+
" ".join(schema.get("fields") or ())))
|
|
177
|
+
if q and q not in hay.lower():
|
|
178
|
+
continue
|
|
179
|
+
name = str(schema.get("name") or "")
|
|
180
|
+
items.append({
|
|
181
|
+
"id": f"{svc.repo_key}:schema:{name}:{schema.get('path')}:{schema.get('line')}",
|
|
182
|
+
"title": name, "kind": "schema",
|
|
183
|
+
"repo_key": svc.repo_key, **schema,
|
|
184
|
+
})
|
|
185
|
+
items.sort(key=lambda x: (x["kind"], x["title"], x.get("path", ""), x.get("line", 0)))
|
|
186
|
+
return _env(items[:limit], has_more=len(items) > limit)
|
|
187
|
+
|
|
188
|
+
|
|
142
189
|
def impact_of_change(svc, symbols) -> dict:
|
|
143
190
|
if svc is None or svc.graph is None:
|
|
144
191
|
return {"ok": False, "content": "index not ready"}
|
|
@@ -4,6 +4,7 @@ cold path (cache-load or full index) off the event loop, `.apply_changes()`
|
|
|
4
4
|
does the cheap incremental re-parse the watcher drives, and `.repo_profile()`
|
|
5
5
|
is the bounded summary handed to the brain."""
|
|
6
6
|
from __future__ import annotations
|
|
7
|
+
import hashlib
|
|
7
8
|
import os
|
|
8
9
|
import subprocess
|
|
9
10
|
import threading
|
|
@@ -14,6 +15,7 @@ from webbee.intel.graph import CodeGraph
|
|
|
14
15
|
from webbee.intel.models import ProjectIndex
|
|
15
16
|
|
|
16
17
|
_MAX_PROFILE_SAMPLE = 20
|
|
18
|
+
_MAX_CONTRACT_EVIDENCE = 5000
|
|
17
19
|
_TEST_HINT_FILES = ("pytest.ini", "tox.ini", "package.json", "Makefile", "pyproject.toml")
|
|
18
20
|
|
|
19
21
|
|
|
@@ -242,13 +244,21 @@ class IntelService:
|
|
|
242
244
|
files = list((self.index or ProjectIndex()).files.values())
|
|
243
245
|
embedded_chunks = len(self.vectors.ids()) if self.vectors else 0
|
|
244
246
|
vectors_ready = self.vectors_ready
|
|
247
|
+
content_hashes = sorted(fi.content_hash for fi in files if fi.content_hash)
|
|
248
|
+
endpoints = [e for fi in files for e in fi.endpoints]
|
|
249
|
+
schemas = [x for fi in files for x in fi.schemas]
|
|
245
250
|
langs = Counter(fi.lang for fi in files if fi.lang != "other")
|
|
246
251
|
kinds = Counter(s.kind for fi in files for s in fi.symbols)
|
|
247
252
|
top = [f"{s.name} ({s.kind}) @ {s.path}:{s.start_line}"
|
|
248
253
|
for fi in files[:200] for s in fi.symbols][:_MAX_PROFILE_SAMPLE]
|
|
249
254
|
hints = [h for h in _TEST_HINT_FILES if os.path.exists(os.path.join(self.root, h))]
|
|
255
|
+
digest = hashlib.sha256("".join(content_hashes).encode()).hexdigest()
|
|
256
|
+
endpoints.sort(key=lambda x: (x.get("route", ""), x.get("method", ""), x.get("path", "")))
|
|
257
|
+
schemas.sort(key=lambda x: (x.get("name", ""), x.get("path", "")))
|
|
250
258
|
return {
|
|
251
259
|
"repo_key": self.repo_key,
|
|
260
|
+
"evidence_version": 1,
|
|
261
|
+
"content_digest": digest,
|
|
252
262
|
"file_count": len(files),
|
|
253
263
|
"languages": dict(langs),
|
|
254
264
|
"symbol_kinds": dict(kinds),
|
|
@@ -258,4 +268,14 @@ class IntelService:
|
|
|
258
268
|
"git_ref": self.git_ref[:12],
|
|
259
269
|
"embedded_chunks": embedded_chunks,
|
|
260
270
|
"vectors_ready": vectors_ready,
|
|
271
|
+
"endpoint_count": len(endpoints),
|
|
272
|
+
"schema_count": len(schemas),
|
|
273
|
+
# Account transport stays bounded; totals + cap metadata make a
|
|
274
|
+
# large repo's projection explicitly partial rather than quietly
|
|
275
|
+
# masquerading as the complete contract graph.
|
|
276
|
+
"contract_evidence_limit": _MAX_CONTRACT_EVIDENCE,
|
|
277
|
+
"contract_evidence_complete": (len(endpoints) <= _MAX_CONTRACT_EVIDENCE
|
|
278
|
+
and len(schemas) <= _MAX_CONTRACT_EVIDENCE),
|
|
279
|
+
"endpoints": endpoints[:_MAX_CONTRACT_EVIDENCE],
|
|
280
|
+
"schemas": schemas[:_MAX_CONTRACT_EVIDENCE],
|
|
261
281
|
}
|
|
@@ -23,7 +23,7 @@ from webbee.intel.models import ProjectIndex, FileIndex, Symbol
|
|
|
23
23
|
# schema 2 holds the OLD id format, so both the index and the vector cache
|
|
24
24
|
# must be treated as a clean miss and rebuilt -- bumping the version is what
|
|
25
25
|
# makes load()/load_vectors() do exactly that.
|
|
26
|
-
SCHEMA_VERSION =
|
|
26
|
+
SCHEMA_VERSION = 4
|
|
27
27
|
|
|
28
28
|
|
|
29
29
|
def _path(cache_dir: str, repo_key: str) -> str:
|
|
@@ -35,6 +35,7 @@ def save(cache_dir: str, repo_key: str, index: ProjectIndex) -> None:
|
|
|
35
35
|
os.makedirs(os.path.dirname(p), exist_ok=True)
|
|
36
36
|
data = {"schema_version": SCHEMA_VERSION, "git_ref": index.git_ref,
|
|
37
37
|
"files": {k: {"path": v.path, "lang": v.lang, "imports": v.imports, "refs": v.refs,
|
|
38
|
+
"content_hash": v.content_hash, "endpoints": v.endpoints, "schemas": v.schemas,
|
|
38
39
|
"symbols": [asdict(s) for s in v.symbols]} for k, v in index.files.items()}}
|
|
39
40
|
tmp = p + ".tmp"
|
|
40
41
|
with open(tmp, "w", encoding="utf-8") as f:
|
|
@@ -59,7 +60,8 @@ def load(cache_dir: str, repo_key: str, git_ref: str) -> ProjectIndex | None:
|
|
|
59
60
|
for k, v in (data.get("files") or {}).items():
|
|
60
61
|
idx.files[k] = FileIndex(path=v["path"], lang=v["lang"],
|
|
61
62
|
imports=v.get("imports", []), refs=v.get("refs", []),
|
|
62
|
-
|
|
63
|
+
content_hash=v.get("content_hash", ""), endpoints=v.get("endpoints", []),
|
|
64
|
+
schemas=v.get("schemas", []), symbols=[Symbol(**s) for s in v.get("symbols", [])])
|
|
63
65
|
return idx
|
|
64
66
|
except Exception:
|
|
65
67
|
return None # miss, corrupt, or wrong-shape -- never raise into the caller
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# Copyright (c) 2026 Imperal, Inc.
|
|
2
|
+
# Licensed under the AGPL-3.0 License.
|
|
3
|
+
"""Native document & image reading for the terminal agent.
|
|
4
|
+
|
|
5
|
+
WHY THIS EXISTS
|
|
6
|
+
---------------
|
|
7
|
+
``read_file`` opened every path as UTF-8 text. A .docx, .pdf, .xlsx or a
|
|
8
|
+
screenshot therefore did not read "badly" -- it raised UnicodeDecodeError and
|
|
9
|
+
the agent got nothing at all, on every OS. Meanwhile the platform ALREADY
|
|
10
|
+
owns a document service (the doc-extractor engine behind the *system* File
|
|
11
|
+
Reader extension) that handles pdf/docx/xlsx/odt/pptx and images (OCR +
|
|
12
|
+
vision). The gap was purely the missing bridge from a LOCAL path to that
|
|
13
|
+
service.
|
|
14
|
+
|
|
15
|
+
DESIGN (three rules that shape everything here)
|
|
16
|
+
1. NO parsers on the client, none in the kernel. The engine is the single
|
|
17
|
+
source of truth for extraction -- reached ONLY through the system
|
|
18
|
+
file-reader extension, never by talking to the engine directly.
|
|
19
|
+
2. Bytes must never travel through the model's context. The file is uploaded
|
|
20
|
+
out-of-band (the same door the panel dropzone, Telegram and clipboard
|
|
21
|
+
paste already use); the brain receives a compact TEXT window plus a
|
|
22
|
+
``file_id`` handle it can page through -- so a 300-page PDF costs a
|
|
23
|
+
window, not a fortune in tokens.
|
|
24
|
+
3. Honest degradation. No cloud reach, no plan, engine down, still indexing:
|
|
25
|
+
each returns a plain readable sentence saying what happened and what the
|
|
26
|
+
agent can do next -- never a stack trace, never a silent empty read.
|
|
27
|
+
"""
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import os
|
|
31
|
+
|
|
32
|
+
# Formats the engine extracts. Value = mime type sent at upload; the ENGINE
|
|
33
|
+
# re-sniffs content itself, so a wrong-but-plausible mime never decides the
|
|
34
|
+
# outcome -- this is a hint, not a contract.
|
|
35
|
+
DOCUMENT_MIMES: dict[str, str] = {
|
|
36
|
+
".pdf": "application/pdf",
|
|
37
|
+
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
38
|
+
".doc": "application/msword",
|
|
39
|
+
".odt": "application/vnd.oasis.opendocument.text",
|
|
40
|
+
".rtf": "application/rtf",
|
|
41
|
+
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
42
|
+
".xls": "application/vnd.ms-excel",
|
|
43
|
+
".ods": "application/vnd.oasis.opendocument.spreadsheet",
|
|
44
|
+
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
45
|
+
".ppt": "application/vnd.ms-powerpoint",
|
|
46
|
+
".odp": "application/vnd.oasis.opendocument.presentation",
|
|
47
|
+
".epub": "application/epub+zip",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
IMAGE_MIMES: dict[str, str] = {
|
|
51
|
+
".png": "image/png",
|
|
52
|
+
".jpg": "image/jpeg",
|
|
53
|
+
".jpeg": "image/jpeg",
|
|
54
|
+
".gif": "image/gif",
|
|
55
|
+
".webp": "image/webp",
|
|
56
|
+
".bmp": "image/bmp",
|
|
57
|
+
".tif": "image/tiff",
|
|
58
|
+
".tiff": "image/tiff",
|
|
59
|
+
".heic": "image/heic",
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
# Product policy of the file-reader dropzone (providers/lifecycle.py).
|
|
63
|
+
MAX_UPLOAD_BYTES = 100 * 1024 * 1024
|
|
64
|
+
|
|
65
|
+
# .svg is genuine text (XML) -- readable as source. Keeping it OUT of the
|
|
66
|
+
# native path means `read_file` on an icon still shows editable markup.
|
|
67
|
+
_TEXT_FIRST = {".svg"}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def classify(path: str) -> tuple[str, str]:
|
|
71
|
+
"""(kind, mime) for a path. kind: 'document' | 'image' | 'text'.
|
|
72
|
+
|
|
73
|
+
Extension-based and deliberately conservative: anything unknown stays
|
|
74
|
+
'text' so the normal UTF-8 read (and its byte-exact edit contract) is
|
|
75
|
+
never disturbed. A binary that slips through is caught at read time by
|
|
76
|
+
the UnicodeDecodeError fallback, which routes here anyway.
|
|
77
|
+
"""
|
|
78
|
+
ext = os.path.splitext(path)[1].lower()
|
|
79
|
+
if ext in _TEXT_FIRST:
|
|
80
|
+
return "text", ""
|
|
81
|
+
if ext in DOCUMENT_MIMES:
|
|
82
|
+
return "document", DOCUMENT_MIMES[ext]
|
|
83
|
+
if ext in IMAGE_MIMES:
|
|
84
|
+
return "image", IMAGE_MIMES[ext]
|
|
85
|
+
return "text", ""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def guess_mime(path: str) -> str:
|
|
89
|
+
"""Best-effort mime for a path the caller already knows is not text."""
|
|
90
|
+
ext = os.path.splitext(path)[1].lower()
|
|
91
|
+
return (DOCUMENT_MIMES.get(ext) or IMAGE_MIMES.get(ext)
|
|
92
|
+
or "application/octet-stream")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def is_native(path: str) -> bool:
|
|
96
|
+
"""True when the path is a document/image the engine should extract."""
|
|
97
|
+
return classify(path)[0] in ("document", "image")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def human_size(n: float) -> str:
|
|
101
|
+
for unit in ("B", "KB", "MB", "GB"):
|
|
102
|
+
if n < 1024 or unit == "GB":
|
|
103
|
+
return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}"
|
|
104
|
+
n /= 1024.0
|
|
105
|
+
return f"{n:.1f} GB"
|