seam-code 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. seam/__init__.py +3 -0
  2. seam/_data/schema.sql +225 -0
  3. seam/_web/assets/index-BL_tqprR.js +216 -0
  4. seam/_web/assets/index-GTKUhVyD.css +1 -0
  5. seam/_web/index.html +13 -0
  6. seam/analysis/__init__.py +14 -0
  7. seam/analysis/affected.py +254 -0
  8. seam/analysis/builtins.py +966 -0
  9. seam/analysis/byte_budget.py +217 -0
  10. seam/analysis/changes.py +709 -0
  11. seam/analysis/cluster_naming.py +260 -0
  12. seam/analysis/clustering.py +216 -0
  13. seam/analysis/confidence.py +699 -0
  14. seam/analysis/embeddings.py +195 -0
  15. seam/analysis/flows.py +708 -0
  16. seam/analysis/impact.py +444 -0
  17. seam/analysis/imports.py +994 -0
  18. seam/analysis/imports_ext.py +780 -0
  19. seam/analysis/imports_resolve.py +176 -0
  20. seam/analysis/processes.py +453 -0
  21. seam/analysis/relevance.py +155 -0
  22. seam/analysis/rwr.py +129 -0
  23. seam/analysis/staleness.py +328 -0
  24. seam/analysis/steer.py +282 -0
  25. seam/analysis/synthesis.py +253 -0
  26. seam/analysis/synthesis_channels.py +433 -0
  27. seam/analysis/testpaths.py +103 -0
  28. seam/analysis/traversal.py +470 -0
  29. seam/cli/__init__.py +0 -0
  30. seam/cli/install.py +232 -0
  31. seam/cli/main.py +2602 -0
  32. seam/cli/output.py +137 -0
  33. seam/cli/read.py +244 -0
  34. seam/cli/serve.py +145 -0
  35. seam/config.py +551 -0
  36. seam/indexer/__init__.py +0 -0
  37. seam/indexer/cluster_index.py +425 -0
  38. seam/indexer/db.py +496 -0
  39. seam/indexer/embedding_index.py +183 -0
  40. seam/indexer/field_access.py +536 -0
  41. seam/indexer/field_access_c_cpp.py +643 -0
  42. seam/indexer/field_access_ext.py +708 -0
  43. seam/indexer/field_access_ext2.py +408 -0
  44. seam/indexer/field_access_go_rust.py +737 -0
  45. seam/indexer/field_access_php_swift.py +888 -0
  46. seam/indexer/field_access_ts.py +626 -0
  47. seam/indexer/graph.py +321 -0
  48. seam/indexer/graph_c.py +562 -0
  49. seam/indexer/graph_c_cpp.py +39 -0
  50. seam/indexer/graph_common.py +644 -0
  51. seam/indexer/graph_cpp.py +615 -0
  52. seam/indexer/graph_csharp.py +651 -0
  53. seam/indexer/graph_go.py +723 -0
  54. seam/indexer/graph_go_rust.py +39 -0
  55. seam/indexer/graph_java.py +689 -0
  56. seam/indexer/graph_java_csharp.py +38 -0
  57. seam/indexer/graph_php.py +914 -0
  58. seam/indexer/graph_python.py +628 -0
  59. seam/indexer/graph_ruby.py +748 -0
  60. seam/indexer/graph_rust.py +653 -0
  61. seam/indexer/graph_scope_infer.py +902 -0
  62. seam/indexer/graph_scope_infer_ext.py +723 -0
  63. seam/indexer/graph_scope_infer_ext2.py +992 -0
  64. seam/indexer/graph_swift.py +1014 -0
  65. seam/indexer/graph_swift_infer.py +515 -0
  66. seam/indexer/graph_typescript.py +663 -0
  67. seam/indexer/migrations.py +816 -0
  68. seam/indexer/parser.py +204 -0
  69. seam/indexer/pipeline.py +197 -0
  70. seam/indexer/signatures.py +634 -0
  71. seam/indexer/signatures_ext.py +780 -0
  72. seam/indexer/sync.py +287 -0
  73. seam/indexer/synthesis_index.py +291 -0
  74. seam/indexer/tokenize.py +79 -0
  75. seam/installer/__init__.py +67 -0
  76. seam/installer/claude.py +97 -0
  77. seam/installer/codex.py +94 -0
  78. seam/installer/core.py +127 -0
  79. seam/installer/cursor.py +61 -0
  80. seam/installer/guide.py +110 -0
  81. seam/installer/jsonfile.py +85 -0
  82. seam/installer/markdownfile.py +146 -0
  83. seam/installer/tomlfile.py +72 -0
  84. seam/query/__init__.py +0 -0
  85. seam/query/clusters.py +206 -0
  86. seam/query/comments.py +217 -0
  87. seam/query/context.py +293 -0
  88. seam/query/engine.py +940 -0
  89. seam/query/fts.py +328 -0
  90. seam/query/names.py +470 -0
  91. seam/query/pack.py +433 -0
  92. seam/query/semantic.py +339 -0
  93. seam/query/structure.py +727 -0
  94. seam/server/__init__.py +0 -0
  95. seam/server/graph_api.py +437 -0
  96. seam/server/handler_common.py +323 -0
  97. seam/server/impact_handler.py +615 -0
  98. seam/server/mcp.py +556 -0
  99. seam/server/tools.py +697 -0
  100. seam/server/trace_handler.py +184 -0
  101. seam/server/web.py +922 -0
  102. seam/watcher/__init__.py +0 -0
  103. seam/watcher/__main__.py +56 -0
  104. seam/watcher/daemon.py +237 -0
  105. seam_code-0.3.0.dist-info/METADATA +318 -0
  106. seam_code-0.3.0.dist-info/RECORD +109 -0
  107. seam_code-0.3.0.dist-info/WHEEL +4 -0
  108. seam_code-0.3.0.dist-info/entry_points.txt +2 -0
  109. seam_code-0.3.0.dist-info/licenses/LICENSE +21 -0
seam/query/pack.py ADDED
@@ -0,0 +1,433 @@
1
+ """Context-Pack primitive — Phase 6.
2
+
3
+ Single public function:
4
+ context_pack(conn, symbol_name) -> ContextPack | None
5
+
6
+ Orchestrates EXISTING read primitives into one ready-to-paste bundle:
7
+ - target: engine.context() verbatim (full 360-degree ContextResult)
8
+ - callers: 1-hop callers enriched to NeighborRef (name, file, line, kind, signature)
9
+ - callees: 1-hop callees enriched to NeighborRef
10
+ - why: comments.why(symbol=...) results, capped
11
+ - cluster_peers: taken directly from target (no extra query)
12
+ - truncated: {callers, callees, comments} counts of entries dropped by caps
13
+
14
+ WHY a new module instead of extending engine.py:
15
+ pack.py is deliberately thin orchestration. Keeping it separate makes it clear
16
+ that it adds no extraction logic — it only composes existing primitives.
17
+ engine.py is already large; mixing in cap/truncation logic would obscure the
18
+ core search/query/context path.
19
+
20
+ Caps are config-driven from seam/config.py:
21
+ SEAM_PACK_NEIGHBOR_LIMIT — global max per list (callers, callees)
22
+ SEAM_PACK_PER_FILE_CAP — max entries from any single file (diversity)
23
+ SEAM_PACK_MAX_COMMENTS — max WHY comments included
24
+ """
25
+
26
+ import logging
27
+ import sqlite3
28
+ from typing import TypedDict
29
+
30
+ import seam.config as config
31
+ from seam.analysis.rwr import personalized_pagerank
32
+ from seam.analysis.testpaths import is_test_file
33
+ from seam.query.comments import CommentHit
34
+ from seam.query.comments import why as comments_why
35
+ from seam.query.engine import ContextResult, decode_enrichment_fields
36
+ from seam.query.engine import context as engine_context
37
+ from seam.query.names import edge_match_names as _edge_match_names
38
+
39
+ logger = logging.getLogger(__name__)
40
+
41
+ # WHY 900 (not 999): SQLite's hard host-parameter limit is 999 by default.
42
+ # Using 900 gives a safety margin for other bindings in the same statement and
43
+ # avoids breaking on SQLite builds compiled with a lower SQLITE_MAX_VARIABLE_NUMBER.
44
+ # This is a module constant (not a config knob) because it reflects a SQLite
45
+ # implementation constraint the user cannot influence at runtime.
46
+ _SQLITE_MAX_IN_PARAMS = 900
47
+
48
+
49
+ # ── TypedDicts ────────────────────────────────────────────────────────────────
50
+
51
+
52
+ class NeighborRef(TypedDict):
53
+ """An enriched 1-hop neighbor entry.
54
+
55
+ The five PRD-required fields are: name, file, line, kind, signature.
56
+ We include all Phase 4 enrichment fields for null-contract consistency
57
+ with other tools (signature/decorators/is_exported/visibility/qualified_name).
58
+ Fields are None for pre-v5 rows or when extraction was not available.
59
+ """
60
+
61
+ name: str
62
+ file: str
63
+ line: int
64
+ kind: str
65
+ signature: str | None
66
+ decorators: list[str]
67
+ is_exported: bool | None
68
+ visibility: str | None
69
+ qualified_name: str | None
70
+
71
+
72
+ class TruncatedCounts(TypedDict):
73
+ """Counts of entries dropped by caps in each list."""
74
+
75
+ callers: int
76
+ callees: int
77
+ comments: int
78
+
79
+
80
+ class ContextPack(TypedDict):
81
+ """A ready-to-paste context bundle for a symbol.
82
+
83
+ Returned by context_pack(). None means the symbol was not found.
84
+
85
+ Fields:
86
+ target: Full 360-degree ContextResult from engine.context().
87
+ callers: Enriched 1-hop callers (NeighborRef list, capped).
88
+ callees: Enriched 1-hop callees (NeighborRef list, capped).
89
+ why: WHY/HACK/NOTE/TODO/FIXME comments attached to the symbol (capped).
90
+ cluster_peers: Functional-area peers taken directly from target.cluster_peers.
91
+ truncated: Counts of entries dropped by caps (callers, callees, comments).
92
+ """
93
+
94
+ target: ContextResult
95
+ callers: list[NeighborRef]
96
+ callees: list[NeighborRef]
97
+ why: list[CommentHit]
98
+ cluster_peers: list[str]
99
+ truncated: TruncatedCounts
100
+
101
+
102
+ # ── Internal helpers ──────────────────────────────────────────────────────────
103
+
104
+
105
+ def _fetch_local_subgraph(
106
+ conn: sqlite3.Connection,
107
+ seeds: set[str],
108
+ *,
109
+ max_depth: int,
110
+ max_nodes: int,
111
+ ) -> dict[str, set[str]]:
112
+ """Collect a bounded UNDIRECTED local subgraph around `seeds` from the edges table.
113
+
114
+ Depth-capped, node-capped BFS: from the current frontier, fetch every edge whose
115
+ source_name OR target_name is in the frontier, add both endpoints as undirected
116
+ neighbours, and advance the frontier — stopping at `max_depth` hops or once `max_nodes`
117
+ distinct nodes have been seen. This keeps the RWR walk O(subgraph), never a whole-graph load.
118
+
119
+ `seeds` is the symbol's edge_match_names (qualified + bare) so the walk is rooted at both
120
+ storage forms. Returns {name: set(neighbour names)} (always includes the seeds). Returns the
121
+ seed-only adjacency on any DB error — RWR then degrades to no ranking, never raises.
122
+ """
123
+ adjacency: dict[str, set[str]] = {s: set() for s in seeds}
124
+ expanded: set[str] = set() # frontier nodes already queried (avoid re-expanding)
125
+ frontier: set[str] = set(seeds)
126
+ # Chunk the IN(...) to respect SQLite's host-parameter limit. The same name set is bound
127
+ # twice (source_name + target_name), so each chunk may use up to 2*len(chunk) params.
128
+ half = max(1, _SQLITE_MAX_IN_PARAMS // 2)
129
+ try:
130
+ for _ in range(max(0, max_depth)):
131
+ if not frontier or len(adjacency) >= max_nodes:
132
+ break
133
+ expanded |= frontier
134
+ next_frontier: set[str] = set()
135
+ frontier_list = sorted(frontier)
136
+ for start in range(0, len(frontier_list), half):
137
+ chunk = frontier_list[start : start + half]
138
+ ph = ",".join("?" * len(chunk))
139
+ rows = conn.execute(
140
+ f"SELECT source_name, target_name FROM edges "
141
+ f"WHERE source_name IN ({ph}) OR target_name IN ({ph})",
142
+ chunk + chunk,
143
+ ).fetchall()
144
+ for row in rows:
145
+ s, t = row["source_name"], row["target_name"]
146
+ if s == t:
147
+ continue # self-loop: irrelevant to ranking
148
+ # Undirected: record both directions.
149
+ adjacency.setdefault(s, set()).add(t)
150
+ adjacency.setdefault(t, set()).add(s)
151
+ for endpoint in (s, t):
152
+ if endpoint not in expanded:
153
+ next_frontier.add(endpoint)
154
+ if len(adjacency) >= max_nodes:
155
+ break # hard stop on hub fan-out within a single depth level
156
+ frontier = next_frontier
157
+ except Exception: # noqa: BLE001 — read path must never raise.
158
+ logger.debug("_fetch_local_subgraph: DB error for seeds=%r", seeds, exc_info=True)
159
+ return {s: set() for s in seeds}
160
+ return adjacency
161
+
162
+
163
+ def _neighbor_scores(conn: sqlite3.Connection, seed: str) -> dict[str, float]:
164
+ """Personalized-PageRank relevance scores for nodes near `seed` (E3).
165
+
166
+ Roots the walk at the seed's edge_match_names (qualified + bare) so neighbours reachable via
167
+ either storage form are scored against the same logical seed. Returns {} when ranking is
168
+ disabled (callers then fall back to min_id order) or on any failure. Never raises.
169
+ """
170
+ if config.SEAM_PACK_RELEVANCE_RANK != "on":
171
+ return {}
172
+ try:
173
+ try:
174
+ seeds = set(_edge_match_names(conn, seed)) or {seed}
175
+ except Exception: # noqa: BLE001
176
+ seeds = {seed}
177
+ adjacency = _fetch_local_subgraph(
178
+ conn, seeds,
179
+ max_depth=config.SEAM_RWR_MAX_DEPTH,
180
+ max_nodes=config.SEAM_RWR_MAX_NODES,
181
+ )
182
+ return personalized_pagerank(adjacency, seeds)
183
+ except Exception: # noqa: BLE001 — ranking is best-effort; never break the pack.
184
+ logger.debug("_neighbor_scores: degraded for seed=%r", seed, exc_info=True)
185
+ return {}
186
+
187
+
188
+ def _enrich_neighbors(
189
+ conn: sqlite3.Connection,
190
+ names: list[str],
191
+ *,
192
+ neighbor_limit: int,
193
+ per_file_cap: int,
194
+ scores: dict[str, float] | None = None,
195
+ ) -> tuple[list[NeighborRef], int]:
196
+ """Enrich a list of neighbor names to NeighborRef dicts and apply caps.
197
+
198
+ Returns (enriched_list, truncated_count).
199
+
200
+ truncated_count counts ONLY entries dropped by caps (global limit or per-file
201
+ cap). Names with no symbols row (external/unindexed symbols) are silently
202
+ skipped and NOT counted — a higher cap never retrieves them so counting them
203
+ would mislead agents into running fallback queries for phantom symbols.
204
+
205
+ Algorithm:
206
+ 1. Deduplicate the input names while preserving order.
207
+ 2. Batch-lookup all distinct names in chunked WHERE name IN (...) queries.
208
+ Chunks are at most _SQLITE_MAX_IN_PARAMS wide to avoid SQLite's hard
209
+ host-parameter limit (default 999). Tie-break: first match per name
210
+ by lowest symbol id (mirrors context()).
211
+ 3. Build all_refs in min_id order (ORDER BY min_id in the SQL, preserved
212
+ via dict insertion order).
213
+ 4. Apply per-file cap in min_id order.
214
+ 5. Apply global neighbor_limit.
215
+ 6. Count dropped entries (cap drops only, NOT unindexed skips).
216
+
217
+ WHY batch: caller/callee lists can be large for hot utilities. N+1 queries
218
+ (one context() call per name) would be O(n) round-trips. The batched IN(...)
219
+ is O(1) round-trips regardless of list size.
220
+
221
+ WHY per-file before global: the PRD spec (§4.5a) requires per-file cap
222
+ applied first so the global limit sees an already-diverse list.
223
+ """
224
+ if not names:
225
+ return [], 0
226
+
227
+ # Step 1: deduplicate while preserving order
228
+ seen: set[str] = set()
229
+ unique_names: list[str] = []
230
+ for n in names:
231
+ if n not in seen:
232
+ seen.add(n)
233
+ unique_names.append(n)
234
+
235
+ if not unique_names:
236
+ return [], 0
237
+
238
+ # Step 2: batch-lookup in chunks of _SQLITE_MAX_IN_PARAMS.
239
+ # WHY chunks: SQLite's SQLITE_MAX_VARIABLE_NUMBER is 999 by default.
240
+ # One hot utility can have thousands of callers; chunking prevents
241
+ # OperationalError that would otherwise be swallowed by the context_pack
242
+ # except block, silently returning empty callers/callees for the busiest symbols.
243
+ # We select ALL Phase 4 enrichment fields for null-contract consistency.
244
+ sql_template = """
245
+ SELECT
246
+ s.name,
247
+ f.path AS file,
248
+ s.start_line AS line,
249
+ s.kind,
250
+ s.signature,
251
+ s.decorators,
252
+ s.is_exported,
253
+ s.visibility,
254
+ s.qualified_name,
255
+ MIN(s.id) AS min_id
256
+ FROM symbols s
257
+ JOIN files f ON f.id = s.file_id
258
+ WHERE s.name IN ({placeholders})
259
+ GROUP BY s.name
260
+ ORDER BY min_id
261
+ """
262
+
263
+ # Build a lookup: name -> NeighborRef (first match per name, lowest id wins).
264
+ # Each name appears in exactly one chunk, and each chunk is ORDER BY min_id, so
265
+ # ordering is exact min_id within a chunk. With ≤_SQLITE_MAX_IN_PARAMS distinct
266
+ # names (the overwhelmingly common case) there is a single chunk and the global
267
+ # order is exact; across multiple chunks the order is still fully deterministic
268
+ # for a given index (chunk slice order is fixed), just not globally min_id-sorted.
269
+ name_to_ref: dict[str, NeighborRef] = {}
270
+
271
+ for chunk_start in range(0, len(unique_names), _SQLITE_MAX_IN_PARAMS):
272
+ chunk = unique_names[chunk_start : chunk_start + _SQLITE_MAX_IN_PARAMS]
273
+ placeholders = ",".join("?" * len(chunk))
274
+ sql = sql_template.format(placeholders=placeholders)
275
+ rows = conn.execute(sql, chunk).fetchall()
276
+
277
+ for row in rows:
278
+ # Reuse the shared decode helper from engine.py — single source of truth
279
+ # for 0/1/NULL→bool and JSON TEXT→list[str] semantics.
280
+ decorators, is_exported = decode_enrichment_fields(row)
281
+
282
+ name_to_ref[row["name"]] = NeighborRef(
283
+ name=row["name"],
284
+ file=row["file"],
285
+ line=row["line"],
286
+ kind=row["kind"],
287
+ signature=row["signature"],
288
+ decorators=decorators,
289
+ is_exported=is_exported,
290
+ visibility=row["visibility"],
291
+ qualified_name=row["qualified_name"],
292
+ )
293
+
294
+ # Log unindexed skips at DEBUG level for observability.
295
+ # These are NOT cap drops — they are edges to external/unindexed symbols.
296
+ unindexed_count = len(unique_names) - len(name_to_ref)
297
+ if unindexed_count > 0:
298
+ logger.debug(
299
+ "_enrich_neighbors: skipped %d unindexed name(s) (external symbols, not in index)",
300
+ unindexed_count,
301
+ )
302
+
303
+ # Step 3: Build all_refs in insertion order (= min_id order within the single
304
+ # common-case chunk; deterministic across chunks). dict insertion order is
305
+ # preserved (Python 3.7+), so values() reflects the Step-2 ordering.
306
+ all_refs: list[NeighborRef] = list(name_to_ref.values())
307
+
308
+ # Step 3b (E3): relevance-rank before the caps so the kept N are the most relevant neighbors,
309
+ # not the lowest-symbol-id ones. Key = (-PPR score, is_test, min_id):
310
+ # - higher personalized-PageRank score (relevance to the seed) first;
311
+ # - production before test on an equal score (usability tie-break);
312
+ # - min_id last — Python's stable sort preserves the Step-3 order within full ties, so an
313
+ # empty `scores` map (ranking disabled or RWR degraded) is a byte-identical no-op.
314
+ if scores:
315
+ all_refs.sort(
316
+ key=lambda r: (-scores.get(r["name"], 0.0), is_test_file(r["file"]))
317
+ )
318
+
319
+ # Step 4: Per-file cap — count entries per file; drop once cap is hit.
320
+ # Iterate in min_id order (already correct from Step 3).
321
+ file_counts: dict[str, int] = {}
322
+ capped_refs: list[NeighborRef] = []
323
+ for ref in all_refs:
324
+ file_key = ref["file"]
325
+ count = file_counts.get(file_key, 0)
326
+ if count < per_file_cap:
327
+ capped_refs.append(ref)
328
+ file_counts[file_key] = count + 1
329
+ # else: silently drop (counted in truncated below)
330
+
331
+ # Step 5: Global limit
332
+ final = capped_refs[:neighbor_limit]
333
+
334
+ # Step 6: truncated counts ONLY cap drops (per-file + global).
335
+ # Unindexed skips are NOT counted — a higher cap never retrieves them.
336
+ truncated = (len(all_refs) - len(capped_refs)) + (len(capped_refs) - len(final))
337
+
338
+ return final, truncated
339
+
340
+
341
+ # ── Public API ────────────────────────────────────────────────────────────────
342
+
343
+
344
+ def context_pack(
345
+ conn: sqlite3.Connection,
346
+ symbol_name: str,
347
+ ) -> ContextPack | None:
348
+ """Build a ready-to-paste context bundle for a symbol.
349
+
350
+ Returns None when the symbol is not in the index (same contract as context()).
351
+ Every sub-lookup degrades to empty rather than raising — NEVER raises.
352
+
353
+ Args:
354
+ conn: Open SQLite connection to the Seam index (read-only).
355
+ symbol_name: The symbol name to look up.
356
+
357
+ Returns:
358
+ A ContextPack TypedDict, or None if the symbol is not found.
359
+ """
360
+ # Step 1: Fetch full 360-degree target context.
361
+ # context() returns None for unknown symbols — propagate that contract.
362
+ try:
363
+ target = engine_context(conn, symbol_name)
364
+ except Exception:
365
+ # engine.context() should never raise for valid DB, but degrade gracefully.
366
+ logger.warning("context_pack: engine.context() raised for %r", symbol_name, exc_info=True)
367
+ return None
368
+
369
+ if target is None:
370
+ return None
371
+
372
+ # Step 2: Gather caller/callee names from target (bare string lists).
373
+ caller_names: list[str] = target["callers"]
374
+ callee_names: list[str] = target["callees"]
375
+
376
+ # Step 3: Enrich callers and callees in batched lookups.
377
+ # Degrade gracefully on any DB error (empty list, not a crash).
378
+ neighbor_limit = config.SEAM_PACK_NEIGHBOR_LIMIT
379
+ per_file_cap = config.SEAM_PACK_PER_FILE_CAP
380
+
381
+ # E3: compute personalized-PageRank relevance scores ONCE (one bounded subgraph + one walk,
382
+ # personalized to the seed) and reuse for both caller and callee ranking. Empty when ranking
383
+ # is disabled or RWR degrades → enrichment falls back to min_id order. Never raises.
384
+ scores = _neighbor_scores(conn, target["symbol"])
385
+
386
+ try:
387
+ enriched_callers, callers_dropped = _enrich_neighbors(
388
+ conn, caller_names,
389
+ neighbor_limit=neighbor_limit,
390
+ per_file_cap=per_file_cap,
391
+ scores=scores,
392
+ )
393
+ except Exception:
394
+ logger.warning("context_pack: caller enrichment failed for %r", symbol_name, exc_info=True)
395
+ enriched_callers, callers_dropped = [], 0
396
+
397
+ try:
398
+ enriched_callees, callees_dropped = _enrich_neighbors(
399
+ conn, callee_names,
400
+ neighbor_limit=neighbor_limit,
401
+ per_file_cap=per_file_cap,
402
+ scores=scores,
403
+ )
404
+ except Exception:
405
+ logger.warning("context_pack: callee enrichment failed for %r", symbol_name, exc_info=True)
406
+ enriched_callees, callees_dropped = [], 0
407
+
408
+ # Step 4: Fetch WHY comments, apply comment cap.
409
+ max_comments = config.SEAM_PACK_MAX_COMMENTS
410
+ try:
411
+ all_comments = comments_why(conn, symbol=symbol_name)
412
+ except Exception:
413
+ logger.warning("context_pack: comments.why() raised for %r", symbol_name, exc_info=True)
414
+ all_comments = []
415
+
416
+ comments_dropped = max(0, len(all_comments) - max_comments)
417
+ capped_comments = all_comments[:max_comments]
418
+
419
+ # Step 5: Cluster peers come directly from target (no extra query needed).
420
+ cluster_peers: list[str] = target.get("cluster_peers") or []
421
+
422
+ return ContextPack(
423
+ target=target,
424
+ callers=enriched_callers,
425
+ callees=enriched_callees,
426
+ why=capped_comments,
427
+ cluster_peers=cluster_peers,
428
+ truncated=TruncatedCounts(
429
+ callers=callers_dropped,
430
+ callees=callees_dropped,
431
+ comments=comments_dropped,
432
+ ),
433
+ )