homegraph 1.5.2 → 1.5.3

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 (150) hide show
  1. package/CHANGELOG.md +131 -0
  2. package/README.md +15 -8
  3. package/dist/arkui/index.d.ts +4 -0
  4. package/dist/arkui/index.d.ts.map +1 -0
  5. package/dist/arkui/index.js +23 -0
  6. package/dist/arkui/index.js.map +1 -0
  7. package/dist/arkui/migrate-passage.d.ts +28 -0
  8. package/dist/arkui/migrate-passage.d.ts.map +1 -0
  9. package/dist/arkui/migrate-passage.js +310 -0
  10. package/dist/arkui/migrate-passage.js.map +1 -0
  11. package/dist/arkui/migrate-semantics.d.ts +47 -0
  12. package/dist/arkui/migrate-semantics.d.ts.map +1 -0
  13. package/dist/arkui/migrate-semantics.js +229 -0
  14. package/dist/arkui/migrate-semantics.js.map +1 -0
  15. package/dist/arkui/migrate-snapshot.d.ts +79 -0
  16. package/dist/arkui/migrate-snapshot.d.ts.map +1 -0
  17. package/dist/arkui/migrate-snapshot.js +340 -0
  18. package/dist/arkui/migrate-snapshot.js.map +1 -0
  19. package/dist/bin/fatal-handler.js +2 -2
  20. package/dist/bin/homegraph.js +105 -156
  21. package/dist/bin/homegraph.js.map +1 -1
  22. package/dist/bin/node-version-check.d.ts +6 -15
  23. package/dist/bin/node-version-check.d.ts.map +1 -1
  24. package/dist/bin/node-version-check.js +8 -37
  25. package/dist/bin/node-version-check.js.map +1 -1
  26. package/dist/db/index.d.ts +36 -1
  27. package/dist/db/index.d.ts.map +1 -1
  28. package/dist/db/index.js +92 -6
  29. package/dist/db/index.js.map +1 -1
  30. package/dist/db/migrations.d.ts +1 -1
  31. package/dist/db/migrations.d.ts.map +1 -1
  32. package/dist/db/migrations.js +15 -1
  33. package/dist/db/migrations.js.map +1 -1
  34. package/dist/db/queries.d.ts +42 -0
  35. package/dist/db/queries.d.ts.map +1 -1
  36. package/dist/db/queries.js +177 -10
  37. package/dist/db/queries.js.map +1 -1
  38. package/dist/db/schema.sql +6 -1
  39. package/dist/db/sqlite-adapter.d.ts +8 -3
  40. package/dist/db/sqlite-adapter.d.ts.map +1 -1
  41. package/dist/db/sqlite-adapter.js +45 -3
  42. package/dist/db/sqlite-adapter.js.map +1 -1
  43. package/dist/extraction/extraction-version.d.ts +1 -1
  44. package/dist/extraction/extraction-version.js +1 -1
  45. package/dist/extraction/generated-detection.d.ts +42 -11
  46. package/dist/extraction/generated-detection.d.ts.map +1 -1
  47. package/dist/extraction/generated-detection.js +141 -20
  48. package/dist/extraction/generated-detection.js.map +1 -1
  49. package/dist/extraction/index.d.ts +42 -0
  50. package/dist/extraction/index.d.ts.map +1 -1
  51. package/dist/extraction/index.js +96 -0
  52. package/dist/extraction/index.js.map +1 -1
  53. package/dist/extraction/languages/arkts.d.ts +19 -2
  54. package/dist/extraction/languages/arkts.d.ts.map +1 -1
  55. package/dist/extraction/languages/arkts.js +460 -110
  56. package/dist/extraction/languages/arkts.js.map +1 -1
  57. package/dist/extraction/wasm-runtime-flags.d.ts.map +1 -1
  58. package/dist/extraction/wasm-runtime-flags.js +9 -6
  59. package/dist/extraction/wasm-runtime-flags.js.map +1 -1
  60. package/dist/graph-sources.d.ts +41 -0
  61. package/dist/graph-sources.d.ts.map +1 -0
  62. package/dist/graph-sources.js +82 -0
  63. package/dist/graph-sources.js.map +1 -0
  64. package/dist/index.d.ts +19 -1
  65. package/dist/index.d.ts.map +1 -1
  66. package/dist/index.js +79 -9
  67. package/dist/index.js.map +1 -1
  68. package/dist/installer/index.d.ts.map +1 -1
  69. package/dist/installer/index.js +0 -48
  70. package/dist/installer/index.js.map +1 -1
  71. package/dist/installer/targets/antigravity.js +1 -1
  72. package/dist/installer/targets/antigravity.js.map +1 -1
  73. package/dist/installer/targets/deveco.js +1 -1
  74. package/dist/installer/targets/deveco.js.map +1 -1
  75. package/dist/installer/targets/hermes.d.ts +1 -1
  76. package/dist/installer/targets/hermes.js +2 -2
  77. package/dist/installer/targets/hermes.js.map +1 -1
  78. package/dist/installer/targets/opencode.js +1 -1
  79. package/dist/installer/targets/opencode.js.map +1 -1
  80. package/dist/installer/targets/shared.js +1 -1
  81. package/dist/installer/targets/shared.js.map +1 -1
  82. package/dist/mcp/daemon-paths.d.ts +5 -4
  83. package/dist/mcp/daemon-paths.d.ts.map +1 -1
  84. package/dist/mcp/daemon-paths.js +28 -13
  85. package/dist/mcp/daemon-paths.js.map +1 -1
  86. package/dist/mcp/daemon-registry.d.ts.map +1 -1
  87. package/dist/mcp/daemon-registry.js +4 -1
  88. package/dist/mcp/daemon-registry.js.map +1 -1
  89. package/dist/mcp/daemon.d.ts +1 -1
  90. package/dist/mcp/daemon.js +1 -1
  91. package/dist/mcp/engine.d.ts.map +1 -1
  92. package/dist/mcp/engine.js +12 -3
  93. package/dist/mcp/engine.js.map +1 -1
  94. package/dist/mcp/explore-dedup.d.ts +137 -0
  95. package/dist/mcp/explore-dedup.d.ts.map +1 -0
  96. package/dist/mcp/explore-dedup.js +236 -0
  97. package/dist/mcp/explore-dedup.js.map +1 -0
  98. package/dist/mcp/explore-session-state.d.ts +217 -0
  99. package/dist/mcp/explore-session-state.d.ts.map +1 -0
  100. package/dist/mcp/explore-session-state.js +322 -0
  101. package/dist/mcp/explore-session-state.js.map +1 -0
  102. package/dist/mcp/index.d.ts.map +1 -1
  103. package/dist/mcp/index.js +4 -7
  104. package/dist/mcp/index.js.map +1 -1
  105. package/dist/mcp/proxy.d.ts.map +1 -1
  106. package/dist/mcp/proxy.js +5 -14
  107. package/dist/mcp/proxy.js.map +1 -1
  108. package/dist/mcp/query-worker.d.ts +1 -1
  109. package/dist/mcp/query-worker.js +1 -1
  110. package/dist/mcp/server-instructions.d.ts +7 -2
  111. package/dist/mcp/server-instructions.d.ts.map +1 -1
  112. package/dist/mcp/server-instructions.js +104 -28
  113. package/dist/mcp/server-instructions.js.map +1 -1
  114. package/dist/mcp/session.d.ts +14 -3
  115. package/dist/mcp/session.d.ts.map +1 -1
  116. package/dist/mcp/session.js +17 -14
  117. package/dist/mcp/session.js.map +1 -1
  118. package/dist/mcp/startup-handshake.d.ts +1 -1
  119. package/dist/mcp/startup-handshake.js +1 -1
  120. package/dist/mcp/tools.d.ts +117 -7
  121. package/dist/mcp/tools.d.ts.map +1 -1
  122. package/dist/mcp/tools.js +3056 -503
  123. package/dist/mcp/tools.js.map +1 -1
  124. package/dist/resolution/frameworks/arkts-napi.d.ts +14 -0
  125. package/dist/resolution/frameworks/arkts-napi.d.ts.map +1 -1
  126. package/dist/resolution/frameworks/arkts-napi.js +169 -49
  127. package/dist/resolution/frameworks/arkts-napi.js.map +1 -1
  128. package/dist/search/query-utils.d.ts +116 -4
  129. package/dist/search/query-utils.d.ts.map +1 -1
  130. package/dist/search/query-utils.js +869 -60
  131. package/dist/search/query-utils.js.map +1 -1
  132. package/dist/types.d.ts +8 -0
  133. package/dist/types.d.ts.map +1 -1
  134. package/dist/upgrade/index.d.ts +14 -26
  135. package/dist/upgrade/index.d.ts.map +1 -1
  136. package/dist/upgrade/index.js +28 -123
  137. package/dist/upgrade/index.js.map +1 -1
  138. package/dist/upgrade/remove-binary.d.ts +19 -19
  139. package/dist/upgrade/remove-binary.d.ts.map +1 -1
  140. package/dist/upgrade/remove-binary.js +20 -21
  141. package/dist/upgrade/remove-binary.js.map +1 -1
  142. package/dist/upgrade/update-check.d.ts +4 -3
  143. package/dist/upgrade/update-check.d.ts.map +1 -1
  144. package/dist/upgrade/update-check.js +4 -3
  145. package/dist/upgrade/update-check.js.map +1 -1
  146. package/package.json +12 -8
  147. package/dist/telemetry/index.d.ts +0 -143
  148. package/dist/telemetry/index.d.ts.map +0 -1
  149. package/dist/telemetry/index.js +0 -541
  150. package/dist/telemetry/index.js.map +0 -1
package/dist/mcp/tools.js CHANGED
@@ -5,7 +5,8 @@
5
5
  * Defines the tools exposed by the HomeGraph MCP server.
6
6
  */
7
7
  Object.defineProperty(exports, "__esModule", { value: true });
8
- exports.ToolHandler = exports.tools = exports.PathRefusalError = exports.NotIndexedError = void 0;
8
+ exports.ToolHandler = exports.tools = exports.PathRefusalError = exports.GraphSourcesDisabledError = exports.NotIndexedError = void 0;
9
+ exports.__setLoadHomeGraphForTests = __setLoadHomeGraphForTests;
9
10
  exports.normalizeQuerySpelling = normalizeQuerySpelling;
10
11
  exports.getExploreBudget = getExploreBudget;
11
12
  exports.getExploreOutputBudget = getExploreOutputBudget;
@@ -22,13 +23,26 @@ const directory_1 = require("../directory");
22
23
  // schemas), but it must NOT drag in sqlite/query layers before the daemon binds;
23
24
  // HomeGraph is pulled in only when a tool actually opens a project. require() is
24
25
  // sync + cached (CommonJS build).
25
- const loadHomeGraph = () => require('../index').default;
26
+ const loadHomeGraph = () => loadHomeGraphForTests ?? require('../index').default;
27
+ // Test seam (same pattern as the watcher's `__setFsWatchForTests`): vitest's
28
+ // module transform can't service the lazy `require('../index')` above, so
29
+ // in-process tests that exercise a genuine cross-project open (an explicit
30
+ // `projectPath` to a different project — issue #1474's repro shape) inject the
31
+ // already-imported class here. Never set outside tests.
32
+ let loadHomeGraphForTests = null;
33
+ function __setLoadHomeGraphForTests(cls) {
34
+ loadHomeGraphForTests = cls;
35
+ }
26
36
  const worktree_1 = require("../sync/worktree");
37
+ const graph_sources_1 = require("../graph-sources");
27
38
  const query_utils_1 = require("../search/query-utils");
28
39
  const fs_1 = require("fs");
40
+ const crypto_1 = require("crypto");
29
41
  const utils_1 = require("../utils");
30
42
  const generated_detection_1 = require("../extraction/generated-detection");
31
43
  const arkts_1 = require("../extraction/languages/arkts");
44
+ const explore_session_state_1 = require("./explore-session-state");
45
+ const explore_dedup_1 = require("./explore-dedup");
32
46
  const dynamic_boundaries_1 = require("./dynamic-boundaries");
33
47
  const query_cache_1 = require("./query-cache");
34
48
  const diff_impact_1 = require("./diff-impact");
@@ -56,6 +70,10 @@ const sqlite_adapter_1 = require("../db/sqlite-adapter");
56
70
  class NotIndexedError extends Error {
57
71
  }
58
72
  exports.NotIndexedError = NotIndexedError;
73
+ /** `HOMEGRAPH_SOURCES=none` (or equivalent) — success-shaped, not isError. */
74
+ class GraphSourcesDisabledError extends Error {
75
+ }
76
+ exports.GraphSourcesDisabledError = GraphSourcesDisabledError;
59
77
  /**
60
78
  * A security refusal (sensitive system path). Stays `isError: true` WITHOUT
61
79
  * retry guidance — abandoning this path is the desired agent reaction.
@@ -114,6 +132,114 @@ function lastQualifierPart(symbol) {
114
132
  const parts = symbol.split(/::|[./]/).filter((p) => p.length > 0);
115
133
  return parts[parts.length - 1] ?? symbol;
116
134
  }
135
+ /**
136
+ * Page/Dialog surface digests — methods that answer "which UI children / how
137
+ * does preview load" without dumping the whole struct. Lifecycle alone is not
138
+ * enough: agents re-Read for PageMap / preview helpers when those are trimmed.
139
+ */
140
+ function isUiSurfaceDigestMethod(name, includePreviewHelpers) {
141
+ if (/^(build|aboutToAppear|aboutToDisappear|PageMap)$/i.test(name))
142
+ return true;
143
+ if (!includePreviewHelpers)
144
+ return false;
145
+ return /preview|pixelmap|loadimage|getimage|decodeimage/i.test(name);
146
+ }
147
+ /** Prefer build/PageMap/preview over aboutToAppear when budget is tight. */
148
+ function surfaceMethodPriority(name) {
149
+ if (/^(build|PageMap)$/i.test(name))
150
+ return 0;
151
+ if (/preview|pixelmap|loadimage|getimage/i.test(name))
152
+ return 1;
153
+ return 2;
154
+ }
155
+ /**
156
+ * Structured UI/nav bullets extracted from surface method bodies so agents can
157
+ * ANSWER NOW without re-Reading for child Types / PageMap routes.
158
+ */
159
+ function extractUiSurfaceInventory(fileLines, methods) {
160
+ const FRAMEWORK_TAG = new Set([
161
+ 'Column', 'Row', 'Stack', 'Flex', 'Grid', 'List', 'ListItem', 'Scroll', 'Tabs', 'TabContent',
162
+ 'Text', 'Image', 'Button', 'Blank', 'Divider', 'ForEach', 'LazyForEach', 'If', 'Else',
163
+ 'RelativeContainer', 'Swiper', 'WaterFlow', 'GridItem', 'Span', 'SymbolGlyph',
164
+ ]);
165
+ const comps = new Set();
166
+ const nav = new Set();
167
+ const media = [];
168
+ for (const m of methods) {
169
+ const from = Math.max(1, m.startLine);
170
+ const to = Math.min(fileLines.length, Math.max(m.endLine, m.startLine));
171
+ for (let i = from; i <= to; i++) {
172
+ const line = fileLines[i - 1] ?? '';
173
+ for (const mm of line.matchAll(/\b([A-Z][A-Za-z0-9_]{2,})\s*\(/g)) {
174
+ const id = mm[1];
175
+ if (FRAMEWORK_TAG.has(id) || (0, query_utils_1.isFrameworkUiDecoratorName)(id))
176
+ continue;
177
+ if (/^(Promise|Array|Map|Set|Date|Error|JSON|Object|Math|Number|String|Boolean)$/.test(id))
178
+ continue;
179
+ comps.add(id);
180
+ }
181
+ if (/PageMap|pushUrl|replaceUrl|pushPath|replacePath|router\./i.test(line)) {
182
+ const clipped = line.trim().replace(/\s+/g, ' ').slice(0, 120);
183
+ if (clipped)
184
+ nav.add(clipped);
185
+ }
186
+ for (const mm of line.matchAll(/\bConstants\.[A-Z][A-Z0-9_]+\b/g)) {
187
+ nav.add(mm[0]);
188
+ }
189
+ // Preview / Image load origin — $r vs file URI vs network.
190
+ if (/\$r\s*\(|Image\s*\(|PixelMap|createPixelMap|decode|tempFileUri|http|download|fileUri|getPreview|previewPixelMap|loadImage/i.test(line)) {
191
+ const clipped = line.trim().replace(/\s+/g, ' ').slice(0, 140);
192
+ if (clipped && media.length < 14) {
193
+ media.push(`L${i} (\`${m.name}\`): \`${clipped}\``);
194
+ }
195
+ }
196
+ }
197
+ }
198
+ if (comps.size === 0 && nav.size === 0 && media.length === 0)
199
+ return '';
200
+ const out = ['**UI surface inventory (from build / PageMap / preview methods)**', ''];
201
+ if (comps.size > 0) {
202
+ out.push('UI / child Types:');
203
+ for (const c of [...comps].sort().slice(0, 24))
204
+ out.push(`- \`${c}\``);
205
+ if (comps.size > 24)
206
+ out.push(`- … and ${comps.size - 24} more`);
207
+ out.push('');
208
+ }
209
+ if (nav.size > 0) {
210
+ out.push('Navigation / PageMap / route constants:');
211
+ for (const n of [...nav].slice(0, 16))
212
+ out.push(`- ${n.startsWith('Constants.') ? `\`${n}\`` : n}`);
213
+ if (nav.size > 16)
214
+ out.push(`- … and ${nav.size - 16} more`);
215
+ out.push('');
216
+ }
217
+ if (media.length > 0) {
218
+ out.push('Image / preview load sites ($r / file / network cues):');
219
+ for (const row of media)
220
+ out.push(`- ${row}`);
221
+ out.push('');
222
+ }
223
+ out.push('> **ANSWER NOW** from this inventory + method digests below — do not Read/Grep for the same Page UI children, routes, or preview-load origin.');
224
+ out.push('');
225
+ return out.join('\n');
226
+ }
227
+ /** C++ `class Foo : public Bar` vs private `: Bar` — prefer public is-a for subtype lists. */
228
+ function cppExtendsLooksPublic(declLine, baseName) {
229
+ const esc = baseName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
230
+ if (new RegExp(`:\\s*public\\s+(?:virtual\\s+)?${esc}\\b`).test(declLine))
231
+ return true;
232
+ if (new RegExp(`:\\s*protected\\s+(?:virtual\\s+)?${esc}\\b`).test(declLine))
233
+ return false;
234
+ if (new RegExp(`:\\s*private\\s+(?:virtual\\s+)?${esc}\\b`).test(declLine))
235
+ return false;
236
+ // `class Cluster : Rectangle` (no access) defaults to private for class.
237
+ if (new RegExp(`:\\s*${esc}\\b`).test(declLine) && /\bclass\b/.test(declLine))
238
+ return false;
239
+ if (new RegExp(`:\\s*${esc}\\b`).test(declLine) && /\bstruct\b/.test(declLine))
240
+ return true;
241
+ return null;
242
+ }
117
243
  /**
118
244
  * Normalize Erlang-native symbol spellings in an explore query into the shapes
119
245
  * the rest of the pipeline already understands. Agents working Erlang code
@@ -252,11 +378,24 @@ function getExploreOutputBudget(fileCount) {
252
378
  * Shrink explore ceilings for local-detail / no-flow named-symbol questions.
253
379
  * Large repos otherwise dump ~24K related source that the agent then still
254
380
  * greps/reads — the main token regression vs without-homegraph.
381
+ *
382
+ * Mechanism / cross-module flows also get a tighter ceiling: full 24K + a
383
+ * second explore/node/Read stack is what blows session tokens on "how is X
384
+ * implemented" questions even when the first answer was already enough.
255
385
  */
256
386
  function tightenExploreBudgetForQuery(budget, query, opts) {
257
387
  const hasFlow = opts?.hasFlowPath === true;
258
388
  if (hasFlow && ((0, query_utils_1.queryAsMechanismSurvey)(query) || (0, query_utils_1.queryAsCrossModuleFlowSurvey)(query))) {
259
- return budget;
389
+ return {
390
+ ...budget,
391
+ maxOutputChars: Math.min(budget.maxOutputChars, 12000),
392
+ defaultMaxFiles: Math.min(budget.defaultMaxFiles, 4),
393
+ maxCharsPerFile: Math.min(budget.maxCharsPerFile, 4500),
394
+ includeRelationships: false,
395
+ includeAdditionalFiles: false,
396
+ includeCompletenessSignal: false,
397
+ includeBudgetNote: false,
398
+ };
260
399
  }
261
400
  const local = (0, query_utils_1.queryAsLocalSymbolDetail)(query);
262
401
  const compact = (0, query_utils_1.shouldUseCompactExploreBudget)(query);
@@ -277,11 +416,14 @@ function tightenExploreBudgetForQuery(budget, query, opts) {
277
416
  }
278
417
  if (!local && !compact)
279
418
  return budget;
419
+ const lean = (0, query_utils_1.queryAsAssignedFlagImpactSurvey)(query)
420
+ || (0, query_utils_1.queryAsFocusedUiCluster)(query)
421
+ || (0, query_utils_1.queryAsTypeLifecycleSurvey)(query);
280
422
  return {
281
423
  ...budget,
282
- maxOutputChars: Math.min(budget.maxOutputChars, local ? 9000 : 12000),
283
- defaultMaxFiles: Math.min(budget.defaultMaxFiles, local ? 2 : 3),
284
- maxCharsPerFile: Math.min(budget.maxCharsPerFile, local ? 4000 : 5000),
424
+ maxOutputChars: Math.min(budget.maxOutputChars, lean ? 4800 : local ? 7000 : 9000),
425
+ defaultMaxFiles: Math.min(budget.defaultMaxFiles, lean ? 2 : local ? 2 : 3),
426
+ maxCharsPerFile: Math.min(budget.maxCharsPerFile, lean ? 2200 : local ? 3500 : 4000),
285
427
  includeRelationships: false,
286
428
  includeAdditionalFiles: false,
287
429
  includeCompletenessSignal: false,
@@ -485,9 +627,9 @@ const READ_ONLY_ANNOTATIONS = {
485
627
  /**
486
628
  * All HomeGraph MCP tools
487
629
  *
488
- * Designed for minimal context usage - use homegraph_explore as the primary tool
489
- * (one call usually answers the whole question), and only use other tools for
490
- * targeted follow-up queries.
630
+ * Prefer the smallest tool that answers: callers/node for one named symbol,
631
+ * explore for multi-file flows. Skip HomeGraph entirely for topic file-lists,
632
+ * concept compares, SDK catalogs, and literal greps.
491
633
  *
492
634
  * All tools support cross-project queries via the optional `projectPath` parameter.
493
635
  */
@@ -495,7 +637,9 @@ exports.tools = [
495
637
  {
496
638
  name: 'homegraph_search',
497
639
  description: 'LAST RESORT spelling lookup — locations only, no source. Required: `query` (e.g. "signIn"). ' +
498
- 'Prefer homegraph_explore when the question already names a symbol/file/@kit. ' +
640
+ 'Prefer explore/callers/node when names are known. ' +
641
+ 'DO NOT call for topic file-lists, concept compares, or SDK/@kit feature catalogs (those return Skip guidance). ' +
642
+ 'Also skip literal string/pattern greps — use Grep instead. ' +
499
643
  'Bare-name search may return a compact explore result instead of locations.',
500
644
  inputSchema: {
501
645
  type: 'object',
@@ -523,9 +667,8 @@ exports.tools = [
523
667
  {
524
668
  name: 'homegraph_callers',
525
669
  description: 'Compact caller list for one NAMED in-repo symbol (no bodies). Required: `symbol` (e.g. "authenticate"). ' +
526
- 'Use after you know the exact name. For full flows use homegraph_explore. ' +
527
- 'DO NOT call for SDK catalogs, one-function semantics, or "what if X fails" hypothetics. ' +
528
- 'Prefer one explore over parallel callers+callees+node.',
670
+ 'Cheaper than explore when you only need who-calls-X. For multi-file flows use homegraph_explore. ' +
671
+ 'DO NOT call for SDK catalogs, topic file-lists, concept compares, or hypothetics.',
529
672
  inputSchema: {
530
673
  type: 'object',
531
674
  properties: {
@@ -551,8 +694,8 @@ exports.tools = [
551
694
  {
552
695
  name: 'homegraph_callees',
553
696
  description: 'Compact callee list for one NAMED in-repo symbol (no bodies). Required: `symbol` (e.g. "authenticate"). ' +
554
- 'For full flows use homegraph_explore. DO NOT use for out-of-repo SDK internals or counterfactual analysis. ' +
555
- 'Prefer one explore over parallel node+callers+callees.',
697
+ 'Cheaper than explore when you only need what-X-calls. For multi-file flows use homegraph_explore. ' +
698
+ 'DO NOT use for out-of-repo SDK catalogs or counterfactual analysis.',
556
699
  inputSchema: {
557
700
  type: 'object',
558
701
  properties: {
@@ -649,10 +792,11 @@ exports.tools = [
649
792
  name: 'homegraph_node',
650
793
  description: 'Depth on ONE known in-repo symbol or indexed file — not a survey tool. ' +
651
794
  'Required: pass `symbol` (symbol mode) OR `file` alone (file mode). ' +
795
+ 'Cheaper than explore when you already know the name and only need one body. ' +
652
796
  'FILE: `file` only → line-numbered source + dependents. ' +
653
797
  'SYMBOL: body via includeCode + short trail; overloads return every body. ' +
654
- 'USE after explore named the symbol and you still need one body. ' +
655
- 'DO NOT crawl a feature with repeated node calls (prefer one explore). ' +
798
+ 'DO NOT call after explore already returned that symbol/file (multiplies tokens). ' +
799
+ 'DO NOT crawl a feature with repeated node calls (prefer one explore for flows). ' +
656
800
  'Treat returned source as already Read.',
657
801
  inputSchema: {
658
802
  type: 'object',
@@ -696,16 +840,22 @@ exports.tools = [
696
840
  },
697
841
  {
698
842
  name: 'homegraph_explore',
699
- description: 'PRIMARY tool for THIS REPO\'s symbol graph (call paths + often line-numbered source). Required: `query` with concrete symbol/file/@kit names. ' +
700
- 'CALL for in-repo structure: feature wiring, A→B path, callers/callees, Type.member usage, click→handler, in-repo @kit usages. ' +
701
- 'Skip search when names are already known. One explore; answer from Source + trail; treat as already Read. Busy/partial retry same explore once.',
843
+ description: 'In-repo multi-file / mechanism tool (call paths + compact line-numbered source). Required: `query`. ' +
844
+ 'CALL FIRST for how/wired questions pass the question or domain keywords; PascalCase names optional. ' +
845
+ 'Also CALL FIRST (alone, no parallel Grep) for named Type/Component/Page/Dialog, Type.member, clickhandler, inheritance/subtypes, ' +
846
+ 'declaration/attribute sites, ALL_CAPS constant / field-mutex usages, path-module NAPI/exports or inter-deps, ' +
847
+ 'and in-repo @kit/@ohos *usages/dependencies* (which files import a named export — not the SDK feature catalog). ' +
848
+ 'On the first turn call explore alone (no parallel Grep). Prefer callers/node when one named symbol is already enough. ' +
849
+ 'DO NOT call for topic file-lists, concept/UI-behavior with no named anchors, literal copy hunts, or SDK/@kit *feature catalogs* — those return Skip guidance. ' +
850
+ 'Literal string/pattern hunts → Grep. ' +
851
+ 'One explore; answer from Source + trail; treat as already Read — do not re-grep/node/read the same symbols. Busy/partial → retry same explore once.',
702
852
  inputSchema: {
703
853
  type: 'object',
704
854
  properties: {
705
855
  query: {
706
856
  type: 'string',
707
- description: 'Required. In-repo symbols, file basenames, or @kit names (e.g. "ParentPage onClick build", "CartRepository.addItem"). ' +
708
- 'For flows, name both endpoints. Not for official SDK API catalogs.',
857
+ description: 'Required. Prefer the user question or domain keywords for how/mechanism. ' +
858
+ 'For named flows, include Type / Type.member / component names. For @kit, ask usages (depend/import sites), not SDK catalogs.',
709
859
  },
710
860
  maxFiles: {
711
861
  type: 'number',
@@ -729,6 +879,25 @@ exports.tools = [
729
879
  },
730
880
  annotations: READ_ONLY_ANNOTATIONS,
731
881
  },
882
+ {
883
+ name: 'homegraph_arkui_migrate',
884
+ description: 'ArkUI migrate / state-semantics snapshot in ONE call (components, state decorators + args, ' +
885
+ 'data-passage types, Provide/Consume/Storage key channels, @Observed classes). ' +
886
+ 'Required: `scope`. PRIMARY for ArkUI V1↔V2 migration decisions — do NOT stitch with explore. ' +
887
+ 'Scope: component name, relative .ets path, or directory prefix. Returns JSON (no source bodies).',
888
+ inputSchema: {
889
+ type: 'object',
890
+ properties: {
891
+ scope: {
892
+ type: 'string',
893
+ description: 'Required. Component name (e.g. "ParentPage"), relative file (e.g. "pages/Index.ets"), or directory prefix.',
894
+ },
895
+ projectPath: projectPathProperty,
896
+ },
897
+ required: ['scope'],
898
+ },
899
+ annotations: READ_ONLY_ANNOTATIONS,
900
+ },
732
901
  {
733
902
  name: 'homegraph_files',
734
903
  description: 'Indexed directory tree (paths and symbol counts only — NO source). ' +
@@ -1115,6 +1284,10 @@ class ToolHandler {
1115
1284
  * similar to how git finds .git/ directories.
1116
1285
  */
1117
1286
  getHomeGraph(projectPath) {
1287
+ const sourcesMode = (0, graph_sources_1.resolveGraphSources)();
1288
+ if (!(0, graph_sources_1.graphSourceFlags)(sourcesMode).openProjectDb) {
1289
+ throw new GraphSourcesDisabledError((0, graph_sources_1.graphSourcesDisabledGuidance)(sourcesMode));
1290
+ }
1118
1291
  if (!projectPath) {
1119
1292
  if (!this.cg) {
1120
1293
  const searched = this.defaultProjectHint ?? process.cwd();
@@ -1126,7 +1299,7 @@ class ToolHandler {
1126
1299
  'explicitly:\n' +
1127
1300
  ' • Pass projectPath to the tool call, e.g. projectPath: "/absolute/path/to/your/project" ' +
1128
1301
  '(any project that has a .homegraph/ — including a sub-project of a monorepo)\n' +
1129
- ' • Or add --path to the server\'s MCP config args: ["serve", "--mcp", "--path", "/absolute/path/to/your/project"]\n' +
1302
+ ' • Or add --path to the server\'s MCP config args: ["serve", "mcp", "--path", "/absolute/path/to/your/project"]\n' +
1130
1303
  'If a project simply has no index, use your built-in tools (Read/Grep/Glob) for THAT ' +
1131
1304
  "project (the user can run 'homegraph init' there to enable it) — you can still query " +
1132
1305
  'other indexed projects by projectPath in the same session.');
@@ -1177,7 +1350,7 @@ class ToolHandler {
1177
1350
  const cached = this.projectCache.get(resolvedRoot);
1178
1351
  if (cached)
1179
1352
  return this.freshen(cached);
1180
- const cg = loadHomeGraph().openSync(resolvedRoot);
1353
+ const cg = loadHomeGraph().openSync(resolvedRoot, { sources: sourcesMode });
1181
1354
  this.projectCache.set(resolvedRoot, cg);
1182
1355
  return cg;
1183
1356
  }
@@ -1326,6 +1499,46 @@ class ToolHandler {
1326
1499
  * Cost when nothing is pending — the common case — is one boolean check.
1327
1500
  * No I/O, no parsing of markdown beyond a per-pending-file substring scan.
1328
1501
  */
1502
+ driftCache = new Map();
1503
+ static DRIFT_TTL_MS = 2000;
1504
+ /**
1505
+ * On-disk drift check for a single indexed file (issue #1474). The code
1506
+ * renderers slice CURRENT bytes at INDEXED line ranges; when the file
1507
+ * changed after its last index sync those ranges can point at a DIFFERENT
1508
+ * symbol's code. Freshness is verified here from data the index already
1509
+ * stores: one stat() (size + mtime); content hashed only on mismatch.
1510
+ */
1511
+ isFileStaleOnDisk(cg, relPath, content) {
1512
+ let root;
1513
+ try {
1514
+ root = cg.getProjectRoot();
1515
+ }
1516
+ catch {
1517
+ return false;
1518
+ }
1519
+ const key = `${root}\0${relPath}`;
1520
+ const now = Date.now();
1521
+ const hit = this.driftCache.get(key);
1522
+ if (hit && now - hit.at < ToolHandler.DRIFT_TTL_MS)
1523
+ return hit.stale;
1524
+ let stale = false;
1525
+ try {
1526
+ const rec = cg.getFile(relPath);
1527
+ const absPath = rec ? (0, utils_1.validatePathWithinRoot)(root, relPath) : null;
1528
+ if (rec && absPath && (0, fs_1.existsSync)(absPath)) {
1529
+ const st = (0, fs_1.statSync)(absPath);
1530
+ if (st.size !== rec.size || Math.floor(st.mtimeMs) !== Math.floor(rec.modifiedAt)) {
1531
+ const data = content ?? (0, fs_1.readFileSync)(absPath, 'utf-8');
1532
+ stale = (0, crypto_1.createHash)('sha256').update(data).digest('hex') !== rec.contentHash;
1533
+ }
1534
+ }
1535
+ }
1536
+ catch {
1537
+ stale = false;
1538
+ }
1539
+ this.driftCache.set(key, { at: now, stale });
1540
+ return stale;
1541
+ }
1329
1542
  withStalenessNotice(result, projectPath) {
1330
1543
  if (result.isError)
1331
1544
  return result;
@@ -1432,9 +1645,12 @@ class ToolHandler {
1432
1645
  return { ...result, content: [{ type: 'text', text: composed }, ...rest] };
1433
1646
  }
1434
1647
  /**
1435
- * Execute a tool by name
1648
+ * Execute a tool by name.
1649
+ *
1650
+ * `sessionState` is the calling MCP session's explore history (CG-17). Omit
1651
+ * it (the CLI does) and explore behaves exactly as before, untracked.
1436
1652
  */
1437
- async execute(toolName, args) {
1653
+ async execute(toolName, args, sessionState) {
1438
1654
  try {
1439
1655
  // Block the first tool call on the engine's post-open reconcile so we
1440
1656
  // never serve rows for files deleted/edited while no MCP server was
@@ -1480,7 +1696,20 @@ class ToolHandler {
1480
1696
  return check;
1481
1697
  }
1482
1698
  const projectPath = args.projectPath;
1483
- const cacheEnabled = (0, query_cache_1.isMcpQueryCacheEnabled)() && (0, query_cache_1.isCacheableMcpTool)(toolName);
1699
+ // Wrong question shapes short Skip (before cache / graph work).
1700
+ if (toolName === 'homegraph_explore' || toolName === 'homegraph_search') {
1701
+ const qEarly = typeof args.query === 'string' ? args.query : '';
1702
+ if (qEarly) {
1703
+ const deferKind = (0, query_utils_1.queryShouldDeferToBuiltinTools)(qEarly);
1704
+ if (deferKind) {
1705
+ return this.textResult((0, query_utils_1.homegraphDeferGuidance)(deferKind, qEarly));
1706
+ }
1707
+ }
1708
+ }
1709
+ // Session-tracked explore must not hit the MCP query cache: a cache hit
1710
+ // would re-serve the first call's full source and defeat CG-18 dedup.
1711
+ const skipCacheForSession = toolName === 'homegraph_explore' && !!sessionState;
1712
+ const cacheEnabled = !skipCacheForSession && (0, query_cache_1.isMcpQueryCacheEnabled)() && (0, query_cache_1.isCacheableMcpTool)(toolName);
1484
1713
  let cacheKey;
1485
1714
  let cacheQueries;
1486
1715
  let cacheIndex;
@@ -1530,10 +1759,10 @@ class ToolHandler {
1530
1759
  const fast = (toolName === 'homegraph_explore' || toolName === 'homegraph_search'
1531
1760
  ? this.tryFastInventoryExplore(cgFast, q, rootFast)
1532
1761
  : null)
1533
- ?? this.tryCompactLocalSymbolExplore(cgFast, q, rootFast)
1534
- ?? (toolName === 'homegraph_explore'
1762
+ ?? (toolName === 'homegraph_explore' || toolName === 'homegraph_search'
1535
1763
  ? this.tryLightMechanismExplore(cgFast, q, rootFast)
1536
- : null);
1764
+ : null)
1765
+ ?? this.tryCompactLocalSymbolExplore(cgFast, q, rootFast);
1537
1766
  if (fast) {
1538
1767
  if (cacheEnabled && cacheKey && cacheQueries && cacheIndex && !fast.isError) {
1539
1768
  cacheIndex.setEntry(cacheQueries, cacheKey, toolName, fast);
@@ -1547,10 +1776,16 @@ class ToolHandler {
1547
1776
  }
1548
1777
  }
1549
1778
  }
1779
+ // Explore also carries the session's own call history down (CG-17) and its
1780
+ // emission record back up. Both travel as plain properties — on the args
1781
+ // object down, on the ToolResult up — because either leg may cross a
1782
+ // structured-clone boundary into a worker.
1783
+ const dispatchArgs = this.withSessionView(toolName, args, sessionState);
1550
1784
  // prefers the query pool so sync SQLite/CPU cannot freeze the transport
1551
1785
  // (a frozen main loop prevents setTimeout deadlines from firing → empty
1552
1786
  // `-32001`). Fast-path surveys run inside the worker via executeReadTool.
1553
- const result = await this.runReadToolWithDeadline(toolName, args);
1787
+ const raw = await this.runReadToolWithDeadline(toolName, dispatchArgs);
1788
+ const result = this.takeExploreEmission(raw, sessionState);
1554
1789
  if (cacheEnabled && cacheKey && cacheQueries && cacheIndex && !result.isError) {
1555
1790
  cacheIndex.setEntry(cacheQueries, cacheKey, toolName, result);
1556
1791
  }
@@ -1564,6 +1799,9 @@ class ToolHandler {
1564
1799
  if (err instanceof NotIndexedError) {
1565
1800
  return this.textResult(err.message);
1566
1801
  }
1802
+ if (err instanceof GraphSourcesDisabledError) {
1803
+ return this.textResult(err.message);
1804
+ }
1567
1805
  // Security refusal: a clean error, no retry encouragement.
1568
1806
  if (err instanceof PathRefusalError) {
1569
1807
  return this.errorResult(err.message);
@@ -1573,6 +1811,50 @@ class ToolHandler {
1573
1811
  'continue without homegraph for this task.');
1574
1812
  }
1575
1813
  }
1814
+ /**
1815
+ * Attach the caller's session view to an explore call's args (CG-17), on a
1816
+ * COPY so the caller's object is never mutated. Nothing else sees it: a
1817
+ * non-explore tool, or a caller with no session state, gets the args
1818
+ * unchanged and pays nothing.
1819
+ *
1820
+ * A client that spells the internal key itself is stripped rather than
1821
+ * trusted — the view decides what source a later call may withhold, so it has
1822
+ * to come from the server's own record, never from the wire.
1823
+ */
1824
+ withSessionView(toolName, args, sessionState) {
1825
+ if (!(explore_session_state_1.EXPLORE_SESSION_VIEW_ARG in args) && (!sessionState || toolName !== 'homegraph_explore')) {
1826
+ return args;
1827
+ }
1828
+ const copy = { ...args };
1829
+ delete copy[explore_session_state_1.EXPLORE_SESSION_VIEW_ARG];
1830
+ if (sessionState && toolName === 'homegraph_explore') {
1831
+ copy[explore_session_state_1.EXPLORE_SESSION_VIEW_ARG] = sessionState.view();
1832
+ }
1833
+ return copy;
1834
+ }
1835
+ /**
1836
+ * Record an explore call's emission into the caller's session state and strip
1837
+ * it from the result (CG-17).
1838
+ *
1839
+ * Unconditional strip: the property is internal, so it comes off even when
1840
+ * there is no session state to record it into (the CLI path) — that is what
1841
+ * keeps the agent-facing response byte-identical. Recording is wrapped
1842
+ * because a bookkeeping bug must never fail a tool call that already
1843
+ * succeeded.
1844
+ */
1845
+ takeExploreEmission(result, sessionState) {
1846
+ const emission = result?.[explore_session_state_1.EXPLORE_EMISSION_KEY];
1847
+ if (emission === undefined)
1848
+ return result;
1849
+ delete result[explore_session_state_1.EXPLORE_EMISSION_KEY];
1850
+ if (sessionState) {
1851
+ try {
1852
+ sessionState.record(emission);
1853
+ }
1854
+ catch { /* bookkeeping only — never fail a served call */ }
1855
+ }
1856
+ return result;
1857
+ }
1576
1858
  /**
1577
1859
  * Dispatch a read tool with a hard deadline under the typical ~60s MCP client
1578
1860
  * timeout.
@@ -1658,6 +1940,9 @@ class ToolHandler {
1658
1940
  if (err instanceof NotIndexedError) {
1659
1941
  return this.textResult(err.message);
1660
1942
  }
1943
+ if (err instanceof GraphSourcesDisabledError) {
1944
+ return this.textResult(err.message);
1945
+ }
1661
1946
  if (err instanceof PathRefusalError) {
1662
1947
  return this.errorResult(err.message);
1663
1948
  }
@@ -1682,6 +1967,7 @@ class ToolHandler {
1682
1967
  case 'homegraph_explore': return await this.handleExplore(args);
1683
1968
  case 'homegraph_node': return await this.handleNode(args);
1684
1969
  case 'homegraph_files': return await this.handleFiles(args);
1970
+ case 'homegraph_arkui_migrate': return await this.handleArkuiMigrate(args);
1685
1971
  case 'homegraph_spec_match': return await this.handleSpecMatch(args);
1686
1972
  case 'homegraph_spec_find': return await this.handleSpecFind(args);
1687
1973
  case 'homegraph_spec_trace': return await this.handleSpecTrace(args);
@@ -1692,6 +1978,10 @@ class ToolHandler {
1692
1978
  const query = this.validateString(args.query, 'query');
1693
1979
  if (typeof query !== 'string')
1694
1980
  return query;
1981
+ const deferKind = (0, query_utils_1.queryShouldDeferToBuiltinTools)(query);
1982
+ if (deferKind) {
1983
+ return this.textResult((0, query_utils_1.homegraphDeferGuidance)(deferKind, query));
1984
+ }
1695
1985
  const cg = this.getHomeGraph(args.projectPath);
1696
1986
  // Explore redirect is best-effort — incomplete/faked graphs (or missing
1697
1987
  // getProjectRoot) must fall through to FTS search rather than error.
@@ -1699,14 +1989,14 @@ class ToolHandler {
1699
1989
  const projectRoot = cg.getProjectRoot();
1700
1990
  if ((0, query_utils_1.queryShouldPreferExploreOverSearch)(query)) {
1701
1991
  const exploreRedirect = this.tryFastInventoryExplore(cg, query, projectRoot)
1702
- ?? this.tryCompactLocalSymbolExplore(cg, query, projectRoot)
1703
- ?? this.tryLightMechanismExplore(cg, query, projectRoot);
1992
+ ?? this.tryLightMechanismExplore(cg, query, projectRoot)
1993
+ ?? this.tryCompactLocalSymbolExplore(cg, query, projectRoot);
1704
1994
  if (exploreRedirect)
1705
1995
  return exploreRedirect;
1706
1996
  }
1707
1997
  const exploreRedirect = this.tryFastInventoryExplore(cg, query, projectRoot)
1708
- ?? this.tryCompactLocalSymbolExplore(cg, query, projectRoot)
1709
- ?? this.tryLightMechanismExplore(cg, query, projectRoot);
1998
+ ?? this.tryLightMechanismExplore(cg, query, projectRoot)
1999
+ ?? this.tryCompactLocalSymbolExplore(cg, query, projectRoot);
1710
2000
  if (exploreRedirect)
1711
2001
  return exploreRedirect;
1712
2002
  }
@@ -1731,8 +2021,8 @@ class ToolHandler {
1731
2021
  // for "Send" surfaces the hand-written keeper before .pb.go stubs
1732
2022
  // that share the name. Stable: only reorders generated vs. not.
1733
2023
  const ranked = [...results].sort((a, b) => {
1734
- const aGen = (0, generated_detection_1.isGeneratedFile)(a.node.filePath) ? 1 : 0;
1735
- const bGen = (0, generated_detection_1.isGeneratedFile)(b.node.filePath) ? 1 : 0;
2024
+ const aGen = this.isGeneratedCandidate(cg, a.node.filePath) ? 1 : 0;
2025
+ const bGen = this.isGeneratedCandidate(cg, b.node.filePath) ? 1 : 0;
1736
2026
  return aGen - bGen;
1737
2027
  });
1738
2028
  const formatted = this.formatSearchResults(ranked);
@@ -1820,13 +2110,28 @@ class ToolHandler {
1820
2110
  if (groups.length === 1) {
1821
2111
  const { callers, labels } = collect(groups[0]);
1822
2112
  if (callers.length === 0) {
2113
+ // Empty callers often means registration/callback wiring (OnSurfaceChangedCB)
2114
+ // — a one-line "no callers" teaches Grep/node thrash. Prefer compact body +
2115
+ // text-usage sites when the symbol is a local anchor.
2116
+ try {
2117
+ const root = cg.getProjectRoot();
2118
+ const compact = this.tryCompactLocalSymbolExplore(cg, symbol, root);
2119
+ if (compact)
2120
+ return compact;
2121
+ }
2122
+ catch { /* fall through */ }
1823
2123
  return this.textResult(`No callers found for "${symbol}"${allMatches.note}${filterNote}`);
1824
2124
  }
1825
2125
  // A successful `file` narrowing makes the multi-symbol aggregation note
1826
2126
  // stale — suppress it.
1827
2127
  const note = fileFilter && !filteredOut ? '' : allMatches.note;
2128
+ const includeBlock = this.formatCallerIncludeVisibility(cg, callers.slice(0, limit));
1828
2129
  const formatted = this.formatNodeList(callers.slice(0, limit), `Callers of ${symbol}`, labels)
1829
- + '\n\n> Caller listing complete — answer from this list; no read/grep needed.'
2130
+ + includeBlock
2131
+ + '\n\n> Caller listing complete — answer from this list'
2132
+ + (includeBlock ? ' + include/import visibility' : '')
2133
+ + '; no read/grep needed.'
2134
+ + '\n> If another type\'s visibility matters, pass **both** names to `homegraph_explore` (not callers alone).'
1830
2135
  + note + filterNote;
1831
2136
  return this.textResult(this.truncateOutput(formatted));
1832
2137
  }
@@ -2831,28 +3136,35 @@ class ToolHandler {
2831
3136
  }
2832
3137
  /**
2833
3138
  * Import sites for @kit.* / *Kit module names — surfaces full `import { … } from '@kit.X'`
2834
- * lines. When the query also names a symbol (taskpool), only matching imports are listed.
3139
+ * lines. When the query also names an export/API token, only matching imports are listed.
2835
3140
  */
2836
3141
  buildImportSitesSection(cg, query, projectRoot) {
2837
3142
  const kitTerms = (0, query_utils_1.extractKitModuleNamesFromQuery)(query);
2838
- const depSymbols = (0, query_utils_1.extractDependencySymbolsFromQuery)(query);
3143
+ const depSymbols = (0, query_utils_1.extractDependencySymbolsFromQuery)(query).filter((s) => !(0, query_utils_1.isMemberLikeIdentifier)(s) && !query_utils_1.GENERIC_VERB_ANCHOR_NOISE.has(s.toLowerCase()));
3144
+ const focusExports = [
3145
+ ...new Set([
3146
+ ...(0, query_utils_1.extractKitSubmoduleNamesFromQuery)(query),
3147
+ ...depSymbols.filter((s) => s.length >= 4),
3148
+ ]),
3149
+ ];
2839
3150
  const kitSearchTerms = (0, query_utils_1.extractImportSearchTerms)(query);
3151
+ // Named export/API + kit (or usage-survey intent) → return the full matching list.
3152
+ const completeInventory = focusExports.length > 0
3153
+ && (kitTerms.length > 0 || (0, query_utils_1.shouldBuildKitModuleUsageSurvey)(query) || (0, query_utils_1.shouldBuildApiUsageSurvey)(query));
2840
3154
  const seen = new Set();
2841
3155
  const sites = [];
2842
3156
  const tryAdd = (node, lineText) => {
3157
+ if ((0, arkts_1.isOhosApiFilePath)(node.filePath))
3158
+ return;
2843
3159
  const lineLc = lineText.toLowerCase();
3160
+ const focus = focusExports.length > 0 ? focusExports : depSymbols;
3161
+ if (focus.length > 0 && !focus.some((s) => lineLc.includes(s.toLowerCase())))
3162
+ return;
3163
+ // When a @kit module is named, require that kit — or @ohos.<focus> module path.
2844
3164
  if (kitTerms.length > 0) {
2845
3165
  const matchesKit = kitTerms.some((k) => lineLc.includes(`@kit.${k.toLowerCase()}`));
2846
- if (!matchesKit)
2847
- return;
2848
- }
2849
- if (depSymbols.length > 0) {
2850
- const matchesSym = depSymbols.some((s) => lineLc.includes(s.toLowerCase()));
2851
- if (!matchesSym)
2852
- return;
2853
- }
2854
- if (depSymbols.includes('taskpool')) {
2855
- if (!lineLc.includes('taskpool') || !lineLc.includes('@kit.arkts'))
3166
+ const matchesOhosModule = focusExports.some((s) => lineLc.includes(`@ohos.${s.toLowerCase()}`));
3167
+ if (!matchesKit && !matchesOhosModule)
2856
3168
  return;
2857
3169
  }
2858
3170
  const key = `${node.filePath}:${node.startLine}`;
@@ -2862,10 +3174,10 @@ class ToolHandler {
2862
3174
  sites.push({ file: node.filePath, line: node.startLine, lineText });
2863
3175
  };
2864
3176
  const resolveImportLine = (node) => (0, query_utils_1.resolveImportLineFromNode)(node, projectRoot);
2865
- const importLimit = depSymbols.includes('taskpool') ? 200
2866
- : depSymbols.length > 0 ? 60 : 20;
2867
- // Symbol-first search: "taskpool" hits `import { taskpool } from '@kit.ArkTS'`.
2868
- for (const sym of depSymbols) {
3177
+ const importLimit = completeInventory ? 200 : focusExports.length > 0 || depSymbols.length > 0 ? 60 : 20;
3178
+ const searchSyms = focusExports.length > 0 ? focusExports : depSymbols;
3179
+ // Symbol-first search: named export hits `import { foo } from '@kit.X'`.
3180
+ for (const sym of searchSyms) {
2869
3181
  let hits = [];
2870
3182
  try {
2871
3183
  hits = cg.searchNodes(sym, { kinds: ['import'], limit: importLimit });
@@ -2878,7 +3190,7 @@ class ToolHandler {
2878
3190
  }
2879
3191
  }
2880
3192
  // Kit-module search (when no symbol filter, or to catch re-exports).
2881
- if (sites.length === 0 || depSymbols.length === 0) {
3193
+ if (sites.length === 0 || searchSyms.length === 0) {
2882
3194
  for (const term of kitSearchTerms) {
2883
3195
  const termLc = term.toLowerCase().replace(/^@kit\./, '');
2884
3196
  let hits = [];
@@ -2892,8 +3204,8 @@ class ToolHandler {
2892
3204
  const lineText = resolveImportLine(r.node);
2893
3205
  if (!lineText.toLowerCase().includes(termLc))
2894
3206
  continue;
2895
- if (depSymbols.length > 0) {
2896
- const matchesSym = depSymbols.some((s) => lineText.toLowerCase().includes(s.toLowerCase()));
3207
+ if (searchSyms.length > 0) {
3208
+ const matchesSym = searchSyms.some((s) => lineText.toLowerCase().includes(s.toLowerCase()));
2897
3209
  if (!matchesSym)
2898
3210
  continue;
2899
3211
  }
@@ -2906,11 +3218,11 @@ class ToolHandler {
2906
3218
  }
2907
3219
  sites.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line);
2908
3220
  const importInventoryFilter = (0, query_utils_1.hasImportInventoryFilter)(query);
2909
- const taskpoolInventory = depSymbols.includes('taskpool');
2910
- const compactListing = taskpoolInventory || (0, query_utils_1.shouldCompactImportListing)(sites.length, importInventoryFilter);
2911
- const cap = compactListing ? (taskpoolInventory ? sites.length : 40) : 15;
3221
+ const compactListing = completeInventory || (0, query_utils_1.shouldCompactImportListing)(sites.length, importInventoryFilter);
3222
+ const cap = completeInventory ? sites.length : compactListing ? 40 : 15;
3223
+ const focusLabel = focusExports.length > 0 ? focusExports.map((s) => `\`${s}\``).join(', ') : 'queried symbol(s)';
2912
3224
  const lines = compactListing
2913
- ? ['**Dependency list**', '', `Files importing the queried symbol(s) (${sites.length} total):`, '']
3225
+ ? ['**Dependency list**', '', `Files importing ${focusLabel} (${sites.length} total):`, '']
2914
3226
  : ['**Import sites**', ''];
2915
3227
  for (const s of sites.slice(0, cap)) {
2916
3228
  if (compactListing) {
@@ -2925,12 +3237,8 @@ class ToolHandler {
2925
3237
  }
2926
3238
  if (compactListing) {
2927
3239
  lines.push('');
2928
- if (taskpoolInventory) {
2929
- lines.push(`> Complete list of **${sites.length}** \`import { taskpool } from '@kit.ArkTS'\` site(s) — answer from this section; no grep/read needed.`);
2930
- }
2931
- else {
2932
- lines.push('> Listing complete — answer from this section; no grep/read needed for the dependency set.');
2933
- }
3240
+ lines.push(`> **ANSWER NOW.** Complete list of **${sites.length}** matching import site(s) for ${focusLabel}. ` +
3241
+ 'Do **not** Grep/Read the same `@kit`/`@ohos` import pattern.');
2934
3242
  }
2935
3243
  lines.push('');
2936
3244
  return { section: lines.join('\n'), siteCount: sites.length, compactListing };
@@ -2938,7 +3246,7 @@ class ToolHandler {
2938
3246
  /**
2939
3247
  * External-caller inventory for a named class — methods → who calls them (paths only).
2940
3248
  */
2941
- buildCallerListingSection(cg, query) {
3249
+ buildCallerListingSection(cg, query, projectRoot) {
2942
3250
  const typeNames = (0, query_utils_1.extractCallerSurveySymbols)(query);
2943
3251
  if (typeNames.length === 0)
2944
3252
  return '';
@@ -2947,11 +3255,34 @@ class ToolHandler {
2947
3255
  // Applying this to every caller-survey query wiped same-file call sites
2948
3256
  // (common in C++/ARK .cpp units) and fell through to a fat full explore.
2949
3257
  const externalOnly = /\bexternal\b/i.test(query) || /外部/.test(query);
3258
+ const listedMethods = new Set((0, query_utils_1.extractListedTypeMethodsFromQuery)(query));
3259
+ const typeNameSet = new Set((0, query_utils_1.extractTypeNamesFromQuery)(query));
2950
3260
  const lines = ['**Caller inventory**', ''];
2951
3261
  let substantive = 0;
2952
3262
  // Function / method symbols named directly (e.g. SortWidgets).
2953
- for (const sym of typeNames.slice(0, 4)) {
2954
- const funcs = cg.getNodesByName(sym).filter((n) => (n.kind === 'function' || n.kind === 'method') && !(0, query_utils_1.isTestFile)(n.filePath));
3263
+ // When Type + listed methods (BinaryGrid Set/Test/Fill), keep defs near the
3264
+ // owning Type's files (template methods often lack reliable contains edges).
3265
+ const ownerDirs = new Set();
3266
+ if (listedMethods.size > 0 && typeNameSet.size > 0) {
3267
+ for (const t of typeNameSet) {
3268
+ for (const n of cg.getNodesByName(t)) {
3269
+ if (n.kind === 'class' || n.kind === 'struct' || n.kind === 'component') {
3270
+ const fp = rel(n.filePath);
3271
+ ownerDirs.add(fp.split('/').slice(0, -1).join('/'));
3272
+ }
3273
+ }
3274
+ }
3275
+ }
3276
+ for (const sym of typeNames.slice(0, 6)) {
3277
+ let funcs = cg.getNodesByName(sym).filter((n) => (n.kind === 'function' || n.kind === 'method') && !(0, query_utils_1.isTestFile)(n.filePath));
3278
+ if (listedMethods.has(sym) && ownerDirs.size > 0) {
3279
+ const near = funcs.filter((n) => {
3280
+ const dir = rel(n.filePath).split('/').slice(0, -1).join('/');
3281
+ return ownerDirs.has(dir);
3282
+ });
3283
+ if (near.length > 0)
3284
+ funcs = near;
3285
+ }
2955
3286
  for (const fn of funcs.slice(0, 3)) {
2956
3287
  let callers = [];
2957
3288
  try {
@@ -2981,6 +3312,8 @@ class ToolHandler {
2981
3312
  }
2982
3313
  }
2983
3314
  for (const typeName of typeNames.slice(0, 3)) {
3315
+ if (!typeNameSet.has(typeName) && listedMethods.size > 0)
3316
+ continue;
2984
3317
  const classes = cg.getNodesByName(typeName)
2985
3318
  .filter((n) => (n.kind === 'class' || n.kind === 'struct' || n.kind === 'component') && !(0, query_utils_1.isTestFile)(n.filePath));
2986
3319
  for (const cls of classes.slice(0, 2)) {
@@ -2999,8 +3332,11 @@ class ToolHandler {
2999
3332
  }
3000
3333
  }
3001
3334
  }
3335
+ const focusMethods = listedMethods.size > 0
3336
+ ? methods.filter((m) => listedMethods.has(m.name))
3337
+ : methods;
3002
3338
  const methodLines = [];
3003
- for (const method of methods.slice(0, 25)) {
3339
+ for (const method of (focusMethods.length > 0 ? focusMethods : methods).slice(0, 25)) {
3004
3340
  let callers = [];
3005
3341
  try {
3006
3342
  callers = cg.getCallers(method.id);
@@ -3034,166 +3370,617 @@ class ToolHandler {
3034
3370
  lines.push('');
3035
3371
  }
3036
3372
  }
3037
- if (substantive === 0)
3038
- return '';
3039
- lines.push('> Caller inventory complete answer from this section.');
3040
- lines.push('');
3041
- return lines.join('\n');
3042
- }
3043
- /**
3044
- * Upstream data-source survey — which services/symbols feed a Manager (e.g. BadgeManager → notification).
3045
- */
3046
- buildDataSourceSection(cg, query) {
3047
- const anchors = (0, query_utils_1.extractTypeNamesFromQuery)(query).filter((n) => /Manager|Service|Handler|Store|Provider|Controller/i.test(n));
3048
- if (anchors.length === 0)
3049
- return { section: '', edgeCount: 0 };
3050
- const rel = (p) => p.replace(/\\/g, '/');
3051
- // Prefer system/SDK / service APIs — not local UI helpers (getBadgeOffsetX).
3052
- const SERVICE_RE = /notification|@ohos|ans|subscribe|publish|bundle|ability|vibrator|telephony|NumBadge|notificationManager|badgeManager|wantAgent|distributed/i;
3053
- const lines = ['**Data sources / upstream services**', ''];
3054
- let edgeCount = 0;
3055
- const addEdge = (bucket, node, via, rank) => {
3056
- if ((0, query_utils_1.isTestFile)(node.filePath))
3057
- return;
3058
- const sig = `${node.name} ${node.filePath} ${node.signature || ''}`;
3059
- if (!SERVICE_RE.test(sig))
3060
- return;
3061
- const prev = bucket.get(node.id);
3062
- if (!prev || rank < prev.rank) {
3063
- bucket.set(node.id, { name: node.name, file: rel(node.filePath), line: node.startLine, via, rank });
3064
- if (!prev)
3065
- edgeCount++;
3066
- }
3067
- };
3068
- for (const name of anchors.slice(0, 3)) {
3069
- const classes = cg.getNodesByName(name).filter((n) => (n.kind === 'class' || n.kind === 'struct' || n.kind === 'component') && !(0, query_utils_1.isTestFile)(n.filePath));
3070
- for (const cls of classes.slice(0, 2)) {
3071
- const upstream = new Map();
3072
- const methods = [];
3073
- for (const e of cg.getOutgoingEdges(cls.id)) {
3074
- if (e.kind !== 'contains')
3075
- continue;
3076
- const m = cg.getNode(e.target);
3077
- if (m && (m.kind === 'method' || m.kind === 'function'))
3078
- methods.push(m);
3079
- }
3080
- if (methods.length === 0) {
3081
- for (const n of cg.getNodesByName(name)) {
3082
- if (n.filePath === cls.filePath && (n.kind === 'method' || n.kind === 'function'))
3083
- methods.push(n);
3084
- }
3085
- }
3086
- // 1) Same-file @ohos / kit imports — the usual system-service surface.
3373
+ // Text call-site fallback for return-value / member consumers when the graph
3374
+ // has no edges onto SDK/.d.ts methods (common for getConnectionState).
3375
+ if (substantive === 0 && (0, query_utils_1.queryAsReturnValueConsumerSurvey)(query)) {
3376
+ const root = projectRoot || '';
3377
+ for (const sym of typeNames.slice(0, 3)) {
3378
+ if (!/^(?:get|is|has|create|on)[A-Z]/.test(sym) && !(0, query_utils_1.isMemberLikeIdentifier)(sym))
3379
+ continue;
3380
+ let scanHits = [];
3087
3381
  try {
3088
- for (const r of cg.searchNodes('@ohos', { kinds: ['import'], limit: 40 })) {
3089
- if (r.node.filePath !== cls.filePath)
3090
- continue;
3091
- addEdge(upstream, r.node, 'import', 0);
3092
- }
3093
- for (const term of ['notification', 'badge', 'NumBadge', 'subscribe', 'bundle', 'ability']) {
3094
- for (const r of cg.searchNodes(term, { kinds: ['import'], limit: 30 })) {
3095
- if (r.node.filePath !== cls.filePath)
3096
- continue;
3097
- addEdge(upstream, r.node, 'import', 1);
3098
- }
3099
- }
3382
+ scanHits = cg.searchNodes(sym, { limit: 40 });
3100
3383
  }
3101
- catch { /* */ }
3102
- // 2) Callees that look like services/APIs.
3103
- for (const method of methods.slice(0, 25)) {
3104
- let callees = [];
3384
+ catch {
3385
+ continue;
3386
+ }
3387
+ const esc = sym.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
3388
+ const callRe = new RegExp(`\\.${esc}\\s*\\(`);
3389
+ const bareRe = new RegExp(`\\b${esc}\\s*\\(`);
3390
+ const seen = new Set();
3391
+ const bullets = [];
3392
+ for (const r of scanHits) {
3393
+ if ((0, query_utils_1.isTestFile)(r.node.filePath))
3394
+ continue;
3395
+ if (/\.d\.ts$/i.test(r.node.filePath) || (0, arkts_1.isOhosApiFilePath)(r.node.filePath))
3396
+ continue;
3397
+ const abs = root
3398
+ ? (0, utils_1.validatePathWithinRoot)(root, r.node.filePath)
3399
+ : ((0, fs_1.existsSync)(r.node.filePath) ? r.node.filePath : null);
3400
+ if (!abs)
3401
+ continue;
3402
+ let fileLines = [];
3105
3403
  try {
3106
- callees = cg.getCallees(method.id);
3404
+ fileLines = (0, fs_1.readFileSync)(abs, 'utf-8').split('\n');
3107
3405
  }
3108
3406
  catch {
3109
3407
  continue;
3110
3408
  }
3111
- for (const c of callees) {
3112
- if (c?.node)
3113
- addEdge(upstream, c.node, method.name, 2);
3409
+ for (let i = 0; i < fileLines.length; i++) {
3410
+ const lineText = fileLines[i] ?? '';
3411
+ if (!callRe.test(lineText) && !bareRe.test(lineText))
3412
+ continue;
3413
+ const key = `${rel(r.node.filePath)}:${i + 1}`;
3414
+ if (seen.has(key))
3415
+ continue;
3416
+ seen.add(key);
3417
+ bullets.push(`- \`${sym}\` ← \`${key}\` \`${lineText.trim().slice(0, 120)}\``);
3418
+ if (bullets.length >= 12)
3419
+ break;
3114
3420
  }
3421
+ if (bullets.length >= 12)
3422
+ break;
3115
3423
  }
3116
- if (upstream.size === 0)
3424
+ if (bullets.length > 0) {
3425
+ lines.push(`### \`${sym}\` (text call sites)`);
3426
+ lines.push(...bullets);
3427
+ lines.push('');
3428
+ substantive += bullets.length;
3429
+ }
3430
+ }
3431
+ }
3432
+ if (substantive === 0) {
3433
+ if ((0, query_utils_1.queryAsReturnValueConsumerSurvey)(query)) {
3434
+ return [
3435
+ '**Caller inventory**',
3436
+ '',
3437
+ '> No in-repo callers of the return-value member in the graph/index. **ANSWER NOW** — do not open SDK `.d.ts` stubs; Grep once for `.member(` only if needed.',
3438
+ '',
3439
+ ].join('\n');
3440
+ }
3441
+ return '';
3442
+ }
3443
+ // Unique caller files — answers "哪些文件调用了 Type::methods" without Grep.
3444
+ if (listedMethods.size > 0 && substantive > 0) {
3445
+ const callerFiles = new Set();
3446
+ for (const line of lines) {
3447
+ const idx = line.indexOf('←');
3448
+ if (idx < 0)
3117
3449
  continue;
3118
- lines.push(`### \`${cls.name}\` (\`${rel(cls.filePath)}\`)`);
3119
- const ranked = [...upstream.values()].sort((a, b) => a.rank - b.rank || a.name.localeCompare(b.name));
3120
- for (const u of ranked.slice(0, 15)) {
3121
- lines.push(`- \`${u.name}\` at \`${u.file}:${u.line}\` (via \`${u.via}\`)`);
3450
+ for (const m of line.slice(idx).matchAll(/`([^`]+?):(\d+)`/g)) {
3451
+ callerFiles.add(m[1]);
3122
3452
  }
3123
- if (ranked.length > 15)
3124
- lines.push(`- and ${ranked.length - 15} more upstream symbol(s)`);
3125
- lines.push('');
3126
- lines.push('> Prefer `@ohos.*` / notification / bundle imports as the system-service answer; local helpers are secondary.');
3453
+ }
3454
+ if (callerFiles.size > 0) {
3127
3455
  lines.push('');
3456
+ lines.push('**Unique caller files**');
3457
+ for (const fp of [...callerFiles].sort()) {
3458
+ lines.push(`- \`${fp}\``);
3459
+ }
3128
3460
  }
3129
3461
  }
3130
- if (edgeCount === 0)
3131
- return { section: '', edgeCount: 0 };
3132
- lines.push('> Data-source survey — answer from upstream symbols above; source bodies omitted.');
3462
+ lines.push('> Caller inventory complete — answer from this section.');
3133
3463
  lines.push('');
3134
- return { section: lines.join('\n'), edgeCount };
3464
+ return lines.join('\n');
3135
3465
  }
3136
3466
  /**
3137
- * Main-thread fast path for inventory surveys skips the worker queue.
3467
+ * Multi-module / circular dependency surveylean cycle list, not an import flood.
3138
3468
  */
3139
- tryFastPathResult(toolName, args) {
3140
- const query = args.query;
3141
- if (typeof query !== 'string')
3469
+ buildModuleDependencySurveySection(cg, query) {
3470
+ // Prefer leaf module tokens (`launchercommon`) over shared parents (`staticcommon`)
3471
+ // parent matches flood unrelated windowscene cycles.
3472
+ const leafModules = new Set();
3473
+ for (const p of (query.match(/\b[A-Za-z][\w]*(?:common|service|component|constants)\b/gi) ?? [])) {
3474
+ const low = p.toLowerCase();
3475
+ if (!/^(?:static|feature|product|base|common)$/i.test(low))
3476
+ leafModules.add(low);
3477
+ }
3478
+ for (const seg of query.match(/[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_-]+)+/g) ?? []) {
3479
+ const leaf = seg.split(/[/\\]/).pop()?.toLowerCase();
3480
+ if (leaf && leaf.length >= 4 && /common|service|component|constants/i.test(leaf)) {
3481
+ leafModules.add(leaf);
3482
+ }
3483
+ }
3484
+ if (leafModules.size === 0)
3485
+ return { section: '', hitCount: 0 };
3486
+ const rel = (p) => p.replace(/\\/g, '/');
3487
+ const hitLeaf = (fp) => {
3488
+ const low = fp.toLowerCase().replace(/\\/g, '/');
3489
+ for (const n of leafModules) {
3490
+ if (low.includes(`/${n}/`) || low.includes(`/${n}.`) || low.endsWith(`/${n}`))
3491
+ return n;
3492
+ }
3142
3493
  return null;
3494
+ };
3495
+ let cycles = [];
3143
3496
  try {
3144
- const cg = this.getHomeGraph(args.projectPath);
3145
- const projectRoot = cg.getProjectRoot();
3146
- if (toolName === 'homegraph_explore') {
3147
- return this.tryFastInventoryExplore(cg, query, projectRoot)
3148
- ?? this.tryCompactLocalSymbolExplore(cg, query, projectRoot)
3149
- ?? this.tryLightMechanismExplore(cg, query, projectRoot);
3150
- }
3151
- if (toolName === 'homegraph_search') {
3152
- return this.tryFastInventoryExplore(cg, query, projectRoot)
3153
- ?? this.tryCompactLocalSymbolExplore(cg, query, projectRoot)
3154
- ?? this.tryLightMechanismExplore(cg, query, projectRoot);
3155
- }
3497
+ cycles = cg.findCircularDependencies();
3156
3498
  }
3157
3499
  catch {
3158
- return null;
3500
+ cycles = [];
3501
+ }
3502
+ const matchedCycles = cycles
3503
+ .map((c) => {
3504
+ const touched = new Set();
3505
+ const kept = [];
3506
+ for (const p of c) {
3507
+ const leaf = hitLeaf(p);
3508
+ if (leaf) {
3509
+ touched.add(leaf);
3510
+ kept.push(p);
3511
+ }
3512
+ }
3513
+ return touched.size >= 2 ? kept : [];
3514
+ })
3515
+ .filter((c) => c.length >= 2)
3516
+ .slice(0, 8);
3517
+ const lines = [
3518
+ '**Module dependency / cycle survey**',
3519
+ '',
3520
+ `> Focused on leaf modules \`${[...leafModules].slice(0, 8).join('`, `')}\`. **ANSWER NOW** — do not glob every \`oh-package.json5\`.`,
3521
+ '',
3522
+ ];
3523
+ if (matchedCycles.length === 0) {
3524
+ lines.push(`- No **circular** import cycle spanning **two or more** of the named leaf modules.`);
3525
+ lines.push('- (Cycles only inside an unrelated sibling under the same parent path are omitted.)');
3526
+ lines.push('');
3527
+ return { section: lines.join('\n'), hitCount: 1 };
3159
3528
  }
3160
- return null;
3529
+ lines.push(`Found **${matchedCycles.length}** cycle(s) spanning named leaf modules:`);
3530
+ for (const c of matchedCycles) {
3531
+ lines.push(`- ${c.map((p) => `\`${rel(p)}\``).join(' → ')}`);
3532
+ }
3533
+ lines.push('');
3534
+ return { section: lines.join('\n'), hitCount: matchedCycles.length };
3161
3535
  }
3162
3536
  /**
3163
- * Fast inventory-only exploreskips findRelevantContext for survey/caller/dependency queries.
3537
+ * Path-module / named-Type NAPI export surface not a 50-file domain dump.
3164
3538
  */
3165
- tryFastInventoryExplore(cg, query, projectRoot) {
3166
- if (!(0, query_utils_1.shouldTryFastInventoryExplore)(query))
3167
- return null;
3168
- const lines = [`**Exploration: ${query}**`, '', ''];
3169
- const summaryLineIdx = 2;
3170
- const inheritanceOnly = (0, query_utils_1.shouldBuildInheritanceSurvey)(query);
3171
- const typeSurface = inheritanceOnly
3172
- || (0, query_utils_1.shouldBuildCallerInventory)(query)
3173
- || (0, query_utils_1.queryIsTypeNameFocus)(query);
3174
- // Type / hierarchy / method-caller surveys: return inventory first — never a
3175
- // definition dump or full-graph explore. Agent already named the type.
3176
- // Exception: caller + co-named "definition visible" needs bodies (compact path).
3177
- if (typeSurface && !(0, query_utils_1.queryNeedsCoNamedUseBridge)(query)) {
3178
- const inheritanceSection = this.buildInheritanceSurveySection(cg, query);
3179
- const callerSection = (0, query_utils_1.shouldBuildCallerInventory)(query)
3180
- ? this.buildCallerListingSection(cg, query)
3181
- : '';
3182
- const inheritanceListed = inheritanceSection
3183
- ? inheritanceSection.split('\n').filter((l) => l.startsWith('- `')).length > 0
3184
- : false;
3185
- const callerBulletCount = callerSection
3186
- ? callerSection.split('\n').filter((l) => l.startsWith('- ') && l.includes(' ← ')).length
3187
- : 0;
3188
- if (inheritanceListed || callerBulletCount > 0) {
3189
- const parts = [
3539
+ buildModuleExportSurveySection(cg, query, projectRoot) {
3540
+ const paths = (0, query_utils_1.extractPathSegmentsFromQuery)(query);
3541
+ const types = (0, query_utils_1.extractTypeNamesFromQuery)(query).filter((t) => !(0, query_utils_1.isFrameworkUiDecoratorName)(t));
3542
+ const needles = [
3543
+ ...paths.map((p) => p.replace(/\\/g, '/').toLowerCase()),
3544
+ ...types.map((t) => t.toLowerCase()),
3545
+ ].filter((n) => n.length >= 4);
3546
+ if (needles.length === 0)
3547
+ return { section: '', hitCount: 0 };
3548
+ const rel = (p) => p.replace(/\\/g, '/');
3549
+ const inScope = (fp) => {
3550
+ const low = rel(fp).toLowerCase();
3551
+ return needles.some((n) => low.includes(n));
3552
+ };
3553
+ const exports = [];
3554
+ const seen = new Set();
3555
+ const seedNames = [
3556
+ 'Export', 'NapiInit', 'napi_init', 'Init', 'napi_module_register',
3557
+ ...types.slice(0, 3),
3558
+ ];
3559
+ for (const seed of seedNames) {
3560
+ let hits = [];
3561
+ try {
3562
+ hits = cg.searchNodes(seed, { limit: 30 });
3563
+ }
3564
+ catch {
3565
+ continue;
3566
+ }
3567
+ for (const r of hits) {
3568
+ if (!inScope(r.node.filePath) || (0, query_utils_1.isTestFile)(r.node.filePath))
3569
+ continue;
3570
+ if (!/\.(cpp|cc|cxx|h|hpp)$/i.test(r.node.filePath) && !/napi/i.test(r.node.filePath)) {
3571
+ continue;
3572
+ }
3573
+ const abs = (0, utils_1.validatePathWithinRoot)(projectRoot, r.node.filePath);
3574
+ if (!abs)
3575
+ continue;
3576
+ let content = '';
3577
+ try {
3578
+ content = (0, fs_1.readFileSync)(abs, 'utf-8');
3579
+ }
3580
+ catch {
3581
+ continue;
3582
+ }
3583
+ const fileLines = content.split('\n');
3584
+ for (let i = 0; i < fileLines.length; i++) {
3585
+ const line = fileLines[i] ?? '';
3586
+ // napi_define_function / exports / Descriptor { "foo", … }
3587
+ const m = line.match(/napi_define_(?:function|property)\s*\([^,]+,\s*["']([\w]+)["']/i)
3588
+ || line.match(/\{\s*["']([\w]+)["']\s*,\s*(?:nullptr|NULL)?\s*,\s*\w+/i)
3589
+ || line.match(/\.?(?:exports|export)\s*[:=].*["']([\w]+)["']/i);
3590
+ if (!m?.[1] || m[1].length < 2)
3591
+ continue;
3592
+ const key = `${m[1]}:${rel(r.node.filePath)}`;
3593
+ if (seen.has(key))
3594
+ continue;
3595
+ seen.add(key);
3596
+ exports.push({
3597
+ name: m[1],
3598
+ file: rel(r.node.filePath),
3599
+ line: i + 1,
3600
+ snippet: line.trim().slice(0, 100),
3601
+ });
3602
+ }
3603
+ // Also list Export / Init method nodes in-scope.
3604
+ if (/^(?:Export|Init|NapiInit)$/i.test(r.node.name)) {
3605
+ const key = `fn:${r.node.name}:${rel(r.node.filePath)}`;
3606
+ if (!seen.has(key)) {
3607
+ seen.add(key);
3608
+ exports.push({
3609
+ name: r.node.name,
3610
+ file: rel(r.node.filePath),
3611
+ line: r.node.startLine,
3612
+ snippet: (r.node.signature || r.node.name).slice(0, 100),
3613
+ });
3614
+ }
3615
+ }
3616
+ }
3617
+ }
3618
+ const lines = [
3619
+ '**NAPI / native export survey**',
3620
+ '',
3621
+ `> In-repo NAPI surface for \`${needles.slice(0, 4).join('`, `')}\`. **ANSWER NOW** — do not Grep \`napi_define\` again.`,
3622
+ '',
3623
+ ];
3624
+ if (exports.length === 0) {
3625
+ lines.push('- No `napi_define_*` / Export descriptors found under the named path/Type.');
3626
+ lines.push('');
3627
+ return { section: lines.join('\n'), hitCount: 0 };
3628
+ }
3629
+ for (const e of exports.slice(0, 30)) {
3630
+ lines.push(`- \`${e.name}\` — \`${e.file}:${e.line}\` \`${e.snippet}\``);
3631
+ }
3632
+ if (exports.length > 30)
3633
+ lines.push(`- … and ${exports.length - 30} more`);
3634
+ lines.push('');
3635
+ return { section: lines.join('\n'), hitCount: Math.min(exports.length, 30) };
3636
+ }
3637
+ /**
3638
+ * Named `.d.ts` wrap — where the basename is imported / called in-repo.
3639
+ */
3640
+ buildDtsWrapSurveySection(cg, query, projectRoot) {
3641
+ const bases = (0, query_utils_1.extractFileBasenamesFromQuery)(query)
3642
+ .map((b) => b.replace(/\.d\.ts$/i, ''))
3643
+ .filter((b) => b.length >= 3);
3644
+ if (bases.length === 0 && /\.d\.ts\b/i.test(query)) {
3645
+ for (const m of query.matchAll(/\b([A-Za-z][\w]*)\.d\.ts\b/g)) {
3646
+ if (m[1])
3647
+ bases.push(m[1]);
3648
+ }
3649
+ }
3650
+ if (bases.length === 0)
3651
+ return { section: '', hitCount: 0 };
3652
+ const rel = (p) => p.replace(/\\/g, '/');
3653
+ const hits = new Map();
3654
+ for (const base of bases.slice(0, 3)) {
3655
+ let nodes = [];
3656
+ try {
3657
+ nodes = cg.searchNodes(base, { limit: 40 });
3658
+ }
3659
+ catch {
3660
+ continue;
3661
+ }
3662
+ const re = new RegExp(`(?:from\\s+['"].*${base}['"]|import\\s+.*\\b${base}\\b|\\b${base}\\.\\w+)`, 'i');
3663
+ const seen = new Set();
3664
+ for (const r of nodes) {
3665
+ if ((0, query_utils_1.isTestFile)(r.node.filePath))
3666
+ continue;
3667
+ const fp = rel(r.node.filePath);
3668
+ if (/\.d\.ts$/i.test(fp))
3669
+ continue;
3670
+ if (seen.has(fp))
3671
+ continue;
3672
+ seen.add(fp);
3673
+ const abs = (0, utils_1.validatePathWithinRoot)(projectRoot, r.node.filePath);
3674
+ if (!abs)
3675
+ continue;
3676
+ let content = '';
3677
+ try {
3678
+ content = (0, fs_1.readFileSync)(abs, 'utf-8');
3679
+ }
3680
+ catch {
3681
+ continue;
3682
+ }
3683
+ const fileLines = content.split('\n');
3684
+ for (let i = 0; i < fileLines.length; i++) {
3685
+ const line = fileLines[i] ?? '';
3686
+ if (!re.test(line) && !line.includes(base))
3687
+ continue;
3688
+ if (!new RegExp(`\\b${base}\\b`, 'i').test(line))
3689
+ continue;
3690
+ const prev = hits.get(fp) ?? { lines: [], snippet: '' };
3691
+ if (!prev.lines.includes(i + 1))
3692
+ prev.lines.push(i + 1);
3693
+ if (!prev.snippet)
3694
+ prev.snippet = line.trim().slice(0, 100);
3695
+ hits.set(fp, prev);
3696
+ }
3697
+ }
3698
+ }
3699
+ const lines = [
3700
+ '**`.d.ts` wrap / call sites**',
3701
+ '',
3702
+ `> In-repo imports/uses of \`${bases.join('`, `')}\` (stub file itself omitted). **ANSWER NOW.**`,
3703
+ '',
3704
+ ];
3705
+ if (hits.size === 0) {
3706
+ lines.push('- No in-repo import/call sites indexed for this wrap.');
3707
+ lines.push('');
3708
+ return { section: lines.join('\n'), hitCount: 0 };
3709
+ }
3710
+ for (const [fp, info] of [...hits.entries()].sort((a, b) => a[0].localeCompare(b[0])).slice(0, 28)) {
3711
+ const ls = info.lines.slice(0, 4).map((l) => `L${l}`).join(', ');
3712
+ lines.push(info.snippet
3713
+ ? `- \`${fp}\` (${ls}) \`${info.snippet}\``
3714
+ : `- \`${fp}\` (${ls})`);
3715
+ }
3716
+ lines.push('');
3717
+ return { section: lines.join('\n'), hitCount: hits.size };
3718
+ }
3719
+ /**
3720
+ * Named Toggle/control state-sync — which files/modules mention the control
3721
+ * (Type may be missing from FTS; seed stems + text scan).
3722
+ */
3723
+ buildNamedControlStateSyncSection(cg, query, projectRoot) {
3724
+ const types = (0, query_utils_1.extractTypeNamesFromQuery)(query).filter((t) => !(0, query_utils_1.isFrameworkUiDecoratorName)(t));
3725
+ if (types.length === 0)
3726
+ return { section: '', hitCount: 0 };
3727
+ const stems = new Set();
3728
+ for (const t of types) {
3729
+ stems.add(t);
3730
+ for (const m of t.matchAll(/[A-Z][a-z]+/g)) {
3731
+ if (m[0] && m[0].length >= 4)
3732
+ stems.add(m[0]);
3733
+ }
3734
+ }
3735
+ const rel = (p) => p.replace(/\\/g, '/');
3736
+ const hits = new Map();
3737
+ for (const stem of [...stems].slice(0, 6)) {
3738
+ let nodes = [];
3739
+ try {
3740
+ nodes = cg.searchNodes(stem, { limit: 35 });
3741
+ }
3742
+ catch {
3743
+ continue;
3744
+ }
3745
+ const re = new RegExp(`\\b${stem.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`);
3746
+ const seen = new Set();
3747
+ for (const r of nodes) {
3748
+ if ((0, query_utils_1.isTestFile)(r.node.filePath))
3749
+ continue;
3750
+ const fp = rel(r.node.filePath);
3751
+ if (seen.has(fp))
3752
+ continue;
3753
+ seen.add(fp);
3754
+ const abs = (0, utils_1.validatePathWithinRoot)(projectRoot, r.node.filePath);
3755
+ if (!abs)
3756
+ continue;
3757
+ let content = '';
3758
+ try {
3759
+ content = (0, fs_1.readFileSync)(abs, 'utf-8');
3760
+ }
3761
+ catch {
3762
+ continue;
3763
+ }
3764
+ const fileLines = content.split('\n');
3765
+ let matched = false;
3766
+ for (let i = 0; i < fileLines.length; i++) {
3767
+ const line = fileLines[i] ?? '';
3768
+ if (!re.test(line) && !line.includes(stem))
3769
+ continue;
3770
+ const prev = hits.get(fp) ?? { lines: [], snippet: '', score: 0 };
3771
+ if (!prev.lines.includes(i + 1))
3772
+ prev.lines.push(i + 1);
3773
+ if (!prev.snippet)
3774
+ prev.snippet = line.trim().slice(0, 100);
3775
+ if (/Manager|Service|Store|Controller|Provider/i.test(fp) || /Manager|Service|Store/.test(line)) {
3776
+ prev.score += 5;
3777
+ }
3778
+ prev.score += 1;
3779
+ hits.set(fp, prev);
3780
+ matched = true;
3781
+ }
3782
+ if (!matched && /Toggle|Switch|Hotspot|Manager/i.test(r.node.name)) {
3783
+ hits.set(fp, {
3784
+ lines: [r.node.startLine],
3785
+ snippet: (r.node.signature || r.node.name).slice(0, 100),
3786
+ score: 2,
3787
+ });
3788
+ }
3789
+ }
3790
+ }
3791
+ const ranked = [...hits.entries()].sort((a, b) => b[1].score - a[1].score || a[0].localeCompare(b[0]));
3792
+ const lines = [
3793
+ '**Control / Toggle state-sync sites**',
3794
+ '',
3795
+ `> In-repo references to \`${types.join('`, `')}\` (and stems). Prefer Manager/Service files for \"which module guarantees consistency\". **ANSWER NOW.**`,
3796
+ '',
3797
+ ];
3798
+ if (ranked.length === 0) {
3799
+ lines.push('- No indexed sites for the named control; answer from domain knowledge only if sure.');
3800
+ lines.push('');
3801
+ return { section: lines.join('\n'), hitCount: 0 };
3802
+ }
3803
+ for (const [fp, info] of ranked.slice(0, 24)) {
3804
+ const ls = info.lines.slice(0, 3).map((l) => `L${l}`).join(', ');
3805
+ lines.push(info.snippet
3806
+ ? `- \`${fp}\` (${ls}) \`${info.snippet}\``
3807
+ : `- \`${fp}\` (${ls})`);
3808
+ }
3809
+ lines.push('');
3810
+ return { section: lines.join('\n'), hitCount: Math.min(ranked.length, 24) };
3811
+ }
3812
+ buildDataSourceSection(cg, query) {
3813
+ const anchors = (0, query_utils_1.extractTypeNamesFromQuery)(query).filter((n) => /Manager|Service|Handler|Store|Provider|Controller/i.test(n));
3814
+ if (anchors.length === 0)
3815
+ return { section: '', edgeCount: 0 };
3816
+ const rel = (p) => p.replace(/\\/g, '/');
3817
+ // Prefer system/SDK / service APIs — not local UI helpers (getBadgeOffsetX).
3818
+ const SERVICE_RE = /notification|@ohos|ans|subscribe|publish|bundle|ability|vibrator|telephony|NumBadge|notificationManager|badgeManager|wantAgent|distributed|account|osAccount|AppAccount|distributedAccount|userId|getOsAccount/i;
3819
+ const lines = ['**Data sources / upstream services**', ''];
3820
+ let edgeCount = 0;
3821
+ const addEdge = (bucket, node, via, rank, opts) => {
3822
+ if ((0, query_utils_1.isTestFile)(node.filePath))
3823
+ return;
3824
+ const sig = `${node.name} ${node.filePath} ${node.signature || ''}`;
3825
+ // Same-file @ohos/@kit imports are always data-source candidates (BadgeManager
3826
+ // → notificationManager), even when the import local name fails SERVICE_RE.
3827
+ if (!opts?.importAlways && !SERVICE_RE.test(sig))
3828
+ return;
3829
+ if (opts?.importAlways && !/@ohos\.|@kit\.|ohos\./i.test(sig))
3830
+ return;
3831
+ const prev = bucket.get(node.id);
3832
+ if (!prev || rank < prev.rank) {
3833
+ bucket.set(node.id, { name: node.name, file: rel(node.filePath), line: node.startLine, via, rank });
3834
+ if (!prev)
3835
+ edgeCount++;
3836
+ }
3837
+ };
3838
+ for (const name of anchors.slice(0, 3)) {
3839
+ const classes = cg.getNodesByName(name).filter((n) => (n.kind === 'class' || n.kind === 'struct' || n.kind === 'component') && !(0, query_utils_1.isTestFile)(n.filePath));
3840
+ for (const cls of classes.slice(0, 2)) {
3841
+ const upstream = new Map();
3842
+ const methods = [];
3843
+ for (const e of cg.getOutgoingEdges(cls.id)) {
3844
+ if (e.kind !== 'contains')
3845
+ continue;
3846
+ const m = cg.getNode(e.target);
3847
+ if (m && (m.kind === 'method' || m.kind === 'function'))
3848
+ methods.push(m);
3849
+ }
3850
+ if (methods.length === 0) {
3851
+ for (const n of cg.getNodesByName(name)) {
3852
+ if (n.filePath === cls.filePath && (n.kind === 'method' || n.kind === 'function'))
3853
+ methods.push(n);
3854
+ }
3855
+ }
3856
+ // 1) Same-file @ohos / kit imports — the usual system-service surface.
3857
+ try {
3858
+ for (const r of cg.searchNodes('@ohos', { kinds: ['import'], limit: 40 })) {
3859
+ if (r.node.filePath !== cls.filePath)
3860
+ continue;
3861
+ addEdge(upstream, r.node, 'import', 0, { importAlways: true });
3862
+ }
3863
+ for (const r of cg.searchNodes('@kit', { kinds: ['import'], limit: 40 })) {
3864
+ if (r.node.filePath !== cls.filePath)
3865
+ continue;
3866
+ addEdge(upstream, r.node, 'import', 0, { importAlways: true });
3867
+ }
3868
+ for (const term of [
3869
+ 'notification', 'badge', 'NumBadge', 'subscribe', 'bundle', 'ability',
3870
+ 'account', 'osAccount', 'AppAccount', 'distributedAccount',
3871
+ ]) {
3872
+ for (const r of cg.searchNodes(term, { kinds: ['import'], limit: 30 })) {
3873
+ if (r.node.filePath !== cls.filePath)
3874
+ continue;
3875
+ addEdge(upstream, r.node, 'import', 1, { importAlways: true });
3876
+ }
3877
+ }
3878
+ }
3879
+ catch { /* */ }
3880
+ // 2) Callees that look like services/APIs.
3881
+ for (const method of methods.slice(0, 25)) {
3882
+ let callees = [];
3883
+ try {
3884
+ callees = cg.getCallees(method.id);
3885
+ }
3886
+ catch {
3887
+ continue;
3888
+ }
3889
+ for (const c of callees) {
3890
+ if (c?.node)
3891
+ addEdge(upstream, c.node, method.name, 2);
3892
+ }
3893
+ }
3894
+ if (upstream.size === 0)
3895
+ continue;
3896
+ lines.push(`### \`${cls.name}\` (\`${rel(cls.filePath)}\`)`);
3897
+ const ranked = [...upstream.values()].sort((a, b) => a.rank - b.rank || a.name.localeCompare(b.name));
3898
+ for (const u of ranked.slice(0, 15)) {
3899
+ lines.push(`- \`${u.name}\` at \`${u.file}:${u.line}\` (via \`${u.via}\`)`);
3900
+ }
3901
+ if (ranked.length > 15)
3902
+ lines.push(`- … and ${ranked.length - 15} more upstream symbol(s)`);
3903
+ lines.push('');
3904
+ lines.push('> Prefer `@ohos.*` / notification / bundle imports as the system-service answer; local helpers are secondary.');
3905
+ lines.push('');
3906
+ }
3907
+ }
3908
+ if (edgeCount === 0) {
3909
+ lines.push('- No `@ohos`/`@kit` / service-like upstream edges indexed for the named Manager. ' +
3910
+ '**ANSWER NOW** from the Manager definition file (prefer in-repo `AccountManager` / `*Manager.ets`, not SDK `.d.ts`).');
3911
+ lines.push('');
3912
+ return { section: lines.join('\n'), edgeCount: 0 };
3913
+ }
3914
+ lines.push('> Data-source survey — **ANSWER NOW** from upstream symbols above (system `@ohos`/`@kit` imports first). ' +
3915
+ 'Do not Grep/search/callers the same Manager for the service name.');
3916
+ lines.push('');
3917
+ return { section: lines.join('\n'), edgeCount };
3918
+ }
3919
+ /**
3920
+ * Main-thread fast path for inventory surveys — skips the worker queue.
3921
+ */
3922
+ tryFastPathResult(toolName, args) {
3923
+ const query = args.query;
3924
+ if (typeof query !== 'string')
3925
+ return null;
3926
+ const deferKind = (0, query_utils_1.queryShouldDeferToBuiltinTools)(query);
3927
+ if (deferKind) {
3928
+ return this.textResult((0, query_utils_1.homegraphDeferGuidance)(deferKind, query));
3929
+ }
3930
+ try {
3931
+ const cg = this.getHomeGraph(args.projectPath);
3932
+ const projectRoot = cg.getProjectRoot();
3933
+ if (toolName === 'homegraph_explore') {
3934
+ return this.tryFastInventoryExplore(cg, query, projectRoot)
3935
+ ?? this.tryLightMechanismExplore(cg, query, projectRoot)
3936
+ ?? this.tryCompactLocalSymbolExplore(cg, query, projectRoot);
3937
+ }
3938
+ if (toolName === 'homegraph_search') {
3939
+ return this.tryFastInventoryExplore(cg, query, projectRoot)
3940
+ ?? this.tryLightMechanismExplore(cg, query, projectRoot)
3941
+ ?? this.tryCompactLocalSymbolExplore(cg, query, projectRoot);
3942
+ }
3943
+ }
3944
+ catch {
3945
+ return null;
3946
+ }
3947
+ return null;
3948
+ }
3949
+ /**
3950
+ * Fast inventory-only explore — skips findRelevantContext for survey/caller/dependency queries.
3951
+ */
3952
+ tryFastInventoryExplore(cg, query, projectRoot) {
3953
+ if (!(0, query_utils_1.shouldTryFastInventoryExplore)(query))
3954
+ return null;
3955
+ const lines = [`**Exploration: ${query}**`, '', ''];
3956
+ const summaryLineIdx = 2;
3957
+ const inheritanceOnly = (0, query_utils_1.shouldBuildInheritanceSurvey)(query);
3958
+ const typeSurface = inheritanceOnly
3959
+ || (0, query_utils_1.shouldBuildCallerInventory)(query)
3960
+ || (0, query_utils_1.queryIsTypeNameFocus)(query);
3961
+ // Type / hierarchy / method-caller surveys: return inventory first — never a
3962
+ // definition dump or full-graph explore. Agent already named the type.
3963
+ // Exception: caller + co-named "definition visible" needs bodies (compact path).
3964
+ if (typeSurface && !(0, query_utils_1.queryNeedsCoNamedUseBridge)(query)) {
3965
+ const inheritanceSection = this.buildInheritanceSurveySection(cg, query);
3966
+ const callerSection = (0, query_utils_1.shouldBuildCallerInventory)(query)
3967
+ ? this.buildCallerListingSection(cg, query, projectRoot)
3968
+ : '';
3969
+ const inheritanceListed = inheritanceSection
3970
+ ? inheritanceSection.split('\n').filter((l) => l.startsWith('- `')).length > 0
3971
+ : false;
3972
+ const callerBulletCount = callerSection
3973
+ ? callerSection.split('\n').filter((l) => l.startsWith('- ') && l.includes(' ← ')).length
3974
+ : 0;
3975
+ if (inheritanceListed || callerBulletCount > 0) {
3976
+ const parts = [
3190
3977
  `**Exploration: ${query}**`,
3191
3978
  '',
3192
3979
  inheritanceListed && callerBulletCount > 0
3193
- ? 'Type surface: inheritance + method caller inventory below — answer from it; do not grep `extends` and do not `homegraph_callers` each method.'
3980
+ ? 'Type surface: inheritance + method caller inventory below — **ANSWER NOW**; do not grep `extends` and do not `homegraph_callers` each method.'
3194
3981
  : inheritanceListed
3195
- ? 'Inheritance survey above lists all direct subtypes found.'
3196
- : 'Caller inventory lists method→caller sites — answer from it; do not fan out `homegraph_callers` per method.',
3982
+ ? 'Inheritance survey above lists all direct subtypes found. **ANSWER NOW** — do not Grep `extends`.'
3983
+ : 'Caller inventory lists method→caller sites — **ANSWER NOW**; do not fan out `homegraph_callers` per method.',
3197
3984
  ];
3198
3985
  if (inheritanceSection)
3199
3986
  parts.push(inheritanceSection);
@@ -3201,47 +3988,128 @@ class ToolHandler {
3201
3988
  parts.push(callerSection);
3202
3989
  return this.textResult(parts.join('\n'));
3203
3990
  }
3204
- // Nothing graph-visible fall through; import/domain may still help.
3205
- }
3206
- const importResult = (inheritanceOnly || (0, query_utils_1.queryIsTypeNameFocus)(query) || (0, query_utils_1.queryAsInheritanceSurvey)(query))
3991
+ // Empty inheritance graph: stop only for *explicit* subclass intent
3992
+ // (子类/extends/subclass). Bare `search("IntGrid")` must fall through to
3993
+ // compact definition an empty subtype ANSWER NOW blocked visibility/use
3994
+ // questions that reuse a type name after callers().
3995
+ if ((0, query_utils_1.queryAsInheritanceSurvey)(query)) {
3996
+ return this.textResult([
3997
+ `**Exploration: ${query}**`,
3998
+ '',
3999
+ 'No direct subtypes / inheritance edges indexed for the named type(s). **ANSWER NOW** — do not Grep `extends` unless you need a second opinion.',
4000
+ '',
4001
+ ].join('\n'));
4002
+ }
4003
+ // Bare type-name focus with no subtypes — fall through to compact / other
4004
+ // inventory (definition + usage), not a fat full explore.
4005
+ }
4006
+ const importResult = (inheritanceOnly
4007
+ || (0, query_utils_1.queryIsTypeNameFocus)(query)
4008
+ || (0, query_utils_1.queryAsInheritanceSurvey)(query)
4009
+ || (0, query_utils_1.queryAsDataSourceSurvey)(query)
4010
+ || (0, query_utils_1.shouldBuildApiUsageSurvey)(query)
4011
+ || (0, query_utils_1.queryAsInRepoSystemCapabilityHowto)(query)
4012
+ || (0, query_utils_1.queryAsReturnValueConsumerSurvey)(query)
4013
+ || (0, query_utils_1.queryAsDeclarationSiteSurvey)(query)
4014
+ || (0, query_utils_1.queryAsModuleExportSurvey)(query)
4015
+ || (0, query_utils_1.queryAsModuleDependencySurvey)(query)
4016
+ // Kit install-deps needs oh-package lines — skip bare import inventory.
4017
+ || (0, query_utils_1.queryAsksKitInstallDeps)(query))
3207
4018
  ? { section: '', siteCount: 0, compactListing: false }
3208
4019
  : this.buildImportSitesSection(cg, query, projectRoot);
3209
4020
  if (importResult.section)
3210
4021
  lines.push(importResult.section);
4022
+ // Kit usage section is redundant when a focused import inventory already listed
4023
+ // every matching `@kit`/`export` site (avoids a second dump + Grep temptation).
4024
+ // Install-deps questions always take the kit survey (oh-package + imports).
3211
4025
  const kitUsageResult = (0, query_utils_1.shouldBuildKitModuleUsageSurvey)(query)
4026
+ && ((0, query_utils_1.queryAsksKitInstallDeps)(query)
4027
+ || !(importResult.compactListing && importResult.siteCount > 0))
3212
4028
  ? this.buildKitModuleUsageSection(cg, query, projectRoot)
3213
4029
  : { section: '', symbolCount: 0 };
3214
4030
  if (kitUsageResult.section)
3215
4031
  lines.push(kitUsageResult.section);
3216
- const domainFileResult = (0, query_utils_1.shouldBuildDomainFileSurvey)(query)
4032
+ const domainFileResult = (0, query_utils_1.shouldBuildDomainFileSurvey)(query) && !(0, query_utils_1.queryAsDataSourceSurvey)(query)
4033
+ && !(0, query_utils_1.queryAsInRepoSystemCapabilityHowto)(query)
4034
+ && !(0, query_utils_1.queryAsModuleDependencySurvey)(query)
4035
+ && !(0, query_utils_1.queryAsModuleExportSurvey)(query)
4036
+ && !(0, query_utils_1.queryAsDtsWrapSurvey)(query)
4037
+ && !(0, query_utils_1.queryAsDeclarationSiteSurvey)(query)
3217
4038
  ? this.buildDomainFileSurveySection(cg, query)
3218
4039
  : { section: '', fileCount: 0 };
3219
4040
  if (domainFileResult.section)
3220
4041
  lines.push(domainFileResult.section);
4042
+ const systemCapResult = (0, query_utils_1.queryAsInRepoSystemCapabilityHowto)(query)
4043
+ ? this.buildSystemCapabilityHowtoSection(cg, query, projectRoot)
4044
+ : { section: '', hitCount: 0 };
4045
+ if (systemCapResult.section)
4046
+ lines.push(systemCapResult.section);
4047
+ const declarationResult = (0, query_utils_1.queryAsDeclarationSiteSurvey)(query)
4048
+ ? this.buildDeclarationSiteSurveySection(cg, query, projectRoot)
4049
+ : { section: '', hitCount: 0, attempted: false };
4050
+ if (declarationResult.section)
4051
+ lines.push(declarationResult.section);
4052
+ // Kit usage surveys already list @kit import sites — a second API-usage dump
4053
+ // (taskpool × 32 files) doubles tokens without new signal.
3221
4054
  const apiUsageResult = (0, query_utils_1.shouldBuildApiUsageSurvey)(query)
4055
+ && !(0, query_utils_1.queryAsDeclarationSiteSurvey)(query)
4056
+ && !(0, query_utils_1.shouldBuildKitModuleUsageSurvey)(query)
3222
4057
  ? this.buildApiUsageSection(cg, query, projectRoot)
3223
4058
  : { section: '', fileCount: 0 };
3224
4059
  if (apiUsageResult.section)
3225
4060
  lines.push(apiUsageResult.section);
4061
+ const moduleDepResult = (0, query_utils_1.queryAsModuleDependencySurvey)(query)
4062
+ ? this.buildModuleDependencySurveySection(cg, query)
4063
+ : { section: '', hitCount: 0 };
4064
+ if (moduleDepResult.section)
4065
+ lines.push(moduleDepResult.section);
4066
+ const moduleExportResult = (0, query_utils_1.queryAsModuleExportSurvey)(query)
4067
+ ? this.buildModuleExportSurveySection(cg, query, projectRoot)
4068
+ : { section: '', hitCount: 0 };
4069
+ if (moduleExportResult.section)
4070
+ lines.push(moduleExportResult.section);
4071
+ const dtsWrapResult = (0, query_utils_1.queryAsDtsWrapSurvey)(query)
4072
+ ? this.buildDtsWrapSurveySection(cg, query, projectRoot)
4073
+ : { section: '', hitCount: 0 };
4074
+ if (dtsWrapResult.section)
4075
+ lines.push(dtsWrapResult.section);
4076
+ const controlSyncResult = (0, query_utils_1.queryAsNamedControlStateSyncSurvey)(query)
4077
+ ? this.buildNamedControlStateSyncSection(cg, query, projectRoot)
4078
+ : { section: '', hitCount: 0 };
4079
+ if (controlSyncResult.section)
4080
+ lines.push(controlSyncResult.section);
3226
4081
  const dataSourceResult = (0, query_utils_1.queryAsDataSourceSurvey)(query)
3227
4082
  ? this.buildDataSourceSection(cg, query)
3228
4083
  : { section: '', edgeCount: 0 };
3229
4084
  if (dataSourceResult.section)
3230
4085
  lines.push(dataSourceResult.section);
4086
+ const hoverResult = (0, query_utils_1.shouldBuildHoverHandlerSurvey)(query)
4087
+ ? this.buildHoverHandlerSurveySection(cg, query, projectRoot)
4088
+ : { section: '', hitCount: 0 };
4089
+ if (hoverResult.section)
4090
+ lines.push(hoverResult.section);
3231
4091
  const importInventoryFilter = (0, query_utils_1.hasImportInventoryFilter)(query);
3232
4092
  const multiAnchor = (0, query_utils_1.queryNamesMultipleExploreAnchors)(query);
3233
4093
  // Type / caller surveys: skip expensive named-symbol flow synthesize —
3234
4094
  // it wanders unrelated dynamic edges and burns tokens.
3235
- const skipFlow = typeSurface || (0, query_utils_1.queryAsCallerOrMethodSurvey)(query);
4095
+ const skipFlow = typeSurface || (0, query_utils_1.queryAsCallerOrMethodSurvey)(query)
4096
+ || (0, query_utils_1.queryAsDeclarationSiteSurvey)(query)
4097
+ || (0, query_utils_1.queryAsInRepoSystemCapabilityHowto)(query)
4098
+ || (0, query_utils_1.queryAsReturnValueConsumerSurvey)(query);
3236
4099
  const flow = skipFlow
3237
4100
  ? { pathNodeIds: new Set(), text: '' }
3238
4101
  : this.buildFlowFromNamedSymbols(cg, query);
3239
4102
  const hasFlowPath = flow.pathNodeIds.size > 0;
3240
4103
  const inheritanceSection = !hasFlowPath && !multiAnchor
3241
4104
  ? this.buildInheritanceSurveySection(cg, query) : '';
3242
- const callerSection = !hasFlowPath && !multiAnchor && (0, query_utils_1.shouldBuildCallerInventory)(query)
3243
- ? this.buildCallerListingSection(cg, query) : '';
4105
+ const listedTypeMethods = (0, query_utils_1.extractListedTypeMethodsFromQuery)(query).length > 0;
4106
+ // Type + listed methods (BinaryGrid Set/Test/Fill) must keep caller inventory even
4107
+ // when multi-anchor would otherwise skip it into a fat source dump.
4108
+ const callerSection = !hasFlowPath && (!multiAnchor || listedTypeMethods) && (0, query_utils_1.shouldBuildCallerInventory)(query)
4109
+ ? this.buildCallerListingSection(cg, query, projectRoot) : '';
4110
+ // Field new/delete inventories already list text sites — skip redundant member scan.
3244
4111
  const memberSection = !hasFlowPath && !multiAnchor && (0, query_utils_1.shouldBuildMemberSurvey)(query)
4112
+ && !((0, query_utils_1.queryAsFieldUsageSurvey)(query) && apiUsageResult.fileCount > 0)
3245
4113
  ? this.buildMemberSurveySection(cg, query, projectRoot) : '';
3246
4114
  const configSection = (0, query_utils_1.shouldBuildConfigSection)(query)
3247
4115
  ? this.buildConfigFileSection(cg, query, projectRoot) : '';
@@ -3267,10 +4135,60 @@ class ToolHandler {
3267
4135
  ? inheritanceSection.split('\n').filter((l) => l.startsWith('- `')).length > 0
3268
4136
  : false;
3269
4137
  const hasAnySection = importResult.section || kitUsageResult.section || domainFileResult.section
3270
- || apiUsageResult.section || dataSourceResult.section || inheritanceSection
3271
- || callerSection || memberSection || configSection;
4138
+ || apiUsageResult.section || dataSourceResult.section || hoverResult.section || inheritanceSection
4139
+ || callerSection || memberSection || configSection
4140
+ || systemCapResult.section || declarationResult.section || moduleDepResult.section
4141
+ || moduleExportResult.section || dtsWrapResult.section || controlSyncResult.section;
3272
4142
  if (!hasAnySection)
3273
4143
  return null;
4144
+ // Data-source / API usage inventories are complete without source dumps —
4145
+ // stop here so agents do not also get a fat import list + follow-up Read.
4146
+ if (systemCapResult.hitCount > 0) {
4147
+ return finishCompact(`System-capability howto — **${systemCapResult.hitCount}** in-repo site(s). **ANSWER NOW** from \`@ohos\`/\`System\` call sites above.`);
4148
+ }
4149
+ // Declaration intent must NOT fall through to a fat explore / include dump when empty.
4150
+ if (declarationResult.attempted) {
4151
+ return finishCompact(declarationResult.hitCount > 0
4152
+ ? `Declaration-site survey — **${declarationResult.hitCount}** site(s). **ANSWER NOW** from ids + bindings above; do not Grep the same Type.`
4153
+ : 'Declaration-site survey complete (0 sites). **ANSWER NOW** from the note above.');
4154
+ }
4155
+ if (controlSyncResult.section) {
4156
+ return finishCompact(`Control/Toggle state-sync survey — **${controlSyncResult.hitCount}** file(s). **ANSWER NOW** from Manager/Service sites above.`);
4157
+ }
4158
+ if (kitUsageResult.section && (0, query_utils_1.shouldBuildKitModuleUsageSurvey)(query)) {
4159
+ return finishCompact((0, query_utils_1.queryAsksKitInstallDeps)(query)
4160
+ ? 'Kit install/deps survey — **ANSWER NOW** from imports + oh-package lines above; do not Grep oh-package again.'
4161
+ : `Kit module **in-repo usage** — **${kitUsageResult.symbolCount}** imported symbol(s). **ANSWER NOW**; do not Grep the same \`@kit\` path.`);
4162
+ }
4163
+ if (moduleExportResult.section) {
4164
+ return finishCompact(`NAPI / native export survey — **${moduleExportResult.hitCount}** export(s). **ANSWER NOW**; do not Grep napi_define again.`);
4165
+ }
4166
+ if (dtsWrapResult.section) {
4167
+ return finishCompact(`\`.d.ts\` wrap call sites — **${dtsWrapResult.hitCount}** file(s). **ANSWER NOW**; do not Grep the wrap basename again.`);
4168
+ }
4169
+ if (moduleDepResult.hitCount > 0 || ((0, query_utils_1.queryAsModuleDependencySurvey)(query) && moduleDepResult.section)) {
4170
+ return finishCompact(`Module dependency / cycle survey — **ANSWER NOW** from the edges above; do not glob every oh-package.`);
4171
+ }
4172
+ // Data-source intent must stop even when edges=0 (empty still beats a 20k dump).
4173
+ if (dataSourceResult.section && (0, query_utils_1.queryAsDataSourceSurvey)(query)) {
4174
+ return finishCompact(dataSourceResult.edgeCount > 0
4175
+ ? `Data-source survey — **${dataSourceResult.edgeCount}** upstream symbol(s). **ANSWER NOW** from system \`@ohos\`/\`@kit\` imports first.`
4176
+ : 'Data-source survey complete (0 service edges). **ANSWER NOW** from the note + Manager defs above; do not Grep broadly.');
4177
+ }
4178
+ if (apiUsageResult.fileCount > 0 && !dataSourceResult.section) {
4179
+ return finishCompact(`API usage survey — **${apiUsageResult.fileCount}** file(s). **ANSWER NOW** from the list above.`);
4180
+ }
4181
+ if ((0, query_utils_1.queryAsReturnValueConsumerSurvey)(query) && callerBulletCount > 0) {
4182
+ return finishCompact(`Return-value consumers — **${callerBulletCount}** caller site(s). **ANSWER NOW** from the caller inventory; do not Grep the same member.`);
4183
+ }
4184
+ // Return-value intent with empty graph callers — stop before SDK .d.ts dumps.
4185
+ if ((0, query_utils_1.queryAsReturnValueConsumerSurvey)(query) && callerSection) {
4186
+ return finishCompact('Return-value consumer survey complete. **ANSWER NOW** from the inventory above; do not open SDK `.d.ts` stubs.');
4187
+ }
4188
+ // Type + listed methods (Set/Test/Fill) — multi-anchor would block omit-source; stop here.
4189
+ if (listedTypeMethods && callerBulletCount > 0) {
4190
+ return finishCompact(`Caller inventory — **${callerBulletCount}** site(s) for listed methods. **ANSWER NOW**; do not fan out callers per method.`);
4191
+ }
3274
4192
  const omitSource = (0, query_utils_1.shouldOmitSourceBodies)({
3275
4193
  importSiteCount: importResult.siteCount,
3276
4194
  hasFilteredImports: importInventoryFilter && importResult.siteCount > 0,
@@ -3282,36 +4200,41 @@ class ToolHandler {
3282
4200
  inheritanceListed,
3283
4201
  domainFileCount: domainFileResult.fileCount,
3284
4202
  dataSourceEdgeCount: dataSourceResult.edgeCount,
3285
- }, hasFlowPath, multiAnchor);
4203
+ }, hasFlowPath, multiAnchor) || hoverResult.hitCount > 0;
3286
4204
  if (!omitSource)
3287
4205
  return null;
3288
4206
  if (configSection)
3289
- return finishCompact('Config/manifest content above — answer from it directly.');
4207
+ return finishCompact('Config/manifest content above — answer from it directly. **ANSWER NOW.**');
4208
+ if (hoverResult.section) {
4209
+ return finishCompact(`Hover/pointer handler survey — **${hoverResult.hitCount}** site(s). **ANSWER NOW** from the list + snippets; do not Grep \`onHover\` again.`);
4210
+ }
3290
4211
  if (kitUsageResult.section) {
3291
- return finishCompact(`Kit module usage survey — **${kitUsageResult.symbolCount}** imported symbol(s) across the repo.`);
4212
+ return finishCompact(`Kit module **in-repo usage** survey — **${kitUsageResult.symbolCount}** imported symbol(s). ` +
4213
+ 'This is not an SDK catalog. **ANSWER NOW** from the usage list; do not Grep the same `@kit` path.');
3292
4214
  }
3293
4215
  if (domainFileResult.fileCount > 0) {
3294
4216
  return finishCompact(`Domain file survey — **${domainFileResult.fileCount}** related file(s) listed above. ` +
3295
- 'This is the exhaustive related-file inventory; no glob/search needed.');
4217
+ 'This is the exhaustive related-file inventory; **ANSWER NOW** — no glob/search needed.');
3296
4218
  }
3297
4219
  if (apiUsageResult.fileCount > 0) {
3298
- return finishCompact(`API usage survey — **${apiUsageResult.fileCount}** file(s) referencing the queried symbol(s).`);
4220
+ return finishCompact(`API usage survey — **${apiUsageResult.fileCount}** file(s). **ANSWER NOW** from the list above.`);
3299
4221
  }
3300
4222
  if (dataSourceResult.edgeCount > 0) {
3301
- return finishCompact(`Data-source survey — **${dataSourceResult.edgeCount}** upstream symbol(s) listed above.`);
4223
+ return finishCompact(`Data-source survey — **${dataSourceResult.edgeCount}** upstream symbol(s). **ANSWER NOW.**`);
4224
+ }
4225
+ if (inheritanceListed) {
4226
+ return finishCompact('Inheritance survey above lists all direct subtypes found. **ANSWER NOW** — do not grep `extends`.');
3302
4227
  }
3303
- if (inheritanceListed)
3304
- return finishCompact('Inheritance survey above lists all direct subtypes found.');
3305
4228
  if (importResult.compactListing) {
3306
- return finishCompact(`Listed **${importResult.siteCount}** import site(s) for the queried symbol(s).`);
4229
+ return finishCompact(`Listed **${importResult.siteCount}** import site(s). **ANSWER NOW** from the dependency list.`);
3307
4230
  }
3308
4231
  if (callerBulletCount >= 1) {
3309
- return finishCompact(`Caller inventory lists **${callerBulletCount}** call site(s) answer from the section above.`);
4232
+ return finishCompact(`Caller inventory lists **${callerBulletCount}** call site(s). **ANSWER NOW** from the section above.`);
3310
4233
  }
3311
4234
  if (memberFileCount >= 2) {
3312
- return finishCompact(`Member/pattern usage in **${memberFileCount}** file(s) answer from the inventory above.`);
4235
+ return finishCompact(`Member/pattern usage in **${memberFileCount}** file(s). **ANSWER NOW** from the inventory above.`);
3313
4236
  }
3314
- return finishCompact('Inventory sections above are complete for this query.');
4237
+ return finishCompact('Inventory sections above are complete for this query. **ANSWER NOW.**');
3315
4238
  }
3316
4239
  /**
3317
4240
  * Render a compact symbol-bounded slice of one file (lightweight mechanism path).
@@ -3359,6 +4282,8 @@ class ToolHandler {
3359
4282
  const fileNodes = new Map();
3360
4283
  const seedIds = new Set();
3361
4284
  const addNode = (n) => {
4285
+ if ((0, arkts_1.isOhosApiFilePath)(n.filePath))
4286
+ return;
3362
4287
  if (isTestPath(n.filePath))
3363
4288
  return;
3364
4289
  if (n.kind !== 'import' && !STRUCTURE_KINDS.has(n.kind))
@@ -3369,6 +4294,39 @@ class ToolHandler {
3369
4294
  list.push(n);
3370
4295
  fileNodes.set(n.filePath, list);
3371
4296
  };
4297
+ const domainTermsEarly = (0, query_utils_1.extractDomainSearchTerms)(query);
4298
+ const distinctiveEarly = domainTermsEarly.filter((t) => /^[\x00-\x7F]+$/.test(t) && !query_utils_1.GENERIC_VERB_ANCHOR_NOISE.has(t.toLowerCase()));
4299
+ // Distinctive-token IMPORT seeds first — `@ohos.convertxml` / `XmlParse*` before
4300
+ // structure FTS fills the budget with unrelated Parser* hits.
4301
+ for (const term of distinctiveEarly.slice(0, 4)) {
4302
+ const termLc = term.toLowerCase();
4303
+ let hits = [];
4304
+ try {
4305
+ hits = cg.searchNodes(term, { kinds: ['import'], limit: 40 });
4306
+ }
4307
+ catch {
4308
+ continue;
4309
+ }
4310
+ for (const r of hits) {
4311
+ if ((0, arkts_1.isOhosApiFilePath)(r.node.filePath))
4312
+ continue;
4313
+ const line = (0, query_utils_1.resolveImportLineFromNode)(r.node, projectRoot).toLowerCase();
4314
+ const nameLc = r.node.name.toLowerCase();
4315
+ const sigLc = (r.node.signature || '').toLowerCase();
4316
+ if (!nameLc.includes(termLc) && !line.includes(termLc) && !sigLc.includes(termLc))
4317
+ continue;
4318
+ addNode(r.node);
4319
+ try {
4320
+ for (const n of cg.getNodesInFile(r.node.filePath)) {
4321
+ if (STRUCTURE_KINDS.has(n.kind) && n.name.toLowerCase().includes(termLc))
4322
+ addNode(n);
4323
+ }
4324
+ }
4325
+ catch { /* optional */ }
4326
+ if (seedIds.size >= 16)
4327
+ break;
4328
+ }
4329
+ }
3372
4330
  for (const seed of (0, query_utils_1.extractMechanismEntrySeeds)(query)) {
3373
4331
  if (seed.startsWith('@')) {
3374
4332
  let hits = [];
@@ -3387,35 +4345,91 @@ class ToolHandler {
3387
4345
  addNode(n);
3388
4346
  }
3389
4347
  }
3390
- if (seedIds.size < 2) {
3391
- const domainTerms = (0, query_utils_1.extractDomainSearchTerms)(query);
3392
- const asciiTerms = domainTerms.filter((t) => /^[\x00-\x7F]+$/.test(t));
3393
- for (const term of asciiTerms.slice(0, 8)) {
4348
+ if (seedIds.size < 2 || distinctiveEarly.length > 0) {
4349
+ const domainTerms = domainTermsEarly;
4350
+ // Prefer distinctive ASCII (xml) then Chinese nouns — never seed on bare verbs
4351
+ // when a distinctive token exists (xml parse convertxml, not ParseNotification*).
4352
+ const distinctive = distinctiveEarly;
4353
+ const ranked = distinctive.length > 0
4354
+ ? [
4355
+ ...distinctive,
4356
+ ...domainTerms.filter((t) => !/^[\x00-\x7F]+$/.test(t) && t.length >= 2 && t.length <= 8),
4357
+ ]
4358
+ : [
4359
+ ...domainTerms.filter((t) => /^[\x00-\x7F]+$/.test(t) && !query_utils_1.GENERIC_VERB_ANCHOR_NOISE.has(t.toLowerCase())),
4360
+ ...domainTerms.filter((t) => !/^[\x00-\x7F]+$/.test(t) && t.length >= 2 && t.length <= 8),
4361
+ ...domainTerms.filter((t) => /^[\x00-\x7F]+$/.test(t) && query_utils_1.GENERIC_VERB_ANCHOR_NOISE.has(t.toLowerCase())),
4362
+ ];
4363
+ for (const term of ranked.slice(0, 10)) {
3394
4364
  let hits = [];
3395
4365
  try {
3396
4366
  hits = cg.searchNodes(term, {
3397
4367
  kinds: ['class', 'struct', 'interface', 'function', 'method', 'import'],
3398
- limit: 16,
4368
+ limit: 20,
3399
4369
  });
3400
4370
  }
3401
4371
  catch {
3402
4372
  continue;
3403
4373
  }
4374
+ const termIsVerb = query_utils_1.GENERIC_VERB_ANCHOR_NOISE.has(term.toLowerCase());
4375
+ const termLc = term.toLowerCase();
3404
4376
  for (const r of hits) {
3405
- // English FTS expansions from Chinese concepts need not be *Manager.
3406
- const nameHit = new RegExp(term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i').test(r.node.name);
3407
- const entryOk = (0, query_utils_1.isImplementationEntrySymbol)(r.node.name, domainTerms)
3408
- || r.node.kind === 'import'
3409
- || ((r.node.kind === 'function' || r.node.kind === 'method') && nameHit);
4377
+ if ((0, arkts_1.isOhosApiFilePath)(r.node.filePath))
4378
+ continue;
4379
+ const nameLc = r.node.name.toLowerCase();
4380
+ const pathLc = r.node.filePath.toLowerCase();
4381
+ const sigLc = (r.node.signature || '').toLowerCase();
4382
+ const nameHit = nameLc.includes(termLc);
4383
+ const pathHit = pathLc.includes(termLc);
4384
+ const sigHit = sigLc.includes(termLc);
4385
+ // Verb-only hits need path affinity; imports for verbs are dropped when
4386
+ // distinctive tokens exist (handled by ranked list above).
4387
+ if (termIsVerb && !pathHit)
4388
+ continue;
4389
+ // Distinctive token: require name/path/signature to actually mention it
4390
+ // (drops FTS neighbors that only share an unrelated Parser suffix).
4391
+ if (!termIsVerb && distinctive.length > 0 && !nameHit && !pathHit && !sigHit)
4392
+ continue;
4393
+ const entryOk = r.node.kind === 'import'
4394
+ || ((r.node.kind === 'function' || r.node.kind === 'method' || r.node.kind === 'class'
4395
+ || r.node.kind === 'interface' || r.node.kind === 'struct')
4396
+ && (nameHit || pathHit));
3410
4397
  if (!entryOk)
3411
4398
  continue;
3412
4399
  addNode(r.node);
3413
- if (seedIds.size >= 10)
4400
+ if (seedIds.size >= 16)
3414
4401
  break;
3415
4402
  }
3416
- if (seedIds.size >= 10)
4403
+ if (seedIds.size >= 12)
3417
4404
  break;
3418
4405
  }
4406
+ // Prefer seeds that mention a distinctive token when we have any.
4407
+ if (distinctive.length > 0 && seedIds.size > 0) {
4408
+ const keep = new Set();
4409
+ for (const id of seedIds) {
4410
+ const n = cg.getNode(id);
4411
+ if (!n)
4412
+ continue;
4413
+ const line = n.kind === 'import' ? (0, query_utils_1.resolveImportLineFromNode)(n, projectRoot) : '';
4414
+ const blob = `${n.name}\n${n.filePath}\n${n.signature || ''}\n${line}`.toLowerCase();
4415
+ if (distinctive.some((t) => blob.includes(t.toLowerCase())))
4416
+ keep.add(id);
4417
+ }
4418
+ if (keep.size > 0) {
4419
+ for (const id of [...seedIds]) {
4420
+ if (!keep.has(id)) {
4421
+ seedIds.delete(id);
4422
+ }
4423
+ }
4424
+ for (const [fp, nodes] of [...fileNodes.entries()]) {
4425
+ const filtered = nodes.filter((n) => keep.has(n.id));
4426
+ if (filtered.length === 0)
4427
+ fileNodes.delete(fp);
4428
+ else
4429
+ fileNodes.set(fp, filtered);
4430
+ }
4431
+ }
4432
+ }
3419
4433
  }
3420
4434
  if (seedIds.size === 0)
3421
4435
  return null;
@@ -3424,18 +4438,109 @@ class ToolHandler {
3424
4438
  const lines = [
3425
4439
  `**Exploration: ${query}**`,
3426
4440
  '',
4441
+ '> **ANSWER NOW from this response.** Do **not** Grep or Read listed files in parallel — Source below is authoritative.',
4442
+ '',
3427
4443
  `Mechanism anchors: **${seedIds.size}** symbol(s) — lightweight explore (seed + flow spine).`,
3428
4444
  '',
3429
4445
  ];
3430
- const importResult = this.buildImportSitesSection(cg, query, projectRoot);
3431
- if (importResult.section)
3432
- lines.push(importResult.section);
4446
+ // Import inventory: prefer concrete API seeds (convertxml / XmlParseUtil) over
4447
+ // bare domain tokens like `xml` that flood every path containing the substring.
4448
+ const seedImportFocus = (0, query_utils_1.extractMechanismEntrySeeds)(query).filter((s) => !query_utils_1.GENERIC_VERB_ANCHOR_NOISE.has(s.toLowerCase())
4449
+ && (s.length >= 6 || /convert|xml|parse|manager|service/i.test(s)));
4450
+ const distinctiveDeps = (0, query_utils_1.extractDependencySymbolsFromQuery)(query)
4451
+ .filter((s) => !query_utils_1.GENERIC_VERB_ANCHOR_NOISE.has(s.toLowerCase()));
4452
+ const importQuery = (seedImportFocus.length > 0
4453
+ ? seedImportFocus
4454
+ : distinctiveDeps.length > 0
4455
+ ? distinctiveDeps
4456
+ : (0, query_utils_1.extractDomainSearchTerms)(query)
4457
+ .filter((t) => /^[\x00-\x7F]+$/.test(t) && !query_utils_1.GENERIC_VERB_ANCHOR_NOISE.has(t.toLowerCase()))).slice(0, 4).join(' ');
4458
+ const importResult = importQuery
4459
+ ? this.buildImportSitesSection(cg, importQuery, projectRoot)
4460
+ : { section: '', siteCount: 0, compactListing: false };
4461
+ // Light path: keep ≤8 import bullets (full inventory belongs to kit/API surveys).
4462
+ if (importResult.section) {
4463
+ const trimmed = this.trimLightImportSection(importResult.section, 8);
4464
+ if (trimmed)
4465
+ lines.push(trimmed);
4466
+ }
3433
4467
  if (flow.text)
3434
4468
  lines.push(flow.text);
4469
+ // Shared-lib + GLES/EGL thread: surface CMake link lines before bodies.
4470
+ if ((0, query_utils_1.queryAsNativeRenderThreadSurvey)(query)) {
4471
+ const cmakeHits = [];
4472
+ try {
4473
+ for (const f of cg.getFiles()) {
4474
+ const fp = f.path.replace(/\\/g, '/');
4475
+ if (!/CMakeLists\.txt$/i.test(fp))
4476
+ continue;
4477
+ if (!/foldeffect|effectrender|render|egl/i.test(fp) && (0, query_utils_1.extractPathSegmentsFromQuery)(query).length > 0) {
4478
+ const segs = (0, query_utils_1.extractPathSegmentsFromQuery)(query);
4479
+ if (!segs.some((s) => fp.toLowerCase().includes(s.toLowerCase().split('/').pop() || s))) {
4480
+ // still allow any CMake with GLESv3/EGL
4481
+ }
4482
+ }
4483
+ const abs = (0, utils_1.validatePathWithinRoot)(projectRoot, f.path);
4484
+ if (!abs)
4485
+ continue;
4486
+ let content = '';
4487
+ try {
4488
+ content = (0, fs_1.readFileSync)(abs, 'utf-8');
4489
+ }
4490
+ catch {
4491
+ continue;
4492
+ }
4493
+ if (!/GLES|EGL|OpenGL/i.test(content))
4494
+ continue;
4495
+ const fileLines = content.split('\n');
4496
+ for (let i = 0; i < fileLines.length; i++) {
4497
+ const line = fileLines[i] ?? '';
4498
+ if (/GLES|EGL|OpenGL|target_link|pthread|thread/i.test(line)) {
4499
+ cmakeHits.push(`- \`${fp}:${i + 1}\` \`${line.trim().slice(0, 100)}\``);
4500
+ }
4501
+ }
4502
+ if (cmakeHits.length >= 12)
4503
+ break;
4504
+ }
4505
+ }
4506
+ catch { /* optional */ }
4507
+ if (cmakeHits.length > 0) {
4508
+ lines.push('**CMake / link + GL libs**', '');
4509
+ lines.push(...cmakeHits.slice(0, 12));
4510
+ lines.push('');
4511
+ }
4512
+ }
4513
+ const distinctiveForScore = (0, query_utils_1.extractDomainSearchTerms)(query).filter((t) => /^[\x00-\x7F]+$/.test(t) && !query_utils_1.GENERIC_VERB_ANCHOR_NOISE.has(t.toLowerCase()));
4514
+ const apiSeeds = (0, query_utils_1.extractMechanismEntrySeeds)(query).map((s) => s.toLowerCase());
3435
4515
  const fileScores = new Map();
3436
4516
  for (const [fp, nodes] of fileNodes) {
4517
+ if ((0, arkts_1.isOhosApiFilePath)(fp))
4518
+ continue;
3437
4519
  let score = 0;
4520
+ const fpLc = fp.toLowerCase();
4521
+ if (distinctiveForScore.some((t) => fpLc.includes(t.toLowerCase())))
4522
+ score += 30;
4523
+ // Prefer real wrappers (XmlParseUtil / convertxml) over incidental Xml* paths.
4524
+ if (apiSeeds.some((s) => s.length >= 6 && fpLc.includes(s)))
4525
+ score += 40;
4526
+ if (/convertxml|xmlparseutil/i.test(fpLc))
4527
+ score += 50;
3438
4528
  for (const n of nodes) {
4529
+ const line = n.kind === 'import' ? (0, query_utils_1.resolveImportLineFromNode)(n, projectRoot) : '';
4530
+ const blob = `${n.name}\n${n.signature || ''}\n${line}`.toLowerCase();
4531
+ if (distinctiveForScore.some((t) => blob.includes(t.toLowerCase())))
4532
+ score += 20;
4533
+ if (apiSeeds.some((s) => s.length >= 6 && blob.includes(s)))
4534
+ score += 25;
4535
+ if (n.kind === 'import'
4536
+ && distinctiveForScore.some((t) => {
4537
+ const tLc = t.toLowerCase();
4538
+ return (line.includes(tLc) || blob.includes(tLc)) && (line.includes('@ohos.') || line.includes('@kit.'));
4539
+ })) {
4540
+ score += 20;
4541
+ }
4542
+ if (/convertxml|@kit\.|@ohos\./i.test(line))
4543
+ score += 30;
3439
4544
  if (flow.pathNodeIds.has(n.id))
3440
4545
  score += 20;
3441
4546
  else if (flow.uniqueNamedNodeIds.has(n.id))
@@ -3448,17 +4553,17 @@ class ToolHandler {
3448
4553
  const sortedFiles = [...fileScores.entries()]
3449
4554
  .filter(([, s]) => s > 0)
3450
4555
  .sort((a, b) => b[1] - a[1])
3451
- .slice(0, 4);
4556
+ .slice(0, 3);
3452
4557
  lines.push('**Source Code**', '');
3453
- lines.push('> Line-numbered source below — treat as already Read. Do not grep/read these files again; ' +
3454
- 'another `homegraph_explore` with more symbol names is cheaper if you need more.');
4558
+ lines.push('> Line-numbered source below — treat as already Read. Do **not** grep/read/`homegraph_node` these files again.');
3455
4559
  lines.push('');
3456
4560
  let totalChars = lines.join('\n').length;
3457
4561
  let filesRendered = 0;
3458
4562
  for (const [fp] of sortedFiles) {
3459
- if (filesRendered >= 4 || totalChars > 14_000)
4563
+ // Token budget: light howto must stay ~3–5k so explore+answer can beat Grep/Read.
4564
+ if (filesRendered >= 2 || totalChars > 4_800)
3460
4565
  break;
3461
- const chunk = this.renderLightMechanismSource(projectRoot, fp, fileNodes.get(fp) ?? [], 4000);
4566
+ const chunk = this.renderLightMechanismSource(projectRoot, fp, fileNodes.get(fp) ?? [], 2000);
3462
4567
  if (!chunk)
3463
4568
  continue;
3464
4569
  lines.push(chunk);
@@ -3468,11 +4573,35 @@ class ToolHandler {
3468
4573
  if (filesRendered === 0 && !importResult.section && !flow.text)
3469
4574
  return null;
3470
4575
  lines.push('---');
3471
- lines.push('> **Mechanism explore complete** for anchored symbols answer from sections above. ' +
3472
- 'Retry explore with more names before grep/read (grep duplicates token cost).');
4576
+ lines.push('> **Mechanism explore complete ANSWER NOW.** Sections above cover the wired mechanism. ' +
4577
+ 'Do **not** call `homegraph_node`, Read, or Grep for symbols/files already shown (that multiplies tokens). ' +
4578
+ 'Only one tighter `homegraph_explore` if a **named** symbol essential to the answer is still missing.');
3473
4579
  lines.push('');
3474
4580
  return this.textResult(lines.join('\n'));
3475
4581
  }
4582
+ /** Cap import bullets inside a light-mechanism response. */
4583
+ trimLightImportSection(section, maxBullets) {
4584
+ const lines = section.split('\n');
4585
+ const out = [];
4586
+ let bullets = 0;
4587
+ let truncated = false;
4588
+ for (const line of lines) {
4589
+ if (line.startsWith('- `')) {
4590
+ if (bullets >= maxBullets) {
4591
+ truncated = true;
4592
+ continue;
4593
+ }
4594
+ bullets++;
4595
+ }
4596
+ if (/^\s*- … and \d+ more/i.test(line))
4597
+ continue;
4598
+ out.push(line);
4599
+ }
4600
+ if (truncated) {
4601
+ out.push(`- … (light-mechanism: showing ${maxBullets} sites — **ANSWER NOW**, do not Grep the same import)`);
4602
+ }
4603
+ return out.join('\n');
4604
+ }
3476
4605
  /**
3477
4606
  * Compact explore for local-symbol behavior questions — skips findRelevantContext
3478
4607
  * and caps to 1–2 defining files (avoids the ~24K related-file dump).
@@ -3485,8 +4614,26 @@ class ToolHandler {
3485
4614
  // Multi-anchor flow bags (routeSave/onSave, aboutToAppear/build, thunk→thunk)
3486
4615
  // must fall through to full explore so Flow / Dynamic-dispatch / adaptive
3487
4616
  // sizing still surface — compact trail is not a substitute.
4617
+ // Mechanism / domain-mechanism bags belong to light-mechanism (or full explore),
4618
+ // never compact — agents rewrite "如何实现 xml 解析" to "xml parse" and would
4619
+ // otherwise seed every method named `parse`.
4620
+ // Light-mechanism owns domain howtos. Bare "how/如何" NL must NOT veto compact
4621
+ // when a local/flag/lifecycle shape already owns the query (flag impact was
4622
+ // falling through to a 20k Dynamic-dispatch dump).
4623
+ if ((0, query_utils_1.shouldTryLightMechanismExplore)(query))
4624
+ return null;
4625
+ if ((0, query_utils_1.queryAsMechanismSurvey)(query)
4626
+ && !(0, query_utils_1.queryAsLocalSymbolDetail)(query)
4627
+ && !(0, query_utils_1.queryAsAssignedFlagImpactSurvey)(query)
4628
+ && !(0, query_utils_1.queryAsTypeLifecycleSurvey)(query)) {
4629
+ return null;
4630
+ }
3488
4631
  const bareId = /^[A-Za-z_][\w]*$/.test(query.trim());
3489
- if (!(0, query_utils_1.queryAsLocalSymbolDetail)(query) && !bareId && !(0, query_utils_1.queryHasFocusedNamedAnchors)(query)) {
4632
+ if (!(0, query_utils_1.queryAsLocalSymbolDetail)(query)
4633
+ && !(0, query_utils_1.queryAsFocusedUiCluster)(query)
4634
+ && !(0, query_utils_1.queryAsComponentSurfaceSurvey)(query)
4635
+ && !bareId
4636
+ && !(0, query_utils_1.queryHasFocusedNamedAnchors)(query)) {
3490
4637
  return null;
3491
4638
  }
3492
4639
  const STRUCTURE_KINDS = new Set([
@@ -3496,17 +4643,28 @@ class ToolHandler {
3496
4643
  const names = (0, query_utils_1.extractLocalDetailAnchors)(query).slice(0, 4);
3497
4644
  if (names.length === 0)
3498
4645
  return null;
3499
- // Multi-anchor bags that form a Flow / Dynamic-dispatch section must use
3500
- // full explore (synth notes, adaptive sizing, boundary announcements).
3501
- // Compact trail alone loses those sections. Probe using the raw query
3502
- // extractLocalDetailAnchors can drop snake_case tokens (handle_save) that
3503
- // buildFlowFromNamedSymbols still resolves.
4646
+ // Multi-anchor bags that form a Flow / Dynamic-dispatch section normally fall
4647
+ // through to full explore. EXCEPT focused UI Type clusters / local-detail /
4648
+ // Type.member full explore is a 15–30k dump for a local composition question.
4649
+ const stayOnCompact = (0, query_utils_1.queryAsFocusedUiCluster)(query)
4650
+ || (0, query_utils_1.queryAsComponentSurfaceSurvey)(query)
4651
+ || (0, query_utils_1.queryAsNamedComponentAction)(query)
4652
+ || (0, query_utils_1.queryHasNamedMemberFocus)(query)
4653
+ || (0, query_utils_1.queryAsLocalSymbolDetail)(query);
3504
4654
  const rawTokens = query.split(/[\s,()[\]]+/).filter((t) => t.length >= 3 && /^[A-Za-z_][\w]*$/.test(t));
4655
+ // Multi-anchor: prefer full explore when Flow / Dynamic-dispatch has content.
4656
+ // Type.member / UI-cluster bags stay on compact UNLESS the flow surfaces
4657
+ // @Prop/@Link state transfers — those hops are the answer for decorator
4658
+ // dependency questions and must not be buried under a compact UI trail.
3505
4659
  if (names.length >= 2 || rawTokens.length >= 2) {
3506
4660
  try {
3507
4661
  const flow = this.buildFlowFromNamedSymbols(cg, query);
3508
- if (flow.text.length > 0)
3509
- return null;
4662
+ if (flow.text.length > 0) {
4663
+ if (!stayOnCompact)
4664
+ return null;
4665
+ if (/state: @(?:Prop|Link)\b/.test(flow.text))
4666
+ return null;
4667
+ }
3510
4668
  }
3511
4669
  catch {
3512
4670
  // Probe is best-effort — stay on compact if flow build fails.
@@ -3525,9 +4683,95 @@ class ToolHandler {
3525
4683
  list.push(n);
3526
4684
  fileNodes.set(n.filePath, list);
3527
4685
  };
4686
+ const typeNames = (0, query_utils_1.extractTypeNamesFromQuery)(query).filter((t) => !(0, query_utils_1.isFrameworkUiDecoratorName)(t));
4687
+ const typeNameSet = new Set(typeNames.map((t) => t.toLowerCase()));
4688
+ const bridge = (0, query_utils_1.queryNeedsCoNamedUseBridge)(query);
4689
+ const memberFocus = (0, query_utils_1.queryHasNamedMemberFocus)(query);
4690
+ const uiCluster = (0, query_utils_1.queryAsFocusedUiCluster)(query);
4691
+ const typeLifecycle = (0, query_utils_1.queryAsTypeLifecycleSurvey)(query);
4692
+ const componentSurface = (0, query_utils_1.queryAsComponentSurfaceSurvey)(query)
4693
+ || uiCluster
4694
+ || typeLifecycle
4695
+ || names.some(query_utils_1.queryLooksLikeUiComponentType);
4696
+ // Page/Dialog surface & UI clusters: trail only — pulling callee files ballooned
4697
+ // ThemeHome-style answers by 5–10k without adding the UI-child / nav answer.
4698
+ const surfaceTrailOnly = (componentSurface || uiCluster || typeLifecycle) && !memberFocus
4699
+ && !(0, query_utils_1.queryAsNamedComponentAction)(query);
4700
+ const bareMemberOnly = bareId && (0, query_utils_1.isMemberLikeIdentifier)(query.trim());
4701
+ const ownedByNamedType = (n) => {
4702
+ if (typeNameSet.size === 0)
4703
+ return true;
4704
+ const qn = (n.qualifiedName || '').toLowerCase();
4705
+ if ([...typeNameSet].some((t) => qn === t || qn.startsWith(`${t}.`) || qn.startsWith(`${t}::`) || qn.includes(`::${t}.`) || qn.endsWith(`::${t}`))) {
4706
+ return true;
4707
+ }
4708
+ // Same file as a named type definition.
4709
+ try {
4710
+ for (const t of typeNames) {
4711
+ for (const owner of cg.getNodesByName(t)) {
4712
+ if ((owner.kind === 'class' || owner.kind === 'struct' || owner.kind === 'interface' || owner.kind === 'component')
4713
+ && owner.filePath === n.filePath) {
4714
+ return true;
4715
+ }
4716
+ }
4717
+ }
4718
+ }
4719
+ catch { /* */ }
4720
+ return false;
4721
+ };
3528
4722
  for (const seed of names) {
3529
- for (const n of cg.getNodesByName(seed)) {
4723
+ const candidates = cg.getNodesByName(seed);
4724
+ const preferOwned = memberFocus
4725
+ && (0, query_utils_1.isMemberLikeIdentifier)(seed)
4726
+ && typeNameSet.size > 0;
4727
+ // Prefer in-repo implementations over SDK `.d.ts` / @ohos API stubs when both exist.
4728
+ const projectFirst = [...candidates].sort((a, b) => {
4729
+ const score = (n) => {
4730
+ let s = 0;
4731
+ if ((0, arkts_1.isOhosApiFilePath)(n.filePath) || /\.d\.ts$/i.test(n.filePath))
4732
+ s -= 50;
4733
+ if ((0, query_utils_1.isTestFile)(n.filePath))
4734
+ s -= 20;
4735
+ if (n.kind === 'class' || n.kind === 'struct' || n.kind === 'component')
4736
+ s += 5;
4737
+ return s;
4738
+ };
4739
+ return score(b) - score(a);
4740
+ });
4741
+ const filtered = preferOwned
4742
+ ? (() => {
4743
+ const owned = projectFirst.filter(ownedByNamedType);
4744
+ return owned.length > 0 ? owned : projectFirst;
4745
+ })()
4746
+ : projectFirst;
4747
+ for (const n of filtered) {
4748
+ // Skip SDK stubs when a project definition of the same name exists.
4749
+ if (((0, arkts_1.isOhosApiFilePath)(n.filePath) || /\.d\.ts$/i.test(n.filePath))
4750
+ && filtered.some((o) => o.name === n.name
4751
+ && !(0, arkts_1.isOhosApiFilePath)(o.filePath)
4752
+ && !/\.d\.ts$/i.test(o.filePath))) {
4753
+ continue;
4754
+ }
3530
4755
  addNode(n);
4756
+ // Page/Component surface digests need build/aboutToAppear as their own
4757
+ // nodes — otherwise we only have the outer struct and dump the whole body.
4758
+ if (componentSurface || uiCluster || typeLifecycle) {
4759
+ if (n.kind === 'component' || n.kind === 'class' || n.kind === 'struct') {
4760
+ try {
4761
+ for (const e of cg.getOutgoingEdges(n.id)) {
4762
+ if (e.kind !== 'contains')
4763
+ continue;
4764
+ const child = cg.getNode(e.target);
4765
+ if (child
4766
+ && (child.kind === 'method' || child.kind === 'function')
4767
+ && isUiSurfaceDigestMethod(child.name, uiCluster)) {
4768
+ addNode(child);
4769
+ }
4770
+ }
4771
+ }
4772
+ catch { /* */ }
4773
+ }
4774
+ }
3531
4775
  if (seedIds.size >= 12)
3532
4776
  break;
3533
4777
  }
@@ -3554,33 +4798,69 @@ class ToolHandler {
3554
4798
  const nearSeed = (fp) => seedPaths.some((sp) => sp === fp || pathAffinity(sp, fp));
3555
4799
  // Neighbor policy (agents often search("Foo") alone — do not dump 11 callees):
3556
4800
  // - bareId / caller-bridge: callers of PRIMARY name only, no callee fan-out
3557
- // - UI action / pinpoint local-detail: callers + callees
3558
- const bridge = (0, query_utils_1.queryNeedsCoNamedUseBridge)(query);
3559
- const callersOnly = bareId
4801
+ // - Type.member / UI action / pinpoint local-detail: callers + callees
4802
+ // - Type.member: expand on the *member* (isExpired), not only the Type, and
4803
+ // do not path-filter callers — UI marking often lives in another package.
4804
+ const callersOnly = surfaceTrailOnly
4805
+ || bareMemberOnly
4806
+ || (bareId && !componentSurface)
3560
4807
  || bridge
3561
- || ((0, query_utils_1.shouldBuildCallerInventory)(query) && !(0, query_utils_1.queryAsNamedComponentAction)(query));
4808
+ || ((0, query_utils_1.shouldBuildCallerInventory)(query) && !(0, query_utils_1.queryAsNamedComponentAction)(query) && !memberFocus && !componentSurface)
4809
+ // Conditional wiring ("失败时还会…") — method body + callers, not logInfo fan-out.
4810
+ || /失败时|还会被注册|还会被|still\s+(?:be\s+)?(?:register|call)|if\s+.+\s+fails?/i.test(query);
3562
4811
  const primaryName = names[0];
4812
+ const memberNames = new Set([
4813
+ ...(0, query_utils_1.extractMemberAccessFromQuery)(query).map((m) => m.member),
4814
+ ...names.filter((n) => (0, query_utils_1.isMemberLikeIdentifier)(n)),
4815
+ ]);
4816
+ // UI Type clusters: expand every named *Page/*Dialog seed — not only the first
4817
+ // token — so Page↔Dialog composition stays in one compact answer.
4818
+ const expandNameSet = new Set(uiCluster || componentSurface
4819
+ ? [...typeNames, primaryName]
4820
+ : [primaryName]);
3563
4821
  const expandIds = [...seedIds].filter((id) => {
3564
4822
  try {
3565
- return cg.getNode(id)?.name === primaryName;
4823
+ const n = cg.getNode(id);
4824
+ if (!n)
4825
+ return false;
4826
+ if (memberFocus && memberNames.size > 0)
4827
+ return memberNames.has(n.name);
4828
+ return expandNameSet.has(n.name);
3566
4829
  }
3567
4830
  catch {
3568
4831
  return false;
3569
4832
  }
3570
4833
  });
4834
+ // Fall back to primary type seeds if member name wasn't indexed as its own node.
4835
+ const expandIdsFinal = expandIds.length > 0
4836
+ ? expandIds
4837
+ : [...seedIds].filter((id) => {
4838
+ try {
4839
+ return cg.getNode(id)?.name === primaryName;
4840
+ }
4841
+ catch {
4842
+ return false;
4843
+ }
4844
+ });
3571
4845
  // Pull one-hop neighbors into the focus set so the answer is graph-complete
3572
4846
  // without dumping an import inventory for isExpired/onClick.
3573
4847
  const neighborIds = new Set();
3574
- for (const id of expandIds) {
4848
+ for (const id of expandIdsFinal) {
3575
4849
  let seedPath = '';
3576
4850
  try {
3577
4851
  seedPath = cg.getNode(id)?.filePath ?? '';
3578
4852
  }
3579
4853
  catch { /* */ }
3580
4854
  try {
3581
- for (const { node: c } of cg.getCallers(id).slice(0, callersOnly ? 8 : 10)) {
3582
- if (!nearSeed(c.filePath) && seedPath && !pathAffinity(seedPath, c.filePath))
4855
+ for (const { node: c } of cg.getCallers(id).slice(0, callersOnly ? 8 : 12)) {
4856
+ // Type.member / UI-marking: keep cross-package callers (path filter drops them).
4857
+ if (!memberFocus
4858
+ && !(0, query_utils_1.queryAsNamedComponentAction)(query)
4859
+ && !nearSeed(c.filePath)
4860
+ && seedPath
4861
+ && !pathAffinity(seedPath, c.filePath)) {
3583
4862
  continue;
4863
+ }
3584
4864
  if (!isTestPath(c.filePath) || (0, query_utils_1.queryAsTestOnlyInterpretation)(query)) {
3585
4865
  neighborIds.add(c.id);
3586
4866
  // List callers in the trail, but don't pull their whole files into Source
@@ -3591,8 +4871,16 @@ class ToolHandler {
3591
4871
  }
3592
4872
  if (!callersOnly) {
3593
4873
  for (const { node: c } of cg.getCallees(id).slice(0, 10)) {
3594
- if (!nearSeed(c.filePath) && seedPath && !pathAffinity(seedPath, c.filePath))
4874
+ // Skip log*/hilog helpers Export→logInfo homonyms ballooned seeds.
4875
+ if (/^log(?:Info|Error|Warn|Debug|Fatal)?$/i.test(c.name) || /^hilog$/i.test(c.name)) {
3595
4876
  continue;
4877
+ }
4878
+ if (!memberFocus
4879
+ && !nearSeed(c.filePath)
4880
+ && seedPath
4881
+ && !pathAffinity(seedPath, c.filePath)) {
4882
+ continue;
4883
+ }
3596
4884
  if (!isTestPath(c.filePath) || (0, query_utils_1.queryAsTestOnlyInterpretation)(query)) {
3597
4885
  neighborIds.add(c.id);
3598
4886
  addNode(c);
@@ -3607,6 +4895,8 @@ class ToolHandler {
3607
4895
  const lines = [
3608
4896
  `**Exploration: ${query}**`,
3609
4897
  '',
4898
+ '> **ANSWER NOW from this response.** Do **not** Grep/Read/search/explore/node/callers for the same symbols — trail + Source below are authoritative.',
4899
+ '',
3610
4900
  `Local-symbol focus: **${seedIds.size}** seed(s)` +
3611
4901
  (neighborIds.size > 0 ? `, **${neighborIds.size}** caller/callee neighbor(s)` : '') +
3612
4902
  ' — compact explore (definition + edge trail, no related-file dump).',
@@ -3637,7 +4927,7 @@ class ToolHandler {
3637
4927
  {
3638
4928
  const trail = ['**Call / use trail**', ''];
3639
4929
  let trailBullets = 0;
3640
- const trailIds = (callersOnly ? expandIds : [...seedIds]).slice(0, 6);
4930
+ const trailIds = (callersOnly ? expandIdsFinal : [...seedIds]).slice(0, 6);
3641
4931
  for (const id of trailIds) {
3642
4932
  let seedNode;
3643
4933
  try {
@@ -3651,7 +4941,7 @@ class ToolHandler {
3651
4941
  let callers = [];
3652
4942
  let callees = [];
3653
4943
  try {
3654
- callers = cg.getCallers(id).slice(0, 8);
4944
+ callers = cg.getCallers(id).slice(0, memberFocus ? 12 : 8);
3655
4945
  }
3656
4946
  catch { /* */ }
3657
4947
  if (!callersOnly) {
@@ -3660,8 +4950,10 @@ class ToolHandler {
3660
4950
  }
3661
4951
  catch { /* */ }
3662
4952
  }
3663
- callers = callers.filter((c) => nearSeed(c.node.filePath) || pathAffinity(seedNode.filePath, c.node.filePath));
3664
- callees = callees.filter((c) => nearSeed(c.node.filePath) || pathAffinity(seedNode.filePath, c.node.filePath));
4953
+ if (!memberFocus) {
4954
+ callers = callers.filter((c) => nearSeed(c.node.filePath) || pathAffinity(seedNode.filePath, c.node.filePath));
4955
+ callees = callees.filter((c) => nearSeed(c.node.filePath) || pathAffinity(seedNode.filePath, c.node.filePath));
4956
+ }
3665
4957
  if (callers.length === 0 && callees.length === 0)
3666
4958
  continue;
3667
4959
  trail.push(`- \`${seedNode.name}\` (${seedNode.kind}) — ${seedNode.filePath}:${seedNode.startLine}`);
@@ -3678,7 +4970,7 @@ class ToolHandler {
3678
4970
  }
3679
4971
  if (trailBullets > 0) {
3680
4972
  trail.push('');
3681
- trail.push('> Prefer answering from this trail + Source below. Do not grep/search the same symbols again.');
4973
+ trail.push('> **ANSWER NOW** from this trail + Source below. Do not grep/search/read the same symbols again.');
3682
4974
  trail.push('');
3683
4975
  lines.push(...trail);
3684
4976
  }
@@ -3689,15 +4981,20 @@ class ToolHandler {
3689
4981
  const textUsageHits = [];
3690
4982
  // Also scan when the agent asks upstream/registration — callee neighbors
3691
4983
  // alone do not show Export / OH_NativeXComponent wiring sites.
3692
- const needUsageScan = neighborIds.size === 0
3693
- || /上下游|上游|下游|注册|挂到|callback|upstream|downstream/i.test(query)
3694
- || names.every((n) => /(?:CB|Callback)$/i.test(n));
4984
+ const needUsageScan = !componentSurface
4985
+ && (neighborIds.size === 0
4986
+ || memberFocus
4987
+ || /上下游|上游|下游|注册|挂到|callback|upstream|downstream/i.test(query)
4988
+ || names.every((n) => /(?:CB|Callback)$/i.test(n)));
3695
4989
  if (needUsageScan) {
3696
4990
  // Scan ALL focused anchors (incl. PascalCase callables) — previously only
3697
4991
  // lowercase names were scanned, so OnSurfaceChangedCB registration sites
3698
4992
  // never surfaced and agents fell through to callers/grep/read.
3699
4993
  const typeSeeds = (0, query_utils_1.extractTypeNamesFromQuery)(query);
3700
- const scanNames = names.slice(0, 3);
4994
+ // Prefer member-like names for Type.member UI questions (isExpired over Type).
4995
+ const scanNames = memberFocus && memberNames.size > 0
4996
+ ? [...memberNames].slice(0, 3)
4997
+ : names.slice(0, 3);
3701
4998
  if (scanNames.length > 0) {
3702
4999
  try {
3703
5000
  const defLineKeys = new Set();
@@ -3707,57 +5004,90 @@ class ToolHandler {
3707
5004
  defLineKeys.add(`${n.filePath}:${n.startLine}`);
3708
5005
  }
3709
5006
  }
3710
- const defDirs = new Set([...fileNodes.keys()].map((fp) => fp.split('/').slice(0, 4).join('/')));
5007
+ const norm = (fp) => fp.replace(/\\/g, '/');
5008
+ const defDirs = new Set([...fileNodes.keys()].map((fp) => norm(fp).split('/').slice(0, 4).join('/')));
5009
+ // Large monorepos: never rely on getFiles() order + hard slice — Type.member
5010
+ // call sites (LauncherCardInfo.isExpired filters) live near the defining
5011
+ // package and must be scanned first or Grep wins.
5012
+ const rankScanFile = (fp) => {
5013
+ const n = norm(fp);
5014
+ let score = 0;
5015
+ if ([...defDirs].some((d) => n === d || n.startsWith(`${d}/`)))
5016
+ score += 100;
5017
+ if (typeSeeds.some((t) => n.toLowerCase().includes(t.toLowerCase())))
5018
+ score += 40;
5019
+ return score;
5020
+ };
3711
5021
  const files = cg.getFiles()
3712
5022
  .map((f) => f.path)
3713
- .filter((fp) => {
3714
- if (isTestPath(fp))
3715
- return false;
3716
- if (defDirs.size === 0)
3717
- return true;
3718
- return [...defDirs].some((d) => fp === d || fp.startsWith(`${d}/`));
3719
- })
3720
- .slice(0, 120);
3721
- for (const fp of files) {
3722
- if (textUsageHits.length >= 12)
3723
- break;
3724
- const absPath = (0, utils_1.validatePathWithinRoot)(projectRoot, fp);
3725
- if (!absPath || !(0, fs_1.existsSync)(absPath))
3726
- continue;
3727
- let content;
3728
- try {
3729
- content = (0, fs_1.readFileSync)(absPath, 'utf-8');
3730
- }
3731
- catch {
3732
- continue;
3733
- }
3734
- // Prefer files that also mention a co-named type when both present
3735
- // (skip for single-anchor upstream/registration scans).
3736
- if (typeSeeds.length > 1
3737
- && names.length > 1
3738
- && !typeSeeds.some((t) => content.includes(t))) {
3739
- continue;
3740
- }
3741
- const fileLines = content.split('\n');
3742
- for (const sym of scanNames) {
3743
- const escaped = sym.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
3744
- // Match call OR bare identifier use (callback registration tables).
3745
- const re = new RegExp(`\\b${escaped}\\b`);
3746
- for (let i = 0; i < fileLines.length; i++) {
3747
- const lineText = fileLines[i] ?? '';
3748
- if (!re.test(lineText))
3749
- continue;
3750
- if (defLineKeys.has(`${fp}:${i + 1}`))
3751
- continue;
3752
- textUsageHits.push({ filePath: fp, line: i + 1, text: lineText.trim(), symbol: sym });
3753
- if (!fileNodes.has(fp))
3754
- fileNodes.set(fp, []);
3755
- if (textUsageHits.length >= 12)
5023
+ .filter((fp) => !isTestPath(fp))
5024
+ .map((fp) => ({ fp, score: rankScanFile(fp) }))
5025
+ .sort((a, b) => b.score - a.score || a.fp.localeCompare(b.fp))
5026
+ .map((x) => x.fp);
5027
+ const hitCap = bareMemberOnly ? 6 : memberFocus ? 10 : 8;
5028
+ const scanFileList = (list, requireTypeMention) => {
5029
+ for (const fp of list) {
5030
+ if (textUsageHits.length >= hitCap)
5031
+ break;
5032
+ const absPath = (0, utils_1.validatePathWithinRoot)(projectRoot, fp);
5033
+ if (!absPath || !(0, fs_1.existsSync)(absPath))
5034
+ continue;
5035
+ let content;
5036
+ try {
5037
+ content = (0, fs_1.readFileSync)(absPath, 'utf-8');
5038
+ }
5039
+ catch {
5040
+ continue;
5041
+ }
5042
+ if (requireTypeMention
5043
+ && typeSeeds.length > 0
5044
+ && !typeSeeds.some((t) => content.includes(t))) {
5045
+ continue;
5046
+ }
5047
+ // Prefer files that also mention a co-named type when both present
5048
+ // (skip for single-anchor upstream/registration scans and Type.member).
5049
+ if (!memberFocus
5050
+ && typeSeeds.length > 1
5051
+ && names.length > 1
5052
+ && !typeSeeds.some((t) => content.includes(t))) {
5053
+ continue;
5054
+ }
5055
+ const fileLines = content.split('\n');
5056
+ for (const sym of scanNames) {
5057
+ const escaped = sym.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
5058
+ // Match call OR bare identifier use; prefer Type.member when typed.
5059
+ const typeAlt = typeSeeds.length > 0
5060
+ ? `(?:\\b(?:${typeSeeds.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')})\\.)?`
5061
+ : '';
5062
+ const re = new RegExp(`${typeAlt}\\b${escaped}\\b`);
5063
+ const defRe = new RegExp(`^\\s*(?:export\\s+|public\\s+|private\\s+|protected\\s+|static\\s+|async\\s+)*${escaped}\\s*\\(`);
5064
+ for (let i = 0; i < fileLines.length; i++) {
5065
+ const lineText = fileLines[i] ?? '';
5066
+ if (!re.test(lineText))
5067
+ continue;
5068
+ if (defLineKeys.has(`${fp}:${i + 1}`))
5069
+ continue;
5070
+ // Skip the method's own definition line (no Type. prefix / no call punctuation).
5071
+ if (defRe.test(lineText) && !lineText.includes(`.${sym}`))
5072
+ continue;
5073
+ textUsageHits.push({ filePath: fp, line: i + 1, text: lineText.trim(), symbol: sym });
5074
+ if (!fileNodes.has(fp))
5075
+ fileNodes.set(fp, []);
5076
+ if (textUsageHits.length >= hitCap)
5077
+ break;
5078
+ }
5079
+ if (textUsageHits.length >= hitCap)
3756
5080
  break;
3757
5081
  }
3758
- if (textUsageHits.length >= 12)
3759
- break;
3760
5082
  }
5083
+ };
5084
+ // Pass 1: same package / type-path affinity (score > 0), require Type mention when known.
5085
+ const nearFiles = files.filter((fp) => rankScanFile(fp) > 0).slice(0, memberFocus ? 80 : 40);
5086
+ scanFileList(nearFiles, memberFocus && typeSeeds.length > 0);
5087
+ // Pass 2: broader if still empty / thin (still bounded).
5088
+ if (textUsageHits.length < 3) {
5089
+ const rest = files.filter((fp) => rankScanFile(fp) === 0).slice(0, memberFocus ? 160 : 80);
5090
+ scanFileList(rest, false);
3761
5091
  }
3762
5092
  }
3763
5093
  catch {
@@ -3770,9 +5100,14 @@ class ToolHandler {
3770
5100
  lines.push(`- \`${h.symbol}\` — ${h.filePath}:${h.line} \`${h.text.slice(0, 120)}\``);
3771
5101
  }
3772
5102
  lines.push('');
3773
- lines.push('> These lines are the in-repo use sites. Answer from them + Source; do not grep the same names again.');
5103
+ lines.push(memberFocus
5104
+ ? '> These lines are the in-repo **call/filter sites** for the named member. **ANSWER NOW** from them + Source — do not Grep/`homegraph_callers` the same member.'
5105
+ : '> These lines are the in-repo use sites. Answer from them + Source; do not grep the same names again.');
3774
5106
  lines.push('');
3775
5107
  }
5108
+ else if (memberFocus) {
5109
+ lines.push('> No in-repo text call sites found for this member beyond its definition. Answer from Source; avoid a broad Grep unless needed.', '');
5110
+ }
3776
5111
  }
3777
5112
  // Also surface #include / import lines that make a co-named type visible
3778
5113
  // near the primary symbol's use sites (IntGrid ↔ SortWidgets shapes).
@@ -3827,39 +5162,72 @@ class ToolHandler {
3827
5162
  }
3828
5163
  }
3829
5164
  const callerSection = (0, query_utils_1.shouldBuildCallerInventory)(query)
3830
- ? this.buildCallerListingSection(cg, query)
5165
+ ? this.buildCallerListingSection(cg, query, projectRoot)
3831
5166
  : '';
3832
5167
  if (callerSection)
3833
5168
  lines.push(callerSection);
3834
5169
  // Prefer files whose nodes exactly match query names; then neighbor / usage files.
3835
5170
  // Primary named symbol (first anchor) always ranks first — secondary types like
3836
5171
  // IntGrid used to drown SortWidgets' defining .cpp via higher exact-count on the header.
5172
+ // UI clusters: co-occurrence of 2+ named Types beats a lonely homonym elsewhere.
3837
5173
  const nameSet = new Set(names);
3838
5174
  const usageFiles = new Set(textUsageHits.map((h) => h.filePath));
3839
- const maxFiles = ((0, query_utils_1.queryAsNamedComponentAction)(query) || (0, query_utils_1.queryHasNamedMemberFocus)(query) || usageFiles.size > 0 || bridge)
3840
- ? 4
3841
- : 2;
5175
+ const maxFiles = bareMemberOnly
5176
+ ? 2
5177
+ : memberFocus
5178
+ // Call/filter sites already listed above — dumping caller file bodies
5179
+ // (FormStack…) burns tokens and invites a follow-up Read of isExpired.
5180
+ ? 1
5181
+ : ((0, query_utils_1.queryAsNamedComponentAction)(query) || componentSurface || usageFiles.size > 0 || bridge)
5182
+ ? (uiCluster ? 2 : (componentSurface ? 2 : 3))
5183
+ : 2;
3842
5184
  const ranked = [...fileNodes.entries()]
5185
+ .filter(([, nodes]) => {
5186
+ if (!memberFocus || memberNames.size === 0)
5187
+ return true;
5188
+ return nodes.some((n) => memberNames.has(n.name) || typeNameSet.has(n.name.toLowerCase()));
5189
+ })
3843
5190
  .map(([fp, nodes]) => {
3844
5191
  const exact = nodes.filter((n) => nameSet.has(n.name)).length;
5192
+ const typeHits = typeNames.filter((t) => nodes.some((n) => n.name === t)).length;
5193
+ const clusterCoOccur = typeHits >= 2 ? 250 : typeHits === 1 ? 80 : 0;
3845
5194
  const primaryHit = nodes.some((n) => n.name === primaryName) ? 100 : 0;
3846
5195
  const neighborHit = nodes.filter((n) => neighborIds.has(n.id)).length;
3847
- const usageHit = usageFiles.has(fp) ? 15 : 0;
5196
+ const usageHit = usageFiles.has(fp) ? (memberFocus ? 60 : 15) : 0;
3848
5197
  return {
3849
5198
  fp,
3850
5199
  nodes,
3851
5200
  exact,
3852
- score: primaryHit + exact * 20 + neighborHit * 5 + usageHit + nodes.length,
5201
+ score: (uiCluster || componentSurface ? clusterCoOccur : 0)
5202
+ + primaryHit
5203
+ + exact * 20
5204
+ + neighborHit * 5
5205
+ + usageHit
5206
+ + nodes.length,
3853
5207
  };
3854
5208
  })
3855
5209
  .sort((a, b) => b.score - a.score || a.fp.localeCompare(b.fp))
3856
5210
  .slice(0, maxFiles);
3857
5211
  lines.push('**Source Code**', '');
3858
- lines.push('> Line-numbered source — treat as already Read. Answer from it; do not re-explore/search/grep the same symbols.');
5212
+ lines.push('> Line-numbered source — treat as already Read. **ANSWER NOW** do not Read/Grep/search/explore/node the same symbols again.');
3859
5213
  lines.push('');
3860
5214
  let totalChars = lines.join('\n').length;
3861
- const maxTotal = 9000;
3862
- const maxPerFile = 4000;
5215
+ // Lean ceilings: without-HG Grep/Read stacks were ~15k session tokens; a 10–14k
5216
+ // compact body + follow-up Read is what loses the token A/B.
5217
+ const maxTotal = bareMemberOnly
5218
+ ? 4500
5219
+ : componentSurface || uiCluster
5220
+ ? 7000
5221
+ : memberFocus && usageFiles.size > 0
5222
+ ? 6500
5223
+ : 5500;
5224
+ const maxPerFile = bareMemberOnly
5225
+ ? 2800
5226
+ : componentSurface || uiCluster
5227
+ ? 4500
5228
+ : memberFocus
5229
+ ? 2800
5230
+ : 3200;
3863
5231
  let rendered = 0;
3864
5232
  for (const { fp, nodes } of ranked) {
3865
5233
  if (rendered >= maxFiles || totalChars > maxTotal)
@@ -3883,23 +5251,114 @@ class ToolHandler {
3883
5251
  let start = 1;
3884
5252
  let end = Math.min(fileLines.length, 120);
3885
5253
  const usageLine = textUsageHits.find((h) => h.filePath === fp)?.line;
5254
+ const surfaceMethodsRaw = (componentSurface || uiCluster)
5255
+ ? focusNodes
5256
+ .filter((n) => (n.kind === 'method' || n.kind === 'function') && isUiSurfaceDigestMethod(n.name, uiCluster))
5257
+ .sort((a, b) => surfaceMethodPriority(a.name) - surfaceMethodPriority(b.name) || a.startLine - b.startLine)
5258
+ : [];
5259
+ // When build/PageMap answer the surface question, skip lifecycle — it ate
5260
+ // the budget and agents re-Read PageMap anyway.
5261
+ const hasBuildOrPageMap = surfaceMethodsRaw.some((n) => /^(build|PageMap)$/i.test(n.name));
5262
+ const surfaceMethods = hasBuildOrPageMap
5263
+ ? surfaceMethodsRaw.filter((n) => !/^(aboutToAppear|aboutToDisappear)$/i.test(n.name))
5264
+ : surfaceMethodsRaw;
5265
+ // Type.member: keep the member body, not the whole owning class dump.
5266
+ const memberOnlyNodes = memberFocus && memberNames.size > 0
5267
+ ? focusNodes.filter((n) => memberNames.has(n.name) && (n.kind === 'method' || n.kind === 'function' || n.kind === 'property' || n.kind === 'field'))
5268
+ : [];
3886
5269
  if (focusNodes.length > 0) {
3887
5270
  const named = focusNodes.filter((n) => nameSet.has(n.name));
3888
5271
  const use = named.length > 0 ? named : focusNodes.slice(0, 3);
3889
- start = Math.max(1, Math.min(...use.map((n) => n.startLine)) - 2);
3890
- end = Math.min(fileLines.length, Math.max(...use.map((n) => n.endLine)) + 2);
3891
- // Keep window bounded
3892
- if ((end - start + 1) * 40 > maxPerFile) {
5272
+ const surfaceNodes = componentSurface
5273
+ ? use.filter((n) => (n.kind === 'component' || n.kind === 'class' || n.kind === 'struct'
5274
+ || (0, query_utils_1.queryLooksLikeUiComponentType)(n.name))
5275
+ && (typeNameSet.size === 0 || typeNameSet.has(n.name.toLowerCase()) || nameSet.has(n.name)))
5276
+ : [];
5277
+ if (surfaceNodes.length === 0 && componentSurface) {
5278
+ const one = use.find((n) => n.kind === 'component' || n.kind === 'class' || n.kind === 'struct'
5279
+ || (0, query_utils_1.queryLooksLikeUiComponentType)(n.name)) ?? use[0];
5280
+ if (one)
5281
+ surfaceNodes.push(one);
5282
+ }
5283
+ if (memberOnlyNodes.length > 0) {
5284
+ start = Math.max(1, Math.min(...memberOnlyNodes.map((n) => n.startLine)) - 2);
5285
+ end = Math.min(fileLines.length, Math.max(...memberOnlyNodes.map((n) => n.endLine)) + 2);
5286
+ }
5287
+ else if (surfaceMethods.length > 0) {
5288
+ // Per-method chunks below — placeholder window unused.
5289
+ start = 1;
5290
+ end = 1;
5291
+ }
5292
+ else if (surfaceNodes.length > 0 && surfaceNodes.some((n) => n.endLine > n.startLine)) {
5293
+ start = Math.max(1, Math.min(...surfaceNodes.map((n) => n.startLine)) - 2);
5294
+ end = Math.min(fileLines.length, Math.max(...surfaceNodes.map((n) => n.endLine)) + 2);
5295
+ }
5296
+ else {
5297
+ start = Math.max(1, Math.min(...use.map((n) => n.startLine)) - 2);
5298
+ end = Math.min(fileLines.length, Math.max(...use.map((n) => n.endLine)) + 2);
5299
+ }
5300
+ // Keep window bounded (non-surface paths)
5301
+ if (surfaceMethods.length === 0 && (end - start + 1) * 40 > maxPerFile) {
3893
5302
  end = Math.min(fileLines.length, start + Math.floor(maxPerFile / 40) - 1);
3894
5303
  }
3895
5304
  }
3896
5305
  else if (usageLine) {
3897
- start = Math.max(1, usageLine - 15);
3898
- end = Math.min(fileLines.length, usageLine + 25);
5306
+ start = Math.max(1, usageLine - 10);
5307
+ end = Math.min(fileLines.length, usageLine + 18);
3899
5308
  }
3900
5309
  const header = fileSectionHeader(fp, focusNodes.map((n) => `${n.name}(${n.kind})`).slice(0, 6).join(', '));
3901
5310
  const withLineNumbers = exploreLineNumbersEnabled();
3902
5311
  const bodyLines = [header, '```' + (nodes[0]?.language || ''), ''];
5312
+ if (surfaceMethods.length > 0) {
5313
+ // Do NOT span aboutToAppear→build as one window — ThemeHome-sized pages
5314
+ // always trim before PageMap/build. Emit imports + each method separately,
5315
+ // prioritizing build/PageMap/preview.
5316
+ let lastImportLine = 0;
5317
+ for (let i = 0; i < Math.min(fileLines.length, 60); i++) {
5318
+ if (/^\s*import\s+/.test(fileLines[i] ?? ''))
5319
+ lastImportLine = i + 1;
5320
+ }
5321
+ const inventory = extractUiSurfaceInventory(fileLines, surfaceMethods);
5322
+ // Inventory is prepended outside the code fence below via chunk assembly.
5323
+ const methodBudget = Math.max(900, Math.floor((maxPerFile - Math.min(lastImportLine, 40) * 40) / Math.max(1, Math.min(surfaceMethods.length, 4))));
5324
+ if (lastImportLine > 0) {
5325
+ const importCap = Math.min(lastImportLine, 45);
5326
+ for (let i = 1; i <= importCap; i++) {
5327
+ const code = fileLines[i - 1] ?? '';
5328
+ bodyLines.push(withLineNumbers ? `${i}\t${code}` : code);
5329
+ }
5330
+ bodyLines.push(withLineNumbers ? `…\t// …` : '// …');
5331
+ }
5332
+ let methodsEmitted = 0;
5333
+ for (const m of surfaceMethods) {
5334
+ if (methodsEmitted >= 5)
5335
+ break;
5336
+ const mStart = Math.max(1, m.startLine - 1);
5337
+ let mEnd = Math.min(fileLines.length, m.endLine + 1);
5338
+ // Cap each method so later build/PageMap still fit.
5339
+ const maxLines = Math.max(25, Math.floor(methodBudget / 40));
5340
+ if (mEnd - mStart + 1 > maxLines) {
5341
+ mEnd = mStart + maxLines - 1;
5342
+ }
5343
+ bodyLines.push(withLineNumbers ? `…\t// --- ${m.name} ---` : `// --- ${m.name} ---`);
5344
+ for (let i = mStart; i <= mEnd; i++) {
5345
+ const code = fileLines[i - 1] ?? '';
5346
+ bodyLines.push(withLineNumbers ? `${i}\t${code}` : code);
5347
+ }
5348
+ methodsEmitted++;
5349
+ }
5350
+ bodyLines.push('```', '');
5351
+ let chunk = (inventory ? inventory + '\n' : '') + bodyLines.join('\n');
5352
+ if (chunk.length > maxPerFile + (inventory ? 1200 : 0)) {
5353
+ chunk = `${chunk.slice(0, maxPerFile + (inventory ? 1200 : 0))}\n... (trimmed — answer from inventory + visible methods; do not Read the rest)\n\`\`\`\n`;
5354
+ }
5355
+ if (totalChars + chunk.length > maxTotal && rendered > 0)
5356
+ break;
5357
+ lines.push(chunk);
5358
+ totalChars += chunk.length;
5359
+ rendered++;
5360
+ continue;
5361
+ }
3903
5362
  for (let i = start; i <= end; i++) {
3904
5363
  const code = fileLines[i - 1] ?? '';
3905
5364
  bodyLines.push(withLineNumbers ? `${i}\t${code}` : code);
@@ -3907,7 +5366,7 @@ class ToolHandler {
3907
5366
  bodyLines.push('```', '');
3908
5367
  let chunk = bodyLines.join('\n');
3909
5368
  if (chunk.length > maxPerFile) {
3910
- chunk = `${chunk.slice(0, maxPerFile)}\n... (trimmed)\n\`\`\`\n`;
5369
+ chunk = `${chunk.slice(0, maxPerFile)}\n... (trimmed — answer from visible lines; do not Read the rest)\n\`\`\`\n`;
3911
5370
  }
3912
5371
  if (totalChars + chunk.length > maxTotal && rendered > 0)
3913
5372
  break;
@@ -3918,11 +5377,100 @@ class ToolHandler {
3918
5377
  if (rendered === 0)
3919
5378
  return null;
3920
5379
  lines.push('---');
3921
- lines.push('> **Compact local explore complete**answer from the definition(s) above. ' +
3922
- 'Do not call `homegraph_search` or another full explore for the same symbol.');
5380
+ lines.push('> **Compact local explore complete — ANSWER NOW.** ' +
5381
+ 'Do **not** Read/Grep/`homegraph_search`/`homegraph_explore`/`homegraph_node` for the same symbols.');
3923
5382
  lines.push('');
3924
5383
  return this.textResult(lines.join('\n'));
3925
5384
  }
5385
+ /**
5386
+ * Hover / pointer-handler inventory — onHover / Hover* symbols + short snippets.
5387
+ * Used when the agent asks about hover effects without naming a Type yet.
5388
+ */
5389
+ buildHoverHandlerSurveySection(cg, query, projectRoot) {
5390
+ const topicBoost = /图标|icon|appicon|dock|launcher/i.test(query);
5391
+ const searchTerms = ['onHover', 'HoverAnimation', 'HoverEffect', 'HoverConstants', 'hover'];
5392
+ const seen = new Set();
5393
+ const hits = [];
5394
+ for (const term of searchTerms) {
5395
+ let results = [];
5396
+ try {
5397
+ results = cg.searchNodes(term, {
5398
+ kinds: ['method', 'function', 'property', 'field', 'variable', 'constant', 'class', 'component'],
5399
+ limit: 30,
5400
+ });
5401
+ }
5402
+ catch {
5403
+ continue;
5404
+ }
5405
+ for (const r of results) {
5406
+ if ((0, arkts_1.isOhosApiFilePath)(r.node.filePath) || (0, query_utils_1.isTestFile)(r.node.filePath))
5407
+ continue;
5408
+ const nameLc = r.node.name.toLowerCase();
5409
+ const pathLc = r.node.filePath.toLowerCase();
5410
+ const termLc = term.toLowerCase();
5411
+ if (!nameLc.includes(termLc) && !pathLc.includes(termLc))
5412
+ continue;
5413
+ const key = `${r.node.filePath}:${r.node.startLine}:${r.node.name}`;
5414
+ if (seen.has(key))
5415
+ continue;
5416
+ seen.add(key);
5417
+ let score = nameLc === 'onhover' || nameLc.startsWith('onhover') ? 40 : 10;
5418
+ if (/hover/.test(nameLc))
5419
+ score += 15;
5420
+ if (topicBoost && /icon|appicon|dock|launcher|smartdock|appcenter/i.test(pathLc))
5421
+ score += 25;
5422
+ let snippet = (r.node.signature || r.node.name).slice(0, 120);
5423
+ try {
5424
+ const abs = (0, utils_1.validatePathWithinRoot)(projectRoot, r.node.filePath);
5425
+ if (abs && (0, fs_1.existsSync)(abs) && r.node.startLine > 0) {
5426
+ const lines = (0, fs_1.readFileSync)(abs, 'utf-8').split('\n');
5427
+ const from = Math.max(0, r.node.startLine - 1);
5428
+ const to = Math.min(lines.length, from + 4);
5429
+ snippet = lines.slice(from, to).map((l) => l.trim()).filter(Boolean).join(' / ').slice(0, 160);
5430
+ }
5431
+ }
5432
+ catch { /* keep signature */ }
5433
+ hits.push({
5434
+ file: r.node.filePath,
5435
+ line: r.node.startLine,
5436
+ name: r.node.name,
5437
+ kind: r.node.kind,
5438
+ score,
5439
+ snippet,
5440
+ });
5441
+ }
5442
+ }
5443
+ if (hits.length === 0) {
5444
+ return {
5445
+ section: [
5446
+ '**Hover / pointer handler survey**',
5447
+ '',
5448
+ 'No `onHover` / Hover* handlers found in the indexed project graph.',
5449
+ '',
5450
+ '> Next: Grep `onHover` / `Hover` in `*.ets` if the index is incomplete.',
5451
+ '',
5452
+ ].join('\n'),
5453
+ hitCount: 0,
5454
+ };
5455
+ }
5456
+ hits.sort((a, b) => b.score - a.score || a.file.localeCompare(b.file));
5457
+ const lines = [
5458
+ '**Hover / pointer handler survey**',
5459
+ '',
5460
+ '> **ANSWER NOW** from these in-repo hover handlers + snippets. Do not Grep `onHover` again unless a named Type is still missing.',
5461
+ '',
5462
+ ];
5463
+ const shown = hits.slice(0, 12);
5464
+ for (const h of shown) {
5465
+ lines.push(`- \`${h.name}\` (${h.kind}) — \`${h.file}:${h.line}\``);
5466
+ if (h.snippet)
5467
+ lines.push(` \`${h.snippet.slice(0, 100)}\``);
5468
+ }
5469
+ if (hits.length > shown.length)
5470
+ lines.push(`- … and ${hits.length - shown.length} more`);
5471
+ lines.push('');
5472
+ return { section: lines.join('\n'), hitCount: shown.length };
5473
+ }
3926
5474
  /**
3927
5475
  * @kit module capability survey — repo import/usage only (SDK defs are not indexed).
3928
5476
  */
@@ -3963,11 +5511,49 @@ class ToolHandler {
3963
5511
  });
3964
5512
  };
3965
5513
  const resolveImportLine = (node) => (0, query_utils_1.resolveImportLineFromNode)(node, projectRoot);
5514
+ const tryPushImport = (node) => {
5515
+ if ((0, arkts_1.isOhosApiFilePath)(node.filePath))
5516
+ return;
5517
+ const lineText = resolveImportLine(node);
5518
+ const lineLc = lineText.toLowerCase();
5519
+ const symbols = parseImportedSymbols(lineText);
5520
+ if (!matchesSubmodule(lineText, symbols))
5521
+ return;
5522
+ if (kitTerms.length > 0) {
5523
+ const matchesKit = kitTerms.some((k) => lineLc.includes(`@kit.${k.toLowerCase()}`));
5524
+ if (!matchesKit)
5525
+ return;
5526
+ }
5527
+ const key = `${node.filePath}:${node.startLine}`;
5528
+ if (seen.has(key))
5529
+ return;
5530
+ seen.add(key);
5531
+ imports.push({
5532
+ file: node.filePath,
5533
+ line: node.startLine,
5534
+ lineText,
5535
+ symbols,
5536
+ });
5537
+ };
5538
+ // Prefer searching the named export/API (taskpool, util, …) — kit-only FTS
5539
+ // with a low limit misses focused import sites among popular @kit modules.
5540
+ const focusSearch = submodules.length > 0 ? submodules : [];
5541
+ for (const sym of focusSearch) {
5542
+ let hits = [];
5543
+ try {
5544
+ hits = cg.searchNodes(sym, { kinds: ['import'], limit: 200 });
5545
+ }
5546
+ catch {
5547
+ continue;
5548
+ }
5549
+ for (const r of hits)
5550
+ tryPushImport(r.node);
5551
+ }
3966
5552
  for (const term of kitSearchTerms) {
3967
5553
  const termLc = term.toLowerCase().replace(/^@kit\./, '');
3968
5554
  let hits = [];
3969
5555
  try {
3970
- hits = cg.searchNodes(term, { kinds: ['import'], limit: 80 });
5556
+ hits = cg.searchNodes(term, { kinds: ['import'], limit: focusSearch.length > 0 ? 40 : 120 });
3971
5557
  }
3972
5558
  catch {
3973
5559
  continue;
@@ -3976,19 +5562,7 @@ class ToolHandler {
3976
5562
  const lineText = resolveImportLine(r.node);
3977
5563
  if (!lineText.toLowerCase().includes(termLc) && !lineText.toLowerCase().includes('@kit.'))
3978
5564
  continue;
3979
- const symbols = parseImportedSymbols(lineText);
3980
- if (!matchesSubmodule(lineText, symbols))
3981
- continue;
3982
- const key = `${r.node.filePath}:${r.node.startLine}`;
3983
- if (seen.has(key))
3984
- continue;
3985
- seen.add(key);
3986
- imports.push({
3987
- file: r.node.filePath,
3988
- line: r.node.startLine,
3989
- lineText,
3990
- symbols,
3991
- });
5565
+ tryPushImport(r.node);
3992
5566
  }
3993
5567
  }
3994
5568
  if (imports.length === 0) {
@@ -4014,6 +5588,7 @@ class ToolHandler {
4014
5588
  symbolFiles.set(sym, set);
4015
5589
  }
4016
5590
  }
5591
+ const importCap = submodules.length > 0 ? 16 : 28;
4017
5592
  const lines = [
4018
5593
  '**Kit module usage (this repo)**',
4019
5594
  '',
@@ -4022,25 +5597,73 @@ class ToolHandler {
4022
5597
  `Imports from ${kitTerms.map((k) => `\`@kit.${k}\``).join(', ')} (${imports.length} site(s)):`,
4023
5598
  '',
4024
5599
  ];
4025
- for (const imp of imports.slice(0, 35)) {
5600
+ for (const imp of imports.slice(0, importCap)) {
4026
5601
  const symStr = imp.symbols.length > 0 ? imp.symbols.join(', ') : '(namespace)';
4027
- lines.push(`- \`${imp.file}:${imp.line}\` — \`${imp.lineText}\` (${symStr})`);
5602
+ lines.push(`- \`${imp.file}:${imp.line}\` — \`${imp.lineText.slice(0, 120)}\` (${symStr})`);
4028
5603
  }
4029
- if (imports.length > 35)
4030
- lines.push(`- … and ${imports.length - 35} more import site(s)`);
5604
+ if (imports.length > importCap)
5605
+ lines.push(`- … and ${imports.length - importCap} more import site(s)`);
4031
5606
  if (symbolFiles.size > 0) {
4032
5607
  lines.push('');
4033
5608
  lines.push('**Symbols imported (unique):**');
4034
- for (const [sym, files] of [...symbolFiles.entries()].sort((a, b) => a[0].localeCompare(b[0])).slice(0, 40)) {
4035
- const fileList = [...files].slice(0, 5).map((f) => `\`${f}\``).join(', ');
4036
- const more = files.size > 5 ? ` +${files.size - 5} files` : '';
5609
+ const focused = submodules.length > 0
5610
+ ? [...symbolFiles.entries()].filter(([sym]) => submodules.some((sm) => sym.toLowerCase() === sm.toLowerCase() || sym.toLowerCase().startsWith(`${sm.toLowerCase()}.`)))
5611
+ : [...symbolFiles.entries()];
5612
+ const ranked = (focused.length > 0 ? focused : [...symbolFiles.entries()])
5613
+ .sort((a, b) => a[0].localeCompare(b[0]))
5614
+ .slice(0, submodules.length > 0 ? 8 : 20);
5615
+ for (const [sym, files] of ranked) {
5616
+ const fileList = [...files].slice(0, 4).map((f) => `\`${f}\``).join(', ');
5617
+ const more = files.size > 4 ? ` +${files.size - 4} files` : '';
4037
5618
  lines.push(`- \`${sym}\` — imported in ${fileList}${more}`);
4038
5619
  }
4039
5620
  }
5621
+ // "需要额外安装哪些依赖" — surface oh-package.json5 lines naming the kit.
5622
+ if (/额外安装|还需.*依赖|需要.*依赖|install(?:ing)?\s+(?:extra\s+)?deps?|which\s+deps?/i.test(query)) {
5623
+ const depLines = [];
5624
+ try {
5625
+ for (const f of cg.getFiles()) {
5626
+ const fp = f.path.replace(/\\/g, '/');
5627
+ if (!/oh-package\.json5?$/i.test(fp))
5628
+ continue;
5629
+ const abs = (0, utils_1.validatePathWithinRoot)(projectRoot, f.path);
5630
+ if (!abs)
5631
+ continue;
5632
+ let content = '';
5633
+ try {
5634
+ content = (0, fs_1.readFileSync)(abs, 'utf-8');
5635
+ }
5636
+ catch {
5637
+ continue;
5638
+ }
5639
+ if (!kitTerms.some((k) => content.toLowerCase().includes(k.toLowerCase())))
5640
+ continue;
5641
+ const fileLines = content.split('\n');
5642
+ for (let i = 0; i < fileLines.length; i++) {
5643
+ const line = fileLines[i] ?? '';
5644
+ if (!kitTerms.some((k) => line.toLowerCase().includes(k.toLowerCase())))
5645
+ continue;
5646
+ depLines.push(`- \`${fp}:${i + 1}\` \`${line.trim().slice(0, 100)}\``);
5647
+ }
5648
+ if (depLines.length >= 16)
5649
+ break;
5650
+ }
5651
+ }
5652
+ catch { /* optional */ }
5653
+ lines.push('');
5654
+ lines.push('**oh-package dependencies naming this Kit**');
5655
+ if (depLines.length === 0) {
5656
+ lines.push('- No `oh-package.json5` dependency line naming this Kit was indexed. ' +
5657
+ '**ANSWER NOW**: calling `@kit.*` typically needs **no extra npm install** beyond the HarmonyOS SDK / DevEco kit — only what `oh-package.json5` already lists.');
5658
+ }
5659
+ else {
5660
+ lines.push(...depLines.slice(0, 16));
5661
+ }
5662
+ }
4040
5663
  lines.push('');
4041
- lines.push('> Kit usage survey complete — answer from this section; do not search external SDK docs or grep the repo again.');
5664
+ lines.push('> Kit usage survey complete — **ANSWER NOW** from this section; do not Grep `oh-package` / SDK docs again.');
4042
5665
  lines.push('');
4043
- return { section: lines.join('\n'), symbolCount: symbolFiles.size };
5666
+ return { section: lines.join('\n'), symbolCount: Math.max(symbolFiles.size, imports.length, 1) };
4044
5667
  }
4045
5668
  /** Disambiguate homonymous types (Configuration, Rectangle) when several defs exist. */
4046
5669
  buildHomonymDefinitionsSection(cg, query) {
@@ -4072,20 +5695,51 @@ class ToolHandler {
4072
5695
  const rel = (p) => p.replace(/\\/g, '/');
4073
5696
  const lines = ['**Inheritance survey**', ''];
4074
5697
  let listed = 0;
5698
+ const projectRoot = (() => {
5699
+ try {
5700
+ return cg.getProjectRoot();
5701
+ }
5702
+ catch {
5703
+ return '';
5704
+ }
5705
+ })();
4075
5706
  for (const typeName of (0, query_utils_1.extractTypeNamesFromQuery)(query).slice(0, 2)) {
4076
5707
  const bases = cg.getNodesByName(typeName).filter((n) => (n.kind === 'class' || n.kind === 'struct' || n.kind === 'interface') && !(0, query_utils_1.isTestFile)(n.filePath));
4077
5708
  for (const base of bases.slice(0, 2)) {
4078
- const subs = [];
5709
+ const publicSubs = [];
5710
+ const otherSubs = [];
4079
5711
  for (const e of cg.getIncomingEdges(base.id)) {
4080
5712
  if (e.kind !== 'extends' && e.kind !== 'implements')
4081
5713
  continue;
4082
5714
  try {
4083
5715
  const child = cg.getNode(e.source);
4084
- if (child && !(0, query_utils_1.isTestFile)(child.filePath))
4085
- subs.push(child);
5716
+ if (!child || (0, query_utils_1.isTestFile)(child.filePath))
5717
+ continue;
5718
+ const isCpp = /\.(hpp|h|hh|hxx|cpp|cc|cxx)$/i.test(child.filePath);
5719
+ if (isCpp && projectRoot) {
5720
+ const abs = (0, utils_1.validatePathWithinRoot)(projectRoot, child.filePath);
5721
+ if (abs && (0, fs_1.existsSync)(abs) && child.startLine > 0) {
5722
+ try {
5723
+ const fileLines = (0, fs_1.readFileSync)(abs, 'utf-8').split('\n');
5724
+ // Class decl may span a few lines (`class Foo\n: public Bar`).
5725
+ const from = Math.max(0, child.startLine - 1);
5726
+ const decl = fileLines.slice(from, Math.min(fileLines.length, from + 4)).join(' ');
5727
+ const pub = cppExtendsLooksPublic(decl, base.name);
5728
+ if (pub === false) {
5729
+ otherSubs.push(child);
5730
+ continue;
5731
+ }
5732
+ }
5733
+ catch { /* keep as public-ish */ }
5734
+ }
5735
+ }
5736
+ publicSubs.push(child);
4086
5737
  }
4087
5738
  catch { /* skip */ }
4088
5739
  }
5740
+ // Prefer public / language-default is-a; only fall back to private if
5741
+ // nothing else is indexed (avoids empty answers).
5742
+ const subs = publicSubs.length > 0 ? publicSubs : otherSubs;
4089
5743
  if (subs.length === 0)
4090
5744
  continue;
4091
5745
  lines.push(`### Subtypes of \`${base.name}\` (\`${rel(base.filePath)}\`)`);
@@ -4095,6 +5749,10 @@ class ToolHandler {
4095
5749
  }
4096
5750
  if (subs.length > 30)
4097
5751
  lines.push(`- … and ${subs.length - 30} more`);
5752
+ if (publicSubs.length > 0 && otherSubs.length > 0) {
5753
+ lines.push(`- _(also private/protected C++ inherits, omitted: ${otherSubs.map((s) => s.name).slice(0, 8).join(', ')})` +
5754
+ `${otherSubs.length > 8 ? ', …' : ''})_`);
5755
+ }
4098
5756
  lines.push('');
4099
5757
  }
4100
5758
  }
@@ -4120,6 +5778,10 @@ class ToolHandler {
4120
5778
  for (const m of query.matchAll(/(\.[a-zA-Z_][\w]*\s*\([^)]*\))/g)) {
4121
5779
  patterns.push(m[1].replace(/\s+/g, ''));
4122
5780
  }
5781
+ // Bare field / mutex names (m_eglMutex) — text-scan without a leading dot.
5782
+ for (const f of (0, query_utils_1.extractFieldLikeSymbolsFromQuery)(query)) {
5783
+ patterns.push(f);
5784
+ }
4123
5785
  if (patterns.length === 0)
4124
5786
  return '';
4125
5787
  const rel = (p) => p.replace(/\\/g, '/');
@@ -4145,21 +5807,264 @@ class ToolHandler {
4145
5807
  }
4146
5808
  // Scan top FTS files for literal pattern in source
4147
5809
  for (const pat of patterns.slice(0, 3)) {
4148
- const literal = pat.startsWith('.') ? pat : `.${pat}`;
5810
+ const literal = pat.startsWith('.') || pat.startsWith('m_') || /(?:Mutex|Lock)$/.test(pat)
5811
+ ? pat
5812
+ : `.${pat}`;
4149
5813
  const re = new RegExp(literal.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
4150
5814
  let files = [];
4151
5815
  try {
4152
- files = cg.searchNodes(patterns[0].replace(/^\./, ''), { limit: 25 });
5816
+ files = cg.searchNodes(patterns[0].replace(/^\./, ''), { limit: 25 });
5817
+ }
5818
+ catch {
5819
+ continue;
5820
+ }
5821
+ for (const r of files) {
5822
+ if ((0, query_utils_1.isTestFile)(r.node.filePath))
5823
+ continue;
5824
+ const abs = (0, utils_1.validatePathWithinRoot)(projectRoot, r.node.filePath);
5825
+ if (!abs)
5826
+ continue;
5827
+ let content;
5828
+ try {
5829
+ content = (0, fs_1.readFileSync)(abs, 'utf-8');
5830
+ }
5831
+ catch {
5832
+ continue;
5833
+ }
5834
+ const fileLines = content.split('\n');
5835
+ for (let i = 0; i < fileLines.length; i++) {
5836
+ if (re.test(fileLines[i] ?? ''))
5837
+ addHit(rel(r.node.filePath), i + 1);
5838
+ }
5839
+ }
5840
+ }
5841
+ // Field patterns with zero FTS: seed from co-named methods/types in the query.
5842
+ const fieldPats = patterns.filter((p) => p.startsWith('m_') || /(?:Mutex|Lock)$/.test(p));
5843
+ if (fieldPats.length > 0 && hits.size < 2) {
5844
+ const seeds = [
5845
+ ...(0, query_utils_1.extractLocalDetailAnchors)(query),
5846
+ ...(0, query_utils_1.extractTypeNamesFromQuery)(query),
5847
+ ].filter((n) => !fieldPats.some((p) => p === n || p.endsWith(n)));
5848
+ const seen = new Set(hits.keys());
5849
+ for (const seed of seeds.slice(0, 5)) {
5850
+ let seedHits = [];
5851
+ try {
5852
+ seedHits = cg.searchNodes(seed, { limit: 20 });
5853
+ }
5854
+ catch {
5855
+ continue;
5856
+ }
5857
+ for (const r of seedHits) {
5858
+ if ((0, query_utils_1.isTestFile)(r.node.filePath))
5859
+ continue;
5860
+ const fp = rel(r.node.filePath);
5861
+ if (seen.has(fp))
5862
+ continue;
5863
+ seen.add(fp);
5864
+ const abs = (0, utils_1.validatePathWithinRoot)(projectRoot, r.node.filePath);
5865
+ if (!abs)
5866
+ continue;
5867
+ let content;
5868
+ try {
5869
+ content = (0, fs_1.readFileSync)(abs, 'utf-8');
5870
+ }
5871
+ catch {
5872
+ continue;
5873
+ }
5874
+ const fileLines = content.split('\n');
5875
+ for (const pat of fieldPats) {
5876
+ const re = new RegExp(pat.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
5877
+ for (let i = 0; i < fileLines.length; i++) {
5878
+ if (re.test(fileLines[i] ?? ''))
5879
+ addHit(fp, i + 1);
5880
+ }
5881
+ }
5882
+ }
5883
+ }
5884
+ }
5885
+ if (hits.size === 0)
5886
+ return '';
5887
+ const lines = ['**Member / pattern usage**', ''];
5888
+ let fileCount = 0;
5889
+ for (const [fp, lineNos] of [...hits.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
5890
+ if (fileCount >= 40)
5891
+ break;
5892
+ const sorted = lineNos.sort((a, b) => a - b).slice(0, 6);
5893
+ const lineStr = sorted.join(', ');
5894
+ const more = lineNos.length > 6 ? ` +${lineNos.length - 6} lines` : '';
5895
+ lines.push(`- \`${fp}\` — lines ${lineStr}${more}`);
5896
+ fileCount++;
5897
+ }
5898
+ if (hits.size > 40)
5899
+ lines.push(`- … and ${hits.size - 40} more file(s)`);
5900
+ lines.push('');
5901
+ lines.push('> Pattern survey complete — answer from this list; run another explore naming missing symbols if needed.');
5902
+ lines.push('');
5903
+ return lines.join('\n');
5904
+ }
5905
+ /**
5906
+ * API call-site survey — where a named API/symbol (statfs, napi_*) appears in repo source.
5907
+ */
5908
+ /**
5909
+ * In-repo how-to for system settings/capabilities (language/locale/…).
5910
+ * Lists concrete `@ohos.i18n` / `getSystemLanguage` call sites — not every
5911
+ * file that mentions the word "language".
5912
+ */
5913
+ buildSystemCapabilityHowtoSection(cg, query, projectRoot) {
5914
+ const searchTerms = [
5915
+ 'getSystemLanguage',
5916
+ 'getSystemRegion',
5917
+ 'System.getSystemLanguage',
5918
+ '@ohos.i18n',
5919
+ 'i18n.System',
5920
+ ];
5921
+ // Topic tokens from the question (language/locale) only as secondary filters.
5922
+ if (/\blanguage\b|语言/i.test(query))
5923
+ searchTerms.push('getSystemLanguage');
5924
+ if (/\blocale\b|地区/i.test(query))
5925
+ searchTerms.push('getSystemRegion');
5926
+ const rel = (p) => p.replace(/\\/g, '/');
5927
+ const rows = [];
5928
+ const seen = new Set();
5929
+ for (const term of searchTerms) {
5930
+ let hits = [];
5931
+ try {
5932
+ hits = cg.searchNodes(term.replace(/^@/, ''), { limit: 35 });
5933
+ }
5934
+ catch {
5935
+ continue;
5936
+ }
5937
+ for (const r of hits) {
5938
+ if ((0, query_utils_1.isTestFile)(r.node.filePath) || (0, arkts_1.isOhosApiFilePath)(r.node.filePath))
5939
+ continue;
5940
+ if (/\.d\.ts$/i.test(r.node.filePath))
5941
+ continue;
5942
+ const abs = (0, utils_1.validatePathWithinRoot)(projectRoot, r.node.filePath);
5943
+ if (!abs || !(0, fs_1.existsSync)(abs))
5944
+ continue;
5945
+ let content;
5946
+ try {
5947
+ content = (0, fs_1.readFileSync)(abs, 'utf-8');
5948
+ }
5949
+ catch {
5950
+ continue;
5951
+ }
5952
+ const fileLines = content.split('\n');
5953
+ const re = new RegExp(term.startsWith('@')
5954
+ ? term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
5955
+ : `\\b${term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`);
5956
+ for (let i = 0; i < fileLines.length; i++) {
5957
+ const lineText = fileLines[i] ?? '';
5958
+ if (!re.test(lineText))
5959
+ continue;
5960
+ // Prefer real get/import sites over comments.
5961
+ if (/getSystemLanguage|getSystemRegion|@ohos\.i18n|i18n\.System/i.test(lineText)
5962
+ || /import\s+.*i18n/i.test(lineText)) {
5963
+ const key = `${r.node.filePath}:${i + 1}`;
5964
+ if (seen.has(key))
5965
+ continue;
5966
+ seen.add(key);
5967
+ rows.push(`- \`${rel(r.node.filePath)}:${i + 1}\` \`${lineText.trim().slice(0, 120)}\``);
5968
+ if (rows.length >= 16)
5969
+ break;
5970
+ }
5971
+ }
5972
+ if (rows.length >= 16)
5973
+ break;
5974
+ }
5975
+ if (rows.length >= 16)
5976
+ break;
5977
+ }
5978
+ if (rows.length === 0) {
5979
+ return {
5980
+ section: [
5981
+ '**System capability howto (in-repo)**',
5982
+ '',
5983
+ 'No `getSystemLanguage` / `@ohos.i18n` call sites indexed. **ANSWER NOW** from SDK docs if the project does not wrap this API.',
5984
+ '',
5985
+ ].join('\n'),
5986
+ hitCount: 0,
5987
+ };
5988
+ }
5989
+ return {
5990
+ section: [
5991
+ '**System capability howto (in-repo)**',
5992
+ '',
5993
+ '> Concrete call/import sites for system language/locale. **ANSWER NOW** — do not Grep `language` broadly.',
5994
+ '',
5995
+ ...rows,
5996
+ '',
5997
+ ].join('\n'),
5998
+ hitCount: rows.length,
5999
+ };
6000
+ }
6001
+ /**
6002
+ * Declaration / id / native-binding sites for a named UI/native Type (XComponent…).
6003
+ */
6004
+ buildDeclarationSiteSurveySection(cg, query, projectRoot) {
6005
+ const types = (0, query_utils_1.extractTypeNamesFromQuery)(query)
6006
+ .filter((t) => !(0, query_utils_1.isFrameworkUiDecoratorName)(t))
6007
+ .slice(0, 3);
6008
+ if (types.length === 0)
6009
+ return { section: '', hitCount: 0, attempted: false };
6010
+ const rel = (p) => p.replace(/\\/g, '/');
6011
+ const rows = [];
6012
+ const seen = new Set();
6013
+ for (const typeName of types) {
6014
+ let hits = [];
6015
+ try {
6016
+ hits = cg.searchNodes(typeName, { limit: 80 });
4153
6017
  }
4154
6018
  catch {
4155
6019
  continue;
4156
6020
  }
4157
- for (const r of files) {
6021
+ // Also pull same-name defs (component/class) even when FTS ranks them low.
6022
+ try {
6023
+ for (const n of cg.getNodesByName(typeName).slice(0, 40)) {
6024
+ hits.push({ node: n, score: 1 });
6025
+ }
6026
+ }
6027
+ catch { /* optional */ }
6028
+ const files = new Map(); // abs → rel
6029
+ for (const r of hits) {
4158
6030
  if ((0, query_utils_1.isTestFile)(r.node.filePath))
4159
6031
  continue;
6032
+ if ((0, arkts_1.isOhosApiFilePath)(r.node.filePath) && /\.d\.ts$/i.test(r.node.filePath))
6033
+ continue;
4160
6034
  const abs = (0, utils_1.validatePathWithinRoot)(projectRoot, r.node.filePath);
4161
- if (!abs)
6035
+ if (!abs || !(0, fs_1.existsSync)(abs))
4162
6036
  continue;
6037
+ files.set(abs, rel(r.node.filePath));
6038
+ }
6039
+ // Framework UI tags (XComponent…) often have no indexed symbol at the JSX
6040
+ // site — expand to sibling .ets under the same feature/staticcommon roots
6041
+ // already touched by native/header hits.
6042
+ const featureRoots = new Set();
6043
+ for (const fp of files.values()) {
6044
+ const m = fp.match(/^(feature\/[^/]+|staticcommon\/[^/]+)/);
6045
+ if (m?.[1])
6046
+ featureRoots.add(m[1]);
6047
+ }
6048
+ if (featureRoots.size > 0 && featureRoots.size <= 6) {
6049
+ try {
6050
+ for (const f of cg.getFiles()) {
6051
+ const fp = rel(f.path);
6052
+ if (!/\.ets$/i.test(fp) || (0, query_utils_1.isTestFile)(fp))
6053
+ continue;
6054
+ if (![...featureRoots].some((root) => fp.startsWith(`${root}/`)))
6055
+ continue;
6056
+ const abs = (0, utils_1.validatePathWithinRoot)(projectRoot, f.path);
6057
+ if (!abs || !(0, fs_1.existsSync)(abs))
6058
+ continue;
6059
+ files.set(abs, fp);
6060
+ }
6061
+ }
6062
+ catch { /* optional */ }
6063
+ }
6064
+ const typeRe = new RegExp(`\\b${typeName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`);
6065
+ const jsxRe = new RegExp(`<${typeName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`);
6066
+ const callRe = new RegExp(`${typeName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*\\(`);
6067
+ for (const [abs, fp] of files) {
4163
6068
  let content;
4164
6069
  try {
4165
6070
  content = (0, fs_1.readFileSync)(abs, 'utf-8');
@@ -4168,68 +6073,123 @@ class ToolHandler {
4168
6073
  continue;
4169
6074
  }
4170
6075
  const fileLines = content.split('\n');
6076
+ const isUi = /\.(ets|ts|tsx|jsx)$/i.test(fp);
6077
+ const isNative = /\.(cpp|cc|cxx|h|hpp)$/i.test(fp);
4171
6078
  for (let i = 0; i < fileLines.length; i++) {
4172
- if (re.test(fileLines[i] ?? ''))
4173
- addHit(rel(r.node.filePath), i + 1);
6079
+ const lineText = fileLines[i] ?? '';
6080
+ if (!typeRe.test(lineText))
6081
+ continue;
6082
+ const window = [lineText, fileLines[i + 1] ?? '', fileLines[i + 2] ?? ''].join('\n');
6083
+ const uiHit = isUi
6084
+ && (jsxRe.test(lineText)
6085
+ || callRe.test(lineText)
6086
+ || (/id\s*[:=]/.test(window) && !/Log(?:Error|Info|Warn|Debug|Message)/i.test(lineText)));
6087
+ const nativeHit = isNative
6088
+ && /OH_Native|RegisterCallback|Export\s*\(|napi_/i.test(window)
6089
+ && !/Log(?:Error|Info|Warn|Debug|Message)/i.test(lineText);
6090
+ // Prefer construction / JSX / id / native bind; skip log-only mentions.
6091
+ if (!uiHit && !nativeHit)
6092
+ continue;
6093
+ const key = `${fp}:${i + 1}`;
6094
+ if (seen.has(key))
6095
+ continue;
6096
+ seen.add(key);
6097
+ const clip = [lineText, fileLines[i + 1] ?? '', fileLines[i + 2] ?? '']
6098
+ .map((l) => l.trim())
6099
+ .filter(Boolean)
6100
+ .join(' / ')
6101
+ .slice(0, 160);
6102
+ rows.push(`- \`${typeName}\` — \`${fp}:${i + 1}\` \`${clip}\``);
6103
+ if (rows.length >= 24)
6104
+ break;
4174
6105
  }
6106
+ if (rows.length >= 24)
6107
+ break;
4175
6108
  }
4176
- }
4177
- if (hits.size === 0)
4178
- return '';
4179
- const lines = ['**Member / pattern usage**', ''];
4180
- let fileCount = 0;
4181
- for (const [fp, lineNos] of [...hits.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
4182
- if (fileCount >= 40)
6109
+ if (rows.length >= 24)
4183
6110
  break;
4184
- const sorted = lineNos.sort((a, b) => a - b).slice(0, 6);
4185
- const lineStr = sorted.join(', ');
4186
- const more = lineNos.length > 6 ? ` +${lineNos.length - 6} lines` : '';
4187
- lines.push(`- \`${fp}\` — lines ${lineStr}${more}`);
4188
- fileCount++;
4189
6111
  }
4190
- if (hits.size > 40)
4191
- lines.push(`- and ${hits.size - 40} more file(s)`);
4192
- lines.push('');
4193
- lines.push('> Pattern survey complete — answer from this list; run another explore naming missing symbols if needed.');
4194
- lines.push('');
4195
- return lines.join('\n');
6112
+ // Prefer UI (.ets) rows first so C++ includes don't dominate.
6113
+ rows.sort((a, b) => {
6114
+ const score = (s) => (/\.ets`/.test(s) ? 0 : /\.cpp`|\.h`/.test(s) ? 2 : 1);
6115
+ return score(a) - score(b);
6116
+ });
6117
+ if (rows.length === 0) {
6118
+ return {
6119
+ section: [
6120
+ '**Declaration / id / native-binding survey**',
6121
+ '',
6122
+ `> No UI/native construction sites for \`${types.join('`, `')}\` in the index. **ANSWER NOW** — do not Grep the same Type for "declaration"; at most one Grep for \`<${types[0]}\` if you must.`,
6123
+ '',
6124
+ ].join('\n'),
6125
+ hitCount: 0,
6126
+ attempted: true,
6127
+ };
6128
+ }
6129
+ return {
6130
+ section: [
6131
+ '**Declaration / id / native-binding survey**',
6132
+ '',
6133
+ '> Construction / JSX / `id` / native register sites. **ANSWER NOW** — do not Grep the same Type again.',
6134
+ '',
6135
+ ...rows.slice(0, 20),
6136
+ '',
6137
+ ].join('\n'),
6138
+ hitCount: Math.min(rows.length, 20),
6139
+ attempted: true,
6140
+ };
4196
6141
  }
4197
- /**
4198
- * API call-site survey — where a named API/symbol (statfs, napi_*) appears in repo source.
4199
- */
4200
6142
  buildApiUsageSection(cg, query, projectRoot) {
4201
6143
  // Include PascalCase SDK modules (Telephony) — deps-only missed them.
4202
- const symbols = (0, query_utils_1.extractApiUsageTokens)(query).slice(0, 6);
4203
- if (symbols.length === 0)
6144
+ const rawSymbols = (0, query_utils_1.extractApiUsageTokens)(query).slice(0, 6);
6145
+ if (rawSymbols.length === 0)
4204
6146
  return { section: '', fileCount: 0 };
6147
+ // Expand Telephony → telephony / @ohos.telephony so import call sites hit.
6148
+ const symbols = [];
6149
+ for (const sym of rawSymbols) {
6150
+ symbols.push(sym);
6151
+ if (/^[A-Z][A-Za-z0-9]+$/.test(sym) && sym.length >= 4) {
6152
+ const lc = sym.toLowerCase();
6153
+ if (!symbols.includes(lc))
6154
+ symbols.push(lc);
6155
+ const ohos = `@ohos.${lc}`;
6156
+ if (!symbols.includes(ohos))
6157
+ symbols.push(ohos);
6158
+ }
6159
+ }
4205
6160
  const rel = (p) => p.replace(/\\/g, '/');
4206
6161
  const hits = new Map();
4207
6162
  const addHit = (file, line) => {
4208
6163
  if ((0, query_utils_1.isTestFile)(file))
4209
6164
  return;
4210
6165
  const fp = rel(file);
4211
- const arr = hits.get(fp) ?? [];
4212
- if (!arr.includes(line))
4213
- arr.push(line);
4214
- hits.set(fp, arr);
6166
+ const stub = /\.d\.ts$/i.test(fp) || /windowsceneinterfaces|@ohos\.|\/api\//i.test(fp);
6167
+ const prev = hits.get(fp) ?? { lines: [], stub };
6168
+ if (!prev.lines.includes(line))
6169
+ prev.lines.push(line);
6170
+ prev.stub = prev.stub && stub;
6171
+ hits.set(fp, prev);
4215
6172
  };
4216
- for (const sym of symbols.slice(0, 4)) {
6173
+ for (const sym of symbols.slice(0, 6)) {
4217
6174
  const symLc = sym.toLowerCase();
4218
6175
  let nodes = [];
4219
6176
  try {
4220
- nodes = cg.searchNodes(sym, { limit: 40 });
6177
+ nodes = cg.searchNodes(sym.replace(/^@/, ''), { limit: 40 });
4221
6178
  }
4222
6179
  catch { /* skip */ }
4223
6180
  for (const r of nodes) {
4224
6181
  const n = r.node;
4225
- if (n.name.toLowerCase() === symLc || (n.signature || '').toLowerCase().includes(symLc)) {
6182
+ const hay = `${n.name} ${n.signature || ''} ${n.filePath}`.toLowerCase();
6183
+ if (hay.includes(symLc) || n.name.toLowerCase() === symLc) {
4226
6184
  addHit(n.filePath, n.startLine);
4227
6185
  }
4228
6186
  }
4229
- const wordRe = new RegExp(`\\b${sym.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`);
6187
+ const wordRe = new RegExp(sym.startsWith('@')
6188
+ ? sym.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
6189
+ : `\\b${sym.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i');
4230
6190
  let scanHits = [];
4231
6191
  try {
4232
- scanHits = cg.searchNodes(sym, { limit: 30 });
6192
+ scanHits = cg.searchNodes(sym.replace(/^@/, ''), { limit: 35 });
4233
6193
  }
4234
6194
  catch {
4235
6195
  continue;
@@ -4259,30 +6219,210 @@ class ToolHandler {
4259
6219
  }
4260
6220
  }
4261
6221
  }
6222
+ // Field/mutex tokens often have zero FTS hits (not extracted as nodes). Seed
6223
+ // scan files from co-named methods/types in the query (drawOnSubThread…).
6224
+ const fieldSyms = (0, query_utils_1.extractFieldLikeSymbolsFromQuery)(query);
6225
+ if (fieldSyms.length > 0) {
6226
+ const seedNames = [
6227
+ ...(0, query_utils_1.extractLocalDetailAnchors)(query),
6228
+ ...(0, query_utils_1.extractTypeNamesFromQuery)(query),
6229
+ ].filter((n) => !fieldSyms.includes(n) && n.length >= 4);
6230
+ const seenSeedFiles = new Set([...hits.keys()]);
6231
+ for (const seed of seedNames.slice(0, 5)) {
6232
+ let seedHits = [];
6233
+ try {
6234
+ seedHits = cg.searchNodes(seed, { limit: 20 });
6235
+ }
6236
+ catch {
6237
+ continue;
6238
+ }
6239
+ for (const r of seedHits) {
6240
+ if ((0, query_utils_1.isTestFile)(r.node.filePath))
6241
+ continue;
6242
+ const fp = rel(r.node.filePath);
6243
+ if (seenSeedFiles.has(fp))
6244
+ continue;
6245
+ seenSeedFiles.add(fp);
6246
+ const abs = (0, utils_1.validatePathWithinRoot)(projectRoot, r.node.filePath);
6247
+ if (!abs)
6248
+ continue;
6249
+ let content;
6250
+ try {
6251
+ content = (0, fs_1.readFileSync)(abs, 'utf-8');
6252
+ }
6253
+ catch {
6254
+ continue;
6255
+ }
6256
+ const fileLines = content.split('\n');
6257
+ for (const field of fieldSyms) {
6258
+ const re = new RegExp(`\\b${field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`);
6259
+ for (let i = 0; i < fileLines.length; i++) {
6260
+ if (re.test(fileLines[i] ?? ''))
6261
+ addHit(fp, i + 1);
6262
+ }
6263
+ }
6264
+ }
6265
+ }
6266
+ }
4262
6267
  if (hits.size === 0)
4263
6268
  return { section: '', fileCount: 0 };
6269
+ // Prefer project call/import sites over SDK .d.ts stubs.
6270
+ const rankedFiles = [...hits.entries()].sort((a, b) => {
6271
+ if (a[1].stub !== b[1].stub)
6272
+ return a[1].stub ? 1 : -1;
6273
+ return a[0].localeCompare(b[0]);
6274
+ });
6275
+ const projectHits = rankedFiles.filter(([, v]) => !v.stub);
6276
+ const display = projectHits.length > 0 ? projectHits : rankedFiles;
6277
+ // In files that import the API, also record alias.method( call sites so
6278
+ // "which call methods" answers don't stop at import lines.
6279
+ const aliasCallRe = /\b((?:call|radio|data|sim|observer|telephony|i18n|sensor|wifi)(?:\.\w+)*)\.([A-Za-z_][\w]*)\s*\(/gi;
6280
+ const wantLifetime = (0, query_utils_1.queryAsFieldUsageSurvey)(query)
6281
+ && /new|delete|allocat|释放|free/i.test(query);
6282
+ for (const [fp] of display.slice(0, 40)) {
6283
+ const abs = (0, utils_1.validatePathWithinRoot)(projectRoot, fp);
6284
+ if (!abs || !(0, fs_1.existsSync)(abs))
6285
+ continue;
6286
+ let fileLines = [];
6287
+ try {
6288
+ fileLines = (0, fs_1.readFileSync)(abs, 'utf-8').split('\n');
6289
+ }
6290
+ catch {
6291
+ continue;
6292
+ }
6293
+ for (let i = 0; i < fileLines.length; i++) {
6294
+ const lineText = fileLines[i] ?? '';
6295
+ if (aliasCallRe.test(lineText))
6296
+ addHit(fp, i + 1);
6297
+ aliasCallRe.lastIndex = 0;
6298
+ if (wantLifetime) {
6299
+ for (const sym of rawSymbols) {
6300
+ if (!lineText.includes(sym))
6301
+ continue;
6302
+ if (/\b(?:new|delete|free|reset)\b/i.test(lineText) || /=\s*new\b/.test(lineText)) {
6303
+ addHit(fp, i + 1);
6304
+ }
6305
+ }
6306
+ }
6307
+ }
6308
+ }
6309
+ // Recompute display after call-site enrichment.
6310
+ const rankedFiles2 = [...hits.entries()].sort((a, b) => {
6311
+ if (a[1].stub !== b[1].stub)
6312
+ return a[1].stub ? 1 : -1;
6313
+ return a[0].localeCompare(b[0]);
6314
+ });
6315
+ const projectHits2 = rankedFiles2.filter(([, v]) => !v.stub);
6316
+ const display2 = projectHits2.length > 0 ? projectHits2 : rankedFiles2;
6317
+ // Extract concrete API method names from snippets (telephony.call.xxx / radio.getY).
6318
+ const methodNames = new Set();
6319
+ for (const [fp, info] of display2.slice(0, 40)) {
6320
+ const abs = (0, utils_1.validatePathWithinRoot)(projectRoot, fp);
6321
+ if (!abs || !(0, fs_1.existsSync)(abs))
6322
+ continue;
6323
+ let fileLines = [];
6324
+ try {
6325
+ fileLines = (0, fs_1.readFileSync)(abs, 'utf-8').split('\n');
6326
+ }
6327
+ catch {
6328
+ continue;
6329
+ }
6330
+ for (const ln of info.lines.slice(0, 12)) {
6331
+ const lineText = fileLines[ln - 1] ?? '';
6332
+ for (const m of lineText.matchAll(/\b((?:call|radio|data|sim|observer|telephony)(?:\.\w+)*)\.([A-Za-z_][\w]*)\s*\(/gi)) {
6333
+ if (m[2] && m[2].length >= 2)
6334
+ methodNames.add(`${m[1]}.${m[2]}`);
6335
+ }
6336
+ for (const sym of rawSymbols) {
6337
+ const esc = sym.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
6338
+ const re = new RegExp(`(?:@ohos\\.)?${esc}(?:\\.[A-Za-z_][\\w]*)*\\.([A-Za-z_][\\w]*)\\s*\\(`, 'g');
6339
+ for (const m of lineText.matchAll(re)) {
6340
+ if (m[1] && m[1].length >= 2)
6341
+ methodNames.add(`${sym}.${m[1]}`);
6342
+ }
6343
+ // import X from '@ohos.telephony.call' → module surface
6344
+ const imp = lineText.match(new RegExp(`from\\s+['"]@ohos\\.${esc}(?:\\.(\\w+))?['"]`, 'i'));
6345
+ if (imp)
6346
+ methodNames.add(imp[1] ? `@ohos.${sym.toLowerCase()}.${imp[1]}` : `@ohos.${sym.toLowerCase()}`);
6347
+ }
6348
+ }
6349
+ }
4264
6350
  const lines = [
4265
6351
  '**API usage sites**',
4266
6352
  '',
4267
- `> Files referencing \`${symbols.join('`, `')}\` in this repo (${hits.size} file(s)). Answer from this list; source bodies omitted.`,
6353
+ `> In-repo references to \`${rawSymbols.join('`, `')}\` (${display2.length} file(s)` +
6354
+ `${projectHits2.length > 0 && projectHits2.length < rankedFiles2.length ? `; SDK stubs omitted` : ''}). ` +
6355
+ '**ANSWER NOW** from this list (+ snippets); do not Grep the same API again.',
4268
6356
  '',
4269
6357
  ];
6358
+ if (methodNames.size > 0) {
6359
+ lines.push('**Called / imported API surfaces**');
6360
+ for (const m of [...methodNames].sort().slice(0, 24))
6361
+ lines.push(`- \`${m}\``);
6362
+ if (methodNames.size > 24)
6363
+ lines.push(`- … and ${methodNames.size - 24} more`);
6364
+ lines.push('');
6365
+ }
4270
6366
  let shown = 0;
4271
- for (const [fp, lineNos] of [...hits.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
4272
- if (shown >= 45)
6367
+ const isImportish = (s) => /^\s*import\b/.test(s) || /\bfrom\s+['"]@/.test(s);
6368
+ const isLifetimeish = (s) => /\b(?:new|delete|free|reset)\b/i.test(s) || /=\s*new\b/.test(s);
6369
+ for (const [fp, info] of display2) {
6370
+ if (shown >= 28)
4273
6371
  break;
4274
- const sortedLines = [...lineNos].sort((a, b) => a - b).slice(0, 6);
6372
+ const abs = (0, utils_1.validatePathWithinRoot)(projectRoot, fp);
6373
+ let fileLines = [];
6374
+ if (abs && (0, fs_1.existsSync)(abs)) {
6375
+ try {
6376
+ fileLines = (0, fs_1.readFileSync)(abs, 'utf-8').split('\n');
6377
+ }
6378
+ catch { /* */ }
6379
+ }
6380
+ // Prefer call / new-delete lines over bare imports for the displayed snippet.
6381
+ const rankedLines = [...info.lines].sort((a, b) => {
6382
+ const ta = fileLines[a - 1] ?? '';
6383
+ const tb = fileLines[b - 1] ?? '';
6384
+ const score = (t) => {
6385
+ let s = 0;
6386
+ if (wantLifetime && isLifetimeish(t))
6387
+ s += 4;
6388
+ if (/\.\w+\s*\(/.test(t) && !isImportish(t))
6389
+ s += 3;
6390
+ if (isImportish(t))
6391
+ s -= 2;
6392
+ return s;
6393
+ };
6394
+ return score(tb) - score(ta) || a - b;
6395
+ });
6396
+ // For field new/delete, surface both sides when present (not only `new`).
6397
+ let sortedLines = rankedLines.slice(0, 4);
6398
+ if (wantLifetime && fileLines.length > 0) {
6399
+ const news = rankedLines.filter((l) => /\bnew\b/i.test(fileLines[l - 1] ?? ''));
6400
+ const dels = rankedLines.filter((l) => /\bdelete\b|\bfree\b|=\s*nullptr/i.test(fileLines[l - 1] ?? ''));
6401
+ sortedLines = [...new Set([...news.slice(0, 2), ...dels.slice(0, 2), ...rankedLines])].slice(0, 4);
6402
+ }
6403
+ let snippet = '';
6404
+ if (fileLines.length > 0) {
6405
+ const ln = sortedLines[0];
6406
+ snippet = (fileLines[ln - 1] ?? '').trim().slice(0, 100);
6407
+ if (wantLifetime && sortedLines.length > 1) {
6408
+ const sn2 = (fileLines[sortedLines[1] - 1] ?? '').trim().slice(0, 80);
6409
+ if (sn2 && sn2 !== snippet)
6410
+ snippet = `${snippet}` + ` | ${sn2}`;
6411
+ }
6412
+ }
4275
6413
  const lineStr = sortedLines.map((l) => `L${l}`).join(', ');
4276
- const more = lineNos.length > sortedLines.length ? ` +${lineNos.length - sortedLines.length} more` : '';
4277
- lines.push(`- \`${fp}\` (${lineStr}${more})`);
6414
+ const more = info.lines.length > sortedLines.length ? ` +${info.lines.length - sortedLines.length} more` : '';
6415
+ lines.push(snippet
6416
+ ? `- \`${fp}\` (${lineStr}${more}) \`${snippet}\``
6417
+ : `- \`${fp}\` (${lineStr}${more})`);
4278
6418
  shown++;
4279
6419
  }
4280
- if (hits.size > shown)
4281
- lines.push(`- … and ${hits.size - shown} more file(s)`);
6420
+ if (display2.length > shown)
6421
+ lines.push(`- … and ${display2.length - shown} more file(s)`);
4282
6422
  lines.push('');
4283
- lines.push(`> API usage survey complete — **${hits.size}** file(s) listed.`);
6423
+ lines.push(`> API usage survey complete — **${display2.length}** file(s). **ANSWER NOW.**`);
4284
6424
  lines.push('');
4285
- return { section: lines.join('\n'), fileCount: hits.size };
6425
+ return { section: lines.join('\n'), fileCount: display2.length };
4286
6426
  }
4287
6427
  /**
4288
6428
  * Domain file inventory — related files, usage existence, or concept comparison.
@@ -4445,12 +6585,37 @@ class ToolHandler {
4445
6585
  const callerFiles = [...new Set(uniq.map((n) => rel(n.filePath)))];
4446
6586
  const testFiles = callerFiles.filter((f) => (0, query_utils_1.isTestFile)(f));
4447
6587
  const nonTest = callerFiles.filter((f) => !(0, query_utils_1.isTestFile)(f));
6588
+ // Indirect test coverage via one hop of callers (tests that call a
6589
+ // direct caller) when nothing calls the root from a test file.
6590
+ let indirectTests = [];
6591
+ if (testFiles.length === 0) {
6592
+ const seenTest = new Set();
6593
+ for (const c of uniq.slice(0, 8)) {
6594
+ let next = [];
6595
+ try {
6596
+ next = cg.getCallers(c.id);
6597
+ }
6598
+ catch {
6599
+ continue;
6600
+ }
6601
+ for (const n of next) {
6602
+ const fp = rel(n.node.filePath);
6603
+ if ((0, query_utils_1.isTestFile)(fp) && !seenTest.has(fp)) {
6604
+ seenTest.add(fp);
6605
+ indirectTests.push(fp);
6606
+ }
6607
+ }
6608
+ }
6609
+ indirectTests = indirectTests.slice(0, FILE_CAP);
6610
+ }
4448
6611
  const shown = nonTest.slice(0, FILE_CAP).map((f) => `\`${f}\``).join(', ');
4449
6612
  const more = nonTest.length > FILE_CAP ? ` +${nonTest.length - FILE_CAP} more` : '';
4450
6613
  const where = nonTest.length > 0 ? ` in ${shown}${more}` : '';
4451
6614
  const tests = testFiles.length > 0
4452
6615
  ? `; tests: ${testFiles.slice(0, FILE_CAP).map((f) => `\`${f}\``).join(', ')}${testFiles.length > FILE_CAP ? ` +${testFiles.length - FILE_CAP}` : ''}`
4453
- : '; ⚠️ no covering tests found';
6616
+ : indirectTests.length > 0
6617
+ ? `; tests (via callers): ${indirectTests.map((f) => `\`${f}\``).join(', ')}`
6618
+ : '; ⚠️ no covering tests found';
4454
6619
  entries.push(`- \`${root.name}\` (${rel(root.filePath)}:${root.startLine}) — ${uniq.length} caller${uniq.length === 1 ? '' : 's'}${where}${tests}`);
4455
6620
  }
4456
6621
  if (entries.length === 0)
@@ -4562,15 +6727,18 @@ class ToolHandler {
4562
6727
  // One normalization point so the flow-builder, relevance search, and
4563
6728
  // ranking all see the same canonical spelling (Erlang `mod:fn/arity`).
4564
6729
  const query = normalizeQuerySpelling(rawQuery);
6730
+ const deferKind = (0, query_utils_1.queryShouldDeferToBuiltinTools)(query);
6731
+ if (deferKind) {
6732
+ return this.textResult((0, query_utils_1.homegraphDeferGuidance)(deferKind, query));
6733
+ }
4565
6734
  const cg = this.getHomeGraph(args.projectPath);
4566
6735
  const projectRoot = cg.getProjectRoot();
4567
6736
  const compactLocal = this.tryFastInventoryExplore(cg, query, projectRoot)
6737
+ ?? this.tryLightMechanismExplore(cg, query, projectRoot)
4568
6738
  ?? this.tryCompactLocalSymbolExplore(cg, query, projectRoot);
4569
6739
  if (compactLocal)
4570
6740
  return compactLocal;
4571
- const lightMechanism = this.tryLightMechanismExplore(cg, query, projectRoot);
4572
- if (lightMechanism)
4573
- return lightMechanism;
6741
+ // Light mechanism already attempted above.
4574
6742
  // Resolve adaptive output budget from project size.
4575
6743
  // largest-tier defaults if stats aren't available, which preserves
4576
6744
  // pre-#185 behavior for callers that hit the rare stats failure.
@@ -4994,8 +7162,10 @@ class ToolHandler {
4994
7162
  // Test/spec/icon/i18n file detector — used both for the pre-sort hard
4995
7163
  // filter (tiny tier) and the comparator deprioritization (all tiers).
4996
7164
  const isLowValue = (p) => {
4997
- const lp = p.toLowerCase();
4998
- return (/\/(tests?|__tests?__|spec)\//.test(lp) ||
7165
+ const lp = p.toLowerCase().replace(/\\/g, '/');
7166
+ return (
7167
+ // Top-level test/ / spec/ as well as nested …/tests?/…
7168
+ /(?:^|\/)(tests?|specs?|__tests?__|testdata|mocks?|fixtures?)\//.test(lp) ||
4999
7169
  /_test\.go$/.test(lp) ||
5000
7170
  /(?:^|\/)test_[^/]+\.py$/.test(lp) ||
5001
7171
  /_test\.py$/.test(lp) ||
@@ -5009,6 +7179,10 @@ class ToolHandler {
5009
7179
  /\bicons?\b/.test(lp) ||
5010
7180
  /\bi18n\b/.test(lp));
5011
7181
  };
7182
+ /** Hand-written ambient `.d.ts` — down-rank for behavior/flow questions (CG-28). */
7183
+ const isAmbientDts = (p) => /\.d\.ts$/i.test(p.replace(/\\/g, '/'));
7184
+ /** Path convention OR persisted content-banner flag. */
7185
+ const isGeneratedCandidate = (fp) => this.isGeneratedCandidate(cg, fp);
5012
7186
  // Hard-exclude test/spec files (ALL tiers, not just tiny). One slipped test
5013
7187
  // file dominates the per-file budget on small repos (cobra's `command_test.go`
5014
7188
  // displaced `args.go`) AND wastes budget on large ones (Django's
@@ -5198,16 +7372,17 @@ class ToolHandler {
5198
7372
  const bLow = isLowValue(bPath);
5199
7373
  if (aLow !== bLow)
5200
7374
  return aLow ? 1 : -1;
5201
- // Deprioritize generated source (.pb.go / .pulsar.go / _mocks.go / …) —
5202
- // the agent rarely needs to see the protobuf scaffold or gomock output
5203
- // when asking about the actual flow, and dumping their bodies inflates
5204
- // the response (the cosmos Q3 explore otherwise leads with
5205
- // `expected_keepers_mocks.go`, displacing the real `tally.go` content
5206
- // and forcing the agent to Read tally.go anyway).
5207
- const aGen = (0, generated_detection_1.isGeneratedFile)(a[0]);
5208
- const bGen = (0, generated_detection_1.isGeneratedFile)(b[0]);
7375
+ // Deprioritize generated source (path suffixes OR content banner).
7376
+ const aGen = isGeneratedCandidate(a[0]);
7377
+ const bGen = isGeneratedCandidate(b[0]);
5209
7378
  if (aGen !== bGen)
5210
7379
  return aGen ? 1 : -1;
7380
+ // Hand-written ambient `.d.ts` — demote unless the agent named a type
7381
+ // defined there (behavior questions want implementations, not typings).
7382
+ const aAmbient = isAmbientDts(a[0]) && !namedSeedFiles.has(a[0]) ? 1 : 0;
7383
+ const bAmbient = isAmbientDts(b[0]) && !namedSeedFiles.has(b[0]) ? 1 : 0;
7384
+ if (aAmbient !== bAmbient)
7385
+ return aAmbient - bAmbient;
5211
7386
  if (a[1].score !== b[1].score)
5212
7387
  return b[1].score - a[1].score;
5213
7388
  return b[1].nodes.length - a[1].nodes.length;
@@ -5231,13 +7406,17 @@ class ToolHandler {
5231
7406
  'production handlers are out of scope unless explicitly referenced in the test.');
5232
7407
  lines.push('');
5233
7408
  }
5234
- const importResult = this.buildImportSitesSection(cg, query, projectRoot);
7409
+ const importResult = (0, query_utils_1.queryAsksKitInstallDeps)(query)
7410
+ ? { section: '', siteCount: 0, compactListing: false }
7411
+ : this.buildImportSitesSection(cg, query, projectRoot);
5235
7412
  if (importResult.section)
5236
7413
  lines.push(importResult.section);
5237
7414
  const homonymSection = this.buildHomonymDefinitionsSection(cg, query);
5238
7415
  if (homonymSection)
5239
7416
  lines.push(homonymSection);
5240
7417
  const kitUsageResult = (0, query_utils_1.shouldBuildKitModuleUsageSurvey)(query)
7418
+ && ((0, query_utils_1.queryAsksKitInstallDeps)(query)
7419
+ || !(importResult.compactListing && importResult.siteCount > 0))
5241
7420
  ? this.buildKitModuleUsageSection(cg, query, projectRoot)
5242
7421
  : { section: '', symbolCount: 0 };
5243
7422
  if (kitUsageResult.section)
@@ -5248,6 +7427,7 @@ class ToolHandler {
5248
7427
  if (domainFileResult.section)
5249
7428
  lines.push(domainFileResult.section);
5250
7429
  const apiUsageResult = (0, query_utils_1.shouldBuildApiUsageSurvey)(query)
7430
+ && !(0, query_utils_1.shouldBuildKitModuleUsageSurvey)(query)
5251
7431
  ? this.buildApiUsageSection(cg, query, projectRoot)
5252
7432
  : { section: '', fileCount: 0 };
5253
7433
  if (apiUsageResult.section)
@@ -5293,9 +7473,14 @@ class ToolHandler {
5293
7473
  const localDetail = (0, query_utils_1.queryAsLocalSymbolDetail)(query);
5294
7474
  const inheritanceSection = !hasFlowPath && !multiAnchor
5295
7475
  ? this.buildInheritanceSurveySection(cg, query) : '';
5296
- const callerSection = !hasFlowPath && !multiAnchor && (0, query_utils_1.shouldBuildCallerInventory)(query)
5297
- ? this.buildCallerListingSection(cg, query) : '';
7476
+ const listedTypeMethods = (0, query_utils_1.extractListedTypeMethodsFromQuery)(query).length > 0;
7477
+ // Type + listed methods (BinaryGrid Set/Test/Fill) must keep caller inventory even
7478
+ // when multi-anchor would otherwise skip it into a fat source dump.
7479
+ const callerSection = !hasFlowPath && (!multiAnchor || listedTypeMethods) && (0, query_utils_1.shouldBuildCallerInventory)(query)
7480
+ ? this.buildCallerListingSection(cg, query, projectRoot) : '';
7481
+ // Field new/delete inventories already list text sites — skip redundant member scan.
5298
7482
  const memberSection = !hasFlowPath && !multiAnchor && (0, query_utils_1.shouldBuildMemberSurvey)(query)
7483
+ && !((0, query_utils_1.queryAsFieldUsageSurvey)(query) && apiUsageResult.fileCount > 0)
5299
7484
  ? this.buildMemberSurveySection(cg, query, projectRoot) : '';
5300
7485
  const configSection = (0, query_utils_1.shouldBuildConfigSection)(query)
5301
7486
  ? this.buildConfigFileSection(cg, query, projectRoot) : '';
@@ -5337,28 +7522,32 @@ class ToolHandler {
5337
7522
  return finishCompact('Config/manifest content above — answer from it directly.');
5338
7523
  }
5339
7524
  if (kitUsageResult.section) {
5340
- return finishCompact(`Kit module usage survey — **${kitUsageResult.symbolCount}** imported symbol(s) across the repo. SDK definitions are not in this project; answer from the usage list above.`);
7525
+ return finishCompact(`Kit module **in-repo usage** survey — **${kitUsageResult.symbolCount}** imported symbol(s). ` +
7526
+ 'Not an SDK catalog. **ANSWER NOW** from the usage list; do not Grep the same `@kit` path.');
5341
7527
  }
5342
7528
  if (domainFileResult.section && domainFileResult.fileCount > 0) {
5343
- return finishCompact(`Domain file survey — **${domainFileResult.fileCount}** related file(s) listed above. Source bodies omitted; answer from the inventory.`);
7529
+ return finishCompact(`Domain file survey — **${domainFileResult.fileCount}** related file(s) listed above. **ANSWER NOW.**`);
5344
7530
  }
5345
7531
  if (apiUsageResult.section && apiUsageResult.fileCount > 0) {
5346
- return finishCompact(`API usage survey — **${apiUsageResult.fileCount}** file(s) referencing the queried symbol(s). Source bodies omitted; answer from the list above.`);
7532
+ return finishCompact(`API usage survey — **${apiUsageResult.fileCount}** file(s). **ANSWER NOW** from the list above.`);
7533
+ }
7534
+ if (listedTypeMethods && callerBulletCount > 0) {
7535
+ return finishCompact(`Caller inventory — **${callerBulletCount}** site(s) for listed methods. **ANSWER NOW**; do not fan out callers per method.`);
5347
7536
  }
5348
7537
  if (dataSourceResult.section && dataSourceResult.edgeCount > 0) {
5349
- return finishCompact(`Data-source survey — **${dataSourceResult.edgeCount}** upstream symbol(s) listed above. Source bodies omitted; answer from the inventory.`);
7538
+ return finishCompact(`Data-source survey — **${dataSourceResult.edgeCount}** upstream symbol(s). **ANSWER NOW.**`);
5350
7539
  }
5351
7540
  if (inheritanceListed) {
5352
- return finishCompact('Inheritance survey above lists all direct subtypes found — source bodies omitted.');
7541
+ return finishCompact('Inheritance survey above lists all direct subtypes found. **ANSWER NOW** do not grep `extends`.');
5353
7542
  }
5354
7543
  if (importResult.compactListing) {
5355
- return finishCompact(`Listed **${importResult.siteCount}** import site(s) for the queried symbol(s). Source bodies omitted — answer from the dependency list above.`);
7544
+ return finishCompact(`Listed **${importResult.siteCount}** import site(s). **ANSWER NOW** from the dependency list above.`);
5356
7545
  }
5357
7546
  if (callerBulletCount >= 1) {
5358
- return finishCompact(`Caller inventory above lists **${callerBulletCount}** method(s) with external callers — source bodies omitted.`);
7547
+ return finishCompact(`Caller inventory above lists **${callerBulletCount}** method(s). **ANSWER NOW.**`);
5359
7548
  }
5360
7549
  if (memberSection && memberFileCount >= 2) {
5361
- return finishCompact(`Member/pattern usage in **${memberFileCount}** file(s) source bodies omitted; answer from the inventory above.`);
7550
+ return finishCompact(`Member/pattern usage in **${memberFileCount}** file(s). **ANSWER NOW** from the inventory above.`);
5362
7551
  }
5363
7552
  }
5364
7553
  // Blast radius only for structural flow / mechanism answers — skip on local-detail
@@ -5458,6 +7647,9 @@ class ToolHandler {
5458
7647
  };
5459
7648
  lines.push('**Source Code**');
5460
7649
  lines.push('');
7650
+ // Recorded so the drift pass below (#1474) can append a per-file exception
7651
+ // to this guarantee after the render loop knows which files drifted.
7652
+ const verbatimHeaderIdx = lines.length;
5461
7653
  lines.push('> Line-numbered source — treat as already Read. Answer from Flow + Source below when you can; ' +
5462
7654
  'do not re-read/grep these files, and do not fan out `homegraph_node` / search for symbols already shown. ' +
5463
7655
  'Another `homegraph_explore` with tighter names only if a needed symbol is missing.');
@@ -5468,6 +7660,28 @@ class ToolHandler {
5468
7660
  // (#1046) — it must reflect what we show, not the raw candidate gather.
5469
7661
  const renderedFilePaths = [];
5470
7662
  let anyFileTrimmed = false;
7663
+ // Files that changed on disk after their last index sync (#1474).
7664
+ const staleRendered = [];
7665
+ const staleOmitted = [];
7666
+ // What this session has already been served for THIS project (CG-17), and
7667
+ // whether this call may act on it (CG-18). Dedup is off on the session's
7668
+ // first call by construction — there is nothing to point back AT — and off
7669
+ // entirely under `HOMEGRAPH_EXPLORE_DEDUP=0`.
7670
+ const priorCalls = (0, explore_session_state_1.viewForProject)((0, explore_session_state_1.readExploreSessionView)(args), projectRoot);
7671
+ const dedupEnabled = (0, explore_dedup_1.exploreDedupEnabled)() && (priorCalls?.calls.length ?? 0) > 0;
7672
+ const emittedByFile = new Map();
7673
+ const noteEmitted = (fp, ranges, bytes, fingerprint) => {
7674
+ const existing = emittedByFile.get(fp);
7675
+ if (existing) {
7676
+ existing.ranges.push(...ranges);
7677
+ existing.bytes += bytes;
7678
+ if (fingerprint)
7679
+ existing.fingerprint = fingerprint;
7680
+ }
7681
+ else {
7682
+ emittedByFile.set(fp, { ranges: [...ranges], bytes, fingerprint });
7683
+ }
7684
+ };
5471
7685
  const limitSingleFile = (0, query_utils_1.shouldLimitToQueryNamedFile)(query, hasFlowPath, multiAnchor)
5472
7686
  || (interpretationQuery && queryFileBasenames.length === 1);
5473
7687
  let filesToRender = sortedFiles;
@@ -5553,6 +7767,49 @@ class ToolHandler {
5553
7767
  }
5554
7768
  const fileLines = fileContent.split('\n');
5555
7769
  const lang = group.nodes[0]?.language || '';
7770
+ // Disk-drift gate (#1474): every render branch below except whole-file
7771
+ // slices fileContent (CURRENT bytes) at INDEXED line ranges.
7772
+ const fileStale = this.isFileStaleOnDisk(cg, filePath, fileContent);
7773
+ // Cross-call dedup (CG-18). `served` is what THIS session already sent the
7774
+ // agent for THIS file, and it is empty unless the file still hashes to the
7775
+ // bytes those spans were sliced from — an edit between calls means the
7776
+ // agent's copy is wrong, so nothing is withheld.
7777
+ const fingerprint = (0, explore_dedup_1.fileFingerprint)(fileContent);
7778
+ const served = dedupEnabled ? (0, explore_dedup_1.servedRangesForFile)(priorCalls, filePath, fingerprint) : [];
7779
+ const withLineNumbers = exploreLineNumbersEnabled();
7780
+ const renderSpan = (r) => {
7781
+ const slice = fileLines.slice(r.start - 1, r.end).join('\n');
7782
+ return withLineNumbers ? numberSourceLines(slice, r.start) : slice;
7783
+ };
7784
+ /**
7785
+ * Emit one file's section — header, optional back-reference for held spans,
7786
+ * and the fence for what is new. Fully-held files become pointers and do
7787
+ * not consume a `maxFiles` slot (budget goes to unseen files).
7788
+ */
7789
+ const emitFileSection = (opts) => {
7790
+ const folded = opts.covered.length > 0 && opts.body.length < explore_dedup_1.EXPLORE_DEDUP.MIN_DELTA_CHARS;
7791
+ const body = folded ? '' : opts.body;
7792
+ const ranges = folded ? [] : opts.ranges;
7793
+ lines.push(opts.header, '');
7794
+ totalChars += opts.header.length + 2;
7795
+ if (opts.covered.length > 0) {
7796
+ const pointer = (0, explore_dedup_1.formatBackReference)(filePath, opts.covered, (0, explore_dedup_1.symbolsInSpans)(group.nodes, opts.covered), { partial: body.length > 0 });
7797
+ lines.push(pointer, '');
7798
+ totalChars += pointer.length + 2;
7799
+ }
7800
+ if (body.length > 0) {
7801
+ lines.push('```' + lang, body, '```', '');
7802
+ totalChars += body.length + lang.length + 11;
7803
+ noteEmitted(filePath, [...ranges, ...opts.covered], body.length, fingerprint);
7804
+ renderedFilePaths.push(filePath);
7805
+ filesIncluded++;
7806
+ return;
7807
+ }
7808
+ // Fully held — pointer only; still record spans at zero bytes.
7809
+ noteEmitted(filePath, opts.covered, 0, fingerprint);
7810
+ renderedFilePaths.push(filePath);
7811
+ // Do not increment filesIncluded: slot goes to a file the agent has not seen.
7812
+ };
5556
7813
  // Adaptive sizing (HOMEGRAPH_ADAPTIVE_EXPLORE, default on): collapse a file
5557
7814
  // to a per-symbol view when it's a redundant member of a polymorphic family.
5558
7815
  // Engages iff ALL hold:
@@ -5591,7 +7848,7 @@ class ToolHandler {
5591
7848
  const onSpineGodFile = hasSpineNode
5592
7849
  && namedBodyChars > budget.maxCharsPerFile
5593
7850
  && group.nodes.some(n => CALLABLE_BODY.has(n.kind) && flow.uniqueNamedNodeIds.has(n.id) && !flow.pathNodeIds.has(n.id));
5594
- if (adaptiveExploreEnabled() && flow.pathNodeIds.size > 0
7851
+ if (!fileStale && adaptiveExploreEnabled() && flow.pathNodeIds.size > 0
5595
7852
  && (onSpineGodFile || (!hasSpineNode && isPolymorphicSibling(group.nodes) && !spared))) {
5596
7853
  const syms = group.nodes
5597
7854
  .filter(n => n.kind !== 'import' && n.kind !== 'export' && n.startLine > 0)
@@ -5673,12 +7930,32 @@ class ToolHandler {
5673
7930
  // a Read of the very file just skeletonized; on a central, wanted file
5674
7931
  // (Session.swift, DataRequest.swift) that fired an over-investigation
5675
7932
  // spiral (the agent Read the skeletonized file, then kept digging).
5676
- // CLAUDE.md: explore output must never tell the agent to Read.
7933
+ // AGENTS.md: explore output must never tell the agent to Read.
5677
7934
  const tag = bodyIds.size > 0
5678
7935
  ? 'focused (the methods you named in full, the rest as signatures — homegraph_explore a signature by name for its body; do NOT Read)'
5679
7936
  : 'skeleton (signatures only — homegraph_explore a name for its full body; do NOT Read)';
5680
- lines.push(fileSectionHeader(filePath, `${names} · ${tag}`), '', '```' + lang, skel.join('\n'), '```', '');
5681
- totalChars += skel.join('\n').length + 120;
7937
+ const skelHeader = fileSectionHeader(filePath, `${names} · ${tag}`);
7938
+ // If this session already holds the whole file, replace the skeleton
7939
+ // with a pointer rather than re-serving signatures/bodies.
7940
+ const wholeRange = { start: 1, end: Math.max(1, fileLines.length) };
7941
+ const ddWhole = (0, explore_dedup_1.dedupeRange)(wholeRange, served);
7942
+ if (ddWhole.covered.length > 0 && ddWhole.emit.length === 0) {
7943
+ emitFileSection({
7944
+ header: skelHeader,
7945
+ body: '',
7946
+ ranges: [],
7947
+ covered: ddWhole.covered,
7948
+ });
7949
+ continue;
7950
+ }
7951
+ const skelBody = skel.join('\n');
7952
+ const bodyRanges = [...bodyIds].map((id) => {
7953
+ const n = group.nodes.find((x) => x.id === id);
7954
+ return n ? { start: n.startLine, end: n.endLine || n.startLine } : null;
7955
+ }).filter((r) => !!r);
7956
+ lines.push(skelHeader, '', '```' + lang, skelBody, '```', '');
7957
+ totalChars += skelBody.length + 120;
7958
+ noteEmitted(filePath, bodyRanges.length > 0 ? bodyRanges : [wholeRange], skelBody.length, fingerprint);
5682
7959
  renderedFilePaths.push(filePath);
5683
7960
  filesIncluded++;
5684
7961
  continue;
@@ -5709,25 +7986,45 @@ class ToolHandler {
5709
7986
  ? Math.min(Math.max(0, budget.maxOutputChars - totalChars - 200), Math.round(budget.maxCharsPerFile * 1.5))
5710
7987
  : budget.maxCharsPerFile * 3;
5711
7988
  if (fileLines.length <= WHOLE_FILE_MAX_LINES && fileContent.length <= WHOLE_FILE_MAX_CHARS) {
5712
- const body = fileContent.replace(/\n+$/, '');
5713
- let wholeSection = exploreLineNumbersEnabled() ? numberSourceLines(body, 1) : body;
7989
+ const wholeRange = { start: 1, end: Math.max(1, fileLines.length) };
7990
+ const dd = (0, explore_dedup_1.dedupeRange)(wholeRange, served);
5714
7991
  const uniqSymbols = [...new Set(group.nodes
5715
7992
  .filter(n => n.kind !== 'import' && n.kind !== 'export')
5716
7993
  .map(n => `${n.name}(${n.kind})`))];
5717
7994
  const headerNames = uniqSymbols.slice(0, budget.maxSymbolsInFileHeader);
5718
7995
  const omitted = uniqSymbols.length - headerNames.length;
5719
- const wholeHeader = fileSectionHeader(filePath, omitted > 0 ? `${headerNames.join(', ')}, +${omitted} more` : headerNames.join(', '));
5720
- if (!fileNecessary && totalChars + wholeSection.length + 200 > budget.maxOutputChars) {
7996
+ // A drifted file rendered WHOLE is still correct (current bytes)
7997
+ // only index-derived symbol list / line refs may be shifted (#1474).
7998
+ const staleSuffix = fileStale ? ' · ⚠ changed since last index sync — source below is current; the symbol list may be outdated' : '';
7999
+ const wholeHeader = fileSectionHeader(filePath, (omitted > 0 ? `${headerNames.join(', ')}, +${omitted} more` : headerNames.join(', ')) + staleSuffix);
8000
+ const emitParts = dd.emit.length > 0
8001
+ ? dd.emit.map((r) => ({ range: r, text: renderSpan(r) }))
8002
+ : [];
8003
+ const wholeSection = emitParts.map((p) => p.text).join('\n');
8004
+ const emitRanges = emitParts.map((p) => p.range);
8005
+ if (!fileNecessary && totalChars + Math.max(wholeSection.length, 80) + 200 > budget.maxOutputChars && dd.covered.length === 0) {
5721
8006
  // Don't slice a whole file mid-method: an incidental file that doesn't
5722
8007
  // fit is skipped; a necessary one (below) renders in full. Half a file
5723
8008
  // forces the Read this is meant to prevent.
5724
8009
  anyFileTrimmed = true;
5725
8010
  continue;
5726
8011
  }
5727
- lines.push(wholeHeader, '', '```' + lang, wholeSection, '```', '');
5728
- totalChars += wholeSection.length + 200;
5729
- renderedFilePaths.push(filePath);
5730
- filesIncluded++;
8012
+ emitFileSection({
8013
+ header: wholeHeader,
8014
+ body: wholeSection,
8015
+ ranges: emitRanges,
8016
+ covered: dd.covered,
8017
+ });
8018
+ if (fileStale && wholeSection.length > 0)
8019
+ staleRendered.push(filePath);
8020
+ continue;
8021
+ }
8022
+ // Drifted file too big for the whole-file window (#1474): omit rather
8023
+ // than risk a mis-sliced cluster/skeleton render.
8024
+ if (fileStale) {
8025
+ staleOmitted.push(filePath);
8026
+ lines.push(fileSectionHeader(filePath, '⚠ changed on disk after the last index sync — source omitted (indexed line ranges no longer match, so a slice could show the wrong code). Read this file directly for current content; the change is picked up on that project\'s next index sync.'), '');
8027
+ totalChars += 260;
5731
8028
  continue;
5732
8029
  }
5733
8030
  // Cluster nearby symbols to avoid reading huge gaps between distant symbols.
@@ -5887,7 +8184,6 @@ class ToolHandler {
5887
8184
  // until the per-file char cap is hit. Truly enormous single clusters
5888
8185
  // get tail-trimmed with a marker.
5889
8186
  const contextPadding = 3;
5890
- const withLineNumbers = exploreLineNumbersEnabled();
5891
8187
  // Language-neutral separator (no `//` — not a comment in Python, Ruby,
5892
8188
  // etc.). With line numbers on, the line-number jump also signals the gap.
5893
8189
  const GAP_MARKER = '\n\n... (gap) ...\n\n';
@@ -5898,29 +8194,38 @@ class ToolHandler {
5898
8194
  // the exact gap this closes. Bounded, so a god-method can't blow the budget yet
5899
8195
  // the spine's call still appears in context.
5900
8196
  const OVERSIZE_SPINE_LINES = 200;
8197
+ const OVERSIZE_BODY_LINES = 160;
5901
8198
  const SPINE_WINDOW = 28; // lines each side of the next-hop call site
5902
- const buildSection = (c) => {
5903
- if (c.hasSpine && c.spineCallLine && (c.end - c.start + 1) > OVERSIZE_SPINE_LINES) {
8199
+ const BODY_WINDOW = 40;
8200
+ const buildSectionRange = (c) => {
8201
+ const span = c.end - c.start + 1;
8202
+ if (c.hasSpine && c.spineCallLine && span > OVERSIZE_SPINE_LINES) {
5904
8203
  const call = c.spineCallLine;
5905
8204
  const winStart = Math.max(c.start, call - SPINE_WINDOW);
5906
8205
  const winEnd = Math.min(c.end, call + SPINE_WINDOW);
5907
- const parts = [];
5908
- // Signature head, only when it sits clearly above the window (else the
5909
- // window already covers the method opening).
8206
+ const ranges = [];
5910
8207
  const headEnd = Math.min(c.start + 4, winStart - 2);
5911
- if (headEnd >= c.start) {
5912
- const head = fileLines.slice(c.start - 1, headEnd).join('\n');
5913
- parts.push(withLineNumbers ? numberSourceLines(head, c.start) : head);
5914
- }
5915
- const win = fileLines.slice(winStart - 1, winEnd).join('\n');
5916
- parts.push(withLineNumbers ? numberSourceLines(win, winStart) : win);
5917
- return parts.join(GAP_MARKER);
5918
- }
5919
- const startIdx = Math.max(0, c.start - 1 - contextPadding);
5920
- const endIdx = Math.min(fileLines.length, c.end + contextPadding);
5921
- const slice = fileLines.slice(startIdx, endIdx).join('\n');
5922
- // startIdx is 0-based, so the slice's first line is line startIdx + 1.
5923
- return withLineNumbers ? numberSourceLines(slice, startIdx + 1) : slice;
8208
+ if (headEnd >= c.start)
8209
+ ranges.push({ start: c.start, end: headEnd });
8210
+ ranges.push({ start: winStart, end: winEnd });
8211
+ return ranges;
8212
+ }
8213
+ // Long non-spine functions get a bounded window, not a full dump.
8214
+ if (!c.hasSpine && span > OVERSIZE_BODY_LINES) {
8215
+ const headEnd = Math.min(c.end, c.start + BODY_WINDOW);
8216
+ const midStart = Math.max(headEnd + 1, Math.floor((c.start + c.end) / 2) - Math.floor(BODY_WINDOW / 2));
8217
+ const midEnd = Math.min(c.end, midStart + BODY_WINDOW);
8218
+ const ranges = [{ start: c.start, end: headEnd }];
8219
+ if (midStart > headEnd)
8220
+ ranges.push({ start: midStart, end: midEnd });
8221
+ return ranges;
8222
+ }
8223
+ const start = Math.max(1, c.start - contextPadding);
8224
+ const end = Math.min(fileLines.length, c.end + contextPadding);
8225
+ return [{ start, end }];
8226
+ };
8227
+ const buildSection = (c) => {
8228
+ return buildSectionRange(c).map((r) => renderSpan(r)).join(GAP_MARKER);
5924
8229
  };
5925
8230
  // Rank clusters for inclusion under the per-file cap. Entry-point
5926
8231
  // clusters come first: a cluster containing a query entry point
@@ -5951,18 +8256,19 @@ class ToolHandler {
5951
8256
  return b.c.score - a.c.score;
5952
8257
  return a.span - b.span;
5953
8258
  });
5954
- // Per-file budget is the SMALLER of the per-file cap and what's left of the
5955
- // total output cap so selection (which ranks by importance) keeps the
5956
- // high-importance clusters and drops peripheral ones, instead of the
5957
- // downstream source-order trim slicing off whatever comes last in the file.
5958
- // That source-order slice is what cut Django's `_fetch_all` (L2237, importance
5959
- // 9 agent-named) when query.py was the last of four big files to be emitted.
5960
- const fileBudget = Math.min(budget.maxCharsPerFile, Math.max(0, budget.maxOutputChars - totalChars - 200));
8259
+ // Per-file budget fairness: reserve a fair share of remaining envelope for
8260
+ // files still waiting, so the first file cannot steal later files' budget.
8261
+ // Still capped by maxCharsPerFile; necessary/spine files can exceed via
8262
+ // SPINE_CEILING below.
8263
+ const filesLeft = Math.max(1, Math.min(maxFiles - filesIncluded, filesToRender.length - filesIncluded));
8264
+ const remainingEnvelope = Math.max(0, budget.maxOutputChars - totalChars - 200);
8265
+ const fairShare = Math.floor(remainingEnvelope / filesLeft);
8266
+ const fileBudget = Math.min(budget.maxCharsPerFile, Math.max(fairShare, Math.min(budget.maxCharsPerFile, remainingEnvelope)));
5961
8267
  // Spine ceiling: a flow-path cluster may exceed the per-file cap (the call
5962
8268
  // path is the answer), but bounded — at most ~2.5× the per-file cap and never
5963
8269
  // past what's left of the total output cap — so a pathological long in-file
5964
8270
  // spine can't run away or starve co-flow files entirely.
5965
- const SPINE_CEILING = Math.min(budget.maxCharsPerFile * 2.5, Math.max(0, budget.maxOutputChars - totalChars - 200));
8271
+ const SPINE_CEILING = Math.min(budget.maxCharsPerFile * 2.5, remainingEnvelope);
5966
8272
  const chosenIndices = new Set();
5967
8273
  let projectedChars = 0;
5968
8274
  for (const rc of rankedClusters) {
@@ -6041,21 +8347,46 @@ class ToolHandler {
6041
8347
  anyFileTrimmed = true;
6042
8348
  continue;
6043
8349
  }
6044
- lines.push(fileHeader);
6045
- lines.push('');
6046
- lines.push('```' + lang);
6047
- lines.push(fileSection);
6048
- lines.push('```');
6049
- lines.push('');
6050
- totalChars += fileSection.length + 200;
6051
- renderedFilePaths.push(filePath);
6052
- filesIncluded++;
8350
+ // Apply session history to the chosen clusters' spans (CG-18).
8351
+ const intendedRanges = [];
8352
+ for (let i = 0; i < clusters.length; i++) {
8353
+ if (!chosenIndices.has(i))
8354
+ continue;
8355
+ intendedRanges.push(...buildSectionRange(clusters[i]));
8356
+ }
8357
+ const coveredParts = [];
8358
+ const emitParts = [];
8359
+ for (const range of intendedRanges) {
8360
+ const split = (0, explore_dedup_1.dedupeRange)(range, served);
8361
+ coveredParts.push(...split.covered);
8362
+ for (const r of split.emit)
8363
+ emitParts.push({ range: r, text: renderSpan(r) });
8364
+ }
8365
+ const dedupedBody = emitParts.map((p) => p.text).join(GAP_MARKER);
8366
+ const dedupedRanges = emitParts.map((p) => p.range);
8367
+ const covered = (0, explore_dedup_1.mergeRanges)(coveredParts);
8368
+ emitFileSection({
8369
+ header: fileHeader,
8370
+ body: dedupedBody.length > 0 ? dedupedBody : (covered.length > 0 ? '' : fileSection),
8371
+ ranges: dedupedRanges.length > 0 ? dedupedRanges : (covered.length > 0 ? [] : intendedRanges),
8372
+ covered,
8373
+ });
6053
8374
  }
6054
8375
  // The curated header count is computed from the files that SURVIVE the final
6055
8376
  // truncation (see end of method) — `filesIncluded` can over-count when the
6056
8377
  // hard ceiling drops trailing sections — so leave a sentinel here and fill it
6057
8378
  // in once the output is final.
6058
8379
  lines[summaryLineIdx] = SUMMARY_SENTINEL;
8380
+ // Drift epilogue (#1474). Everything actually rendered is still byte-accurate
8381
+ // (drifted files ship whole or not at all), but omitted files need Reading and
8382
+ // index-derived line references to drifted files may be shifted.
8383
+ if (staleOmitted.length > 0) {
8384
+ lines[verbatimHeaderIdx] += ' (Exception: files flagged "⚠ changed on disk" below drifted from the index after their last sync — their source is omitted rather than risk a mis-sliced block; Read those specific files.)';
8385
+ }
8386
+ const staleAll = [...new Set([...staleOmitted, ...staleRendered])];
8387
+ if (staleAll.length > 0) {
8388
+ lines.push('', `> ⚠ Changed on disk after the last index sync: ${staleAll.join(', ')}. Line numbers referencing ${staleAll.length === 1 ? 'this file' : 'these files'} elsewhere in this response (flow steps, blast radius, symbol lists) may be shifted until that project's next sync re-indexes ${staleAll.length === 1 ? 'it' : 'them'}.`);
8389
+ }
6059
8390
  // Add remaining files as references (from both relevant and peripheral files).
6060
8391
  // Small projects (per budget) skip this — the relevant story already fits
6061
8392
  // in the source section, and a trailing pointer list is pure overhead.
@@ -6096,7 +8427,9 @@ class ToolHandler {
6096
8427
  const stats = cg.getStats();
6097
8428
  const callBudget = getExploreBudget(stats.fileCount);
6098
8429
  lines.push('');
6099
- lines.push(`> **Explore budget: ${callBudget} calls for this project (${stats.fileCount.toLocaleString()} files indexed).** Each call covers ~6 files; if your question spans more, spend your remaining calls on the uncovered area BEFORE falling back to Read — another explore is cheaper and more complete than reading those files. Synthesize once you've used ${callBudget}.`);
8430
+ lines.push(`> **Usually one explore is enough** (budget ≤${callBudget} for ${stats.fileCount.toLocaleString()} files). ` +
8431
+ 'Answer from Flow + Source above. Extra explore/`homegraph_node`/Read for the **same** symbols multiplies tokens — ' +
8432
+ 'only call again for a missing **named** symbol.');
6100
8433
  }
6101
8434
  catch {
6102
8435
  // Stats unavailable — skip budget note
@@ -6114,16 +8447,54 @@ class ToolHandler {
6114
8447
  const hardCeiling = Math.min(Math.round(budget.maxOutputChars * 1.5), 25000);
6115
8448
  let finalText;
6116
8449
  if (output.length > hardCeiling) {
6117
- // Cut at a FILE-SECTION boundary (the last ``**` `` file header before the
6118
- // ceiling) so we drop whole trailing file-sections rather than slicing
6119
- // through a method body — a half-rendered method just forces the Read this
6120
- // tool exists to prevent. Fall back to a line boundary only if no section
6121
- // header sits in the back half (degenerate single-giant-section case).
6122
- const cut = output.slice(0, hardCeiling);
6123
- const lastSection = cut.lastIndexOf('\n' + FILE_SECTION_PREFIX);
6124
- const boundary = lastSection > hardCeiling * 0.5 ? lastSection : cut.lastIndexOf('\n');
6125
- const safe = boundary > 0 ? cut.slice(0, boundary) : cut;
6126
- finalText = safe + '\n\n... (output truncated to budget; the source above is complete and verbatim — treat it as already Read. For uncovered files/symbols, run another homegraph_explore with their exact names — not grep/read/node for symbols already shown.)';
8450
+ // Prefer dropping trailing notes ("Not shown above", completeness, budget)
8451
+ // over dropping a whole file's source notes are recoverable, source isn't.
8452
+ let trimmed = output;
8453
+ const noteMarkers = [
8454
+ '\n**Not shown above explore these names for their source**',
8455
+ '\n---\n> **Complete source for ',
8456
+ '\n> Some file sections were trimmed for size.',
8457
+ '\n> **Usually one explore is enough**',
8458
+ ];
8459
+ for (const marker of noteMarkers) {
8460
+ if (trimmed.length <= hardCeiling)
8461
+ break;
8462
+ const at = trimmed.lastIndexOf(marker);
8463
+ if (at > hardCeiling * 0.4)
8464
+ trimmed = trimmed.slice(0, at).replace(/\n+$/, '');
8465
+ }
8466
+ if (trimmed.length > hardCeiling) {
8467
+ // Cut at a FILE-SECTION boundary so we drop whole trailing file-sections
8468
+ // rather than slicing through a method body. Prefer dropping sections for
8469
+ // files that are NOT agent-named / spine-necessary when possible.
8470
+ const cut = trimmed.slice(0, hardCeiling);
8471
+ let boundary = -1;
8472
+ let searchFrom = cut.length;
8473
+ while (searchFrom > hardCeiling * 0.5) {
8474
+ const at = cut.lastIndexOf('\n' + FILE_SECTION_PREFIX, searchFrom - 1);
8475
+ if (at < 0)
8476
+ break;
8477
+ // Extract path between **` and `**
8478
+ const after = cut.slice(at + 1 + FILE_SECTION_PREFIX.length);
8479
+ const endTick = after.indexOf('`');
8480
+ const fp = endTick > 0 ? after.slice(0, endTick) : '';
8481
+ const necessary = fp && (namedSeedFiles.has(fp) || entryFiles.has(fp) || centralFiles.has(fp));
8482
+ if (!necessary) {
8483
+ boundary = at;
8484
+ break;
8485
+ }
8486
+ searchFrom = at;
8487
+ }
8488
+ if (boundary < 0) {
8489
+ const lastSection = cut.lastIndexOf('\n' + FILE_SECTION_PREFIX);
8490
+ boundary = lastSection > hardCeiling * 0.5 ? lastSection : cut.lastIndexOf('\n');
8491
+ }
8492
+ const safe = boundary > 0 ? cut.slice(0, boundary) : cut;
8493
+ finalText = safe + '\n\n... (output truncated to budget; the source above is complete and verbatim — treat it as already Read. For uncovered files/symbols, run another homegraph_explore with their exact names — not grep/read/node for symbols already shown.)';
8494
+ }
8495
+ else {
8496
+ finalText = trimmed;
8497
+ }
6127
8498
  }
6128
8499
  else {
6129
8500
  finalText = output;
@@ -6131,7 +8502,7 @@ class ToolHandler {
6131
8502
  if (!finalText.includes('**Partial result**') && !finalText.includes('> **Explore complete**')) {
6132
8503
  finalText +=
6133
8504
  '\n\n> **Explore complete — ANSWER NOW.** Flow + Source above are authoritative for this query. ' +
6134
- 'Do **not** grep/read/`homegraph_node`/`homegraph_search` for the same symbols or files. ' +
8505
+ 'Do **not** grep/read/`homegraph_node`/`homegraph_search` for the same symbols or files (multiplies tokens). ' +
6135
8506
  'Only call one tighter `homegraph_explore` if a named symbol essential to the answer is missing.';
6136
8507
  }
6137
8508
  // Curated header (#1046): substitute the sentinel with the count of files
@@ -6152,7 +8523,40 @@ class ToolHandler {
6152
8523
  ? `Found ${shownSymbols} symbol${shownSymbols === 1 ? '' : 's'} across ${survivors.length} file${survivors.length === 1 ? '' : 's'}.`
6153
8524
  : `Found ${subgraph.nodes.size} symbol${subgraph.nodes.size === 1 ? '' : 's'} across ${fileGroups.size} file${fileGroups.size === 1 ? '' : 's'}.`;
6154
8525
  finalText = finalText.replace(SUMMARY_SENTINEL, summaryLine);
6155
- return this.textResult(finalText);
8526
+ // Session record (CG-17): only the files that SURVIVED the hard ceiling —
8527
+ // a section the truncation dropped was never delivered, and recording it
8528
+ // would let a later call withhold source the agent has never seen.
8529
+ const emittedFiles = [];
8530
+ let sourceBytes = 0;
8531
+ for (const fp of survivors) {
8532
+ const emitted = emittedByFile.get(fp);
8533
+ if (!emitted || emitted.ranges.length === 0)
8534
+ continue;
8535
+ emittedFiles.push({
8536
+ path: fp,
8537
+ ranges: emitted.ranges,
8538
+ bytes: emitted.bytes,
8539
+ fingerprint: emitted.fingerprint,
8540
+ });
8541
+ sourceBytes += emitted.bytes;
8542
+ }
8543
+ return this.exploreResult(finalText, {
8544
+ projectRoot,
8545
+ query,
8546
+ files: emittedFiles,
8547
+ sourceBytes,
8548
+ responseBytes: finalText.length,
8549
+ });
8550
+ }
8551
+ /**
8552
+ * An explore response plus the record of what it emitted (CG-17). The record
8553
+ * rides the result only as far as {@link execute}, which files it into the
8554
+ * calling session's state and deletes it — see {@link EXPLORE_EMISSION_KEY}.
8555
+ */
8556
+ exploreResult(text, emission) {
8557
+ const result = this.textResult(text);
8558
+ result[explore_session_state_1.EXPLORE_EMISSION_KEY] = emission;
8559
+ return result;
6156
8560
  }
6157
8561
  /**
6158
8562
  * Handle homegraph_node
@@ -6406,6 +8810,11 @@ class ToolHandler {
6406
8810
  }
6407
8811
  /** Render one symbol: details + (optional) body/outline + its caller/callee trail. */
6408
8812
  async renderNodeSection(cg, node, includeCode) {
8813
+ // Disk-drift gate (issue #1474): body is CURRENT bytes sliced at INDEXED
8814
+ // line ranges. Never emit a slice from a drifted file.
8815
+ if (this.isFileStaleOnDisk(cg, node.filePath)) {
8816
+ return this.renderStaleNodeSection(cg, node, includeCode);
8817
+ }
6409
8818
  let code = null;
6410
8819
  let outline = null;
6411
8820
  if (includeCode) {
@@ -6422,6 +8831,48 @@ class ToolHandler {
6422
8831
  }
6423
8832
  return this.formatNodeDetails(node, code, outline) + this.formatTrail(cg, node);
6424
8833
  }
8834
+ // Whole-file fallback caps for a drifted file (#1474).
8835
+ static STALE_WHOLE_FILE_MAX_LINES = 300;
8836
+ static STALE_WHOLE_FILE_MAX_CHARS = 12000;
8837
+ /**
8838
+ * homegraph_node render for a symbol whose file changed on disk after the
8839
+ * last index sync (issue #1474). Indexed line ranges are untrustworthy, so
8840
+ * no slice is emitted: small files get full CURRENT source; large ones get
8841
+ * an explicit notice.
8842
+ */
8843
+ renderStaleNodeSection(cg, node, includeCode) {
8844
+ const lines = [
8845
+ `**${node.name}** (${node.kind})`,
8846
+ '',
8847
+ `**Location:** ${node.filePath}${node.startLine ? `:${node.startLine}` : ''} — ⚠ as of the last index sync; the file has changed on disk since, so this line may be shifted`,
8848
+ ];
8849
+ if (node.signature) {
8850
+ lines.push(...formatNodeSignatureBlock(node.signature));
8851
+ }
8852
+ lines.push('');
8853
+ let embedded = false;
8854
+ if (includeCode) {
8855
+ try {
8856
+ const absPath = (0, utils_1.validatePathWithinRoot)(cg.getProjectRoot(), node.filePath);
8857
+ if (absPath && (0, fs_1.existsSync)(absPath) && !(0, utils_1.isConfigLeafNode)(node)) {
8858
+ const content = (0, fs_1.readFileSync)(absPath, 'utf-8');
8859
+ const body = content.replace(/\n+$/, '');
8860
+ if (body.length <= ToolHandler.STALE_WHOLE_FILE_MAX_CHARS &&
8861
+ body.split('\n').length <= ToolHandler.STALE_WHOLE_FILE_MAX_LINES) {
8862
+ lines.push(`> ⚠ \`${node.filePath}\` changed on disk after it was last indexed, so the indexed line range for this symbol may no longer match. Showing the file's full CURRENT source instead (Read-parity — treat it as already Read):`, '', '```' + (node.language || ''), numberSourceLines(body, 1), '```');
8863
+ embedded = true;
8864
+ }
8865
+ }
8866
+ }
8867
+ catch {
8868
+ /* fall through to the notice */
8869
+ }
8870
+ }
8871
+ if (!embedded) {
8872
+ lines.push(`> ⚠ \`${node.filePath}\` changed on disk after it was last indexed — the indexed line range for this symbol no longer reliably matches, so its body is omitted rather than risk showing a different symbol's code. For current content, call homegraph_node with \`file: "${node.filePath}"\` (no symbol; \`offset\`/\`limit\` narrow it like Read), or Read the file. The change is picked up automatically on that project's next index sync.`);
8873
+ }
8874
+ return lines.join('\n') + this.formatTrail(cg, node);
8875
+ }
6425
8876
  /**
6426
8877
  * Build the "trail" for a symbol: its direct callees (what it calls) and
6427
8878
  * callers (what calls it), each with file:line — so homegraph_node doubles as
@@ -6466,6 +8917,16 @@ class ToolHandler {
6466
8917
  * Handle homegraph_status
6467
8918
  */
6468
8919
  async handleStatus(args) {
8920
+ const sourcesMode = (0, graph_sources_1.resolveGraphSources)();
8921
+ if (!(0, graph_sources_1.graphSourceFlags)(sourcesMode).openProjectDb) {
8922
+ return this.textResult([
8923
+ '**HomeGraph Status**',
8924
+ '',
8925
+ `**Graph sources:** ${sourcesMode}`,
8926
+ '',
8927
+ (0, graph_sources_1.graphSourcesDisabledGuidance)(sourcesMode),
8928
+ ].join('\n'));
8929
+ }
6469
8930
  let cg = this.getHomeGraph(args.projectPath);
6470
8931
  // Same trick as withStalenessNotice — when an explicit projectPath
6471
8932
  // resolves to the same project as the default session cg, prefer the
@@ -6493,7 +8954,9 @@ class ToolHandler {
6493
8954
  if (mismatch) {
6494
8955
  lines.push(`> ⚠ ${(0, worktree_1.worktreeMismatchWarning)(mismatch).replace(/\n/g, '\n> ')}`, '');
6495
8956
  }
6496
- lines.push(`**Files indexed:** ${stats.fileCount}`, `**Total nodes:** ${stats.nodeCount}`, `**Total edges:** ${stats.edgeCount}`, `**Database size:** ${(stats.dbSizeBytes / 1024 / 1024).toFixed(2)} MB`);
8957
+ lines.push(`**Files indexed:** ${stats.fileCount}`, `**Total nodes:** ${stats.nodeCount}`, `**Total edges:** ${stats.edgeCount}`, `**Database size:** ${(stats.dbSizeBytes / 1024 / 1024).toFixed(2)} MB`, ...(stats.walSizeBytes > 0
8958
+ ? [`**WAL size:** ${(stats.walSizeBytes / 1024 / 1024).toFixed(2)} MB`]
8959
+ : []), `**Graph sources:** ${cg.getGraphSources()}`);
6497
8960
  // Surface the active SQLite backend: node:sqlite → better-sqlite3 → wasm.
6498
8961
  const backend = cg.getBackend();
6499
8962
  if (backend === 'node-sqlite') {
@@ -6552,6 +9015,27 @@ class ToolHandler {
6552
9015
  }
6553
9016
  return this.textResult(lines.join('\n'));
6554
9017
  }
9018
+ /**
9019
+ * Handle homegraph_arkui_migrate — one-shot ArkUI migrate / state snapshot (spec 0007).
9020
+ */
9021
+ async handleArkuiMigrate(args) {
9022
+ const scope = this.validateString(args.scope, 'scope');
9023
+ if (typeof scope !== 'string')
9024
+ return scope;
9025
+ const cg = this.getHomeGraph(args.projectPath);
9026
+ try {
9027
+ const snapshot = cg.getArkUIMigrateSnapshot(scope);
9028
+ return this.textResult(JSON.stringify(snapshot, null, 2));
9029
+ }
9030
+ catch (err) {
9031
+ const message = err instanceof Error ? err.message : String(err);
9032
+ return this.textResult([
9033
+ `No ArkUI migrate snapshot for scope "${scope}".`,
9034
+ message,
9035
+ 'Pass a component name, relative .ets path, or directory prefix. Ensure the project is indexed (`homegraph init` / `index`).',
9036
+ ].join('\n'));
9037
+ }
9038
+ }
6555
9039
  /**
6556
9040
  * Handle homegraph_files - get project file structure from the index
6557
9041
  */
@@ -7108,6 +9592,19 @@ class ToolHandler {
7108
9592
  const segments = node.filePath.split('/').filter((s) => s.length > 0);
7109
9593
  return containerHints.every((hint) => segments.some((seg) => seg === hint || seg.replace(/\.[^.]+$/, '') === hint));
7110
9594
  }
9595
+ /**
9596
+ * Generated-file demotion signal: path convention OR persisted content banner.
9597
+ */
9598
+ isGeneratedCandidate(cg, filePath) {
9599
+ if ((0, generated_detection_1.isGeneratedFile)(filePath))
9600
+ return true;
9601
+ try {
9602
+ return cg.getFile(filePath)?.generated === true;
9603
+ }
9604
+ catch {
9605
+ return false;
9606
+ }
9607
+ }
7111
9608
  /**
7112
9609
  * Find ALL definitions matching a name, ranked, so homegraph_node can return
7113
9610
  * every overload instead of guessing one (the wrong guess → a Read). Keepers
@@ -7127,7 +9624,8 @@ class ToolHandler {
7127
9624
  if (!isQualified) {
7128
9625
  const exact = cg.getNodesByName(symbol);
7129
9626
  if (exact.length > 0) {
7130
- return [...exact].sort((a, b) => ((0, generated_detection_1.isGeneratedFile)(a.filePath) ? 1 : 0) - ((0, generated_detection_1.isGeneratedFile)(b.filePath) ? 1 : 0));
9627
+ return [...exact].sort((a, b) => (this.isGeneratedCandidate(cg, a.filePath) ? 1 : 0) -
9628
+ (this.isGeneratedCandidate(cg, b.filePath) ? 1 : 0));
7131
9629
  }
7132
9630
  // No exact match — use the single top fuzzy result (e.g. a file basename).
7133
9631
  const fuzzy = cg.searchNodes(symbol, { limit: 10 });
@@ -7152,10 +9650,9 @@ class ToolHandler {
7152
9650
  // hit (#173); a bare name may use the single top fuzzy result.
7153
9651
  return isQualified ? [] : results[0] ? [results[0].node] : [];
7154
9652
  }
7155
- // Down-rank generated files (.pb.go, .pulsar.go, _grpc.pb.go, …) so a flow
7156
- // query prefers the keeper implementation over the protobuf-generated stub.
7157
9653
  return [...exactMatches]
7158
- .sort((a, b) => ((0, generated_detection_1.isGeneratedFile)(a.node.filePath) ? 1 : 0) - ((0, generated_detection_1.isGeneratedFile)(b.node.filePath) ? 1 : 0))
9654
+ .sort((a, b) => (this.isGeneratedCandidate(cg, a.node.filePath) ? 1 : 0) -
9655
+ (this.isGeneratedCandidate(cg, b.node.filePath) ? 1 : 0))
7159
9656
  .map((r) => r.node);
7160
9657
  }
7161
9658
  /**
@@ -7206,8 +9703,8 @@ class ToolHandler {
7206
9703
  // /impact aggregation aligned (a query against "Send" returns the
7207
9704
  // hand-written implementations before the protobuf scaffold).
7208
9705
  const ranked = [...pool].sort((a, b) => {
7209
- const aGen = (0, generated_detection_1.isGeneratedFile)(a.filePath) ? 1 : 0;
7210
- const bGen = (0, generated_detection_1.isGeneratedFile)(b.filePath) ? 1 : 0;
9706
+ const aGen = this.isGeneratedCandidate(cg, a.filePath) ? 1 : 0;
9707
+ const bGen = this.isGeneratedCandidate(cg, b.filePath) ? 1 : 0;
7211
9708
  return aGen - bGen;
7212
9709
  });
7213
9710
  const locations = ranked.map(n => `${n.kind} at ${n.filePath}:${n.startLine}`);
@@ -7253,6 +9750,62 @@ class ToolHandler {
7253
9750
  }
7254
9751
  return lines.join('\n');
7255
9752
  }
9753
+ /**
9754
+ * At caller sites, list nearby `#include` / `import` lines — answers
9755
+ * "which call makes Type X's definition visible" without a second Grep/Read
9756
+ * when the agent only called `homegraph_callers`.
9757
+ */
9758
+ formatCallerIncludeVisibility(cg, callers) {
9759
+ if (callers.length === 0)
9760
+ return '';
9761
+ let projectRoot = '';
9762
+ try {
9763
+ projectRoot = cg.getProjectRoot();
9764
+ }
9765
+ catch {
9766
+ return '';
9767
+ }
9768
+ const rows = [];
9769
+ const seen = new Set();
9770
+ for (const caller of callers.slice(0, 8)) {
9771
+ const abs = (0, utils_1.validatePathWithinRoot)(projectRoot, caller.filePath);
9772
+ if (!abs || !(0, fs_1.existsSync)(abs))
9773
+ continue;
9774
+ let content;
9775
+ try {
9776
+ content = (0, fs_1.readFileSync)(abs, 'utf-8');
9777
+ }
9778
+ catch {
9779
+ continue;
9780
+ }
9781
+ const fileLines = content.split('\n');
9782
+ const scanTo = Math.min(fileLines.length, Math.max(120, (caller.startLine || 1) + 5));
9783
+ for (let i = 0; i < scanTo; i++) {
9784
+ const lineText = fileLines[i] ?? '';
9785
+ if (!/^\s*(?:#\s*include|import\s)/.test(lineText))
9786
+ continue;
9787
+ const key = `${caller.filePath}:${i + 1}`;
9788
+ if (seen.has(key))
9789
+ continue;
9790
+ seen.add(key);
9791
+ rows.push(`- \`${caller.filePath}:${i + 1}\` \`${lineText.trim().slice(0, 140)}\``);
9792
+ if (rows.length >= 12)
9793
+ break;
9794
+ }
9795
+ if (rows.length >= 12)
9796
+ break;
9797
+ }
9798
+ if (rows.length === 0)
9799
+ return '';
9800
+ return [
9801
+ '',
9802
+ '**Include / import at caller files** (definition visibility cues)',
9803
+ '',
9804
+ ...rows,
9805
+ '',
9806
+ '> Use these includes with the caller list when asking which call makes another type visible.',
9807
+ ].join('\n');
9808
+ }
7256
9809
  /**
7257
9810
  * Relationship label for a non-`calls` edge in callers/callees lists. A
7258
9811
  * function-as-value edge (#756) is the high-signal one: `callers(cb)`