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.
Files changed (134) hide show
  1. {webbee-0.3.52 → webbee-0.3.53}/CHANGELOG.md +41 -0
  2. {webbee-0.3.52 → webbee-0.3.53}/PKG-INFO +2 -2
  3. {webbee-0.3.52 → webbee-0.3.53}/pyproject.toml +1 -1
  4. webbee-0.3.53/src/webbee/intel/contracts.py +183 -0
  5. {webbee-0.3.52 → webbee-0.3.53}/src/webbee/intel/indexer.py +6 -1
  6. {webbee-0.3.52 → webbee-0.3.53}/src/webbee/intel/models.py +3 -0
  7. {webbee-0.3.52 → webbee-0.3.53}/src/webbee/intel/query.py +47 -0
  8. {webbee-0.3.52 → webbee-0.3.53}/src/webbee/intel/service.py +20 -0
  9. {webbee-0.3.52 → webbee-0.3.53}/src/webbee/intel/store.py +4 -2
  10. webbee-0.3.53/src/webbee/native_files.py +105 -0
  11. webbee-0.3.53/src/webbee/native_read.py +283 -0
  12. {webbee-0.3.52 → webbee-0.3.53}/src/webbee/session.py +23 -2
  13. {webbee-0.3.52 → webbee-0.3.53}/src/webbee/tools.py +38 -3
  14. webbee-0.3.53/tests/test_intel_contracts.py +101 -0
  15. {webbee-0.3.52 → webbee-0.3.53}/tests/test_intel_service.py +20 -0
  16. {webbee-0.3.52 → webbee-0.3.53}/tests/test_marathon.py +2 -2
  17. webbee-0.3.53/tests/test_native_files.py +208 -0
  18. {webbee-0.3.52 → webbee-0.3.53}/tests/test_session.py +7 -7
  19. {webbee-0.3.52 → webbee-0.3.53}/.github/workflows/publish.yml +0 -0
  20. {webbee-0.3.52 → webbee-0.3.53}/.gitignore +0 -0
  21. {webbee-0.3.52 → webbee-0.3.53}/LICENSE +0 -0
  22. {webbee-0.3.52 → webbee-0.3.53}/README.md +0 -0
  23. {webbee-0.3.52 → webbee-0.3.53}/install.sh +0 -0
  24. {webbee-0.3.52 → webbee-0.3.53}/src/webbee/__init__.py +0 -0
  25. {webbee-0.3.52 → webbee-0.3.53}/src/webbee/account.py +0 -0
  26. {webbee-0.3.52 → webbee-0.3.53}/src/webbee/active_sessions.py +0 -0
  27. {webbee-0.3.52 → webbee-0.3.53}/src/webbee/banner_art.py +0 -0
  28. {webbee-0.3.52 → webbee-0.3.53}/src/webbee/boot.py +0 -0
  29. {webbee-0.3.52 → webbee-0.3.53}/src/webbee/checkpoints.py +0 -0
  30. {webbee-0.3.52 → webbee-0.3.53}/src/webbee/cli.py +0 -0
  31. {webbee-0.3.52 → webbee-0.3.53}/src/webbee/clipboard.py +0 -0
  32. {webbee-0.3.52 → webbee-0.3.53}/src/webbee/clipboard_read.py +0 -0
  33. {webbee-0.3.52 → webbee-0.3.53}/src/webbee/clipboard_session.py +0 -0
  34. {webbee-0.3.52 → webbee-0.3.53}/src/webbee/coding_context.py +0 -0
  35. {webbee-0.3.52 → webbee-0.3.53}/src/webbee/commands.py +0 -0
  36. {webbee-0.3.52 → webbee-0.3.53}/src/webbee/config.py +0 -0
  37. {webbee-0.3.52 → webbee-0.3.53}/src/webbee/consent.py +0 -0
  38. {webbee-0.3.52 → webbee-0.3.53}/src/webbee/details.py +0 -0
  39. {webbee-0.3.52 → webbee-0.3.53}/src/webbee/events.py +0 -0
  40. {webbee-0.3.52 → webbee-0.3.53}/src/webbee/frames.py +0 -0
  41. {webbee-0.3.52 → webbee-0.3.53}/src/webbee/home.py +0 -0
  42. {webbee-0.3.52 → webbee-0.3.53}/src/webbee/home_view.py +0 -0
  43. {webbee-0.3.52 → webbee-0.3.53}/src/webbee/http.py +0 -0
  44. {webbee-0.3.52 → webbee-0.3.53}/src/webbee/instance_lock.py +0 -0
  45. {webbee-0.3.52 → webbee-0.3.53}/src/webbee/intel/__init__.py +0 -0
  46. {webbee-0.3.52 → webbee-0.3.53}/src/webbee/intel/chunker.py +0 -0
  47. {webbee-0.3.52 → webbee-0.3.53}/src/webbee/intel/embed.py +0 -0
  48. {webbee-0.3.52 → webbee-0.3.53}/src/webbee/intel/graph.py +0 -0
  49. {webbee-0.3.52 → webbee-0.3.53}/src/webbee/intel/vectors.py +0 -0
  50. {webbee-0.3.52 → webbee-0.3.53}/src/webbee/intel/watch.py +0 -0
  51. {webbee-0.3.52 → webbee-0.3.53}/src/webbee/mode_store.py +0 -0
  52. {webbee-0.3.52 → webbee-0.3.53}/src/webbee/newtab_mode.py +0 -0
  53. {webbee-0.3.52 → webbee-0.3.53}/src/webbee/output_pane.py +0 -0
  54. {webbee-0.3.52 → webbee-0.3.53}/src/webbee/queue_panel.py +0 -0
  55. {webbee-0.3.52 → webbee-0.3.53}/src/webbee/reflow.py +0 -0
  56. {webbee-0.3.52 → webbee-0.3.53}/src/webbee/remote.py +0 -0
  57. {webbee-0.3.52 → webbee-0.3.53}/src/webbee/render.py +0 -0
  58. {webbee-0.3.52 → webbee-0.3.53}/src/webbee/repl.py +0 -0
  59. {webbee-0.3.52 → webbee-0.3.53}/src/webbee/repo.py +0 -0
  60. {webbee-0.3.52 → webbee-0.3.53}/src/webbee/selection.py +0 -0
  61. {webbee-0.3.52 → webbee-0.3.53}/src/webbee/sessions.py +0 -0
  62. {webbee-0.3.52 → webbee-0.3.53}/src/webbee/sizing.py +0 -0
  63. {webbee-0.3.52 → webbee-0.3.53}/src/webbee/slots.py +0 -0
  64. {webbee-0.3.52 → webbee-0.3.53}/src/webbee/steer.py +0 -0
  65. {webbee-0.3.52 → webbee-0.3.53}/src/webbee/stream.py +0 -0
  66. {webbee-0.3.52 → webbee-0.3.53}/src/webbee/tab_store.py +0 -0
  67. {webbee-0.3.52 → webbee-0.3.53}/src/webbee/tabs.py +0 -0
  68. {webbee-0.3.52 → webbee-0.3.53}/src/webbee/thread.py +0 -0
  69. {webbee-0.3.52 → webbee-0.3.53}/src/webbee/tier_store.py +0 -0
  70. {webbee-0.3.52 → webbee-0.3.53}/src/webbee/todo_panel.py +0 -0
  71. {webbee-0.3.52 → webbee-0.3.53}/src/webbee/tokens.py +0 -0
  72. {webbee-0.3.52 → webbee-0.3.53}/src/webbee/tui.py +0 -0
  73. {webbee-0.3.52 → webbee-0.3.53}/src/webbee/update.py +0 -0
  74. {webbee-0.3.52 → webbee-0.3.53}/src/webbee/urlopen.py +0 -0
  75. {webbee-0.3.52 → webbee-0.3.53}/src/webbee/wallet.py +0 -0
  76. {webbee-0.3.52 → webbee-0.3.53}/src/webbee/worktrees.py +0 -0
  77. {webbee-0.3.52 → webbee-0.3.53}/tests/__init__.py +0 -0
  78. {webbee-0.3.52 → webbee-0.3.53}/tests/conftest.py +0 -0
  79. {webbee-0.3.52 → webbee-0.3.53}/tests/test_account.py +0 -0
  80. {webbee-0.3.52 → webbee-0.3.53}/tests/test_active_sessions.py +0 -0
  81. {webbee-0.3.52 → webbee-0.3.53}/tests/test_audit_2026_07_25_findings.py +0 -0
  82. {webbee-0.3.52 → webbee-0.3.53}/tests/test_checkpoints.py +0 -0
  83. {webbee-0.3.52 → webbee-0.3.53}/tests/test_cli.py +0 -0
  84. {webbee-0.3.52 → webbee-0.3.53}/tests/test_clipboard.py +0 -0
  85. {webbee-0.3.52 → webbee-0.3.53}/tests/test_clipboard_read.py +0 -0
  86. {webbee-0.3.52 → webbee-0.3.53}/tests/test_coding_context.py +0 -0
  87. {webbee-0.3.52 → webbee-0.3.53}/tests/test_commands.py +0 -0
  88. {webbee-0.3.52 → webbee-0.3.53}/tests/test_config.py +0 -0
  89. {webbee-0.3.52 → webbee-0.3.53}/tests/test_cpc_contract_stable.py +0 -0
  90. {webbee-0.3.52 → webbee-0.3.53}/tests/test_details.py +0 -0
  91. {webbee-0.3.52 → webbee-0.3.53}/tests/test_events.py +0 -0
  92. {webbee-0.3.52 → webbee-0.3.53}/tests/test_freeze_fix.py +0 -0
  93. {webbee-0.3.52 → webbee-0.3.53}/tests/test_home.py +0 -0
  94. {webbee-0.3.52 → webbee-0.3.53}/tests/test_home_view.py +0 -0
  95. {webbee-0.3.52 → webbee-0.3.53}/tests/test_instance_lock.py +0 -0
  96. {webbee-0.3.52 → webbee-0.3.53}/tests/test_intel_chunker.py +0 -0
  97. {webbee-0.3.52 → webbee-0.3.53}/tests/test_intel_embed.py +0 -0
  98. {webbee-0.3.52 → webbee-0.3.53}/tests/test_intel_graph.py +0 -0
  99. {webbee-0.3.52 → webbee-0.3.53}/tests/test_intel_indexer.py +0 -0
  100. {webbee-0.3.52 → webbee-0.3.53}/tests/test_intel_no_numpy.py +0 -0
  101. {webbee-0.3.52 → webbee-0.3.53}/tests/test_intel_query.py +0 -0
  102. {webbee-0.3.52 → webbee-0.3.53}/tests/test_intel_store.py +0 -0
  103. {webbee-0.3.52 → webbee-0.3.53}/tests/test_intel_vectors.py +0 -0
  104. {webbee-0.3.52 → webbee-0.3.53}/tests/test_intel_watch.py +0 -0
  105. {webbee-0.3.52 → webbee-0.3.53}/tests/test_mode_store.py +0 -0
  106. {webbee-0.3.52 → webbee-0.3.53}/tests/test_newtab_mode.py +0 -0
  107. {webbee-0.3.52 → webbee-0.3.53}/tests/test_packaging.py +0 -0
  108. {webbee-0.3.52 → webbee-0.3.53}/tests/test_queue_manage_0337.py +0 -0
  109. {webbee-0.3.52 → webbee-0.3.53}/tests/test_reflow.py +0 -0
  110. {webbee-0.3.52 → webbee-0.3.53}/tests/test_render.py +0 -0
  111. {webbee-0.3.52 → webbee-0.3.53}/tests/test_repl.py +0 -0
  112. {webbee-0.3.52 → webbee-0.3.53}/tests/test_repo.py +0 -0
  113. {webbee-0.3.52 → webbee-0.3.53}/tests/test_sessions.py +0 -0
  114. {webbee-0.3.52 → webbee-0.3.53}/tests/test_sizing.py +0 -0
  115. {webbee-0.3.52 → webbee-0.3.53}/tests/test_slots.py +0 -0
  116. {webbee-0.3.52 → webbee-0.3.53}/tests/test_steer.py +0 -0
  117. {webbee-0.3.52 → webbee-0.3.53}/tests/test_stream.py +0 -0
  118. {webbee-0.3.52 → webbee-0.3.53}/tests/test_tab_restore_0337.py +0 -0
  119. {webbee-0.3.52 → webbee-0.3.53}/tests/test_tabs.py +0 -0
  120. {webbee-0.3.52 → webbee-0.3.53}/tests/test_terminal_ux_0336.py +0 -0
  121. {webbee-0.3.52 → webbee-0.3.53}/tests/test_thread.py +0 -0
  122. {webbee-0.3.52 → webbee-0.3.53}/tests/test_tier_store.py +0 -0
  123. {webbee-0.3.52 → webbee-0.3.53}/tests/test_todo_panel.py +0 -0
  124. {webbee-0.3.52 → webbee-0.3.53}/tests/test_tokens.py +0 -0
  125. {webbee-0.3.52 → webbee-0.3.53}/tests/test_tools.py +0 -0
  126. {webbee-0.3.52 → webbee-0.3.53}/tests/test_tui.py +0 -0
  127. {webbee-0.3.52 → webbee-0.3.53}/tests/test_tui_hardening.py +0 -0
  128. {webbee-0.3.52 → webbee-0.3.53}/tests/test_update.py +0 -0
  129. {webbee-0.3.52 → webbee-0.3.53}/tests/test_update_badge_0340.py +0 -0
  130. {webbee-0.3.52 → webbee-0.3.53}/tests/test_urlopen.py +0 -0
  131. {webbee-0.3.52 → webbee-0.3.53}/tests/test_version.py +0 -0
  132. {webbee-0.3.52 → webbee-0.3.53}/tests/test_version_badge_corner.py +0 -0
  133. {webbee-0.3.52 → webbee-0.3.53}/tests/test_wallet.py +0 -0
  134. {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
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: webbee
3
- Version: 0.3.52
3
+ Version: 0.3.53
4
4
  Summary: Webbee 🐝 — the Imperal Cloud coding agent in your terminal
5
5
  Project-URL: Homepage, https://imperal.io
6
6
  Project-URL: Documentation, https://docs.imperal.io
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "webbee"
3
- version = "0.3.52"
3
+ version = "0.3.53"
4
4
  description = "Webbee 🐝 — the Imperal Cloud coding agent in your terminal"
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -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 = 3
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
- symbols=[Symbol(**s) for s in v.get("symbols", [])])
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"