@allansantos-dev/smart-tool 0.9.40 → 0.9.42

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.
package/document_text.py CHANGED
@@ -1,13 +1,16 @@
1
- """Extração local de texto DOCX e PDF, sem Office, execução de macros ou acesso externo."""
1
+ """Extração local de texto DOCX, PPTX, XLSX e PDF, sem Office, execução de macros ou acesso externo."""
2
+ import contextlib
2
3
  import io
3
4
  import json
5
+ import posixpath
4
6
  import re
5
7
  import subprocess
6
8
  import sys
7
9
  import zipfile
8
10
  import xml.etree.ElementTree as ET
11
+ import zlib
9
12
 
10
- VERSION = 2
13
+ VERSION = 3
11
14
  PDF_MAX_BYTES = 20 * 1024 * 1024
12
15
  PDF_TIMEOUT_S = 20
13
16
  PDF_MAX_PAGES = 300
@@ -15,6 +18,8 @@ PDF_MAX_CHARS = 1_000_000
15
18
  MAX_ARCHIVE_BYTES = 10 * 1024 * 1024
16
19
  MAX_XML_BYTES = 8 * 1024 * 1024
17
20
  MAX_TEXT_BYTES = 1024 * 1024
21
+ XLSX_MAX_ROWS = 100_000
22
+ XLSX_MAX_COLUMNS = 16_384
18
23
  W = '{http://schemas.openxmlformats.org/wordprocessingml/2006/main}'
19
24
 
20
25
 
@@ -37,53 +42,264 @@ class PdfUnreadable(DocumentError):
37
42
 
38
43
 
39
44
  class _DocumentTreeBuilder(ET.TreeBuilder):
45
+ label = 'DOCX'
46
+
40
47
  def doctype(self, name, pubid, system):
41
48
  # O callback também recusa DTD em XML UTF-16, antes de expandir entidades.
42
- raise DocumentError('DOCX contains disallowed entities.')
49
+ raise DocumentError(f'{self.label} contains disallowed entities.')
50
+
51
+
52
+ class _Package:
53
+ """An open OOXML zip: the parts it holds and a guarded parser that charges every part to one XML budget."""
54
+
55
+ def __init__(self, archive, entries, label):
56
+ self.archive = archive
57
+ self.entries = entries
58
+ self.label = label
59
+ self.xml_bytes = 0
60
+
61
+ def parse(self, name):
62
+ info = self.entries[name]
63
+ if info.flag_bits & 1 or info.compress_type not in (zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED):
64
+ raise DocumentError(f'Encrypted {self.label} or unsupported compression.')
65
+ if info.file_size > MAX_XML_BYTES - self.xml_bytes:
66
+ raise DocumentError(f'{self.label} XML content over the limit.')
67
+ with self.archive.open(info) as stream:
68
+ raw = stream.read(MAX_XML_BYTES - self.xml_bytes + 1)
69
+ self.xml_bytes += len(raw)
70
+ if self.xml_bytes > MAX_XML_BYTES or b'<!DOCTYPE' in raw.upper() or b'<!ENTITY' in raw.upper():
71
+ raise DocumentError(f'{self.label} contains excessive XML or disallowed entities.')
72
+ builder = _DocumentTreeBuilder()
73
+ builder.label = self.label
74
+ return ET.fromstring(raw, parser=ET.XMLParser(target=builder))
75
+
76
+
77
+ @contextlib.contextmanager
78
+ def _open_package(data, label, required):
79
+ """The one place that opens an OOXML zip (docx, pptx, xlsx) with the guards: archive size, entry count, a required
80
+ part, no encryption, one XML budget, no DOCTYPE or ENTITY, and every low-level failure turned into a DocumentError.
81
+
82
+ Parameters: data (zip bytes), label (`DOCX`, `PPTX` or `XLSX`, used in the reasons), required (a part name, or a
83
+ predicate over the part names, that must exist). Yields: a _Package. Raises: DocumentError with a readable reason."""
84
+ if len(data) > MAX_ARCHIVE_BYTES:
85
+ raise DocumentError(f'{label} over the 10 MiB limit.')
86
+ try:
87
+ with zipfile.ZipFile(io.BytesIO(data)) as archive:
88
+ listing = archive.infolist()
89
+ entries = {info.filename: info for info in listing}
90
+ present = required(entries) if callable(required) else required in entries
91
+ if len(listing) > 2000 or not present:
92
+ raise DocumentError(f'Invalid {label} or excessive structure.')
93
+ yield _Package(archive, entries, label)
94
+ except (zipfile.BadZipFile, ET.ParseError, RuntimeError, NotImplementedError, OSError, zlib.error, EOFError) as exc:
95
+ raise DocumentError(f'Could not extract text from this {label}.') from exc
96
+
97
+
98
+ class _Lines(list):
99
+ """Output lines of an extractor; `add` refuses to grow past MAX_TEXT_BYTES of UTF-8 text."""
100
+
101
+ def __init__(self, label):
102
+ super().__init__()
103
+ self.label = label
104
+ self.size = 0
105
+
106
+ def add(self, *lines):
107
+ for line in lines:
108
+ self.size += len(line.encode('utf-8')) + 1
109
+ if self.size > MAX_TEXT_BYTES:
110
+ raise DocumentError(f'Text extracted from {self.label} exceeds 1 MiB.')
111
+ self.append(line)
112
+
113
+
114
+ def _local(tag):
115
+ return tag.rsplit('}', 1)[-1]
43
116
 
44
117
 
45
118
  def extract_docx(data):
46
- if len(data)>MAX_ARCHIVE_BYTES:
47
- raise DocumentError('DOCX over the 10 MiB limit.')
48
- try:
49
- with zipfile.ZipFile(io.BytesIO(data)) as archive:
50
- listing=archive.infolist()
51
- entries={info.filename:info for info in listing}
52
- if len(listing)>2000 or 'word/document.xml' not in entries:
53
- raise DocumentError('Invalid DOCX or excessive structure.')
54
- names=['word/document.xml']+sorted(name for name in entries if re.fullmatch(r'word/(?:header\d+|footer\d+|footnotes|endnotes)\.xml',name))
55
- paragraphs=[];xml_bytes=0;text_bytes=0
56
- for name in names:
57
- info=entries[name]
58
- if info.flag_bits&1 or info.compress_type not in (zipfile.ZIP_STORED,zipfile.ZIP_DEFLATED):
59
- raise DocumentError('Encrypted DOCX or unsupported compression.')
60
- if info.file_size>MAX_XML_BYTES-xml_bytes:
61
- raise DocumentError('DOCX XML content over the limit.')
62
- with archive.open(info) as stream:
63
- raw=stream.read(MAX_XML_BYTES-xml_bytes+1)
64
- xml_bytes+=len(raw)
65
- if xml_bytes>MAX_XML_BYTES or b'<!DOCTYPE' in raw.upper() or b'<!ENTITY' in raw.upper():
66
- raise DocumentError('DOCX contains excessive XML or disallowed entities.')
67
- root=ET.fromstring(raw,parser=ET.XMLParser(target=_DocumentTreeBuilder()))
68
- namespace=W if root.tag.startswith(W) else '{http://purl.oclc.org/ooxml/wordprocessingml/main}'
69
- for paragraph in root.iter(namespace+'p'):
70
- def parts(node):
71
- pending=list(reversed(node))
72
- while pending:
73
- child=pending.pop()
74
- if child.tag==namespace+'p':continue
75
- if child.tag==namespace+'t':yield child.text or ''
76
- elif child.tag==namespace+'tab':yield '\t'
77
- elif child.tag in (namespace+'br',namespace+'cr'):yield '\n'
78
- else:pending.extend(reversed(child))
79
- text=''.join(parts(paragraph))
80
- if text.strip():
81
- text_bytes+=len(text.encode('utf-8'))+1
82
- if text_bytes>MAX_TEXT_BYTES:raise DocumentError('Text extracted from DOCX exceeds 1 MiB.')
83
- paragraphs.append(text)
84
- return ('\n'.join(paragraphs)+'\n' if paragraphs else '').encode('utf-8')
85
- except (zipfile.BadZipFile,ET.ParseError,RuntimeError,NotImplementedError,OSError) as exc:
86
- raise DocumentError('Could not extract text from this DOCX.') from exc
119
+ """Text of a .docx: body, headers, footers, footnotes and endnotes, one line per non-empty paragraph.
120
+
121
+ Parameters: data (the file bytes). Returns: UTF-8 text bytes. Raises: DocumentError with a readable reason for a
122
+ corrupt, encrypted or oversized package, or one with a DOCTYPE or entity."""
123
+ with _open_package(data, 'DOCX', 'word/document.xml') as package:
124
+ names = ['word/document.xml'] + sorted(name for name in package.entries if re.fullmatch(
125
+ r'word/(?:header\d+|footer\d+|footnotes|endnotes)\.xml', name))
126
+ paragraphs = _Lines('DOCX')
127
+ for name in names:
128
+ root = package.parse(name)
129
+ namespace = W if root.tag.startswith(W) else '{http://purl.oclc.org/ooxml/wordprocessingml/main}'
130
+ for paragraph in root.iter(namespace + 'p'):
131
+ def parts(node):
132
+ pending = list(reversed(node))
133
+ while pending:
134
+ child = pending.pop()
135
+ if child.tag == namespace + 'p':
136
+ continue
137
+ if child.tag == namespace + 't':
138
+ yield child.text or ''
139
+ elif child.tag == namespace + 'tab':
140
+ yield '\t'
141
+ elif child.tag in (namespace + 'br', namespace + 'cr'):
142
+ yield '\n'
143
+ else:
144
+ pending.extend(reversed(child))
145
+ text = ''.join(parts(paragraph))
146
+ if text.strip():
147
+ paragraphs.add(text)
148
+ return ('\n'.join(paragraphs) + '\n' if paragraphs else '').encode('utf-8')
149
+
150
+
151
+ def _drawing_paragraphs(root):
152
+ """Non-empty paragraphs (`a:p`) of a slide or notes part, from the `a:t` runs; the slide-number field is not text."""
153
+ lines = []
154
+ for paragraph in root.iter():
155
+ if _local(paragraph.tag) != 'p':
156
+ continue
157
+ pending = list(reversed(paragraph))
158
+ pieces = []
159
+ while pending:
160
+ child = pending.pop()
161
+ kind = _local(child.tag)
162
+ if kind == 'fld' and child.get('type', '').startswith('slidenum'):
163
+ continue
164
+ if kind == 't':
165
+ pieces.append(child.text or '')
166
+ elif kind == 'br':
167
+ pieces.append('\n')
168
+ elif kind != 'p':
169
+ pending.extend(reversed(child))
170
+ text = ''.join(pieces)
171
+ if text.strip():
172
+ lines.append(text)
173
+ return lines
174
+
175
+
176
+ def extract_pptx(data):
177
+ """Text of a .pptx: each slide under `[slide N]`, then its speaker notes under `[notes N]`, in slide order.
178
+
179
+ Parameters: data (the file bytes). Returns: UTF-8 text bytes. Raises: DocumentError with a readable reason for a
180
+ corrupt, encrypted, oversized or hostile package, or a deck without any text."""
181
+ def has_slides(names):
182
+ return any(re.fullmatch(r'ppt/slides/slide\d+\.xml', name) for name in names)
183
+
184
+ with _open_package(data, 'PPTX', has_slides) as package:
185
+ slides = sorted((int(m.group(1)), name) for name in package.entries
186
+ if (m := re.fullmatch(r'ppt/slides/slide(\d+)\.xml', name)))
187
+ lines = _Lines('PPTX')
188
+ for number, name in slides:
189
+ body = _drawing_paragraphs(package.parse(name))
190
+ if body:
191
+ lines.add(f'[slide {number}]', *body)
192
+ notes_name = f'ppt/notesSlides/notesSlide{number}.xml'
193
+ notes = _drawing_paragraphs(package.parse(notes_name)) if notes_name in package.entries else []
194
+ if notes:
195
+ lines.add(f'[notes {number}]', *notes)
196
+ if not lines:
197
+ raise DocumentError('PPTX has no text.')
198
+ return ('\n'.join(lines) + '\n').encode('utf-8')
199
+
200
+
201
+ def _shared_strings(package):
202
+ if 'xl/sharedStrings.xml' not in package.entries:
203
+ return []
204
+ strings = []
205
+ for item in package.parse('xl/sharedStrings.xml'):
206
+ if _local(item.tag) != 'si':
207
+ continue
208
+ pending = list(reversed(item))
209
+ pieces = []
210
+ while pending:
211
+ child = pending.pop()
212
+ kind = _local(child.tag)
213
+ if kind == 't':
214
+ pieces.append(child.text or '')
215
+ elif kind != 'rPh':
216
+ pending.extend(reversed(child))
217
+ strings.append(''.join(pieces))
218
+ return strings
219
+
220
+
221
+ def _column_index(ref):
222
+ letters = re.match(r'[A-Za-z]+', ref or '')
223
+ if not letters:
224
+ return None
225
+ index = 0
226
+ for char in letters.group().upper():
227
+ index = index * 26 + ord(char) - 64
228
+ return index - 1
229
+
230
+
231
+ def _sheet_rows(root, strings):
232
+ """Tab-joined text of each non-empty row of a worksheet; an empty cell keeps the columns after it aligned."""
233
+ for row in root.iter():
234
+ if _local(row.tag) != 'row':
235
+ continue
236
+ values = []
237
+ for cell in row:
238
+ if _local(cell.tag) != 'c':
239
+ continue
240
+ kind = cell.get('t')
241
+ value = ''
242
+ if kind == 'inlineStr':
243
+ value = ''.join(node.text or '' for node in cell.iter() if _local(node.tag) == 't')
244
+ else:
245
+ raw = next((node.text or '' for node in cell if _local(node.tag) == 'v'), '')
246
+ if kind == 's':
247
+ position = int(raw) if raw.strip().isdigit() else -1
248
+ if not 0 <= position < len(strings):
249
+ raise DocumentError('Could not extract text from this XLSX.')
250
+ value = strings[position]
251
+ else:
252
+ value = raw
253
+ column = _column_index(cell.get('r'))
254
+ if column is None:
255
+ column = len(values)
256
+ if column >= XLSX_MAX_COLUMNS:
257
+ raise DocumentError(f'XLSX over {XLSX_MAX_COLUMNS} columns.')
258
+ values += [''] * (column - len(values))
259
+ if column == len(values):
260
+ values.append(value)
261
+ else:
262
+ values[column] = value
263
+ if any(value.strip() for value in values):
264
+ yield '\t'.join(values)
265
+
266
+
267
+ def extract_xlsx(data):
268
+ """Text of an .xlsx: each sheet, in workbook order, under `[sheet NAME]`, then one line per row with the cells
269
+ separated by tabs. Shared strings are resolved; numbers and inline strings are kept; a formula contributes its cached
270
+ value, nothing is calculated.
271
+
272
+ Parameters: data (the file bytes). Returns: UTF-8 text bytes. Raises: DocumentError with a readable reason for a
273
+ corrupt, encrypted, oversized or hostile package, a workbook over XLSX_MAX_ROWS rows or MAX_TEXT_BYTES of text, or
274
+ one without any text."""
275
+ with _open_package(data, 'XLSX', 'xl/workbook.xml') as package:
276
+ workbook = package.parse('xl/workbook.xml')
277
+ targets = {}
278
+ if 'xl/_rels/workbook.xml.rels' in package.entries:
279
+ for rel in package.parse('xl/_rels/workbook.xml.rels'):
280
+ target = rel.get('Target', '')
281
+ targets[rel.get('Id')] = posixpath.normpath(target.lstrip('/') if target.startswith('/')
282
+ else posixpath.join('xl', target))
283
+ strings = _shared_strings(package)
284
+ lines = _Lines('XLSX')
285
+ rows = 0
286
+ for sheet in (node for node in workbook.iter() if _local(node.tag) == 'sheet'):
287
+ rid = next((value for key, value in sheet.attrib.items() if _local(key) == 'id'), None)
288
+ part = targets.get(rid)
289
+ if part not in package.entries:
290
+ raise DocumentError('Invalid XLSX or excessive structure.')
291
+ header = f"[sheet {' '.join(sheet.get('name', '').split())}]"
292
+ for line in _sheet_rows(package.parse(part), strings):
293
+ rows += 1
294
+ if rows > XLSX_MAX_ROWS:
295
+ raise DocumentError(f'XLSX over {XLSX_MAX_ROWS} rows.')
296
+ if header:
297
+ lines.add(header)
298
+ header = ''
299
+ lines.add(line)
300
+ if not lines:
301
+ raise DocumentError('XLSX has no text.')
302
+ return ('\n'.join(lines) + '\n').encode('utf-8')
87
303
 
88
304
 
89
305
  PDF_SCRIPT = """
@@ -150,11 +366,15 @@ def extract_pdf(data):
150
366
  return (text + "\n").encode("utf-8", "replace")
151
367
 
152
368
 
369
+ _EXTENSIONS = ('.docx', '.pptx', '.xlsx', '.pdf')
370
+
371
+
153
372
  def is_extracted(path):
154
373
  """Whether the file type goes through a text extractor before indexing.
155
374
 
156
- Parameters: path (any file name or path). Returns: True for `.docx` and `.pdf`, case-insensitive."""
157
- return str(path).lower().endswith(('.docx', '.pdf'))
375
+ Parameters: path (any file name or path). Returns: True for `.docx`, `.pptx`, `.xlsx` and `.pdf`, case-insensitive;
376
+ macro and binary formats (`.xlsm`, `.xlsb`, `.xls`, `.ppt`, `.pptm`) are not extracted."""
377
+ return str(path).lower().endswith(_EXTENSIONS)
158
378
 
159
379
 
160
380
  def size_limit(path):
@@ -169,7 +389,14 @@ def extract(path, data):
169
389
 
170
390
  Parameters: path (decides the type, see is_extracted), data (file bytes). Returns: UTF-8 text bytes. Raises:
171
391
  DocumentError with a readable reason."""
172
- return extract_pdf(data) if str(path).lower().endswith('.pdf') else extract_docx(data)
392
+ name = str(path).lower()
393
+ if name.endswith('.pdf'):
394
+ return extract_pdf(data)
395
+ if name.endswith('.pptx'):
396
+ return extract_pptx(data)
397
+ if name.endswith('.xlsx'):
398
+ return extract_xlsx(data)
399
+ return extract_docx(data)
173
400
 
174
401
 
175
402
  def location_kind(path):
package/freshness.py CHANGED
@@ -73,11 +73,13 @@ def npm_registry_evidence(package):
73
73
 
74
74
 
75
75
  NPM_NAME_RE = re.compile(r"(?:@[a-z0-9._-]+/)?[a-z0-9._-]+")
76
+ NAME_RES = {"npm": NPM_NAME_RE, "PyPI": re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*"),
77
+ "Go": re.compile(r"[A-Za-z0-9][A-Za-z0-9._~/-]*")}
76
78
 
77
79
 
78
- def _osv_query(package, package_version):
79
- """One OSV query object: the package (npm) and the version asked about."""
80
- return {"package": {"name": package, "ecosystem": "npm"}, "version": package_version}
80
+ def _osv_query(ecosystem, package, package_version):
81
+ """One OSV query object: the package in `ecosystem` and the version asked about."""
82
+ return {"package": {"name": package, "ecosystem": ecosystem}, "version": package_version}
81
83
 
82
84
 
83
85
  def _osv_request(endpoint, payload):
@@ -107,7 +109,7 @@ def npm_advisories(package, package_version):
107
109
  """IDs conhecidos pela OSV para a versão; None significa checagem indisponível."""
108
110
  if not package or not package_version or not NPM_NAME_RE.fullmatch(package):
109
111
  return None
110
- request = _osv_request("query", _osv_query(package, package_version))
112
+ request = _osv_request("query", _osv_query("npm", package, package_version))
111
113
  try:
112
114
  with urllib.request.urlopen(request, timeout=7) as response:
113
115
  return _osv_ids(json.load(response))
@@ -115,17 +117,21 @@ def npm_advisories(package, package_version):
115
117
  return None
116
118
 
117
119
 
118
- def npm_advisories_batch(packages, timeout):
120
+ def advisories_batch(ecosystem, packages, timeout):
119
121
  """Advisory IDs for each (package, version) of `packages` from one OSV `querybatch` call.
120
122
 
121
- Parameters: packages (list of (name, version)); timeout (float) seconds for the request.
123
+ Parameters: ecosystem (str) "npm", "PyPI" or "Go"; packages (list of (name, version)); timeout (float) seconds for
124
+ the request.
122
125
  Returns: list in the same order, each the sorted ID list or None when that package's answer is incomplete
123
126
  (next_page_token) or malformed, so it is unverified and not clean.
124
- Raises: RuntimeError when the service cannot be reached, answers badly or answers a different number of results.
127
+ Raises: ValueError for an unknown ecosystem or an invalid name or version; RuntimeError when the service cannot be
128
+ reached, answers badly or answers a different number of results.
125
129
  """
126
- if any(not NPM_NAME_RE.fullmatch(name) or not package_version for name, package_version in packages):
127
- raise ValueError(f"Invalid npm package query: {packages!r}")
128
- request = _osv_request("querybatch", {"queries": [_osv_query(name, package_version)
130
+ if ecosystem not in NAME_RES:
131
+ raise ValueError(f"Unknown OSV ecosystem: {ecosystem!r}")
132
+ if any(not NAME_RES[ecosystem].fullmatch(name) or not package_version for name, package_version in packages):
133
+ raise ValueError(f"Invalid {ecosystem} package query: {packages!r}")
134
+ request = _osv_request("querybatch", {"queries": [_osv_query(ecosystem, name, package_version)
129
135
  for name, package_version in packages]})
130
136
  try:
131
137
  with urllib.request.urlopen(request, timeout=timeout) as response:
package/hook_decision.py CHANGED
@@ -230,20 +230,21 @@ def _edit_review(payload, doc_mode, duplicate_mode, dependency_mode):
230
230
  """Checks of an edit before it runs. Docstrings follow doc_mode, in code files of registered projects only (tests,
231
231
  docs and files outside a project are left out, as in the docs coverage): missing ones deny the edit in require mode and become a reminder in remind mode; a
232
232
  documented function whose signature changed always gets a reminder. Copies of indexed functions follow
233
- duplicate_mode and only add context, never deny. An edit of a package.json follows dependency_mode: advisories
233
+ duplicate_mode and only add context, never deny. An edit of a package.json, requirements*.txt or go.mod follows dependency_mode: advisories
234
234
  of the added or changed dependencies are added as context, never a denial."""
235
235
  with code_graph.pending_scope():
236
236
  return _edit_review_scoped(payload, doc_mode, duplicate_mode, dependency_mode)
237
237
 
238
238
 
239
- def _dependency_part(before, after):
240
- """The dependency notice of one package.json edit, or the "dependency check skipped" line when it cannot run.
239
+ def _dependency_part(before, after, ecosystem):
240
+ """The dependency notice of one manifest edit, or the "dependency check skipped" line when it cannot run.
241
241
 
242
- Parameters: before (str or None) text before the edit; after (str) text after it.
242
+ Parameters: before (str or None) text before the edit; after (str) text after it; ecosystem (str) from
243
+ `dependency_notice.ecosystem_of`.
243
244
  Returns: str, empty when there is nothing to say.
244
245
  """
245
246
  try:
246
- return dependency_notice.notice(before, after)
247
+ return dependency_notice.notice(before, after, ecosystem)
247
248
  except dependency_notice.DependencyCheckError as exc:
248
249
  return f"{dependency_notice.PREFIX}dependency check skipped: {exc}"
249
250
 
@@ -252,8 +253,9 @@ def _edit_review_scoped(payload, doc_mode, duplicate_mode, dependency_mode):
252
253
  """`_edit_review` inside one `code_graph.pending_scope`, so the pending-changes scan runs once per review."""
253
254
  doc_findings, duplicate_findings, failures, dependency_parts = [], [], [], []
254
255
  for path, before, after in edit_preview.preview(payload.get("tool_name"), payload.get("tool_input"), payload.get("cwd")):
255
- if dependency_mode != "off" and os.path.basename(path) == "package.json":
256
- dependency_parts += [part for part in (_dependency_part(before, after),) if part]
256
+ ecosystem = dependency_notice.ecosystem_of(path)
257
+ if dependency_mode != "off" and ecosystem:
258
+ dependency_parts += [part for part in (_dependency_part(before, after, ecosystem),) if part]
257
259
  continue
258
260
  if not doc_check.supported(path):
259
261
  continue
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env python3
2
- """SessionStart hook of Claude Code (opt-in, from the setup screen): forwards the payload to the Smart Tool daemon
2
+ """SessionStart hook of Claude Code and Codex (opt-in, from the setup screen): forwards the payload to the Smart Tool daemon
3
3
  (POST /hooks/session-audit) and prints its answer, the stored setup audit notice of the session folder as additional
4
4
  context. Never starts the daemon or an audit: daemon down or any error means empty output, so a session start is
5
5
  never delayed or blocked.
package/index_profile.py CHANGED
@@ -11,7 +11,7 @@ PROFILE_VERSION = 2
11
11
  KINDS = ("code", "test", "doc")
12
12
  RETRY_AFTER_S = 3600
13
13
  MAX_DIRS = 90
14
- PROSE = {"md", "txt", "docx", "pdf", "rst", "adoc"}
14
+ PROSE = {"md", "txt", "docx", "pptx", "xlsx", "pdf", "rst", "adoc"}
15
15
  # Data and markup inside a documentation, design or data-dump folder are reports and mockups, not code (GitHub Linguist
16
16
  # treats docs/ as documentation; a design/ HTML or a probe's JSON result pushed hook_decision.py out of the code block).
17
17
  DOC_DATA = {"html", "htm", "json", "csv", "xml", "yaml", "yml", "svg"}
package/index_scope.py CHANGED
@@ -40,10 +40,13 @@ _PROJECT_TYPE_EXCLUDES = (
40
40
  (("Podfile",), ("Pods",)),
41
41
  )
42
42
 
43
+ # Files a tool writes while it works and replaces or deletes right after (atomic-write temp, backup, editor swap).
44
+ TRANSIENT_FILES = {"*.tmp", "*.bak", "*.swp", "*~"}
45
+
43
46
  # Mesmo critério do `_ALWAYS_EXCLUDE`, para arquivo: o significado não varia por
44
47
  # projeto. Lockfile é grafo de dependência resolvido por ferramenta — nunca é fonte de
45
48
  # resposta numa busca semântica, e é grande o bastante pra dominar o índice.
46
- _ALWAYS_EXCLUDE_FILES = {
49
+ _ALWAYS_EXCLUDE_FILES = TRANSIENT_FILES | {
47
50
  "package-lock.json", "yarn.lock", "pnpm-lock.yaml", "npm-shrinkwrap.json",
48
51
  "poetry.lock", "pdm.lock", "uv.lock", "Pipfile.lock", "Cargo.lock",
49
52
  "composer.lock", "go.sum", "Gemfile.lock", "packages.lock.json",
@@ -110,6 +113,11 @@ def normalize_include(include):
110
113
  return bases, dropped
111
114
 
112
115
 
116
+ def is_transient(name):
117
+ """Whether a file name is a temporary, backup or editor-swap file (`*.tmp`, `*.bak`, `*.swp`, `*~`)."""
118
+ return any(fnmatch.fnmatch(name.casefold(), pattern) for pattern in TRANSIENT_FILES)
119
+
120
+
113
121
  def always_excluded(name):
114
122
  """Directory name in _ALWAYS_EXCLUDE, entries may be globs (e.g. *.egg-info)."""
115
123
  folded = name.casefold()
package/indexer.py CHANGED
@@ -31,8 +31,10 @@ CHUNK_MAX_CHARS = 8000
31
31
  CHUNK_OVERLAP_LINES = 12
32
32
  CHUNKER_VERSION = 3
33
33
  SCHEMA_VERSION = 2
34
- REINDEX_BATCH_MAX_CHARS = 32_000
35
- REINDEX_BATCH_MAX_ITEMS = 32
34
+ # An embeddings request holds up to 128 items and 120,000 characters (about 30,000 tokens): far under the 2048 items and
35
+ # 300,000 tokens per request that OpenAI and Azure accept (docs/research/2026-10-27-excesso-de-chamadas-embed-rerank.md).
36
+ REINDEX_BATCH_MAX_CHARS = 120_000
37
+ REINDEX_BATCH_MAX_ITEMS = 128
36
38
  # Embedding requests in flight per flush. Measured 2026-10-03 (docs/spikes/embed_parallel_spike.py, 30 batches,
37
39
  # 3 interleaved rounds): 1 worker 33-50 s, 2 workers 19-27 s, 4 workers 11-20 s (~2.8x), no 429 from the gateway.
38
40
  EMBED_WORKERS = 4
@@ -0,0 +1,70 @@
1
+ """Protection of the model gateway, per model id: a circuit that opens after consecutive 429/503 answers and a cap on
2
+ the requests in flight.
3
+
4
+ Pattern: a circuit breaker (closed, open for a pause, then one trial call decides). Retrying a gateway that answers
5
+ busy only adds load (AWS Bedrock throughput guide: honor Retry-After, back off with jitter); once it keeps answering
6
+ busy the caller stops asking for a while and serves what it can without the model.
7
+ """
8
+ import contextlib
9
+ import threading
10
+ import time
11
+ import urllib.error
12
+
13
+ FAILURES_TO_OPEN = 3
14
+ PAUSE_S = 30
15
+ MAX_IN_FLIGHT = 6
16
+ _BUSY_CODES = (429, 503)
17
+ _lock = threading.Lock()
18
+ _failures = {}
19
+ _open_until = {}
20
+ _slots = {}
21
+
22
+
23
+ class CircuitOpen(RuntimeError):
24
+ """The model is paused after repeated busy answers; no request was sent."""
25
+
26
+
27
+ def guard(model):
28
+ """Raises CircuitOpen while `model` is paused. Nothing happens for a call without a model id."""
29
+ if not model:
30
+ return
31
+ with _lock:
32
+ left = _open_until.get(model, 0) - time.monotonic()
33
+ if left > 0:
34
+ raise CircuitOpen(f"Model {model} is paused for {int(left) + 1} s after {FAILURES_TO_OPEN} consecutive "
35
+ "HTTP 429/503 answers from the gateway; no request was sent.")
36
+
37
+
38
+ def note(model, failure=None):
39
+ """Records the outcome of a request: a success closes the circuit, a 429/503 counts toward opening it, any other
40
+ failure changes nothing. After the pause the first failure opens it again."""
41
+ if not model:
42
+ return
43
+ busy = isinstance(failure, urllib.error.HTTPError) and failure.code in _BUSY_CODES
44
+ with _lock:
45
+ if failure is None:
46
+ _failures[model] = 0
47
+ elif busy:
48
+ _failures[model] = _failures.get(model, 0) + 1
49
+ if _failures[model] >= FAILURES_TO_OPEN:
50
+ _open_until[model] = time.monotonic() + PAUSE_S
51
+
52
+
53
+ @contextlib.contextmanager
54
+ def slot(model):
55
+ """Holds one of the `MAX_IN_FLIGHT` places of `model` for the duration of a request."""
56
+ if not model:
57
+ yield
58
+ return
59
+ with _lock:
60
+ semaphore = _slots.setdefault(model, threading.BoundedSemaphore(MAX_IN_FLIGHT))
61
+ with semaphore:
62
+ yield
63
+
64
+
65
+ def reset():
66
+ """Forgets every circuit and cap (tests)."""
67
+ with _lock:
68
+ _failures.clear()
69
+ _open_until.clear()
70
+ _slots.clear()
package/model_client.py CHANGED
@@ -9,6 +9,7 @@ import usage_meter
9
9
 
10
10
  import config
11
11
  import gateway
12
+ import model_breaker
12
13
  import model_health
13
14
 
14
15
  OFFLINE = contextvars.ContextVar("model_offline", default=None)
@@ -26,18 +27,24 @@ def get_token():
26
27
 
27
28
 
28
29
  def fetch(path, token, method="GET", body=None, timeout=15):
30
+ """One gateway request; returns the parsed JSON. A call that names a model first passes that model's circuit and
31
+ request cap (model_breaker, CircuitOpen when paused), and every attempt is metered. HTTP errors propagate."""
29
32
  if OFFLINE.get():
30
33
  raise RuntimeError(OFFLINE.get())
31
- started=time.monotonic();result=None;headers=None;failure=None
32
- try:
33
- result,headers=_fetch(path,token,method,body,timeout)
34
- return result
35
- except Exception as exc:
36
- failure=exc
37
- raise
38
- finally:
39
- usage_meter.record(path,body,result,headers,failure,time.monotonic()-started)
40
- model_health.record(path,body,failure)
34
+ model=body.get("model") if isinstance(body,dict) else None
35
+ model_breaker.guard(model)
36
+ with model_breaker.slot(model):
37
+ started=time.monotonic();result=None;headers=None;failure=None
38
+ try:
39
+ result,headers=_fetch(path,token,method,body,timeout)
40
+ return result
41
+ except Exception as exc:
42
+ failure=exc
43
+ raise
44
+ finally:
45
+ model_breaker.note(model,failure)
46
+ usage_meter.record(path,body,result,headers,failure,time.monotonic()-started)
47
+ model_health.record(path,body,failure)
41
48
 
42
49
 
43
50
  def _fetch(path, _token, method="GET", body=None, timeout=15):