@exellix/graphs-studio-data-flow 0.1.0

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.
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Task-node execution authoring — graph-engine 5.0+ (`taskConfiguration`), not `metadata`.
3
+ *
4
+ * Pure metadata on task nodes is allowlisted in `@exellix/graph-engine` (graphReadability,
5
+ * catalogBinding, …); execution wiring lives here.
6
+ */
7
+
8
+ /**
9
+ * @param {unknown} node
10
+ * @returns {Record<string, unknown> | null}
11
+ */
12
+ export function readTaskConfiguration(node) {
13
+ if (!node || typeof node !== 'object' || Array.isArray(node)) return null;
14
+ const n = /** @type {Record<string, unknown>} */ (node);
15
+ const tc = n.taskConfiguration;
16
+ if (tc && typeof tc === 'object' && !Array.isArray(tc)) {
17
+ return /** @type {Record<string, unknown>} */ (tc);
18
+ }
19
+ const params = n.parameters;
20
+ if (params && typeof params === 'object' && !Array.isArray(params)) {
21
+ const ptc = /** @type {Record<string, unknown>} */ (params).taskConfiguration;
22
+ if (ptc && typeof ptc === 'object' && !Array.isArray(ptc)) {
23
+ return /** @type {Record<string, unknown>} */ (ptc);
24
+ }
25
+ }
26
+ return null;
27
+ }
28
+
29
+ /**
30
+ * Shallow node copy with an isolated `taskConfiguration` bag (avoids cross-node mutation when
31
+ * template/import code reused the same `taskConfiguration` object on multiple nodes).
32
+ *
33
+ * @template {Record<string, unknown>} T
34
+ * @param {T} node
35
+ * @returns {T}
36
+ */
37
+ export function cloneNodeForTaskConfigurationEdit(node) {
38
+ if (!node || typeof node !== 'object' || Array.isArray(node)) {
39
+ return /** @type {T} */ (node);
40
+ }
41
+ const copy = { ...node };
42
+ const tc = node.taskConfiguration;
43
+ if (tc && typeof tc === 'object' && !Array.isArray(tc)) {
44
+ try {
45
+ if (typeof structuredClone === 'function') {
46
+ copy.taskConfiguration = structuredClone(tc);
47
+ } else {
48
+ copy.taskConfiguration = JSON.parse(JSON.stringify(tc));
49
+ }
50
+ } catch {
51
+ copy.taskConfiguration = { .../** @type {Record<string, unknown>} */ (tc) };
52
+ }
53
+ }
54
+ if ('modelConfig' in copy) delete copy.modelConfig;
55
+ return /** @type {T} */ (copy);
56
+ }
57
+
58
+ /**
59
+ * @param {Record<string, unknown>} node mutates
60
+ * @returns {Record<string, unknown>}
61
+ */
62
+ export function ensureTaskConfiguration(node) {
63
+ if (
64
+ !node.taskConfiguration ||
65
+ typeof node.taskConfiguration !== 'object' ||
66
+ Array.isArray(node.taskConfiguration)
67
+ ) {
68
+ node.taskConfiguration = {};
69
+ }
70
+ return /** @type {Record<string, unknown>} */ (node.taskConfiguration);
71
+ }
72
+
73
+ /**
74
+ * @param {Record<string, unknown>} node mutates
75
+ * @param {(input: Record<string, unknown>) => Record<string, unknown> | undefined} update
76
+ */
77
+ export function patchInputSynthesisOnNode(node, update) {
78
+ const tc = ensureTaskConfiguration(node);
79
+ const profileRaw = tc.aiTaskProfile;
80
+ const profile =
81
+ profileRaw && typeof profileRaw === 'object' && !Array.isArray(profileRaw)
82
+ ? /** @type {Record<string, unknown>} */ ({ ...profileRaw })
83
+ : {};
84
+ const isRaw = profile.inputSynthesis;
85
+ const current =
86
+ isRaw && typeof isRaw === 'object' && !Array.isArray(isRaw)
87
+ ? /** @type {Record<string, unknown>} */ ({ ...isRaw })
88
+ : {};
89
+ const next = update(current);
90
+ if (!next || Object.keys(next).length === 0) {
91
+ delete profile.inputSynthesis;
92
+ } else {
93
+ profile.inputSynthesis = next;
94
+ }
95
+ if (Object.keys(profile).length === 0) {
96
+ delete tc.aiTaskProfile;
97
+ } else {
98
+ tc.aiTaskProfile = profile;
99
+ }
100
+ pruneEmptyTaskConfiguration(node);
101
+ }
102
+
103
+ export { patchWebQueryTemplateOnNode, patchWebScopingOnNode } from './webQueryTemplate.js';
104
+
105
+ /**
106
+ * Drop empty `taskConfiguration` after edits.
107
+ *
108
+ * @param {unknown} node mutates
109
+ */
110
+ export function pruneEmptyTaskConfiguration(node) {
111
+ if (!node || typeof node !== 'object' || Array.isArray(node)) return;
112
+ const tc = /** @type {Record<string, unknown>} */ (node).taskConfiguration;
113
+ if (!tc || typeof tc !== 'object' || Array.isArray(tc)) return;
114
+ if (Object.keys(tc).length === 0) {
115
+ delete /** @type {Record<string, unknown>} */ (node).taskConfiguration;
116
+ }
117
+ }
@@ -0,0 +1,277 @@
1
+ /**
2
+ * Graphenix 2.7.3+ web scope authoring on `taskConfiguration.aiTaskProfile.webQueryTemplate`.
3
+ * Replaces legacy `aiTaskProfile.webScoping` and narrix web keys.
4
+ */
5
+
6
+ import { readTaskConfiguration, ensureTaskConfiguration, pruneEmptyTaskConfiguration } from './taskNodeConfiguration.js';
7
+
8
+ /** Narrix keys forbidden under `taskConfiguration.narrix` (graph-engine 8.6). */
9
+ export const FORBIDDEN_NARRIX_WEB_KEYS = Object.freeze([
10
+ 'enableWebScope',
11
+ 'forceWebScope',
12
+ 'webScopeQuestions',
13
+ 'webScoping',
14
+ 'webScopeTemplates',
15
+ 'webScopeQuestionTemplate',
16
+ 'webScopeObjects',
17
+ 'webScopeEntityIdPath',
18
+ 'webScopeEntityTypePath',
19
+ 'enrichWebScopeQuestionFromExecutionRaw',
20
+ ]);
21
+
22
+ /**
23
+ * @param {unknown} node
24
+ * @returns {Record<string, unknown> | null}
25
+ */
26
+ export function readAiTaskProfile(node) {
27
+ const tc = readTaskConfiguration(node);
28
+ if (!tc) return null;
29
+ const profile = tc.aiTaskProfile;
30
+ if (!profile || typeof profile !== 'object' || Array.isArray(profile)) return null;
31
+ return /** @type {Record<string, unknown>} */ (profile);
32
+ }
33
+
34
+ function cleanString(value) {
35
+ return typeof value === 'string' ? value.trim() : '';
36
+ }
37
+
38
+ /**
39
+ * @param {unknown} profile
40
+ * @returns {boolean}
41
+ */
42
+ export function hasWebScopeAuthoring(profile) {
43
+ if (!profile || typeof profile !== 'object' || Array.isArray(profile)) return false;
44
+ const p = /** @type {Record<string, unknown>} */ (profile);
45
+ if (cleanString(p.webQueryTemplate)) return true;
46
+ const pack = p.webQueryTemplates;
47
+ return (
48
+ Array.isArray(pack) &&
49
+ pack.some((entry) => typeof entry === 'string' && entry.trim().length > 0)
50
+ );
51
+ }
52
+
53
+ /**
54
+ * @param {unknown} node
55
+ * @returns {string}
56
+ */
57
+ export function readNodeWebQueryTemplate(node) {
58
+ const profile = readAiTaskProfile(node);
59
+ return profile ? cleanString(profile.webQueryTemplate) : '';
60
+ }
61
+
62
+ /**
63
+ * @param {unknown} node
64
+ * @returns {string[]}
65
+ */
66
+ export function readNodeWebQueryTemplates(node) {
67
+ const profile = readAiTaskProfile(node);
68
+ if (!profile) return [];
69
+ const pack = profile.webQueryTemplates;
70
+ if (!Array.isArray(pack)) return [];
71
+ return pack
72
+ .filter((q) => typeof q === 'string' && q.trim().length > 0)
73
+ .map((q) => /** @type {string} */ (q).trim());
74
+ }
75
+
76
+ /**
77
+ * @param {unknown} node
78
+ * @returns {Record<string, unknown>}
79
+ */
80
+ export function readNodeWebScopeOptions(node) {
81
+ const profile = readAiTaskProfile(node);
82
+ if (!profile) return {};
83
+ const opts = profile.webScopeOptions;
84
+ if (opts && typeof opts === 'object' && !Array.isArray(opts)) {
85
+ return /** @type {Record<string, unknown>} */ ({ ...opts });
86
+ }
87
+ return {};
88
+ }
89
+
90
+ /**
91
+ * Web scope PRE unit is active when a non-empty template (or pack) is authored.
92
+ *
93
+ * @param {unknown} node
94
+ * @returns {boolean}
95
+ */
96
+ export function readNodeWebScopeEnabled(node) {
97
+ return hasWebScopeAuthoring(readAiTaskProfile(node));
98
+ }
99
+
100
+ /**
101
+ * All authored query strings (primary + pack) for display.
102
+ *
103
+ * @param {unknown} node
104
+ * @returns {string[]}
105
+ */
106
+ export function readNodeWebQueryStrings(node) {
107
+ const primary = readNodeWebQueryTemplate(node);
108
+ const pack = readNodeWebQueryTemplates(node);
109
+ if (primary && !pack.includes(primary)) return [primary, ...pack];
110
+ if (primary) return [primary, ...pack.filter((q) => q !== primary)];
111
+ return pack;
112
+ }
113
+
114
+ /** @deprecated Use {@link readNodeWebScopeOptions} for entity path hints. */
115
+ export function readWebScoping(node) {
116
+ const opts = readNodeWebScopeOptions(node);
117
+ const legacy = readAiTaskProfile(node)?.webScoping;
118
+ if (legacy && typeof legacy === 'object' && !Array.isArray(legacy)) {
119
+ return { .../** @type {Record<string, unknown>} */ (legacy), ...opts };
120
+ }
121
+ return Object.keys(opts).length > 0 ? opts : null;
122
+ }
123
+
124
+ /** @deprecated Alias for {@link readNodeWebScopeEnabled}. */
125
+ export function readNodeWebScopingEnabled(node) {
126
+ return readNodeWebScopeEnabled(node);
127
+ }
128
+
129
+ /** @deprecated Use {@link readNodeWebQueryStrings}. */
130
+ export function readNodeWebScopingQuestions(node) {
131
+ return readNodeWebQueryStrings(node);
132
+ }
133
+
134
+ /**
135
+ * @param {Record<string, unknown>} narrix mutates
136
+ */
137
+ export function stripForbiddenNarrixWebKeys(narrix) {
138
+ for (const k of FORBIDDEN_NARRIX_WEB_KEYS) {
139
+ if (k in narrix) delete narrix[k];
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Migrate legacy `aiTaskProfile.webScoping` → `webQueryTemplate` on a profile object.
145
+ *
146
+ * @param {Record<string, unknown>} profile mutates
147
+ * @returns {boolean} true when migration changed the profile
148
+ */
149
+ export function migrateLegacyWebScopingOnProfile(profile) {
150
+ const ws = profile.webScoping;
151
+ if (!ws || typeof ws !== 'object' || Array.isArray(ws)) {
152
+ if ('webScoping' in profile) {
153
+ delete profile.webScoping;
154
+ return true;
155
+ }
156
+ return false;
157
+ }
158
+ const legacy = /** @type {Record<string, unknown>} */ (ws);
159
+ let changed = false;
160
+
161
+ const fromQueryTemplate = cleanString(legacy.queryTemplate);
162
+ const questions = Array.isArray(legacy.questions)
163
+ ? legacy.questions.filter((q) => typeof q === 'string' && q.trim()).map((q) => String(q).trim())
164
+ : [];
165
+
166
+ if (!cleanString(profile.webQueryTemplate)) {
167
+ if (fromQueryTemplate) {
168
+ profile.webQueryTemplate = fromQueryTemplate;
169
+ changed = true;
170
+ } else if (questions.length > 0) {
171
+ profile.webQueryTemplate = questions[0];
172
+ if (questions.length > 1) {
173
+ profile.webQueryTemplates = questions.slice(1);
174
+ }
175
+ changed = true;
176
+ } else if (legacy.enabled === true) {
177
+ profile.webQueryTemplate = '{{input.question}}';
178
+ changed = true;
179
+ }
180
+ }
181
+
182
+ delete profile.webScoping;
183
+ return true;
184
+ }
185
+
186
+ /**
187
+ * @param {Record<string, unknown>} node mutates
188
+ * @returns {boolean}
189
+ */
190
+ export function migrateTaskNodeWebQueryTemplate(node) {
191
+ if (!node || typeof node !== 'object' || Array.isArray(node)) return false;
192
+ if (node.type === 'finalizer') return false;
193
+ let changed = false;
194
+
195
+ const tc = ensureTaskConfiguration(node);
196
+ const profileRaw = tc.aiTaskProfile;
197
+ const profile =
198
+ profileRaw && typeof profileRaw === 'object' && !Array.isArray(profileRaw)
199
+ ? /** @type {Record<string, unknown>} */ ({ ...profileRaw })
200
+ : null;
201
+
202
+ if (profile) {
203
+ if (migrateLegacyWebScopingOnProfile(profile)) changed = true;
204
+ tc.aiTaskProfile = profile;
205
+ }
206
+
207
+ const narrixRaw = tc.narrix;
208
+ if (narrixRaw && typeof narrixRaw === 'object' && !Array.isArray(narrixRaw)) {
209
+ const narrix = /** @type {Record<string, unknown>} */ ({ ...narrixRaw });
210
+ const before = JSON.stringify(narrix);
211
+ stripForbiddenNarrixWebKeys(narrix);
212
+ if (JSON.stringify(narrix) !== before) {
213
+ tc.narrix = Object.keys(narrix).length > 0 ? narrix : undefined;
214
+ if (!tc.narrix) delete tc.narrix;
215
+ changed = true;
216
+ }
217
+ }
218
+
219
+ pruneEmptyTaskConfiguration(node);
220
+ return changed;
221
+ }
222
+
223
+ /**
224
+ * @param {Record<string, unknown>} doc mutates
225
+ */
226
+ export function migrateAuthoringDocWebQueryTemplate(doc) {
227
+ const nodes = doc.nodes ?? doc.graph?.nodes;
228
+ if (!Array.isArray(nodes)) return;
229
+ for (const node of nodes) {
230
+ if (node && typeof node === 'object' && !Array.isArray(node)) {
231
+ migrateTaskNodeWebQueryTemplate(/** @type {Record<string, unknown>} */ (node));
232
+ const params = /** @type {Record<string, unknown>} */ (node).parameters;
233
+ if (params && typeof params === 'object' && !Array.isArray(params)) {
234
+ migrateTaskNodeWebQueryTemplate(params);
235
+ }
236
+ }
237
+ }
238
+ }
239
+
240
+ /**
241
+ * @param {Record<string, unknown>} node mutates
242
+ * @param {(profile: Record<string, unknown>) => Record<string, unknown> | undefined} update
243
+ */
244
+ export function patchWebQueryTemplateOnNode(node, update) {
245
+ const tc = ensureTaskConfiguration(node);
246
+ const profileRaw = tc.aiTaskProfile;
247
+ const profile =
248
+ profileRaw && typeof profileRaw === 'object' && !Array.isArray(profileRaw)
249
+ ? /** @type {Record<string, unknown>} */ ({ ...profileRaw })
250
+ : {};
251
+ migrateLegacyWebScopingOnProfile(profile);
252
+ const next = update(profile);
253
+ if (!next || Object.keys(next).length === 0) {
254
+ delete tc.aiTaskProfile;
255
+ } else {
256
+ migrateLegacyWebScopingOnProfile(next);
257
+ tc.aiTaskProfile = next;
258
+ }
259
+ pruneEmptyTaskConfiguration(node);
260
+ }
261
+
262
+ /** @deprecated Use {@link patchWebQueryTemplateOnNode}. */
263
+ export function patchWebScopingOnNode(node, update) {
264
+ return patchWebQueryTemplateOnNode(node, (profile) => {
265
+ const ws = profile.webScoping;
266
+ const current =
267
+ ws && typeof ws === 'object' && !Array.isArray(ws)
268
+ ? /** @type {Record<string, unknown>} */ ({ ...ws })
269
+ : {};
270
+ const next = update(current);
271
+ if (!next || Object.keys(next).length === 0) {
272
+ const { webScoping: _drop, ...rest } = profile;
273
+ return Object.keys(rest).length > 0 ? rest : undefined;
274
+ }
275
+ return { ...profile, webScoping: next };
276
+ });
277
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Classify execution-memory paths for Information Flow grouping and display.
3
+ */
4
+ import { isDebugResponsePath } from './graphContractMetadata.js';
5
+
6
+ /**
7
+ * @typedef {'input'|'scoped'|'inference'|'triage'|'scopedAnswer'|'response'|'trace'|'external'|'meta'|'job'|'task'|'xynthesized'|'xynthesis'|'other'} PathClassification
8
+ */
9
+
10
+ /**
11
+ * @param {string} path
12
+ * @returns {{ namespace: string, classification: PathClassification, displayPath: string }}
13
+ */
14
+ export function classifyExecutionPath(path) {
15
+ const raw = String(path || '').trim();
16
+ if (!raw) {
17
+ return { namespace: 'other', classification: 'other', displayPath: '' };
18
+ }
19
+
20
+ if (raw.startsWith('xmemory-op:')) {
21
+ return {
22
+ namespace: 'xmemory-op',
23
+ classification: 'external',
24
+ displayPath: raw,
25
+ };
26
+ }
27
+ if (raw.startsWith('xmemory-meta:')) {
28
+ return {
29
+ namespace: 'xmemory-meta',
30
+ classification: 'meta',
31
+ displayPath: raw,
32
+ };
33
+ }
34
+
35
+ if (raw === 'finalOutput') {
36
+ return { namespace: 'finalOutput', classification: 'response', displayPath: 'finalOutput' };
37
+ }
38
+
39
+ if (raw.startsWith('execution._trace') || raw.includes('._trace.') || isDebugResponsePath(raw)) {
40
+ return {
41
+ namespace: '_trace',
42
+ classification: 'trace',
43
+ displayPath: stripExecutionPrefix(raw),
44
+ };
45
+ }
46
+
47
+ // Distinguish synthesis-context memory snapshot (xynthesized) from PRE/POST utility outputs (xynthesis).
48
+ if (raw.startsWith('execution.xynthesized.')) {
49
+ return {
50
+ namespace: 'xynthesized',
51
+ classification: 'xynthesized',
52
+ displayPath: stripExecutionPrefix(raw),
53
+ };
54
+ }
55
+ if (raw.startsWith('execution.xynthesis.')) {
56
+ return {
57
+ namespace: 'xynthesis',
58
+ classification: 'xynthesis',
59
+ displayPath: stripExecutionPrefix(raw),
60
+ };
61
+ }
62
+
63
+ let rest = raw;
64
+ if (rest.startsWith('execution.')) {
65
+ rest = rest.slice('execution.'.length);
66
+ }
67
+
68
+ const first = rest.split('.')[0] || '';
69
+
70
+ if (first === 'input' || raw.startsWith('execution.input.')) {
71
+ return {
72
+ namespace: 'input',
73
+ classification: 'input',
74
+ displayPath: rest.startsWith('input.') ? rest.slice('input.'.length) : rest,
75
+ };
76
+ }
77
+
78
+ if (first === 'scoped' || rest.startsWith('scoped.')) {
79
+ return {
80
+ namespace: 'scoped',
81
+ classification: 'scoped',
82
+ displayPath: rest,
83
+ };
84
+ }
85
+ if (first === 'inference' || rest.startsWith('inference.')) {
86
+ return {
87
+ namespace: 'inference',
88
+ classification: 'inference',
89
+ displayPath: rest,
90
+ };
91
+ }
92
+ if (first === 'triage' || rest.startsWith('triage.')) {
93
+ return {
94
+ namespace: 'triage',
95
+ classification: 'triage',
96
+ displayPath: rest,
97
+ };
98
+ }
99
+ if (first === 'scopedAnswer' || rest.startsWith('scopedAnswer.')) {
100
+ return {
101
+ namespace: 'scopedAnswer',
102
+ classification: 'scopedAnswer',
103
+ displayPath: rest,
104
+ };
105
+ }
106
+
107
+ if (rest.startsWith('jobMemory.') || rest.startsWith('jobContext.') || raw.startsWith('jobMemory.')) {
108
+ return {
109
+ namespace: 'job',
110
+ classification: 'job',
111
+ displayPath: rest,
112
+ };
113
+ }
114
+ if (rest.startsWith('taskMemory.') || raw.startsWith('taskMemory.')) {
115
+ return {
116
+ namespace: 'task',
117
+ classification: 'task',
118
+ displayPath: rest,
119
+ };
120
+ }
121
+
122
+ return {
123
+ namespace: first || 'other',
124
+ classification: 'other',
125
+ displayPath: rest,
126
+ };
127
+ }
128
+
129
+ /** Prefer short labels: input.* instead of execution.input.* */
130
+ function stripExecutionPrefix(p) {
131
+ if (p.startsWith('execution.')) return p.slice('execution.'.length);
132
+ return p;
133
+ }
@@ -0,0 +1,3 @@
1
+ export { buildPlanningInformationFlow } from './informationFlow.js';
2
+ export { buildFocusedInformationFlow } from './informationFlowFocus.js';
3
+ export { buildInformationFlowOutputSurface } from './informationFlowOutputSurface.js';
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Planning source families for Information Flow — editor-only classification.
3
+ * @typedef {'scoped-read'|'web-scope'|'other'|'none'} PlanningSourceFamily
4
+ */
5
+ import { readNodeWebScopeEnabled, readNodeWebQueryTemplate } from './lib/nodeMetadataAccessors.js';
6
+ import { readTaskConfiguration } from './lib/taskNodeConfiguration.js';
7
+
8
+ /** Future dedicated web retrieval / search skills; aiTaskProfile.webQueryTemplate is primary. */
9
+ export const WEB_SCOPE_SKILL_KEYS = [];
10
+
11
+ const FAMILY_LABEL = {
12
+ 'scoped-read': 'Scoped read',
13
+ 'web-scope': 'Web scope',
14
+ other: 'Other',
15
+ none: '—',
16
+ };
17
+
18
+ const SECTION_ORDER = ['scoped-read', 'web-scope'];
19
+
20
+ /**
21
+ * @param {object|null|undefined} node
22
+ * @returns {PlanningSourceFamily}
23
+ */
24
+ export function getPlanningSourceFamily(node) {
25
+ if (!node || typeof node !== 'object') return 'none';
26
+ if (node.skillKey === 'scoped-data-reader') return 'scoped-read';
27
+ if (node.skillKey && WEB_SCOPE_SKILL_KEYS.includes(node.skillKey)) return 'web-scope';
28
+ if (readNodeWebScopeEnabled(node)) return 'web-scope';
29
+ return 'none';
30
+ }
31
+
32
+ /**
33
+ * @param {PlanningSourceFamily} family
34
+ */
35
+ export function getPlanningSourceFamilyLabel(family) {
36
+ return FAMILY_LABEL[family] || family;
37
+ }
38
+
39
+ /** Stable section order for Sources column (families only). */
40
+ export function planningSourceFamilySectionOrder() {
41
+ return [...SECTION_ORDER];
42
+ }
43
+
44
+ /**
45
+ * @param {object} node - graph node
46
+ * @returns {{ lines: string[], searchText: string }}
47
+ */
48
+ export function buildWebScopeCardSummary(node) {
49
+ if (readNodeWebScopeEnabled(node)) {
50
+ const narrix = node?.taskConfiguration?.narrix || node?.metadata?.narrix || {};
51
+ const template = readNodeWebQueryTemplate(node);
52
+ const lines = [];
53
+ const parts = [];
54
+
55
+ if (template) {
56
+ const s = `Template: ${template}`;
57
+ lines.push(s);
58
+ parts.push(s, template);
59
+ }
60
+ if (narrix.questionId) {
61
+ const s = `Question: ${narrix.questionId}`;
62
+ lines.push(s);
63
+ parts.push(s, narrix.questionId);
64
+ }
65
+ if (narrix.datasetId) {
66
+ const s = `Dataset: ${narrix.datasetId}`;
67
+ lines.push(s);
68
+ parts.push(s, narrix.datasetId);
69
+ }
70
+ if (narrix.layer != null && narrix.layer !== '') {
71
+ const s = `Layer: ${narrix.layer}`;
72
+ lines.push(s);
73
+ parts.push(s, String(narrix.layer));
74
+ }
75
+
76
+ if (Array.isArray(narrix.narrativeTypeIds) && narrix.narrativeTypeIds.length > 0) {
77
+ const short = narrix.narrativeTypeIds.slice(0, 4).join(', ');
78
+ const more = narrix.narrativeTypeIds.length > 4 ? ` (+${narrix.narrativeTypeIds.length - 4} more)` : '';
79
+ const s = `Narrative types: ${short}${more}`;
80
+ lines.push(s);
81
+ parts.push(...narrix.narrativeTypeIds.map(String));
82
+ }
83
+
84
+ return {
85
+ lines,
86
+ searchText: parts.join(' '),
87
+ };
88
+ }
89
+
90
+ if (node?.skillKey && WEB_SCOPE_SKILL_KEYS.includes(node.skillKey)) {
91
+ const sk = node.skillKey;
92
+ return { lines: [`Web skill: ${sk}`], searchText: sk };
93
+ }
94
+
95
+ return { lines: [], searchText: '' };
96
+ }
97
+
98
+ /**
99
+ * Sort key for web-scope flow nodes (stable, deterministic).
100
+ * @param {object} node - raw graph node
101
+ * @param {string} title - graphReadability title or id
102
+ */
103
+ export function webScopeSortKey(node, title) {
104
+ const tcNar = readTaskConfiguration(node)?.narrix;
105
+ const narrix = tcNar && typeof tcNar === 'object' ? tcNar : {};
106
+ const q = narrix.questionId;
107
+ const d = narrix.datasetId;
108
+ return [q || '', d || '', title || '', node?.id || ''].join('\0');
109
+ }