homegraph 1.5.5 → 1.5.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +46 -0
- package/README.md +10 -4
- package/dist/bin/homegraph.js +6 -0
- package/dist/bin/homegraph.js.map +1 -1
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +120 -15
- package/dist/context/index.js.map +1 -1
- package/dist/db/migrations.d.ts +1 -1
- package/dist/db/migrations.d.ts.map +1 -1
- package/dist/db/migrations.js +37 -1
- package/dist/db/migrations.js.map +1 -1
- package/dist/db/queries.d.ts +25 -0
- package/dist/db/queries.d.ts.map +1 -1
- package/dist/db/queries.js +50 -2
- package/dist/db/queries.js.map +1 -1
- package/dist/db/schema.sql +24 -0
- package/dist/extraction/languages/arkts.d.ts +40 -3
- package/dist/extraction/languages/arkts.d.ts.map +1 -1
- package/dist/extraction/languages/arkts.js +302 -22
- package/dist/extraction/languages/arkts.js.map +1 -1
- package/dist/index.d.ts +31 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +87 -1
- package/dist/index.js.map +1 -1
- package/dist/mcp/engine.d.ts +26 -0
- package/dist/mcp/engine.d.ts.map +1 -1
- package/dist/mcp/engine.js +229 -8
- package/dist/mcp/engine.js.map +1 -1
- package/dist/mcp/evidence-rendering.d.ts +10 -0
- package/dist/mcp/evidence-rendering.d.ts.map +1 -0
- package/dist/mcp/evidence-rendering.js +71 -0
- package/dist/mcp/evidence-rendering.js.map +1 -0
- package/dist/mcp/explore-repeat-guard.d.ts +8 -13
- package/dist/mcp/explore-repeat-guard.d.ts.map +1 -1
- package/dist/mcp/explore-repeat-guard.js +54 -114
- package/dist/mcp/explore-repeat-guard.js.map +1 -1
- package/dist/mcp/explore-session-state.d.ts +26 -4
- package/dist/mcp/explore-session-state.d.ts.map +1 -1
- package/dist/mcp/explore-session-state.js +50 -4
- package/dist/mcp/explore-session-state.js.map +1 -1
- package/dist/mcp/index.d.ts.map +1 -1
- package/dist/mcp/index.js +16 -1
- package/dist/mcp/index.js.map +1 -1
- package/dist/mcp/query-cache.d.ts +1 -1
- package/dist/mcp/query-cache.d.ts.map +1 -1
- package/dist/mcp/query-cache.js +86 -3
- package/dist/mcp/query-cache.js.map +1 -1
- package/dist/mcp/query-pool.js +4 -4
- package/dist/mcp/server-instructions.d.ts +2 -2
- package/dist/mcp/server-instructions.d.ts.map +1 -1
- package/dist/mcp/server-instructions.js +36 -21
- package/dist/mcp/server-instructions.js.map +1 -1
- package/dist/mcp/tools.d.ts +29 -3
- package/dist/mcp/tools.d.ts.map +1 -1
- package/dist/mcp/tools.js +827 -109
- package/dist/mcp/tools.js.map +1 -1
- package/dist/project-map/index.d.ts +33 -0
- package/dist/project-map/index.d.ts.map +1 -0
- package/dist/project-map/index.js +260 -0
- package/dist/project-map/index.js.map +1 -0
- package/dist/search/literal-evidence.d.ts +44 -0
- package/dist/search/literal-evidence.d.ts.map +1 -0
- package/dist/search/literal-evidence.js +278 -0
- package/dist/search/literal-evidence.js.map +1 -0
- package/dist/search/query-plan-provider.d.ts +6 -0
- package/dist/search/query-plan-provider.d.ts.map +1 -0
- package/dist/search/query-plan-provider.js +289 -0
- package/dist/search/query-plan-provider.js.map +1 -0
- package/dist/search/query-plan.d.ts +98 -0
- package/dist/search/query-plan.d.ts.map +1 -0
- package/dist/search/query-plan.js +334 -0
- package/dist/search/query-plan.js.map +1 -0
- package/dist/search/query-utils.d.ts +6 -1
- package/dist/search/query-utils.d.ts.map +1 -1
- package/dist/search/query-utils.js +64 -5
- package/dist/search/query-utils.js.map +1 -1
- package/dist/sync/watcher.d.ts +15 -3
- package/dist/sync/watcher.d.ts.map +1 -1
- package/dist/sync/watcher.js +14 -1
- package/dist/sync/watcher.js.map +1 -1
- package/dist/types.d.ts +23 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/mcp/tools.js
CHANGED
|
@@ -21,6 +21,8 @@ exports.queryAsBareSymbolInventory = queryAsBareSymbolInventory;
|
|
|
21
21
|
exports.hasPositiveAnswerNowDirective = hasPositiveAnswerNowDirective;
|
|
22
22
|
exports.reconcilePartialAnswerNow = reconcilePartialAnswerNow;
|
|
23
23
|
const query_pool_1 = require("./query-pool");
|
|
24
|
+
const evidence_rendering_1 = require("./evidence-rendering");
|
|
25
|
+
const query_plan_1 = require("../search/query-plan");
|
|
24
26
|
const memory_budget_1 = require("./memory-budget");
|
|
25
27
|
const directory_1 = require("../directory");
|
|
26
28
|
// Lazy-load the heavy HomeGraph chain off the MCP startup path — see the same
|
|
@@ -99,6 +101,20 @@ const MAX_OUTPUT_LENGTH = 15000;
|
|
|
99
101
|
* far beyond any realistic legitimate query.
|
|
100
102
|
*/
|
|
101
103
|
const MAX_INPUT_LENGTH = 10_000;
|
|
104
|
+
// Server-owned, structured-clone-safe request context. Never accept these from MCP clients.
|
|
105
|
+
const QUERY_PLAN_ARG = '__homegraphQueryPlan';
|
|
106
|
+
const QUERY_DEADLINE_ARG = '__homegraphQueryDeadlineAt';
|
|
107
|
+
const QUERY_STARTED_ARG = '__homegraphQueryStartedAt';
|
|
108
|
+
const QUERY_INDEX_STATE_ARG = '__homegraphQueryIndexState';
|
|
109
|
+
const QUERY_FAST_ATTEMPTED_ARG = '__homegraphQueryFastAttempted';
|
|
110
|
+
function readQueryPlan(args) {
|
|
111
|
+
const plan = args[QUERY_PLAN_ARG];
|
|
112
|
+
return plan?.version === query_plan_1.QUERY_PLAN_VERSION && typeof plan.originalQuery === 'string'
|
|
113
|
+
&& typeof plan.canonicalQuery === 'string' && Array.isArray(plan.steps) ? plan : undefined;
|
|
114
|
+
}
|
|
115
|
+
function planFeature(plan, name, query, fallback) {
|
|
116
|
+
return plan?.features[name] ?? fallback(query);
|
|
117
|
+
}
|
|
102
118
|
/** Example values for success-shaped bad-arg guidance (keyed by arg name). */
|
|
103
119
|
const BAD_ARG_EXAMPLES = {
|
|
104
120
|
query: 'authenticate login',
|
|
@@ -843,95 +859,107 @@ exports.tools = [
|
|
|
843
859
|
annotations: READ_ONLY_ANNOTATIONS,
|
|
844
860
|
},
|
|
845
861
|
{
|
|
846
|
-
name: '
|
|
847
|
-
description: 'PRIMARY
|
|
848
|
-
'
|
|
849
|
-
'
|
|
850
|
-
'
|
|
851
|
-
'named Type/Component/Page/Dialog, Type.member, click→handler, inheritance/subtypes, declaration/attribute sites, ' +
|
|
852
|
-
'ALL_CAPS constant / field-mutex usages, path-module NAPI/exports or inter-deps, and in-repo @kit/@ohos *usages/dependencies* ' +
|
|
853
|
-
'(which files import a named export — not the SDK feature catalog). PascalCase names optional when domain keywords suffice. ' +
|
|
854
|
-
'Prefer callers/node when one named symbol is already enough. ' +
|
|
855
|
-
'DO NOT call for topic file-lists with no Type/file, literal copy hunts / pure existence compares with no anchors, ' +
|
|
856
|
-
'official-docs-only asks, empty-project-from-scratch scaffolds, git history, or media/binary asset inventories — those return Skip. ' +
|
|
857
|
-
'@kit / OHOS API questions ARE in scope when the SDK API graph is available — call explore (usages or API symbols). ' +
|
|
858
|
-
'Literal string/pattern hunts → Grep; media assets → Glob; git history → git. ' +
|
|
859
|
-
'One explore; then answer or edit from Source + trail — do not re-grep/node/read the same symbols. ' +
|
|
860
|
-
'Overlapping paraphrase explores are refused (name a new Type/file/@kit to continue). ' +
|
|
861
|
-
'Busy/partial → retry ONCE with the named Next anchor or ONE narrow Grep — not a Grep/node storm '
|
|
862
|
-
+ '(session refuses further explore / depth fan-out after Partial).',
|
|
862
|
+
name: 'homegraph_usages',
|
|
863
|
+
description: 'PRIMARY first tool for a narrow WHERE-USED question about one named API, `.member`, ALL_CAPS constant, field, or mutex. ' +
|
|
864
|
+
'Choose this instead of homegraph_explore when the requested answer is usage/reference locations. ' +
|
|
865
|
+
'Required: `query`. Returns usage files/lines only; it does not build a general flow or source dump. ' +
|
|
866
|
+
'`homegraph_explore` auto-routes equivalent high-confidence queries here only for compatibility.',
|
|
863
867
|
inputSchema: {
|
|
864
868
|
type: 'object',
|
|
865
869
|
properties: {
|
|
866
870
|
query: {
|
|
867
871
|
type: 'string',
|
|
868
|
-
description: 'Required.
|
|
869
|
-
'For named flows, include Type / Type.member / component names. For @kit, ask usages (depend/import sites), not SDK catalogs.',
|
|
870
|
-
},
|
|
871
|
-
maxFiles: {
|
|
872
|
-
type: 'number',
|
|
873
|
-
description: 'Maximum number of files to include source code from (default: 12)',
|
|
874
|
-
default: 12,
|
|
872
|
+
description: 'Required. Exact API/member/constant/field name, with or without where-used wording.',
|
|
875
873
|
},
|
|
876
874
|
projectPath: projectPathProperty,
|
|
877
875
|
},
|
|
878
876
|
required: ['query'],
|
|
879
877
|
},
|
|
880
|
-
annotations: READ_ONLY_ANNOTATIONS,
|
|
878
|
+
annotations: { ...READ_ONLY_ANNOTATIONS, title: 'HomeGraph Where-used Inventory' },
|
|
881
879
|
},
|
|
882
880
|
{
|
|
883
|
-
name: '
|
|
884
|
-
description: '
|
|
885
|
-
'
|
|
886
|
-
'
|
|
881
|
+
name: 'homegraph_modules',
|
|
882
|
+
description: 'PRIMARY first tool for a narrow DEPENDENCY/CYCLE question about named path modules or `*common` / `*service` / `*component` / `*constants` modules. ' +
|
|
883
|
+
'Choose this instead of homegraph_explore when the requested answer is module topology. ' +
|
|
884
|
+
'Required: `query`. It does not build a general code flow or scan unrelated survey families. ' +
|
|
885
|
+
'`homegraph_explore` auto-routes equivalent high-confidence dependency questions here only for compatibility.',
|
|
887
886
|
inputSchema: {
|
|
888
887
|
type: 'object',
|
|
889
888
|
properties: {
|
|
890
889
|
query: {
|
|
891
890
|
type: 'string',
|
|
892
|
-
description: 'Required.
|
|
891
|
+
description: 'Required. Dependency/cycle question containing the exact module names or paths.',
|
|
893
892
|
},
|
|
894
893
|
projectPath: projectPathProperty,
|
|
895
894
|
},
|
|
896
895
|
required: ['query'],
|
|
897
896
|
},
|
|
898
|
-
annotations: READ_ONLY_ANNOTATIONS,
|
|
897
|
+
annotations: { ...READ_ONLY_ANNOTATIONS, title: 'HomeGraph Module Dependencies' },
|
|
899
898
|
},
|
|
900
899
|
{
|
|
901
|
-
name: '
|
|
902
|
-
description: '
|
|
903
|
-
'
|
|
904
|
-
'
|
|
900
|
+
name: 'homegraph_native',
|
|
901
|
+
description: 'PRIMARY first tool for a narrow NAPI/NATIVE EXPORT or registration question about one named path or Type. ' +
|
|
902
|
+
'Choose this instead of homegraph_explore when the requested answer is the ArkTS↔native export surface. Required: `query`. ' +
|
|
903
|
+
'Returns indexed export descriptors/registration sites without a general domain file dump. ' +
|
|
904
|
+
'`homegraph_explore` auto-routes equivalent high-confidence NAPI/export questions here only for compatibility.',
|
|
905
905
|
inputSchema: {
|
|
906
906
|
type: 'object',
|
|
907
907
|
properties: {
|
|
908
908
|
query: {
|
|
909
909
|
type: 'string',
|
|
910
|
-
description: 'Required.
|
|
910
|
+
description: 'Required. NAPI/native export question containing the exact path or Type name.',
|
|
911
911
|
},
|
|
912
912
|
projectPath: projectPathProperty,
|
|
913
913
|
},
|
|
914
914
|
required: ['query'],
|
|
915
915
|
},
|
|
916
|
-
annotations: READ_ONLY_ANNOTATIONS,
|
|
916
|
+
annotations: { ...READ_ONLY_ANNOTATIONS, title: 'HomeGraph Native Exports' },
|
|
917
917
|
},
|
|
918
918
|
{
|
|
919
|
-
name: '
|
|
920
|
-
description: '
|
|
921
|
-
'
|
|
922
|
-
'
|
|
919
|
+
name: 'homegraph_explore',
|
|
920
|
+
description: 'GENERAL PRIMARY entry for understanding THIS repo before you edit or answer structural questions. ' +
|
|
921
|
+
'For an explicit narrow where-used, named module dependency/cycle, or NAPI/native export inventory, choose ' +
|
|
922
|
+
'homegraph_usages, homegraph_modules, or homegraph_native instead; this tool keeps conservative auto-routing only for compatibility. ' +
|
|
923
|
+
'Returns call paths + compact line-numbered source for the relevant symbols. Required: `query`. ' +
|
|
924
|
+
'CALL FIRST (alone, no parallel Grep/Read) when you will change an existing codebase — pass the user task or domain keywords ' +
|
|
925
|
+
'(page/module/feature/component words); locate where to edit before writing code. Also CALL FIRST for how/wired questions, ' +
|
|
926
|
+
'named Type/Component/Page/Dialog, Type.member, click→handler, inheritance/subtypes, declaration/attribute sites, ' +
|
|
927
|
+
'or a cross-symbol mechanism/flow. Use the named focused tools for exact usage, module-topology, or native-export inventories. ' +
|
|
928
|
+
'PascalCase names optional when domain keywords suffice. ' +
|
|
929
|
+
'For edits preserve the requested action, target product/module and exclusions; taskContext may carry the full task. ' +
|
|
930
|
+
'Prefer callers/node when one named symbol is already enough. ' +
|
|
931
|
+
'DO NOT call for topic file-lists with no Type/file, literal copy hunts / pure existence compares with no anchors, ' +
|
|
932
|
+
'official-docs-only asks, empty-project-from-scratch scaffolds, git history, or media/binary asset inventories — those return Skip. ' +
|
|
933
|
+
'@kit / OHOS API questions ARE in scope when the SDK API graph is available — exact where-used → homegraph_usages; ' +
|
|
934
|
+
'mechanism/API-symbol flow → homegraph_explore. ' +
|
|
935
|
+
'Literal string/pattern hunts → Grep; media assets → Glob; git history → git. ' +
|
|
936
|
+
'One explore; then answer or edit from Source + trail — do not re-grep/node/read the same symbols. ' +
|
|
937
|
+
'Overlapping paraphrase explores are refused (name a new Type/file/@kit to continue). ' +
|
|
938
|
+
'Busy/partial → retry ONCE with the named Next anchor or ONE narrow Grep — not a Grep/node storm '
|
|
939
|
+
+ '(session refuses further explore / depth fan-out after Partial).',
|
|
923
940
|
inputSchema: {
|
|
924
941
|
type: 'object',
|
|
925
942
|
properties: {
|
|
926
943
|
query: {
|
|
927
944
|
type: 'string',
|
|
928
|
-
description: 'Required.
|
|
945
|
+
description: 'Required. For pre-edit orientation or how/mechanism: pass the user task or domain keywords (page/module/feature words). ' +
|
|
946
|
+
'For named flows, include Type / Type.member / component names. For @kit mechanism/flow, include module/export tokens; ' +
|
|
947
|
+
'use homegraph_usages for a narrow import/usage inventory, not SDK catalogs.',
|
|
948
|
+
},
|
|
949
|
+
taskContext: {
|
|
950
|
+
type: 'string',
|
|
951
|
+
description: 'Optional original task, including requested changes, product/module scope, exclusions and acceptance requirements. Not source evidence; bounded to 4000 characters.',
|
|
952
|
+
},
|
|
953
|
+
maxFiles: {
|
|
954
|
+
type: 'number',
|
|
955
|
+
description: 'Maximum number of files to include source code from (default: 12)',
|
|
956
|
+
default: 12,
|
|
929
957
|
},
|
|
930
958
|
projectPath: projectPathProperty,
|
|
931
959
|
},
|
|
932
960
|
required: ['query'],
|
|
933
961
|
},
|
|
934
|
-
annotations: READ_ONLY_ANNOTATIONS,
|
|
962
|
+
annotations: { ...READ_ONLY_ANNOTATIONS, title: 'HomeGraph General Explore' },
|
|
935
963
|
},
|
|
936
964
|
{
|
|
937
965
|
name: 'homegraph_status',
|
|
@@ -944,6 +972,27 @@ exports.tools = [
|
|
|
944
972
|
},
|
|
945
973
|
annotations: READ_ONLY_ANNOTATIONS,
|
|
946
974
|
},
|
|
975
|
+
{
|
|
976
|
+
name: 'homegraph_project',
|
|
977
|
+
description: 'Shallow project map: modules + files per module (no symbols/call edges). ' +
|
|
978
|
+
'PRIMARY for engineering overview while the full index is still building; also useful after full index. ' +
|
|
979
|
+
'Optional `module` filters by name/path; `includeFiles` defaults true.',
|
|
980
|
+
inputSchema: {
|
|
981
|
+
type: 'object',
|
|
982
|
+
properties: {
|
|
983
|
+
module: {
|
|
984
|
+
type: 'string',
|
|
985
|
+
description: 'Optional module name or root path substring to filter.',
|
|
986
|
+
},
|
|
987
|
+
includeFiles: {
|
|
988
|
+
type: 'boolean',
|
|
989
|
+
description: 'Include per-module file lists (default true). Set false for module list only.',
|
|
990
|
+
},
|
|
991
|
+
projectPath: projectPathProperty,
|
|
992
|
+
},
|
|
993
|
+
},
|
|
994
|
+
annotations: READ_ONLY_ANNOTATIONS,
|
|
995
|
+
},
|
|
947
996
|
{
|
|
948
997
|
name: 'homegraph_arkui_migrate',
|
|
949
998
|
description: 'ArkUI migrate / state-semantics snapshot in ONE call (components, state decorators + args, ' +
|
|
@@ -1256,10 +1305,15 @@ function reconcilePartialAnswerNow(text) {
|
|
|
1256
1305
|
if (!/\*\*Partial locator\*\*/i.test(text))
|
|
1257
1306
|
return text;
|
|
1258
1307
|
let changed = false;
|
|
1308
|
+
let fenced = false;
|
|
1259
1309
|
const out = text
|
|
1260
1310
|
.split('\n')
|
|
1261
1311
|
.map((line) => {
|
|
1262
|
-
if (
|
|
1312
|
+
if (/^\s*```/.test(line)) {
|
|
1313
|
+
fenced = !fenced;
|
|
1314
|
+
return line;
|
|
1315
|
+
}
|
|
1316
|
+
if (fenced || !/ANSWER NOW/i.test(line))
|
|
1263
1317
|
return line;
|
|
1264
1318
|
const stripped = stripPositiveAnswerNowFromLine(line);
|
|
1265
1319
|
if (stripped.changed)
|
|
@@ -1270,7 +1324,7 @@ function reconcilePartialAnswerNow(text) {
|
|
|
1270
1324
|
.join('\n');
|
|
1271
1325
|
if (!changed)
|
|
1272
1326
|
return text;
|
|
1273
|
-
return `${out}\n\n> This survey is a **Partial locator**: the sections above are anchors, not a closed answer.
|
|
1327
|
+
return `${out}\n\n> This survey is a **Partial locator**: the sections above are anchors, not a closed answer. Inspect missing source with a focused lookup, continue the requested edits, and verify the resulting code.`;
|
|
1274
1328
|
}
|
|
1275
1329
|
class ToolHandler {
|
|
1276
1330
|
cg;
|
|
@@ -1459,8 +1513,11 @@ class ToolHandler {
|
|
|
1459
1513
|
'homegraph_search',
|
|
1460
1514
|
'homegraph_node',
|
|
1461
1515
|
'homegraph_diff_impact',
|
|
1516
|
+
'homegraph_project',
|
|
1462
1517
|
]);
|
|
1463
|
-
|
|
1518
|
+
// An explicit host selection overrides the default size-based surface.
|
|
1519
|
+
// Otherwise small ArkTS repos silently lose requested specialized tools.
|
|
1520
|
+
if (!allow && stats.fileCount < TINY_REPO_FILE_THRESHOLD) {
|
|
1464
1521
|
visible = visible.filter(t => TINY_REPO_CORE_TOOLS.has(t.name));
|
|
1465
1522
|
}
|
|
1466
1523
|
return visible.map(tool => {
|
|
@@ -1854,6 +1911,10 @@ class ToolHandler {
|
|
|
1854
1911
|
* it (the CLI does) and explore behaves exactly as before, untracked.
|
|
1855
1912
|
*/
|
|
1856
1913
|
async execute(toolName, args, sessionState) {
|
|
1914
|
+
const requestStartedAt = Date.now();
|
|
1915
|
+
args = { ...args };
|
|
1916
|
+
for (const key of [QUERY_PLAN_ARG, QUERY_DEADLINE_ARG, QUERY_STARTED_ARG, QUERY_INDEX_STATE_ARG, QUERY_FAST_ATTEMPTED_ARG])
|
|
1917
|
+
delete args[key];
|
|
1857
1918
|
try {
|
|
1858
1919
|
// Block the first tool call on the engine's post-open reconcile so we
|
|
1859
1920
|
// never serve rows for files deleted/edited while no MCP server was
|
|
@@ -1894,13 +1955,37 @@ class ToolHandler {
|
|
|
1894
1955
|
return check;
|
|
1895
1956
|
}
|
|
1896
1957
|
const projectPath = args.projectPath;
|
|
1958
|
+
if (toolName === 'homegraph_explore' && process.env.HOMEGRAPH_QUERY_PLANNER !== 'off') {
|
|
1959
|
+
const query = this.validateString(args.query, 'query');
|
|
1960
|
+
if (typeof query !== 'string')
|
|
1961
|
+
return query;
|
|
1962
|
+
const cg = this.getHomeGraph(projectPath);
|
|
1963
|
+
const gated = this.maybeDeepToolPhaseGate(cg, toolName);
|
|
1964
|
+
if (gated)
|
|
1965
|
+
return gated;
|
|
1966
|
+
// Refused repeats must not spend a model call or re-serve cached evidence.
|
|
1967
|
+
if (sessionState) {
|
|
1968
|
+
const repeat = (0, explore_repeat_guard_1.decideExploreRepeat)(sessionState.forProject(cg.getProjectRoot()), query);
|
|
1969
|
+
if (this.shouldRefuseRepeatedEvidence(repeat, cg.getProjectRoot())) {
|
|
1970
|
+
return this.textResult((0, explore_repeat_guard_1.formatExploreRepeatRefuse)(repeat, query));
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1973
|
+
args[QUERY_STARTED_ARG] = requestStartedAt;
|
|
1974
|
+
args[QUERY_DEADLINE_ARG] = Date.now() + (0, query_pool_1.resolveToolDeadlineMs)();
|
|
1975
|
+
args[QUERY_PLAN_ARG] = await (0, query_plan_1.planQuery)(query, {
|
|
1976
|
+
deadlineAt: args[QUERY_DEADLINE_ARG],
|
|
1977
|
+
taskContext: (0, query_plan_1.mergeQueryPlanTaskContext)(process.env.HOMEGRAPH_QUERY_TASK_CONTEXT, typeof args.taskContext === 'string' ? args.taskContext : undefined),
|
|
1978
|
+
validateAnchor: (anchor) => this.isExactPlanAnchor(cg, anchor),
|
|
1979
|
+
});
|
|
1980
|
+
args[QUERY_INDEX_STATE_ARG] = `${cg.getBuildPhase()}:${cg.getStats().nodeCount}:${cg.getLastIndexedAt() ?? 0}`;
|
|
1981
|
+
}
|
|
1897
1982
|
// Wrong question shapes → short Skip (before cache / graph work).
|
|
1898
1983
|
if (toolName === 'homegraph_explore' || toolName === 'homegraph_search') {
|
|
1899
1984
|
const qEarly = typeof args.query === 'string' ? args.query : '';
|
|
1900
1985
|
if (qEarly) {
|
|
1901
1986
|
const deferKind = (0, query_utils_1.queryShouldDeferToBuiltinTools)(qEarly);
|
|
1902
|
-
if (deferKind) {
|
|
1903
|
-
return this.textResult((0, query_utils_1.homegraphDeferGuidance)(deferKind, qEarly));
|
|
1987
|
+
if (deferKind && readQueryPlan(args)?.source !== 'llm') {
|
|
1988
|
+
return this.withQueryPlanDiagnostics(this.textResult((0, query_utils_1.homegraphDeferGuidance)(deferKind, qEarly)), args);
|
|
1904
1989
|
}
|
|
1905
1990
|
}
|
|
1906
1991
|
}
|
|
@@ -1917,7 +2002,9 @@ class ToolHandler {
|
|
|
1917
2002
|
// Session-tracked explore must not hit the MCP query cache: a cache hit
|
|
1918
2003
|
// would re-serve the first call's full source and defeat CG-18 dedup.
|
|
1919
2004
|
const skipCacheForSession = toolName === 'homegraph_explore' && !!sessionState;
|
|
1920
|
-
const
|
|
2005
|
+
const requestPlan = readQueryPlan(args);
|
|
2006
|
+
const cacheEnabled = !skipCacheForSession && requestPlan?.source !== 'llm'
|
|
2007
|
+
&& !requestPlan?.telemetry.fallbackReason && (0, query_cache_1.isMcpQueryCacheEnabled)() && (0, query_cache_1.isCacheableMcpTool)(toolName);
|
|
1921
2008
|
let cacheKey;
|
|
1922
2009
|
let cacheQueries;
|
|
1923
2010
|
let cacheIndex;
|
|
@@ -1937,7 +2024,8 @@ class ToolHandler {
|
|
|
1937
2024
|
cacheKey = (0, query_cache_1.buildMcpQueryCacheKey)(toolName, args, fileCount);
|
|
1938
2025
|
const cached = cacheIndex.getEntry(cacheQueries, cacheKey);
|
|
1939
2026
|
if (cached) {
|
|
1940
|
-
const
|
|
2027
|
+
const diagnosed = this.withQueryPlanDiagnostics(cached, args, true);
|
|
2028
|
+
const withWorktree = this.withWorktreeNotice(diagnosed, projectPath);
|
|
1941
2029
|
return this.withStalenessNotice(withWorktree, projectPath);
|
|
1942
2030
|
}
|
|
1943
2031
|
}
|
|
@@ -1953,27 +2041,43 @@ class ToolHandler {
|
|
|
1953
2041
|
if (toolName === 'homegraph_status') {
|
|
1954
2042
|
return await this.handleStatus(args);
|
|
1955
2043
|
}
|
|
2044
|
+
// While auto-init is still on the fast/indexing phases (no symbols yet),
|
|
2045
|
+
// deep tools return guidance so the agent uses homegraph_project instead
|
|
2046
|
+
// of abandoning HomeGraph after empty explore results.
|
|
2047
|
+
if (toolName !== 'homegraph_project') {
|
|
2048
|
+
try {
|
|
2049
|
+
const gated = this.maybeDeepToolPhaseGate(this.getHomeGraph(projectPath), toolName);
|
|
2050
|
+
if (gated)
|
|
2051
|
+
return gated;
|
|
2052
|
+
}
|
|
2053
|
+
catch {
|
|
2054
|
+
/* NotIndexed / sources disabled — fall through to normal handlers */
|
|
2055
|
+
}
|
|
2056
|
+
}
|
|
1956
2057
|
// Every read tool races a deadline ≪ MCP client ~60s hard timeout and
|
|
1957
2058
|
// Named-member / local-compact questions finish in tens of ms on the warm
|
|
1958
2059
|
// main connection. Serving them here — before the query-pool offload —
|
|
1959
2060
|
// avoids cold-worker / wedged-daemon paths that otherwise surface as empty
|
|
1960
2061
|
// MCP client `-32001` (the handler itself is fine; the transport times out).
|
|
1961
|
-
if (toolName === 'homegraph_explore' || toolName === 'homegraph_search')
|
|
2062
|
+
if ((toolName === 'homegraph_explore' || toolName === 'homegraph_search')
|
|
2063
|
+
&& (!requestPlan || (requestPlan.source === 'rules' && requestPlan.steps.length === 1
|
|
2064
|
+
&& !requestPlan.telemetry.requestCount))) {
|
|
1962
2065
|
const q = typeof args.query === 'string' ? args.query : '';
|
|
1963
2066
|
if (q) {
|
|
1964
2067
|
try {
|
|
1965
2068
|
const cgFast = this.getHomeGraph(projectPath);
|
|
1966
2069
|
const rootFast = cgFast.getProjectRoot();
|
|
1967
|
-
const fast = (
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
?? (toolName === 'homegraph_explore' || toolName === 'homegraph_search'
|
|
1971
|
-
? this.tryFastInventoryExplore(cgFast, q, rootFast)
|
|
1972
|
-
: null)
|
|
1973
|
-
?? (toolName === 'homegraph_explore' || toolName === 'homegraph_search'
|
|
1974
|
-
? this.tryLightMechanismExplore(cgFast, q, rootFast)
|
|
2070
|
+
const fast = requestPlan ? this.tryPlannedFastPath(cgFast, requestPlan, rootFast) :
|
|
2071
|
+
(toolName === 'homegraph_explore'
|
|
2072
|
+
? this.trySpecializedExploreRoute(cgFast, q, rootFast)
|
|
1975
2073
|
: null)
|
|
1976
|
-
|
|
2074
|
+
?? (toolName === 'homegraph_explore' || toolName === 'homegraph_search'
|
|
2075
|
+
? this.tryFastInventoryExplore(cgFast, q, rootFast)
|
|
2076
|
+
: null)
|
|
2077
|
+
?? (toolName === 'homegraph_explore' || toolName === 'homegraph_search'
|
|
2078
|
+
? this.tryLightMechanismExplore(cgFast, q, rootFast)
|
|
2079
|
+
: null)
|
|
2080
|
+
?? this.tryCompactLocalSymbolExplore(cgFast, q, rootFast);
|
|
1977
2081
|
if (fast) {
|
|
1978
2082
|
// Fast explore must still file Partial/ANSWER meta into the session
|
|
1979
2083
|
// so explore + depth fuses see it (textResult-only paths used to skip).
|
|
@@ -1981,12 +2085,15 @@ class ToolHandler {
|
|
|
1981
2085
|
if (toolName === 'homegraph_explore' && sessionState) {
|
|
1982
2086
|
served = this.takeExploreEmission(this.ensureExploreEmission(fast, rootFast, q), sessionState);
|
|
1983
2087
|
}
|
|
2088
|
+
served = this.withQueryPlanDiagnostics(served, args);
|
|
1984
2089
|
if (cacheEnabled && cacheKey && cacheQueries && cacheIndex && !served.isError) {
|
|
1985
2090
|
cacheIndex.setEntry(cacheQueries, cacheKey, toolName, served);
|
|
1986
2091
|
}
|
|
1987
2092
|
const withWorktree = this.withWorktreeNotice(served, projectPath);
|
|
1988
2093
|
return this.withStalenessNotice(withWorktree, projectPath);
|
|
1989
2094
|
}
|
|
2095
|
+
if (requestPlan)
|
|
2096
|
+
args[QUERY_FAST_ATTEMPTED_ARG] = true;
|
|
1990
2097
|
}
|
|
1991
2098
|
catch {
|
|
1992
2099
|
// Not indexed / path issue — fall through to normal dispatch.
|
|
@@ -2002,7 +2109,7 @@ class ToolHandler {
|
|
|
2002
2109
|
// (a frozen main loop prevents setTimeout deadlines from firing → empty
|
|
2003
2110
|
// `-32001`). Fast-path surveys run inside the worker via executeReadTool.
|
|
2004
2111
|
const raw = await this.runReadToolWithDeadline(toolName, dispatchArgs);
|
|
2005
|
-
const result = this.takeExploreEmission(raw, sessionState);
|
|
2112
|
+
const result = this.withQueryPlanDiagnostics(this.takeExploreEmission(raw, sessionState), args);
|
|
2006
2113
|
if (sessionState
|
|
2007
2114
|
&& !result.isError
|
|
2008
2115
|
&& (toolName === 'homegraph_node'
|
|
@@ -2078,16 +2185,67 @@ class ToolHandler {
|
|
|
2078
2185
|
return null;
|
|
2079
2186
|
}
|
|
2080
2187
|
}
|
|
2188
|
+
/** A repeated query is duplicate evidence only while its source is unchanged. */
|
|
2189
|
+
shouldRefuseRepeatedEvidence(decision, projectRoot) {
|
|
2190
|
+
if (!decision.refuse)
|
|
2191
|
+
return false;
|
|
2192
|
+
// Source changes do not reset the total retrieval budget.
|
|
2193
|
+
if (decision.reason !== 'overlap')
|
|
2194
|
+
return true;
|
|
2195
|
+
const files = decision.matched?.files.filter((file) => file.bytes > 0 && file.ranges.length > 0) ?? [];
|
|
2196
|
+
if (files.length === 0 || files.length > 24)
|
|
2197
|
+
return false;
|
|
2198
|
+
let remainingBytes = 2 * 1024 * 1024;
|
|
2199
|
+
for (const file of files) {
|
|
2200
|
+
if (!file.fingerprint)
|
|
2201
|
+
return false;
|
|
2202
|
+
const absolute = (0, utils_1.validatePathWithinRoot)(projectRoot, file.path);
|
|
2203
|
+
if (!absolute)
|
|
2204
|
+
return false;
|
|
2205
|
+
let descriptor;
|
|
2206
|
+
try {
|
|
2207
|
+
const stat = (0, fs_1.statSync)(absolute);
|
|
2208
|
+
const limit = Math.min(1024 * 1024, remainingBytes);
|
|
2209
|
+
if (!stat.isFile() || stat.size > limit)
|
|
2210
|
+
return false;
|
|
2211
|
+
// Fixed-size reads also remain bounded if the file grows after stat.
|
|
2212
|
+
descriptor = (0, fs_1.openSync)(absolute, 'r');
|
|
2213
|
+
const buffer = Buffer.alloc(stat.size + 1);
|
|
2214
|
+
let bytes = 0;
|
|
2215
|
+
while (bytes < buffer.length) {
|
|
2216
|
+
const read = (0, fs_1.readSync)(descriptor, buffer, bytes, buffer.length - bytes, bytes);
|
|
2217
|
+
if (read === 0)
|
|
2218
|
+
break;
|
|
2219
|
+
bytes += read;
|
|
2220
|
+
}
|
|
2221
|
+
if (bytes !== stat.size)
|
|
2222
|
+
return false;
|
|
2223
|
+
remainingBytes -= bytes;
|
|
2224
|
+
if ((0, explore_dedup_1.fileFingerprint)(buffer.subarray(0, bytes).toString('utf8')) !== file.fingerprint)
|
|
2225
|
+
return false;
|
|
2226
|
+
}
|
|
2227
|
+
catch {
|
|
2228
|
+
return false;
|
|
2229
|
+
}
|
|
2230
|
+
finally {
|
|
2231
|
+
if (descriptor !== undefined) {
|
|
2232
|
+
try {
|
|
2233
|
+
(0, fs_1.closeSync)(descriptor);
|
|
2234
|
+
}
|
|
2235
|
+
catch { /* bookkeeping only */ }
|
|
2236
|
+
}
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
return true;
|
|
2240
|
+
}
|
|
2081
2241
|
/** Count a successful depth-tool call when the latest explore was Partial. */
|
|
2082
2242
|
noteDepthToolUse(args, sessionState) {
|
|
2083
2243
|
try {
|
|
2084
2244
|
const cg = this.getHomeGraph(args.projectPath);
|
|
2085
2245
|
const root = cg.getProjectRoot();
|
|
2086
2246
|
const prior = sessionState.forProject(root);
|
|
2087
|
-
const last = prior?.calls.length
|
|
2088
|
-
|
|
2089
|
-
: undefined;
|
|
2090
|
-
if (!last?.partial)
|
|
2247
|
+
const last = prior?.calls[prior.calls.length - 1];
|
|
2248
|
+
if (!last || (0, explore_session_state_1.inferExploreEvidenceStatus)(last) === 'complete')
|
|
2091
2249
|
return;
|
|
2092
2250
|
sessionState.recordDepthTool(root);
|
|
2093
2251
|
}
|
|
@@ -2107,17 +2265,22 @@ class ToolHandler {
|
|
|
2107
2265
|
const emission = result?.[explore_session_state_1.EXPLORE_EMISSION_KEY];
|
|
2108
2266
|
if (emission === undefined)
|
|
2109
2267
|
return result;
|
|
2268
|
+
emission.evidenceStatus = (0, explore_session_state_1.inferExploreEvidenceStatus)(emission);
|
|
2269
|
+
emission.partial = emission.evidenceStatus !== 'complete';
|
|
2270
|
+
result._meta = { ...result._meta, homegraphEvidence: { status: emission.evidenceStatus,
|
|
2271
|
+
files: emission.files, locatedNodes: emission.locatedNodes, coveredObligations: emission.coveredObligations,
|
|
2272
|
+
uncoveredObligations: emission.uncoveredObligations } };
|
|
2110
2273
|
delete result[explore_session_state_1.EXPLORE_EMISSION_KEY];
|
|
2111
2274
|
if (sessionState) {
|
|
2112
2275
|
try {
|
|
2113
2276
|
const prior = sessionState.forProject(emission.projectRoot);
|
|
2114
|
-
const priorPartials = (prior?.calls ?? []).filter((c) =>
|
|
2277
|
+
const priorPartials = (prior?.calls ?? []).filter((c) => (0, explore_session_state_1.inferExploreEvidenceStatus)(c) !== 'complete');
|
|
2115
2278
|
const metaPartial = emission.partial === true
|
|
2116
2279
|
|| (emission.partial === undefined
|
|
2117
2280
|
&& (0, explore_repeat_guard_1.inferExplorePartialMeta)(result.content?.[0]?.text ?? '').partial);
|
|
2118
2281
|
if (metaPartial && priorPartials.length >= 1) {
|
|
2119
2282
|
const text = result.content?.[0]?.text ?? '';
|
|
2120
|
-
if (text && !/Second Partial
|
|
2283
|
+
if (text && !/Second Partial/i.test(text)) {
|
|
2121
2284
|
const stop = (0, explore_repeat_guard_1.formatSecondPartialStopFooter)(emission.nextAnchor);
|
|
2122
2285
|
result = {
|
|
2123
2286
|
...result,
|
|
@@ -2150,12 +2313,19 @@ class ToolHandler {
|
|
|
2150
2313
|
* the success-shaped reply never flushed → empty client `-32001`.
|
|
2151
2314
|
*/
|
|
2152
2315
|
async runReadToolWithDeadline(toolName, args) {
|
|
2153
|
-
const deadlineMs =
|
|
2316
|
+
const deadlineMs = typeof args[QUERY_DEADLINE_ARG] === 'number'
|
|
2317
|
+
? Math.max(0, Math.min((0, query_pool_1.resolveToolDeadlineMs)(), args[QUERY_DEADLINE_ARG] - Date.now()))
|
|
2318
|
+
: (0, query_pool_1.resolveToolDeadlineMs)();
|
|
2319
|
+
if (deadlineMs <= 0)
|
|
2320
|
+
return this.deadlineBusyResult((0, query_pool_1.resolveToolDeadlineMs)(), args);
|
|
2154
2321
|
const light = toolName === 'homegraph_search'
|
|
2155
2322
|
|| toolName === 'homegraph_node'
|
|
2156
2323
|
|| toolName === 'homegraph_callers'
|
|
2157
2324
|
|| toolName === 'homegraph_callees'
|
|
2158
|
-
|| toolName === 'homegraph_files'
|
|
2325
|
+
|| toolName === 'homegraph_files'
|
|
2326
|
+
|| toolName === 'homegraph_project'
|
|
2327
|
+
// Project maps may be built on demand: never run them on a read worker.
|
|
2328
|
+
|| (readQueryPlan(args)?.steps.some((step) => step.intent === 'overview') ?? false);
|
|
2159
2329
|
const work = () => {
|
|
2160
2330
|
if (!light && this.queryPool && this.queryPool.healthy) {
|
|
2161
2331
|
return this.queryPool.run(toolName, args, {
|
|
@@ -2210,6 +2380,14 @@ class ToolHandler {
|
|
|
2210
2380
|
*/
|
|
2211
2381
|
async executeReadTool(toolName, args) {
|
|
2212
2382
|
try {
|
|
2383
|
+
if (toolName !== 'homegraph_project' && toolName !== 'homegraph_status') {
|
|
2384
|
+
const gate = this.maybeDeepToolPhaseGate(this.getHomeGraph(args.projectPath), toolName);
|
|
2385
|
+
if (gate)
|
|
2386
|
+
return gate;
|
|
2387
|
+
}
|
|
2388
|
+
const plan = toolName === 'homegraph_explore' ? readQueryPlan(args) : undefined;
|
|
2389
|
+
if (plan)
|
|
2390
|
+
return await this.executeQueryPlan(args, plan);
|
|
2213
2391
|
// Compact inventory / one-symbol surveys — safe on the worker (keeps the
|
|
2214
2392
|
// daemon main loop free). Never run these unprotected on the MCP transport
|
|
2215
2393
|
// thread: they can block long enough for the client to emit empty `-32001`.
|
|
@@ -2240,6 +2418,16 @@ class ToolHandler {
|
|
|
2240
2418
|
* NotIndexed/PathRefusal, which {@link executeReadTool} classifies.
|
|
2241
2419
|
*/
|
|
2242
2420
|
async dispatchTool(toolName, args) {
|
|
2421
|
+
if (toolName !== 'homegraph_project' && toolName !== 'homegraph_status') {
|
|
2422
|
+
try {
|
|
2423
|
+
const gated = this.maybeDeepToolPhaseGate(this.getHomeGraph(args.projectPath), toolName);
|
|
2424
|
+
if (gated)
|
|
2425
|
+
return gated;
|
|
2426
|
+
}
|
|
2427
|
+
catch {
|
|
2428
|
+
/* fall through */
|
|
2429
|
+
}
|
|
2430
|
+
}
|
|
2243
2431
|
switch (toolName) {
|
|
2244
2432
|
case 'homegraph_search': return await this.handleSearch(args);
|
|
2245
2433
|
case 'homegraph_callers': return await this.handleCallers(args);
|
|
@@ -2252,6 +2440,7 @@ class ToolHandler {
|
|
|
2252
2440
|
case 'homegraph_native': return await this.handleSpecializedNative(args);
|
|
2253
2441
|
case 'homegraph_node': return await this.handleNode(args);
|
|
2254
2442
|
case 'homegraph_files': return await this.handleFiles(args);
|
|
2443
|
+
case 'homegraph_project': return await this.handleProject(args);
|
|
2255
2444
|
case 'homegraph_arkui_migrate': return await this.handleArkuiMigrate(args);
|
|
2256
2445
|
case 'homegraph_spec_match': return await this.handleSpecMatch(args);
|
|
2257
2446
|
case 'homegraph_spec_find': return await this.handleSpecFind(args);
|
|
@@ -2259,6 +2448,36 @@ class ToolHandler {
|
|
|
2259
2448
|
default: return this.errorResult(`Unknown tool: ${toolName}`);
|
|
2260
2449
|
}
|
|
2261
2450
|
}
|
|
2451
|
+
/**
|
|
2452
|
+
* Gate symbol/graph tools while auto-init is still on the fast map or
|
|
2453
|
+
* background full index (and no nodes exist yet). Success-shaped guidance —
|
|
2454
|
+
* never isError — so agents keep using HomeGraph.
|
|
2455
|
+
*/
|
|
2456
|
+
maybeDeepToolPhaseGate(cg, _toolName) {
|
|
2457
|
+
const phase = cg.getBuildPhase();
|
|
2458
|
+
if (phase === 'full')
|
|
2459
|
+
return null;
|
|
2460
|
+
try {
|
|
2461
|
+
if (cg.getStats().nodeCount > 0)
|
|
2462
|
+
return null;
|
|
2463
|
+
}
|
|
2464
|
+
catch {
|
|
2465
|
+
/* ignore */
|
|
2466
|
+
}
|
|
2467
|
+
if (phase === 'fast' || phase === 'indexing') {
|
|
2468
|
+
return this.textResult([
|
|
2469
|
+
`Full symbol index is still building (phase=${phase}).`,
|
|
2470
|
+
'Use `homegraph_project` for the module/file map now, then retry this tool once indexing finishes.',
|
|
2471
|
+
].join('\n'));
|
|
2472
|
+
}
|
|
2473
|
+
if (phase === 'building_fast' || phase === 'none') {
|
|
2474
|
+
return this.textResult([
|
|
2475
|
+
'HomeGraph is still preparing the project map.',
|
|
2476
|
+
'Retry in a few seconds, or call `homegraph_project` once the fast build completes.',
|
|
2477
|
+
].join('\n'));
|
|
2478
|
+
}
|
|
2479
|
+
return null;
|
|
2480
|
+
}
|
|
2262
2481
|
async handleSearch(args) {
|
|
2263
2482
|
const query = this.validateString(args.query, 'query');
|
|
2264
2483
|
if (typeof query !== 'string')
|
|
@@ -5054,9 +5273,261 @@ class ToolHandler {
|
|
|
5054
5273
|
lines.push('');
|
|
5055
5274
|
return { section: lines.join('\n'), hitCount: locs.length + edges.length + extendsHits.length };
|
|
5056
5275
|
}
|
|
5057
|
-
/**
|
|
5058
|
-
|
|
5059
|
-
|
|
5276
|
+
/** Exact validation for model-proposed names: a fuzzy hit is not proof. */
|
|
5277
|
+
isExactPlanAnchor(cg, anchor) {
|
|
5278
|
+
if (!anchor || anchor.length > 256)
|
|
5279
|
+
return false;
|
|
5280
|
+
try {
|
|
5281
|
+
const matches = cg.searchNodes(anchor, { limit: 12 });
|
|
5282
|
+
return matches.some(({ node }) => node.name === anchor || node.qualifiedName === anchor
|
|
5283
|
+
|| node.filePath.replace(/\\/g, '/') === anchor.replace(/\\/g, '/'));
|
|
5284
|
+
}
|
|
5285
|
+
catch {
|
|
5286
|
+
return false;
|
|
5287
|
+
}
|
|
5288
|
+
}
|
|
5289
|
+
/** Bind only declarations returned by this step, not input or global fuzzy hits. */
|
|
5290
|
+
locatedPlanBindings(cg, result, inherited) {
|
|
5291
|
+
const seen = new Set(inherited.map((binding) => binding.id));
|
|
5292
|
+
const declarations = new Set(inherited.map((binding) => `${binding.filePath}:${binding.startLine}:${binding.name}`));
|
|
5293
|
+
const located = [];
|
|
5294
|
+
for (const receipt of (result[explore_session_state_1.EXPLORE_EMISSION_KEY]?.locatedNodes ?? []).slice(0, 32)) {
|
|
5295
|
+
if (seen.has(receipt.id) || declarations.has(`${receipt.filePath}:${receipt.startLine}:${receipt.name}`))
|
|
5296
|
+
continue;
|
|
5297
|
+
const node = cg.getNode(receipt.id);
|
|
5298
|
+
if (!node || ['file', 'import', 'export', 'parameter'].includes(node.kind)
|
|
5299
|
+
|| node.name === 'constructor' || node.name.startsWith('%AM') || node.filePath.includes('@dummy')
|
|
5300
|
+
|| node.name !== receipt.name || node.filePath !== receipt.filePath || node.startLine !== receipt.startLine
|
|
5301
|
+
|| node.qualifiedName !== receipt.qualifiedName)
|
|
5302
|
+
continue;
|
|
5303
|
+
seen.add(node.id);
|
|
5304
|
+
declarations.add(`${node.filePath}:${node.startLine}:${node.name}`);
|
|
5305
|
+
located.push({ id: node.id, name: node.name, qualifiedName: node.qualifiedName,
|
|
5306
|
+
filePath: node.filePath, startLine: node.startLine });
|
|
5307
|
+
if (located.length >= 8)
|
|
5308
|
+
break;
|
|
5309
|
+
}
|
|
5310
|
+
return located;
|
|
5311
|
+
}
|
|
5312
|
+
withQueryPlanDiagnostics(result, args, cacheHit = false) {
|
|
5313
|
+
const plan = readQueryPlan(args);
|
|
5314
|
+
if (!plan)
|
|
5315
|
+
return result;
|
|
5316
|
+
const prior = result._meta?.homegraphQueryPlan;
|
|
5317
|
+
const started = typeof args[QUERY_STARTED_ARG] === 'number' ? args[QUERY_STARTED_ARG] : Date.now();
|
|
5318
|
+
return { ...result, _meta: { ...result._meta, homegraphQueryPlan: {
|
|
5319
|
+
...prior, version: plan.version, source: plan.source, intent: plan.intent, route: plan.route,
|
|
5320
|
+
confidence: plan.confidence,
|
|
5321
|
+
plannerSeeds: { anchors: plan.anchors, searchTerms: plan.searchTerms, literalTexts: plan.literalTexts,
|
|
5322
|
+
sourceScope: plan.sourceScope, relation: plan.relation },
|
|
5323
|
+
hasTaskContext: !!plan.taskContext,
|
|
5324
|
+
matchedFeatures: Object.entries(plan.features).filter(([, matched]) => matched).map(([name]) => name).slice(0, 12),
|
|
5325
|
+
planningMs: plan.telemetry.durationMs, durationMs: Math.max(plan.telemetry.durationMs, Date.now() - started),
|
|
5326
|
+
modelRequests: plan.telemetry.requestCount ?? 0,
|
|
5327
|
+
planningEligible: plan.telemetry.decision?.eligible,
|
|
5328
|
+
planningReason: plan.telemetry.decision?.reason,
|
|
5329
|
+
skip_reason: plan.telemetry.decision?.eligible === false ? plan.telemetry.decision.reason : undefined,
|
|
5330
|
+
ruleRoute: plan.telemetry.decision?.ruleRoute,
|
|
5331
|
+
inputTokens: plan.telemetry.inputTokens ?? (plan.telemetry.requestCount ? null : 0),
|
|
5332
|
+
outputTokens: plan.telemetry.outputTokens ?? (plan.telemetry.requestCount ? null : 0),
|
|
5333
|
+
fallbackReason: plan.telemetry.fallbackReason, cacheHit,
|
|
5334
|
+
steps: cacheHit ? [] : prior?.steps ?? [{ id: plan.steps[0]?.id, intent: plan.intent,
|
|
5335
|
+
status: result.isError ? 'failed'
|
|
5336
|
+
: (0, explore_repeat_guard_1.inferExplorePartialMeta)(result.content[0]?.text ?? '').partial ? 'partial'
|
|
5337
|
+
: /Status: (?:no_indexed_evidence|not_surveyed)|No relevant code|Skip HomeGraph/i.test(result.content[0]?.text ?? '')
|
|
5338
|
+
? 'no_evidence' : 'evidence', resolvedAnchors: [] }],
|
|
5339
|
+
} } };
|
|
5340
|
+
}
|
|
5341
|
+
/** Select once; legacy section builders consume the same canonical query/features. */
|
|
5342
|
+
tryPlannedFastPath(cg, plan, root) {
|
|
5343
|
+
const query = plan.canonicalQuery;
|
|
5344
|
+
if (plan.route === 'usages' || plan.route === 'modules' || plan.route === 'native') {
|
|
5345
|
+
return this.runSpecializedExploreRoute(plan.route, cg, query, root, plan);
|
|
5346
|
+
}
|
|
5347
|
+
// These legacy paths re-extract seeds from text and cannot consume bound
|
|
5348
|
+
// node identity / step hints. Model general/flow plans use full explore;
|
|
5349
|
+
// rule/default and specialized routes retain their existing fast behavior.
|
|
5350
|
+
if (plan.source === 'llm' && (plan.intent === 'general' || plan.intent === 'flow'))
|
|
5351
|
+
return null;
|
|
5352
|
+
return this.tryFastInventoryExplore(cg, query, root, plan)
|
|
5353
|
+
?? this.tryLightMechanismExplore(cg, query, root, plan)
|
|
5354
|
+
?? this.tryCompactLocalSymbolExplore(cg, query, root, plan);
|
|
5355
|
+
}
|
|
5356
|
+
/** Internal execution only: no recursive MCP calls, model requests or new deadline. */
|
|
5357
|
+
async executePlannedStep(args, plan) {
|
|
5358
|
+
const cg = this.getHomeGraph(args.projectPath);
|
|
5359
|
+
if (plan.intent === 'overview') {
|
|
5360
|
+
return this.handleProject({ projectPath: args.projectPath });
|
|
5361
|
+
}
|
|
5362
|
+
const gate = this.maybeDeepToolPhaseGate(cg, 'homegraph_explore');
|
|
5363
|
+
if (gate)
|
|
5364
|
+
return gate;
|
|
5365
|
+
const query = plan.canonicalQuery;
|
|
5366
|
+
const defer = (0, query_utils_1.queryShouldDeferToBuiltinTools)(query);
|
|
5367
|
+
if (defer && plan.source === 'rules')
|
|
5368
|
+
return this.textResult((0, query_utils_1.homegraphDeferGuidance)(defer, query));
|
|
5369
|
+
// A typed relationship still needs a real target. Discover source from the
|
|
5370
|
+
// same bounded hints first; do not turn an unanchored usage request into an
|
|
5371
|
+
// empty survey or claim that its reference obligation has been covered.
|
|
5372
|
+
if (plan.source === 'llm' && plan.route === 'usages'
|
|
5373
|
+
&& !plan.anchors.length && !plan.bindings?.length) {
|
|
5374
|
+
const result = await this.handleExplore({ ...args, query, [QUERY_PLAN_ARG]: plan });
|
|
5375
|
+
const served = this.ensureExploreEmission(result, cg.getProjectRoot(), plan.originalQuery);
|
|
5376
|
+
const emission = served[explore_session_state_1.EXPLORE_EMISSION_KEY];
|
|
5377
|
+
emission.partial = true;
|
|
5378
|
+
emission.evidenceStatus = emission.sourceBytes > 0 ? 'partial' : 'empty';
|
|
5379
|
+
emission.coveredObligations = [];
|
|
5380
|
+
emission.uncoveredObligations = [plan.relation ?? 'incoming_references'];
|
|
5381
|
+
if (served.content[0]?.type === 'text')
|
|
5382
|
+
served.content[0].text =
|
|
5383
|
+
'**Reference target discovery — partial**\nSource candidates follow. Verify the relevant target before surveying its references; the reference obligation remains open.\n\n'
|
|
5384
|
+
+ served.content[0].text;
|
|
5385
|
+
return served;
|
|
5386
|
+
}
|
|
5387
|
+
const fast = args[QUERY_FAST_ATTEMPTED_ARG] === true && plan.source === 'rules'
|
|
5388
|
+
? null : this.tryPlannedFastPath(cg, plan, cg.getProjectRoot());
|
|
5389
|
+
if (fast)
|
|
5390
|
+
return fast;
|
|
5391
|
+
return this.handleExplore({ ...args, query, [QUERY_PLAN_ARG]: plan });
|
|
5392
|
+
}
|
|
5393
|
+
async executeQueryPlan(args, plan) {
|
|
5394
|
+
const cg = this.getHomeGraph(args.projectPath);
|
|
5395
|
+
const root = cg.getProjectRoot();
|
|
5396
|
+
const deadline = typeof args[QUERY_DEADLINE_ARG] === 'number'
|
|
5397
|
+
? args[QUERY_DEADLINE_ARG] : Date.now() + (0, query_pool_1.resolveToolDeadlineMs)();
|
|
5398
|
+
const prior = (0, explore_session_state_1.viewForProject)((0, explore_session_state_1.readExploreSessionView)(args), root);
|
|
5399
|
+
const repeat = (0, explore_repeat_guard_1.decideExploreRepeat)(prior, plan.originalQuery);
|
|
5400
|
+
if (this.shouldRefuseRepeatedEvidence(repeat, root)) {
|
|
5401
|
+
return this.textResult((0, explore_repeat_guard_1.formatExploreRepeatRefuse)(repeat, plan.originalQuery));
|
|
5402
|
+
}
|
|
5403
|
+
const multi = plan.steps.length > 1;
|
|
5404
|
+
let outputBudget = Math.min(MAX_OUTPUT_LENGTH, getExploreOutputBudget(cg.getStats().fileCount).maxOutputChars);
|
|
5405
|
+
if (!Number.isFinite(outputBudget))
|
|
5406
|
+
outputBudget = MAX_OUTPUT_LENGTH;
|
|
5407
|
+
const constraints = [plan.originalQuery, plan.taskContext].filter(Boolean).join('\n');
|
|
5408
|
+
const constraintLimit = Math.min(2000, Math.floor(outputBudget / 5));
|
|
5409
|
+
const constraintNotice = constraints.length > constraintLimit
|
|
5410
|
+
? constraints.slice(0, constraintLimit) + '\n[Constraint display shortened; the original task remains authoritative.]'
|
|
5411
|
+
: constraints;
|
|
5412
|
+
const preamble = [
|
|
5413
|
+
'**Planned exploration — Partial locator**',
|
|
5414
|
+
'> **Partial locator** — scoped subquestion evidence, not proof that every requirement in the original question is covered.',
|
|
5415
|
+
'**Task constraints (not search seeds or verified source evidence)**\n' + constraintNotice,
|
|
5416
|
+
].join('\n\n');
|
|
5417
|
+
const pieces = [];
|
|
5418
|
+
const diagnostics = [];
|
|
5419
|
+
const bindings = new Map();
|
|
5420
|
+
const files = [];
|
|
5421
|
+
const seenQueries = new Set();
|
|
5422
|
+
let single;
|
|
5423
|
+
let used = multi ? preamble.length + 400 : 400;
|
|
5424
|
+
for (const step of plan.steps.slice(0, 3)) {
|
|
5425
|
+
const started = Date.now();
|
|
5426
|
+
const diagnostic = { id: step.id, intent: step.intent, status: 'pending', resolvedAnchors: [],
|
|
5427
|
+
locatedNodes: [], durationMs: 0 };
|
|
5428
|
+
diagnostics.push(diagnostic);
|
|
5429
|
+
if (Date.now() >= deadline || used >= outputBudget) {
|
|
5430
|
+
diagnostic.status = 'budget_exhausted';
|
|
5431
|
+
pieces.push(`**Step ${step.id}: ${step.intent}** — not executed: shared request budget exhausted.`);
|
|
5432
|
+
continue;
|
|
5433
|
+
}
|
|
5434
|
+
if (step.dependsOn.some((id) => !bindings.get(id)?.length)) {
|
|
5435
|
+
diagnostic.status = 'dependency_unresolved';
|
|
5436
|
+
pieces.push(`**Step ${step.id}: ${step.intent}** — not executed: predecessor supplied no resolved symbol anchors.`);
|
|
5437
|
+
continue;
|
|
5438
|
+
}
|
|
5439
|
+
const resolved = step.dependsOn.flatMap((id) => bindings.get(id) ?? []);
|
|
5440
|
+
// Keep rule-only single queries byte-compatible; only model subplans are adapted.
|
|
5441
|
+
const compiled = plan.source === 'rules' && !multi ? plan : {
|
|
5442
|
+
...(0, query_plan_1.compileQueryPlanStep)(plan, step, resolved.map((node) => node.qualifiedName || node.name)), bindings: resolved,
|
|
5443
|
+
};
|
|
5444
|
+
if (seenQueries.has(`${compiled.intent}:${compiled.canonicalQuery}`)) {
|
|
5445
|
+
diagnostic.status = 'duplicate_skipped';
|
|
5446
|
+
pieces.push(`**Step ${step.id}: ${step.intent}** — duplicate query skipped.`);
|
|
5447
|
+
continue;
|
|
5448
|
+
}
|
|
5449
|
+
seenQueries.add(`${compiled.intent}:${compiled.canonicalQuery}`);
|
|
5450
|
+
try {
|
|
5451
|
+
const result = await this.executePlannedStep({ ...args, [QUERY_DEADLINE_ARG]: deadline }, compiled);
|
|
5452
|
+
const body = result.content.map((part) => part.text).join('\n');
|
|
5453
|
+
const candidates = result.isError ? [] : this.locatedPlanBindings(cg, result, resolved);
|
|
5454
|
+
const childEmission = result[explore_session_state_1.EXPLORE_EMISSION_KEY];
|
|
5455
|
+
const hasLocalEvidence = candidates.length > 0 || (childEmission?.sourceBytes ?? 0) > 0;
|
|
5456
|
+
diagnostic.status = result.isError ? 'failed'
|
|
5457
|
+
: childEmission?.evidenceStatus === 'complete' ? 'evidence'
|
|
5458
|
+
: hasLocalEvidence ? (childEmission?.partial ? 'partial' : 'evidence')
|
|
5459
|
+
: /HarmonyOS SDK API|ohos-sdk:/.test(body) ? 'sdk_only' : 'no_evidence';
|
|
5460
|
+
if (!multi) {
|
|
5461
|
+
diagnostic.locatedNodes = candidates;
|
|
5462
|
+
diagnostic.resolvedAnchors = candidates.map((node) => node.name);
|
|
5463
|
+
single = result;
|
|
5464
|
+
break;
|
|
5465
|
+
}
|
|
5466
|
+
// A child cannot declare the original multi-part question answered.
|
|
5467
|
+
let fenced = false;
|
|
5468
|
+
const neutralBody = body.split('\n').filter((line) => {
|
|
5469
|
+
if (/^\s*```/.test(line)) {
|
|
5470
|
+
fenced = !fenced;
|
|
5471
|
+
return true;
|
|
5472
|
+
}
|
|
5473
|
+
return fenced || !/ANSWER NOW|Compact local explore complete|Do \*\*not\*\* (?:Read|Grep)|ONE tighter|ONE narrow/i.test(line);
|
|
5474
|
+
}).join('\n');
|
|
5475
|
+
const cap = Math.max(0, Math.floor((outputBudget - used) / Math.max(1, plan.steps.length - diagnostics.length + 1)) - 100);
|
|
5476
|
+
// Keep the grounded location receipt before source trimming. Only the
|
|
5477
|
+
// identities visibly delivered here may be consumed by the next step.
|
|
5478
|
+
let receipt = '';
|
|
5479
|
+
const visibleBindings = [];
|
|
5480
|
+
const inline = (value) => value.replace(/[`\r\n]/g, ' ');
|
|
5481
|
+
for (const candidate of candidates) {
|
|
5482
|
+
const next = (receipt ? '' : '**Located source candidates — relevance still needs verification**\n')
|
|
5483
|
+
+ `- \`${inline(candidate.qualifiedName || candidate.name)}\` — \`${inline(candidate.filePath)}:${candidate.startLine}\`\n`;
|
|
5484
|
+
if (receipt.length + next.length > Math.min(1600, Math.floor(cap / 2)))
|
|
5485
|
+
break;
|
|
5486
|
+
receipt += next;
|
|
5487
|
+
visibleBindings.push(candidate);
|
|
5488
|
+
}
|
|
5489
|
+
diagnostic.locatedNodes = visibleBindings;
|
|
5490
|
+
diagnostic.resolvedAnchors = visibleBindings.map((node) => node.name);
|
|
5491
|
+
bindings.set(step.id, visibleBindings);
|
|
5492
|
+
const bodyCap = Math.max(0, cap - receipt.length - 2);
|
|
5493
|
+
const trimmed = neutralBody.length > bodyCap;
|
|
5494
|
+
const kept = (0, evidence_rendering_1.trimEvidenceAtLine)(neutralBody, bodyCap);
|
|
5495
|
+
if (trimmed)
|
|
5496
|
+
diagnostic.status = 'partial';
|
|
5497
|
+
else
|
|
5498
|
+
files.push(...(result[explore_session_state_1.EXPLORE_EMISSION_KEY]?.files ?? []));
|
|
5499
|
+
const piece = `**Step ${step.id}: ${step.intent}** (${diagnostic.status})\n${receipt}\n${kept}`;
|
|
5500
|
+
pieces.push(piece);
|
|
5501
|
+
used += piece.length;
|
|
5502
|
+
}
|
|
5503
|
+
catch (error) {
|
|
5504
|
+
if (error instanceof PathRefusalError)
|
|
5505
|
+
throw error;
|
|
5506
|
+
diagnostic.status = 'failed';
|
|
5507
|
+
pieces.push(`**Step ${step.id}: ${step.intent}** — retrieval failed; other step evidence is retained.`);
|
|
5508
|
+
}
|
|
5509
|
+
finally {
|
|
5510
|
+
diagnostic.durationMs = Date.now() - started;
|
|
5511
|
+
}
|
|
5512
|
+
// Yield between synchronous graph stages so deadlines and MCP I/O can run.
|
|
5513
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
5514
|
+
}
|
|
5515
|
+
const result = single ?? this.textResult([preamble, ...pieces].join('\n\n').slice(0, outputBudget));
|
|
5516
|
+
const served = this.ensureExploreEmission(result, root, plan.originalQuery);
|
|
5517
|
+
const emission = served[explore_session_state_1.EXPLORE_EMISSION_KEY];
|
|
5518
|
+
emission.query = plan.originalQuery;
|
|
5519
|
+
if (multi) {
|
|
5520
|
+
emission.partial = true;
|
|
5521
|
+
emission.evidenceStatus = 'partial';
|
|
5522
|
+
emission.coveredObligations = diagnostics.filter((step) => step.status === 'evidence').map((step) => step.id);
|
|
5523
|
+
emission.uncoveredObligations = diagnostics.filter((step) => step.status !== 'evidence').map((step) => step.id);
|
|
5524
|
+
emission.files = files;
|
|
5525
|
+
emission.sourceBytes = files.reduce((sum, file) => sum + file.bytes, 0);
|
|
5526
|
+
}
|
|
5527
|
+
served._meta = { ...served._meta, homegraphQueryPlan: { steps: diagnostics } };
|
|
5528
|
+
return served;
|
|
5529
|
+
}
|
|
5530
|
+
/** Main-thread fast path for inventory surveys — skips the worker queue. */
|
|
5060
5531
|
tryFastPathResult(toolName, args) {
|
|
5061
5532
|
const query = args.query;
|
|
5062
5533
|
if (typeof query !== 'string')
|
|
@@ -5104,11 +5575,13 @@ class ToolHandler {
|
|
|
5104
5575
|
: null;
|
|
5105
5576
|
}
|
|
5106
5577
|
/** Run exactly one bounded survey family and expose the selected route. */
|
|
5107
|
-
runSpecializedExploreRoute(route, cg, query, projectRoot) {
|
|
5578
|
+
runSpecializedExploreRoute(route, cg, query, projectRoot, plan) {
|
|
5108
5579
|
let section = '';
|
|
5109
5580
|
let status = 'no_indexed_evidence';
|
|
5110
5581
|
let coverage = '';
|
|
5111
5582
|
if (route === 'modules') {
|
|
5583
|
+
if (plan?.relation === 'module_imports')
|
|
5584
|
+
return this.renderModuleImports(cg, plan);
|
|
5112
5585
|
const manifestResult = this.buildFocusedModuleManifestSection(projectRoot, query);
|
|
5113
5586
|
const graphResult = manifestResult.manifestCount === 0
|
|
5114
5587
|
? this.buildModuleDependencySurveySection(cg, query)
|
|
@@ -5126,10 +5599,10 @@ class ToolHandler {
|
|
|
5126
5599
|
coverage = 'named path/Type NAPI export registrations only; no domain file dump was built';
|
|
5127
5600
|
}
|
|
5128
5601
|
else {
|
|
5129
|
-
const apiUsage = (
|
|
5602
|
+
const apiUsage = planFeature(plan, 'shouldBuildApiUsageSurvey', query, query_utils_1.shouldBuildApiUsageSurvey)
|
|
5130
5603
|
? this.buildApiUsageSection(cg, query, projectRoot)
|
|
5131
5604
|
: { section: '', fileCount: 0 };
|
|
5132
|
-
const memberUsage = (
|
|
5605
|
+
const memberUsage = planFeature(plan, 'shouldBuildMemberSurvey', query, query_utils_1.shouldBuildMemberSurvey)
|
|
5133
5606
|
&& !((0, query_utils_1.queryAsFieldUsageSurvey)(query) && apiUsage.fileCount > 0)
|
|
5134
5607
|
? this.buildMemberSurveySection(cg, query, projectRoot)
|
|
5135
5608
|
: '';
|
|
@@ -5147,13 +5620,43 @@ class ToolHandler {
|
|
|
5147
5620
|
|| (status === 'not_surveyed'
|
|
5148
5621
|
? '- No survey ran: this query carries no symbol name to scan. Re-run naming the symbol(s), or use `homegraph_explore`.'
|
|
5149
5622
|
: '- No matching evidence was found in the current index for this focused survey.');
|
|
5150
|
-
|
|
5623
|
+
const text = this.truncateOutput([
|
|
5151
5624
|
`**HomeGraph specialized route: ${route}**`,
|
|
5152
5625
|
`Status: ${status}`,
|
|
5153
5626
|
`Coverage: ${coverage}.`,
|
|
5154
5627
|
'',
|
|
5155
5628
|
body,
|
|
5156
|
-
].join('\n'))
|
|
5629
|
+
].join('\n'));
|
|
5630
|
+
return this.exploreResult(text, { projectRoot, query, files: [], sourceBytes: 0,
|
|
5631
|
+
responseBytes: text.length, evidenceStatus: status === 'complete' ? 'complete' : 'empty',
|
|
5632
|
+
partial: status !== 'complete', coveredObligations: status === 'complete' ? [plan?.relation ?? route] : [],
|
|
5633
|
+
uncoveredObligations: status !== 'complete' ? [plan?.relation ?? route] : [] });
|
|
5634
|
+
}
|
|
5635
|
+
/** Directed file import witnesses for an explicit module-import relation. */
|
|
5636
|
+
renderModuleImports(cg, plan) {
|
|
5637
|
+
const scope = [...new Set([...plan.anchors, ...(plan.bindings ?? []).map(node => node.filePath)])]
|
|
5638
|
+
.map(value => value.replace(/\\/g, '/')).filter(Boolean);
|
|
5639
|
+
const paths = cg.getFiles().map(file => file.path).filter(file => scope.some(anchor => file === anchor || file.startsWith(anchor + '/') || file.split('/').includes(anchor)));
|
|
5640
|
+
const rows = [];
|
|
5641
|
+
let scanned = 0;
|
|
5642
|
+
for (const file of paths.slice(0, 120)) {
|
|
5643
|
+
scanned++;
|
|
5644
|
+
for (const target of cg.getFileDependencies(file)) {
|
|
5645
|
+
rows.push(`- \`${file}\` imports → \`${target}\``);
|
|
5646
|
+
if (rows.length >= 40)
|
|
5647
|
+
break;
|
|
5648
|
+
}
|
|
5649
|
+
if (rows.length >= 40)
|
|
5650
|
+
break;
|
|
5651
|
+
}
|
|
5652
|
+
const partial = scanned < paths.length || rows.length >= 40;
|
|
5653
|
+
const text = ['**Directed module imports**',
|
|
5654
|
+
rows.length ? rows.join('\n') : 'No indexed import edges found for the supplied module paths.',
|
|
5655
|
+
`Coverage: ${scanned} of ${paths.length} scoped files scanned; cycle analysis was not requested.`].join('\n\n');
|
|
5656
|
+
return this.exploreResult(text, { projectRoot: cg.getProjectRoot(), query: plan.originalQuery,
|
|
5657
|
+
files: [], sourceBytes: 0, responseBytes: text.length,
|
|
5658
|
+
evidenceStatus: !rows.length ? 'empty' : partial ? 'partial' : 'complete',
|
|
5659
|
+
partial: partial || !rows.length });
|
|
5157
5660
|
}
|
|
5158
5661
|
/** Usage sites for a bare symbol bag, including non-call textual references. */
|
|
5159
5662
|
buildBareSymbolUsageSection(cg, query, projectRoot) {
|
|
@@ -5315,10 +5818,10 @@ class ToolHandler {
|
|
|
5315
5818
|
/**
|
|
5316
5819
|
* Fast inventory-only explore — skips findRelevantContext for survey/caller/dependency queries.
|
|
5317
5820
|
*/
|
|
5318
|
-
tryFastInventoryExplore(cg, query, projectRoot) {
|
|
5319
|
-
if (!(
|
|
5821
|
+
tryFastInventoryExplore(cg, query, projectRoot, plan) {
|
|
5822
|
+
if (!planFeature(plan, 'shouldTryFastInventoryExplore', query, query_utils_1.shouldTryFastInventoryExplore))
|
|
5320
5823
|
return null;
|
|
5321
|
-
// Multi-Type dependency asks: inventory-only early exit (avoids fat compact /
|
|
5824
|
+
// Multi-Type dependency asks: inventory-only early exit (avoids fat compact / busy timeout).
|
|
5322
5825
|
if ((0, query_utils_1.queryAsMultiTypeDependencySurvey)(query)) {
|
|
5323
5826
|
const multi = this.buildMultiTypeDependencySection(cg, query);
|
|
5324
5827
|
if (multi.section) {
|
|
@@ -5720,8 +6223,8 @@ class ToolHandler {
|
|
|
5720
6223
|
* findRelevantContext. Fast enough for MCP budget; complete enough to avoid
|
|
5721
6224
|
* agent grep/read loops (token savings).
|
|
5722
6225
|
*/
|
|
5723
|
-
tryLightMechanismExplore(cg, query, projectRoot) {
|
|
5724
|
-
if (!(
|
|
6226
|
+
tryLightMechanismExplore(cg, query, projectRoot, plan) {
|
|
6227
|
+
if (!planFeature(plan, 'shouldTryLightMechanismExplore', query, query_utils_1.shouldTryLightMechanismExplore))
|
|
5725
6228
|
return null;
|
|
5726
6229
|
const STRUCTURE_KINDS = new Set(['class', 'struct', 'interface', 'component', 'method', 'function']);
|
|
5727
6230
|
const isTestPath = (p) => /(^|\/)(tests?|spec)\//i.test(p) || /\.(test|spec)\./i.test(p);
|
|
@@ -6358,7 +6861,7 @@ class ToolHandler {
|
|
|
6358
6861
|
* Compact explore for local-symbol behavior questions — skips findRelevantContext
|
|
6359
6862
|
* and caps to 1–2 defining files (avoids the ~24K related-file dump).
|
|
6360
6863
|
*/
|
|
6361
|
-
tryCompactLocalSymbolExplore(cg, query, projectRoot) {
|
|
6864
|
+
tryCompactLocalSymbolExplore(cg, query, projectRoot, plan) {
|
|
6362
6865
|
// Inventory runs *before* this on the call sites. Do not refuse compact
|
|
6363
6866
|
// merely because inventory *intent* matched — empty inventory must fall
|
|
6364
6867
|
// through here (bare callbacks like OnSurfaceChangedCB).
|
|
@@ -6372,8 +6875,13 @@ class ToolHandler {
|
|
|
6372
6875
|
// Light-mechanism owns domain howtos. Bare "how/如何" NL must NOT veto compact
|
|
6373
6876
|
// when a local/flag/lifecycle shape already owns the query (flag impact was
|
|
6374
6877
|
// falling through to a 20k Dynamic-dispatch dump).
|
|
6375
|
-
if ((
|
|
6878
|
+
if (planFeature(plan, 'shouldTryLightMechanismExplore', query, query_utils_1.shouldTryLightMechanismExplore))
|
|
6376
6879
|
return null;
|
|
6880
|
+
// Explicit `…/Foo.ets` path asks need file-scoped full explore, not compact
|
|
6881
|
+
// trails seeded from path-segment homonyms (`/order/` → `order` property).
|
|
6882
|
+
if ((0, query_utils_1.shouldLimitToQueryNamedFile)(query, false, (0, query_utils_1.queryNamesMultipleExploreAnchors)(query))) {
|
|
6883
|
+
return null;
|
|
6884
|
+
}
|
|
6377
6885
|
if ((0, query_utils_1.queryAsMechanismSurvey)(query)
|
|
6378
6886
|
&& !(0, query_utils_1.queryAsLocalSymbolDetail)(query)
|
|
6379
6887
|
&& !(0, query_utils_1.queryAsAssignedFlagImpactSurvey)(query)
|
|
@@ -8994,8 +9502,10 @@ class ToolHandler {
|
|
|
8994
9502
|
// One normalization point so the flow-builder, relevance search, and
|
|
8995
9503
|
// ranking all see the same canonical spelling (Erlang `mod:fn/arity`).
|
|
8996
9504
|
const query = normalizeQuerySpelling(rawQuery);
|
|
9505
|
+
const plan = readQueryPlan(args);
|
|
9506
|
+
const feature = (name, fallback) => planFeature(plan, name, query, fallback);
|
|
8997
9507
|
const deferKind = (0, query_utils_1.queryShouldDeferToBuiltinTools)(query);
|
|
8998
|
-
if (deferKind) {
|
|
9508
|
+
if (deferKind && plan?.source !== 'llm') {
|
|
8999
9509
|
return this.textResult((0, query_utils_1.homegraphDeferGuidance)(deferKind, query));
|
|
9000
9510
|
}
|
|
9001
9511
|
const cg = this.getHomeGraph(args.projectPath);
|
|
@@ -9003,7 +9513,7 @@ class ToolHandler {
|
|
|
9003
9513
|
// Same-bag / call-budget refuse (session view injected by execute).
|
|
9004
9514
|
const sessionPrior = (0, explore_session_state_1.viewForProject)((0, explore_session_state_1.readExploreSessionView)(args), projectRoot);
|
|
9005
9515
|
const repeat = (0, explore_repeat_guard_1.decideExploreRepeat)(sessionPrior, query);
|
|
9006
|
-
if (repeat
|
|
9516
|
+
if (!plan && this.shouldRefuseRepeatedEvidence(repeat, projectRoot)) {
|
|
9007
9517
|
const text = (0, explore_repeat_guard_1.formatExploreRepeatRefuse)(repeat, query);
|
|
9008
9518
|
return this.exploreResult(text, {
|
|
9009
9519
|
projectRoot,
|
|
@@ -9013,7 +9523,8 @@ class ToolHandler {
|
|
|
9013
9523
|
responseBytes: text.length,
|
|
9014
9524
|
});
|
|
9015
9525
|
}
|
|
9016
|
-
|
|
9526
|
+
// A planned step already tried fast paths once, before entering full explore.
|
|
9527
|
+
const compactLocal = plan ? null : this.tryFastInventoryExplore(cg, query, projectRoot)
|
|
9017
9528
|
?? this.tryLightMechanismExplore(cg, query, projectRoot)
|
|
9018
9529
|
?? this.tryCompactLocalSymbolExplore(cg, query, projectRoot);
|
|
9019
9530
|
if (compactLocal)
|
|
@@ -9033,19 +9544,53 @@ class ToolHandler {
|
|
|
9033
9544
|
const explicitMaxFiles = typeof args.maxFiles === 'number' && !Number.isNaN(args.maxFiles);
|
|
9034
9545
|
let maxFiles = (0, utils_1.clamp)(args.maxFiles || budget.defaultMaxFiles, 1, 20);
|
|
9035
9546
|
const queryFileBasenames = (0, query_utils_1.extractFileBasenamesFromQuery)(query);
|
|
9036
|
-
const interpretationQuery = (
|
|
9037
|
-
const testOnlyInterpretation = (
|
|
9038
|
-
const crossModuleFlow = (
|
|
9547
|
+
const interpretationQuery = feature('queryAsInterpretationSurvey', query_utils_1.queryAsInterpretationSurvey);
|
|
9548
|
+
const testOnlyInterpretation = feature('queryAsTestOnlyInterpretation', query_utils_1.queryAsTestOnlyInterpretation);
|
|
9549
|
+
const crossModuleFlow = feature('queryAsCrossModuleFlowSurvey', query_utils_1.queryAsCrossModuleFlowSurvey) || plan?.intent === 'flow';
|
|
9039
9550
|
// Step 1: Find relevant context with generous parameters.
|
|
9040
9551
|
const contextOpts = interpretationQuery && queryFileBasenames.length === 1
|
|
9041
9552
|
? { searchLimit: 6, traversalDepth: 2, maxNodes: 60, minScore: 0.25 }
|
|
9042
9553
|
: { searchLimit: 8, traversalDepth: 3, maxNodes: 200, minScore: 0.2 };
|
|
9043
9554
|
const contextQuery = interpretationQuery && queryFileBasenames.length === 1
|
|
9044
9555
|
? `${queryFileBasenames[0]} ${query}`
|
|
9045
|
-
:
|
|
9046
|
-
|
|
9556
|
+
: queryFileBasenames.length === 1
|
|
9557
|
+
? `${queryFileBasenames[0]} ${query}`
|
|
9558
|
+
: query;
|
|
9559
|
+
const subgraph = await cg.findRelevantContext(contextQuery, {
|
|
9560
|
+
...contextOpts,
|
|
9561
|
+
...(plan && (plan.source === 'llm' || plan.literalTexts?.length) ? { retrievalHints: {
|
|
9562
|
+
symbols: plan.anchors.filter((anchor) => !(plan.bindings ?? []).some((node) => anchor === node.name || anchor === node.qualifiedName)),
|
|
9563
|
+
searchTerms: plan.searchTerms, literalTexts: plan.literalTexts, sourceScope: plan.sourceScope, nodeIds: (plan.bindings ?? []).map((node) => node.id),
|
|
9564
|
+
} } : {}),
|
|
9565
|
+
});
|
|
9566
|
+
// Path-first: always seed nodes from an explicit `Foo.ets` basename so a
|
|
9567
|
+
// CJK-only ask + path (or a shared prop like showSearchIcon) cannot leave
|
|
9568
|
+
// the named file out of the subgraph / digests.
|
|
9569
|
+
if (queryFileBasenames.length > 0 && plan?.sourceScope !== 'sdk') {
|
|
9570
|
+
for (const base of queryFileBasenames.slice(0, 3)) {
|
|
9571
|
+
let hits = [];
|
|
9572
|
+
try {
|
|
9573
|
+
hits = cg.searchNodes(base, { limit: 50 });
|
|
9574
|
+
}
|
|
9575
|
+
catch {
|
|
9576
|
+
continue;
|
|
9577
|
+
}
|
|
9578
|
+
for (const r of hits) {
|
|
9579
|
+
if (!(0, query_utils_1.fileMatchesQueryBasename)(r.node.filePath, [base]))
|
|
9580
|
+
continue;
|
|
9581
|
+
if (!subgraph.nodes.has(r.node.id)) {
|
|
9582
|
+
subgraph.nodes.set(r.node.id, r.node);
|
|
9583
|
+
subgraph.roots.push(r.node.id);
|
|
9584
|
+
}
|
|
9585
|
+
}
|
|
9586
|
+
}
|
|
9587
|
+
}
|
|
9588
|
+
const literalSource = this.renderLiteralSource(cg, subgraph);
|
|
9047
9589
|
if (subgraph.nodes.size === 0) {
|
|
9048
|
-
|
|
9590
|
+
const text = literalSource.text || `No relevant code found for "${query}"`;
|
|
9591
|
+
return this.exploreResult(text, { projectRoot, query, files: literalSource.files,
|
|
9592
|
+
sourceBytes: literalSource.files.reduce((sum, file) => sum + file.bytes, 0), responseBytes: text.length,
|
|
9593
|
+
locatedNodes: literalSource.nodes, partial: true, evidenceStatus: literalSource.text ? 'partial' : 'empty' });
|
|
9049
9594
|
}
|
|
9050
9595
|
// Seed import nodes for @kit.* / *Kit names (and named symbols like taskpool).
|
|
9051
9596
|
const importTerms = (0, query_utils_1.extractImportSearchTerms)(query);
|
|
@@ -9184,8 +9729,8 @@ class ToolHandler {
|
|
|
9184
9729
|
namedParts.push(m[3]);
|
|
9185
9730
|
}
|
|
9186
9731
|
const tokens = [...new Set([
|
|
9187
|
-
...namedParts,
|
|
9188
|
-
...query.split(/[\s,()[\]]+/)
|
|
9732
|
+
...(plan?.source === 'llm' ? plan.anchors : namedParts),
|
|
9733
|
+
...(plan?.source === 'llm' ? plan.anchors.join(' ') : query).split(/[\s,()[\]]+/)
|
|
9189
9734
|
.map((t) => t.replace(FILE_EXT, '').trim())
|
|
9190
9735
|
.filter((t) => t.length >= 3 && /^[A-Za-z_$][\w$]*(?:(?:::|\.)[\w$]+)*$/.test(t)),
|
|
9191
9736
|
])].slice(0, 16);
|
|
@@ -9212,7 +9757,8 @@ class ToolHandler {
|
|
|
9212
9757
|
const isQual = /[.\/]|::/.test(t);
|
|
9213
9758
|
const raw = isQual ? this.findAllSymbols(cg, t).nodes : cg.getNodesByName(t);
|
|
9214
9759
|
let cands = raw
|
|
9215
|
-
.filter((n) => SEED_KINDS.has(n.kind) && !isTestPath(n.filePath)
|
|
9760
|
+
.filter((n) => SEED_KINDS.has(n.kind) && !isTestPath(n.filePath)
|
|
9761
|
+
&& !(plan?.sourceScope === 'local' && (0, arkts_1.isOhosApiFilePath)(n.filePath)))
|
|
9216
9762
|
.sort((a, b) => {
|
|
9217
9763
|
// Prefer callables over types when both share a name, then body size.
|
|
9218
9764
|
const ac = CALLABLE.has(a.kind) ? 1 : 0;
|
|
@@ -9427,6 +9973,8 @@ class ToolHandler {
|
|
|
9427
9973
|
}
|
|
9428
9974
|
fileGroups.set(node.filePath, group);
|
|
9429
9975
|
}
|
|
9976
|
+
for (const group of fileGroups.values())
|
|
9977
|
+
group.nodes = (0, evidence_rendering_1.canonicalSourceDeclarations)(group.nodes);
|
|
9430
9978
|
if (testOnlyInterpretation) {
|
|
9431
9979
|
for (const [, group] of fileGroups) {
|
|
9432
9980
|
const fp = group.nodes[0]?.filePath ?? '';
|
|
@@ -9622,6 +10170,15 @@ class ToolHandler {
|
|
|
9622
10170
|
const sortedFiles = relevantFiles.sort((a, b) => {
|
|
9623
10171
|
const aPath = a[0].toLowerCase();
|
|
9624
10172
|
const bPath = b[0].toLowerCase();
|
|
10173
|
+
if (plan?.sourceScope === 'local') {
|
|
10174
|
+
const sdkOrder = Number((0, arkts_1.isOhosApiFilePath)(a[0])) - Number((0, arkts_1.isOhosApiFilePath)(b[0]));
|
|
10175
|
+
if (sdkOrder)
|
|
10176
|
+
return sdkOrder;
|
|
10177
|
+
}
|
|
10178
|
+
const literalOrder = Number((subgraph.literalEvidence?.hits ?? []).some((hit) => hit.filePath === b[0]))
|
|
10179
|
+
- Number((subgraph.literalEvidence?.hits ?? []).some((hit) => hit.filePath === a[0]));
|
|
10180
|
+
if (literalOrder)
|
|
10181
|
+
return literalOrder;
|
|
9625
10182
|
// Query-named file (LocationController.ets in the question) before partial
|
|
9626
10183
|
// substring matches (control.ets matching "Controller" inside LocationController).
|
|
9627
10184
|
const aExactBase = (0, query_utils_1.fileMatchesQueryBasename)(a[0], queryFileBasenames) ? 1 : 0;
|
|
@@ -9681,6 +10238,8 @@ class ToolHandler {
|
|
|
9681
10238
|
'',
|
|
9682
10239
|
];
|
|
9683
10240
|
const summaryLineIdx = 2;
|
|
10241
|
+
if (literalSource.text)
|
|
10242
|
+
lines.push(literalSource.text);
|
|
9684
10243
|
if (testOnlyInterpretation) {
|
|
9685
10244
|
lines.push('> **Test-file scope only** — answer from the named `.test.ets` file below; ' +
|
|
9686
10245
|
'production handlers are out of scope unless explicitly referenced in the test.');
|
|
@@ -9705,30 +10264,30 @@ class ToolHandler {
|
|
|
9705
10264
|
: { section: '', symbolCount: 0 };
|
|
9706
10265
|
if (kitUsageResult.section)
|
|
9707
10266
|
lines.push(kitUsageResult.section);
|
|
9708
|
-
const domainFileResult = (
|
|
10267
|
+
const domainFileResult = feature('shouldBuildDomainFileSurvey', query_utils_1.shouldBuildDomainFileSurvey)
|
|
9709
10268
|
? this.buildDomainFileSurveySection(cg, query)
|
|
9710
10269
|
: { section: '', fileCount: 0 };
|
|
9711
10270
|
if (domainFileResult.section)
|
|
9712
10271
|
lines.push(domainFileResult.section);
|
|
9713
|
-
const apiUsageResult = (
|
|
9714
|
-
&& !(
|
|
10272
|
+
const apiUsageResult = feature('shouldBuildApiUsageSurvey', query_utils_1.shouldBuildApiUsageSurvey)
|
|
10273
|
+
&& !feature('shouldBuildKitModuleUsageSurvey', query_utils_1.shouldBuildKitModuleUsageSurvey)
|
|
9715
10274
|
? this.buildApiUsageSection(cg, query, projectRoot)
|
|
9716
10275
|
: { section: '', fileCount: 0 };
|
|
9717
10276
|
if (apiUsageResult.section)
|
|
9718
10277
|
lines.push(apiUsageResult.section);
|
|
9719
|
-
const dataSourceResult = (
|
|
10278
|
+
const dataSourceResult = feature('queryAsDataSourceSurvey', query_utils_1.queryAsDataSourceSurvey)
|
|
9720
10279
|
? this.buildDataSourceSection(cg, query)
|
|
9721
10280
|
: { section: '', edgeCount: 0, sdkImportCount: 0, strongCount: 0 };
|
|
9722
10281
|
if (dataSourceResult.section)
|
|
9723
10282
|
lines.push(dataSourceResult.section);
|
|
9724
|
-
const eventDispatchResult = (
|
|
10283
|
+
const eventDispatchResult = feature('queryAsEventDispatchSurvey', query_utils_1.queryAsEventDispatchSurvey)
|
|
9725
10284
|
? this.buildEventDispatchSection(cg, query, projectRoot)
|
|
9726
10285
|
: { section: '', hitCount: 0, eventCount: 0, handlerCount: 0, memberCount: 0, complete: false };
|
|
9727
10286
|
if (eventDispatchResult.section)
|
|
9728
10287
|
lines.push(eventDispatchResult.section);
|
|
9729
10288
|
const importInventoryFilter = (0, query_utils_1.hasImportInventoryFilter)(query);
|
|
9730
10289
|
const multiAnchor = (0, query_utils_1.queryNamesMultipleExploreAnchors)(query) || crossModuleFlow;
|
|
9731
|
-
const mechanismSurvey = (
|
|
10290
|
+
const mechanismSurvey = feature('queryAsMechanismSurvey', query_utils_1.queryAsMechanismSurvey);
|
|
9732
10291
|
// Flow path — computed before omit-source so graph connectivity drives the decision,
|
|
9733
10292
|
// not question-text keyword matching. Mechanism/cross-module surveys augment the
|
|
9734
10293
|
// query with seeded entry symbol names so buildFlowFromNamedSymbols can connect them.
|
|
@@ -9959,6 +10518,17 @@ class ToolHandler {
|
|
|
9959
10518
|
const priorCalls = (0, explore_session_state_1.viewForProject)((0, explore_session_state_1.readExploreSessionView)(args), projectRoot);
|
|
9960
10519
|
const dedupEnabled = (0, explore_dedup_1.exploreDedupEnabled)() && (priorCalls?.calls.length ?? 0) > 0;
|
|
9961
10520
|
const emittedByFile = new Map();
|
|
10521
|
+
// Session emissions may include prior-call coverage or skeleton envelopes.
|
|
10522
|
+
// Dependency receipts instead require *fresh*, actually printed source and
|
|
10523
|
+
// a whole surviving file section (never the tail cut by the hard ceiling).
|
|
10524
|
+
const freshRangesByFile = new Map();
|
|
10525
|
+
const sourceEndByFile = new Map();
|
|
10526
|
+
const noteFreshSource = (fp, ranges) => {
|
|
10527
|
+
if (plan?.source !== 'llm' || !ranges.length)
|
|
10528
|
+
return;
|
|
10529
|
+
freshRangesByFile.set(fp, [...(freshRangesByFile.get(fp) ?? []), ...ranges]);
|
|
10530
|
+
sourceEndByFile.set(fp, lines.join('\n').length);
|
|
10531
|
+
};
|
|
9962
10532
|
const noteEmitted = (fp, ranges, bytes, fingerprint) => {
|
|
9963
10533
|
const existing = emittedByFile.get(fp);
|
|
9964
10534
|
if (existing) {
|
|
@@ -10130,6 +10700,7 @@ class ToolHandler {
|
|
|
10130
10700
|
}
|
|
10131
10701
|
if (body.length > 0) {
|
|
10132
10702
|
lines.push('```' + lang, body, '```', '');
|
|
10703
|
+
noteFreshSource(filePath, ranges);
|
|
10133
10704
|
totalChars += body.length + lang.length + 11;
|
|
10134
10705
|
noteEmitted(filePath, [...ranges, ...opts.covered], body.length, fingerprint);
|
|
10135
10706
|
renderedFilePaths.push(filePath);
|
|
@@ -10216,6 +10787,7 @@ class ToolHandler {
|
|
|
10216
10787
|
// signature line (capped, with a "+N more" tail so the structure map of a
|
|
10217
10788
|
// god-file doesn't itself bloat the budget).
|
|
10218
10789
|
const skel = [];
|
|
10790
|
+
const freshSkelRanges = [];
|
|
10219
10791
|
let coveredUntil = 0; // skip symbols already inside an emitted body
|
|
10220
10792
|
let sigCount = 0, sigDropped = 0;
|
|
10221
10793
|
const SIG_MAX = Math.max(12, budget.maxSymbolsInFileHeader * 2);
|
|
@@ -10226,6 +10798,7 @@ class ToolHandler {
|
|
|
10226
10798
|
const end = n.endLine;
|
|
10227
10799
|
const body = fileLines.slice(n.startLine - 1, end).join('\n');
|
|
10228
10800
|
skel.push(exploreLineNumbersEnabled() ? numberSourceLines(body, n.startLine) : body);
|
|
10801
|
+
freshSkelRanges.push({ start: n.startLine, end });
|
|
10229
10802
|
coveredUntil = end;
|
|
10230
10803
|
}
|
|
10231
10804
|
else {
|
|
@@ -10248,6 +10821,7 @@ class ToolHandler {
|
|
|
10248
10821
|
if (sig) {
|
|
10249
10822
|
skel.push(exploreLineNumbersEnabled() ? `${lineNo}\t${sig}` : sig);
|
|
10250
10823
|
sigCount++;
|
|
10824
|
+
freshSkelRanges.push({ start: lineNo, end: lineNo });
|
|
10251
10825
|
}
|
|
10252
10826
|
}
|
|
10253
10827
|
}
|
|
@@ -10285,6 +10859,7 @@ class ToolHandler {
|
|
|
10285
10859
|
return n ? { start: n.startLine, end: n.endLine || n.startLine } : null;
|
|
10286
10860
|
}).filter((r) => !!r);
|
|
10287
10861
|
lines.push(skelHeader, '', '```' + lang, skelBody, '```', '');
|
|
10862
|
+
noteFreshSource(filePath, freshSkelRanges);
|
|
10288
10863
|
totalChars += skelBody.length + 120;
|
|
10289
10864
|
noteEmitted(filePath, bodyRanges.length > 0 ? bodyRanges : [wholeRange], skelBody.length, fingerprint);
|
|
10290
10865
|
renderedFilePaths.push(filePath);
|
|
@@ -10317,7 +10892,15 @@ class ToolHandler {
|
|
|
10317
10892
|
? Math.min(Math.max(0, budget.maxOutputChars - totalChars - 200), Math.round(budget.maxCharsPerFile * 1.5))
|
|
10318
10893
|
: budget.maxCharsPerFile * 3;
|
|
10319
10894
|
if (fileLines.length <= WHOLE_FILE_MAX_LINES && fileContent.length <= WHOLE_FILE_MAX_CHARS) {
|
|
10320
|
-
|
|
10895
|
+
let sourceStart = 0;
|
|
10896
|
+
if (fileLines[0]?.trim().startsWith('/*')) {
|
|
10897
|
+
const endComment = fileLines.findIndex((line) => line.includes('*/'));
|
|
10898
|
+
if (endComment >= 0 && endComment < 40)
|
|
10899
|
+
sourceStart = endComment + 1;
|
|
10900
|
+
}
|
|
10901
|
+
while (sourceStart < fileLines.length - 1 && !fileLines[sourceStart]?.trim())
|
|
10902
|
+
sourceStart++;
|
|
10903
|
+
const wholeRange = { start: sourceStart + 1, end: Math.max(1, fileLines.length) };
|
|
10321
10904
|
const dd = (0, explore_dedup_1.dedupeRange)(wholeRange, served);
|
|
10322
10905
|
const uniqSymbols = [...new Set(group.nodes
|
|
10323
10906
|
.filter(n => n.kind !== 'import' && n.kind !== 'export')
|
|
@@ -10778,6 +11361,7 @@ class ToolHandler {
|
|
|
10778
11361
|
const output = flow.text + lines.join('\n');
|
|
10779
11362
|
const hardCeiling = Math.min(Math.round(budget.maxOutputChars * 1.5), 25000);
|
|
10780
11363
|
let finalText;
|
|
11364
|
+
let sourceCutoff = output.length;
|
|
10781
11365
|
if (output.length > hardCeiling) {
|
|
10782
11366
|
// Prefer dropping trailing notes ("Not shown above", completeness, budget)
|
|
10783
11367
|
// over dropping a whole file's source — notes are recoverable, source isn't.
|
|
@@ -10792,8 +11376,10 @@ class ToolHandler {
|
|
|
10792
11376
|
if (trimmed.length <= hardCeiling)
|
|
10793
11377
|
break;
|
|
10794
11378
|
const at = trimmed.lastIndexOf(marker);
|
|
10795
|
-
if (at > hardCeiling * 0.4)
|
|
11379
|
+
if (at > hardCeiling * 0.4) {
|
|
10796
11380
|
trimmed = trimmed.slice(0, at).replace(/\n+$/, '');
|
|
11381
|
+
sourceCutoff = Math.min(sourceCutoff, trimmed.length);
|
|
11382
|
+
}
|
|
10797
11383
|
}
|
|
10798
11384
|
if (trimmed.length > hardCeiling) {
|
|
10799
11385
|
// Cut at a FILE-SECTION boundary so we drop whole trailing file-sections
|
|
@@ -10822,6 +11408,7 @@ class ToolHandler {
|
|
|
10822
11408
|
boundary = lastSection > hardCeiling * 0.5 ? lastSection : cut.lastIndexOf('\n');
|
|
10823
11409
|
}
|
|
10824
11410
|
const safe = boundary > 0 ? cut.slice(0, boundary) : cut;
|
|
11411
|
+
sourceCutoff = Math.min(sourceCutoff, safe.length);
|
|
10825
11412
|
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.)';
|
|
10826
11413
|
}
|
|
10827
11414
|
else {
|
|
@@ -10872,14 +11459,85 @@ class ToolHandler {
|
|
|
10872
11459
|
});
|
|
10873
11460
|
sourceBytes += emitted.bytes;
|
|
10874
11461
|
}
|
|
11462
|
+
// Do not promote unseen subgraph hits or a large parent's unshown header.
|
|
11463
|
+
// The declaration's starting line must be in a surviving, fresh source span.
|
|
11464
|
+
const rootIds = new Set(subgraph.roots);
|
|
11465
|
+
for (const file of literalSource.files) {
|
|
11466
|
+
if (!finalText.includes(literalSource.text))
|
|
11467
|
+
break;
|
|
11468
|
+
emittedFiles.push(file);
|
|
11469
|
+
sourceBytes += file.bytes;
|
|
11470
|
+
}
|
|
11471
|
+
const locatedNodes = plan?.source === 'llm' ? [...(finalText.includes(literalSource.text) ? literalSource.nodes : []), ...emittedFiles.flatMap((file) => staleRendered.includes(file.path) || flow.text.length + (sourceEndByFile.get(file.path) ?? Infinity) > sourceCutoff
|
|
11472
|
+
? [] : (fileGroups.get(file.path)?.nodes ?? []).filter((node) => !['file', 'import', 'export', 'parameter'].includes(node.kind)
|
|
11473
|
+
&& (freshRangesByFile.get(file.path) ?? []).some((range) => node.startLine >= range.start && node.startLine <= range.end)))]
|
|
11474
|
+
.sort((a, b) => Number(rootIds.has(b.id)) - Number(rootIds.has(a.id)))
|
|
11475
|
+
.filter((node, i, nodes) => nodes.findIndex((other) => other.id === node.id) === i)
|
|
11476
|
+
.slice(0, 32).map((node) => ({ id: node.id, name: node.name, qualifiedName: node.qualifiedName,
|
|
11477
|
+
filePath: node.filePath, startLine: node.startLine })) : undefined;
|
|
10875
11478
|
return this.exploreResult(finalText, {
|
|
10876
11479
|
projectRoot,
|
|
10877
11480
|
query,
|
|
10878
11481
|
files: emittedFiles,
|
|
10879
11482
|
sourceBytes,
|
|
11483
|
+
evidenceStatus: sourceBytes > 0 ? (anyFileTrimmed || subgraph.confidence === 'low' || sourceCutoff < output.length
|
|
11484
|
+
|| (plan?.source === 'llm' && !(locatedNodes?.length)) ? 'partial' : 'complete')
|
|
11485
|
+
: filesIncluded > 0 && /HarmonyOS SDK API/.test(finalText) ? 'sdk-only' : 'empty',
|
|
10880
11486
|
responseBytes: finalText.length,
|
|
11487
|
+
...(locatedNodes ? { locatedNodes } : {}),
|
|
10881
11488
|
});
|
|
10882
11489
|
}
|
|
11490
|
+
/** Render literal witnesses before graph-heavy sections, including unindexed UI. */
|
|
11491
|
+
renderLiteralSource(cg, subgraph) {
|
|
11492
|
+
const sections = [];
|
|
11493
|
+
const files = [];
|
|
11494
|
+
const nodes = [];
|
|
11495
|
+
const seen = new Set();
|
|
11496
|
+
let chars = 0;
|
|
11497
|
+
for (const hit of subgraph.literalEvidence?.hits ?? []) {
|
|
11498
|
+
if (files.length >= 2 || seen.has(hit.filePath))
|
|
11499
|
+
continue;
|
|
11500
|
+
const absolute = (0, utils_1.validatePathWithinRoot)(cg.getProjectRoot(), hit.filePath);
|
|
11501
|
+
if (!absolute)
|
|
11502
|
+
continue;
|
|
11503
|
+
let source;
|
|
11504
|
+
try {
|
|
11505
|
+
source = (0, fs_1.readFileSync)(absolute, 'utf8');
|
|
11506
|
+
}
|
|
11507
|
+
catch {
|
|
11508
|
+
continue;
|
|
11509
|
+
}
|
|
11510
|
+
const lines = source.split('\n');
|
|
11511
|
+
// Never serve an earlier witness against a changed file.
|
|
11512
|
+
if (lines.slice(hit.startLine - 1, hit.endLine).join('\n') !== hit.text)
|
|
11513
|
+
continue;
|
|
11514
|
+
const containing = this.isFileStaleOnDisk(cg, hit.filePath, source) ? undefined
|
|
11515
|
+
: (0, evidence_rendering_1.canonicalSourceDeclarations)(cg.getNodesInFile(hit.filePath))
|
|
11516
|
+
.filter(node => !['file', 'import', 'export', 'parameter'].includes(node.kind)
|
|
11517
|
+
&& !node.name.startsWith('%') && node.name !== 'constructor'
|
|
11518
|
+
&& node.startLine <= hit.line && node.endLine >= hit.line)
|
|
11519
|
+
.sort((a, b) => (a.endLine - a.startLine) - (b.endLine - b.startLine))[0];
|
|
11520
|
+
const ranges = [{ start: hit.startLine, end: hit.endLine }];
|
|
11521
|
+
if (containing && containing.startLine < hit.startLine) {
|
|
11522
|
+
ranges.unshift({ start: containing.startLine, end: Math.min(containing.startLine + 2, hit.startLine - 1) });
|
|
11523
|
+
}
|
|
11524
|
+
const body = ranges.map(range => lines.slice(range.start - 1, range.end)
|
|
11525
|
+
.map((line, index) => `${range.start + index}\t${line}`).join('\n')).join('\n... (gap) ...\n');
|
|
11526
|
+
const resource = hit.resource
|
|
11527
|
+
? `\nResource: \`${hit.resource.filePath}:${hit.resource.line}\` — ${JSON.stringify(hit.resource.value)} → \`${hit.resource.key}\`.` : '';
|
|
11528
|
+
const section = `**Literal source witness: \`${hit.filePath}:${hit.line}\`**${resource}\n\n\`\`\`\n${body}\n\`\`\``;
|
|
11529
|
+
if (chars + section.length > 3000)
|
|
11530
|
+
continue;
|
|
11531
|
+
sections.push(section);
|
|
11532
|
+
chars += section.length;
|
|
11533
|
+
seen.add(hit.filePath);
|
|
11534
|
+
files.push({ path: hit.filePath, ranges, bytes: body.length, fingerprint: (0, explore_dedup_1.fileFingerprint)(source) });
|
|
11535
|
+
if (containing)
|
|
11536
|
+
nodes.push({ id: containing.id, name: containing.name,
|
|
11537
|
+
qualifiedName: containing.qualifiedName, filePath: containing.filePath, startLine: containing.startLine });
|
|
11538
|
+
}
|
|
11539
|
+
return { text: sections.join('\n\n'), files, nodes };
|
|
11540
|
+
}
|
|
10883
11541
|
/**
|
|
10884
11542
|
* An explore response plus the record of what it emitted (CG-17). The record
|
|
10885
11543
|
* rides the result only as far as {@link execute}, which files it into the
|
|
@@ -10890,7 +11548,8 @@ class ToolHandler {
|
|
|
10890
11548
|
const result = this.textResult(text);
|
|
10891
11549
|
result[explore_session_state_1.EXPLORE_EMISSION_KEY] = {
|
|
10892
11550
|
...emission,
|
|
10893
|
-
|
|
11551
|
+
evidenceStatus: emission.evidenceStatus ?? (emission.sourceBytes > 0 ? (meta.partial ? 'partial' : 'complete') : 'partial'),
|
|
11552
|
+
partial: emission.partial ?? (emission.evidenceStatus && emission.evidenceStatus !== 'complete' ? true : meta.partial),
|
|
10894
11553
|
nextAnchor: emission.nextAnchor ?? meta.nextAnchor,
|
|
10895
11554
|
};
|
|
10896
11555
|
return result;
|
|
@@ -10916,7 +11575,8 @@ class ToolHandler {
|
|
|
10916
11575
|
files: [],
|
|
10917
11576
|
sourceBytes: 0,
|
|
10918
11577
|
responseBytes: text.length,
|
|
10919
|
-
partial:
|
|
11578
|
+
partial: true,
|
|
11579
|
+
evidenceStatus: /HarmonyOS SDK API|ohos-sdk:/.test(text) ? 'sdk-only' : /No relevant code|No matching evidence|No survey ran/.test(text) ? 'empty' : 'partial',
|
|
10920
11580
|
nextAnchor: meta.nextAnchor,
|
|
10921
11581
|
};
|
|
10922
11582
|
return result;
|
|
@@ -10951,7 +11611,7 @@ class ToolHandler {
|
|
|
10951
11611
|
const symbol = this.validateString(args.symbol, 'symbol');
|
|
10952
11612
|
if (typeof symbol !== 'string')
|
|
10953
11613
|
return symbol;
|
|
10954
|
-
let matches = this.findSymbolMatches(cg, symbol);
|
|
11614
|
+
let matches = (0, evidence_rendering_1.canonicalSourceDeclarations)(this.findSymbolMatches(cg, symbol));
|
|
10955
11615
|
if (matches.length === 0) {
|
|
10956
11616
|
return this.textResult(`Symbol "${symbol}" not found in the codebase`);
|
|
10957
11617
|
}
|
|
@@ -11317,7 +11977,7 @@ class ToolHandler {
|
|
|
11317
11977
|
if (mismatch) {
|
|
11318
11978
|
lines.push(`> ⚠ ${(0, worktree_1.worktreeMismatchWarning)(mismatch).replace(/\n/g, '\n> ')}`, '');
|
|
11319
11979
|
}
|
|
11320
|
-
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
|
|
11980
|
+
lines.push(`**Build phase:** ${cg.getBuildPhase()}`, `**Files indexed:** ${stats.fileCount}`, `**Total nodes:** ${stats.nodeCount}`, `**Total edges:** ${stats.edgeCount}`, `**Database size:** ${(stats.dbSizeBytes / 1024 / 1024).toFixed(2)} MB`, ...(stats.walSizeBytes > 0
|
|
11321
11981
|
? [`**WAL size:** ${(stats.walSizeBytes / 1024 / 1024).toFixed(2)} MB`]
|
|
11322
11982
|
: []), `**Graph sources:** ${cg.getGraphSources()}`);
|
|
11323
11983
|
// Surface the active SQLite backend: node:sqlite → better-sqlite3 → wasm.
|
|
@@ -11382,6 +12042,64 @@ class ToolHandler {
|
|
|
11382
12042
|
}
|
|
11383
12043
|
return this.textResult(lines.join('\n'));
|
|
11384
12044
|
}
|
|
12045
|
+
/**
|
|
12046
|
+
* Handle homegraph_project — shallow module → file map (fast build).
|
|
12047
|
+
*/
|
|
12048
|
+
async handleProject(args) {
|
|
12049
|
+
const cg = this.getHomeGraph(args.projectPath);
|
|
12050
|
+
const phase = cg.getBuildPhase();
|
|
12051
|
+
if (phase === 'building_fast') {
|
|
12052
|
+
return this.textResult('Fast project map is still building — retry in a few seconds.');
|
|
12053
|
+
}
|
|
12054
|
+
let map = cg.getProjectMap({
|
|
12055
|
+
module: typeof args.module === 'string' ? args.module : undefined,
|
|
12056
|
+
includeFiles: args.includeFiles !== false,
|
|
12057
|
+
});
|
|
12058
|
+
// On-demand build for indexes that never ran the fast path (legacy / CLI init).
|
|
12059
|
+
if (map.modules.length === 0) {
|
|
12060
|
+
try {
|
|
12061
|
+
cg.buildProjectMap();
|
|
12062
|
+
if (phase === 'none') {
|
|
12063
|
+
cg.setBuildPhase(cg.getStats().nodeCount > 0 ? 'full' : 'fast');
|
|
12064
|
+
}
|
|
12065
|
+
map = cg.getProjectMap({
|
|
12066
|
+
module: typeof args.module === 'string' ? args.module : undefined,
|
|
12067
|
+
includeFiles: args.includeFiles !== false,
|
|
12068
|
+
});
|
|
12069
|
+
}
|
|
12070
|
+
catch (err) {
|
|
12071
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
12072
|
+
return this.textResult(`Failed to build project map: ${message}`);
|
|
12073
|
+
}
|
|
12074
|
+
}
|
|
12075
|
+
if (map.modules.length === 0) {
|
|
12076
|
+
return this.textResult('No modules found in the project map.');
|
|
12077
|
+
}
|
|
12078
|
+
const FILE_CAP = 80;
|
|
12079
|
+
const lines = [
|
|
12080
|
+
`**Project map** (phase=${map.phase})`,
|
|
12081
|
+
`modules: ${map.modules.length} · files: ${map.fileCount}`,
|
|
12082
|
+
'',
|
|
12083
|
+
];
|
|
12084
|
+
if (map.phase === 'fast' || map.phase === 'indexing') {
|
|
12085
|
+
lines.push('_Full symbol index still building — this map has modules/files only (no call graph)._', '');
|
|
12086
|
+
}
|
|
12087
|
+
for (const m of map.modules) {
|
|
12088
|
+
const rootLabel = m.rootPath || '.';
|
|
12089
|
+
lines.push(`### ${m.name} (\`${rootLabel}\`) · ${m.kind} · ${m.fileCount} files`);
|
|
12090
|
+
if (m.files && m.files.length > 0) {
|
|
12091
|
+
const shown = m.files.slice(0, FILE_CAP);
|
|
12092
|
+
for (const f of shown) {
|
|
12093
|
+
lines.push(`- ${f.path}`);
|
|
12094
|
+
}
|
|
12095
|
+
if (m.files.length > FILE_CAP) {
|
|
12096
|
+
lines.push(`- … +${m.files.length - FILE_CAP} more`);
|
|
12097
|
+
}
|
|
12098
|
+
}
|
|
12099
|
+
lines.push('');
|
|
12100
|
+
}
|
|
12101
|
+
return this.textResult(lines.join('\n').trimEnd());
|
|
12102
|
+
}
|
|
11385
12103
|
/**
|
|
11386
12104
|
* Handle homegraph_arkui_migrate — one-shot ArkUI migrate / state snapshot (spec 0007).
|
|
11387
12105
|
*/
|
|
@@ -12269,7 +12987,7 @@ class ToolHandler {
|
|
|
12269
12987
|
return {
|
|
12270
12988
|
// Single choke point for every tool's text, so no section builder can ship
|
|
12271
12989
|
// a stop-searching directive on an output that declares itself partial.
|
|
12272
|
-
content: [{ type: 'text', text: reconcilePartialAnswerNow(text) }],
|
|
12990
|
+
content: [{ type: 'text', text: (0, evidence_rendering_1.neutralRetrievalGuidance)(reconcilePartialAnswerNow(text)) }],
|
|
12273
12991
|
};
|
|
12274
12992
|
}
|
|
12275
12993
|
/**
|