@exellix/graphs-studio-composer-context 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.
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@exellix/graphs-studio-composer-context",
3
+ "version": "0.1.0",
4
+ "description": "Layer-aware composer context for Graphs Studio UI. Not @exellix/graph-composer (OpenRouter execution).",
5
+ "type": "module",
6
+ "engines": {
7
+ "node": ">=20.19.0 || >=22.12.0"
8
+ },
9
+ "publishConfig": {
10
+ "access": "public",
11
+ "registry": "https://registry.npmjs.org/"
12
+ },
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+ssh://git@github.com/exellix/graphs-studio.git",
16
+ "directory": "packages/graphs-studio-composer-context"
17
+ },
18
+ "main": "./src/index.js",
19
+ "exports": {
20
+ ".": "./src/index.js"
21
+ },
22
+ "files": [
23
+ "src"
24
+ ],
25
+ "scripts": {
26
+ "prepublishOnly": "node --test tests/graphs-studio-composer-context.test.mjs"
27
+ },
28
+ "dependencies": {
29
+ "@exellix/graph-engine": "^9.2.3",
30
+ "@exellix/graphs-studio-concept": "^0.1.0",
31
+ "@exellix/graphs-studio-data-flow": "^0.1.0"
32
+ }
33
+ }
@@ -0,0 +1,75 @@
1
+ import type { GraphAnalysisResult } from './types.js';
2
+
3
+ function pickNodeSummary(analysis: GraphAnalysisResult, nodeId: string) {
4
+ const c = analysis.nodeAnalysisById[nodeId];
5
+ if (!c) return { nodeId, error: 'No contract data' };
6
+ return {
7
+ nodeId: c.nodeId,
8
+ executionType: c.executionType,
9
+ skillKey: c.skillKey,
10
+ localSkillKind: c.localSkillKind,
11
+ sideEffects: c.sideEffects,
12
+ validation: {
13
+ hasOutputValidation: Boolean(c.validation?.outputValidation?.items),
14
+ hasOutputSchema: Boolean(c.validation?.outputSchema?.items),
15
+ },
16
+ control: c.control
17
+ ? {
18
+ isRoutingNode: c.control.isRoutingNode,
19
+ predicatePathsThatMatchedWrites: c.control.predicatePathsThatMatchedWrites,
20
+ }
21
+ : null,
22
+ issueTitles: (analysis.issuesByNodeId[nodeId] ?? []).map((i) => ({
23
+ severity: i.severity,
24
+ title: i.title,
25
+ })),
26
+ };
27
+ }
28
+
29
+ function pickEdgeSummary(analysis: GraphAnalysisResult, edgeKey: string) {
30
+ const e = analysis.edgeAnalysisByKey[edgeKey];
31
+ if (!e) return { edgeKey, error: 'No edge analysis' };
32
+ return {
33
+ from: e.from,
34
+ to: e.to,
35
+ flowType: e.flowType,
36
+ branchType: e.branchType,
37
+ matchedWritesToReads: e.matchedWritesToReads,
38
+ predicatePaths: e.predicatePaths,
39
+ predicateProviders: e.predicateProviders,
40
+ predicateUnresolved: e.predicateUnresolved,
41
+ issueTitles: (analysis.issuesByEdgeKey[edgeKey] ?? []).map((i) => ({
42
+ severity: i.severity,
43
+ title: i.title,
44
+ })),
45
+ };
46
+ }
47
+
48
+ export function buildComposerAnalysisContext(
49
+ analysis: GraphAnalysisResult | null,
50
+ scope: {
51
+ type: 'graph' | 'node' | 'edge';
52
+ nodeId?: string;
53
+ edgeKey?: string;
54
+ }
55
+ ): Record<string, unknown> | null {
56
+ if (!analysis || analysis.status !== 'ok') return null;
57
+ const base = {
58
+ graphSummary: analysis.graphSummary,
59
+ executionOrder: analysis.executionOrder,
60
+ issueCounts: analysis.graphSummary.issueCounts,
61
+ topIssues: analysis.graphIssues.slice(0, 12).map((i) => ({
62
+ severity: i.severity,
63
+ scopeType: i.scopeType,
64
+ scopeId: i.scopeId,
65
+ title: i.title,
66
+ description: i.description,
67
+ })),
68
+ };
69
+ if (scope.type === 'graph') return { ...base, scope: 'graph' };
70
+ if (scope.type === 'node' && scope.nodeId)
71
+ return { ...base, scope: 'node', node: pickNodeSummary(analysis, scope.nodeId) };
72
+ if (scope.type === 'edge' && scope.edgeKey)
73
+ return { ...base, scope: 'edge', edge: pickEdgeSummary(analysis, scope.edgeKey) };
74
+ return base;
75
+ }
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Shared graph-composer request shape for Prompt panel and Concept Edit.
3
+ */
4
+
5
+ export const DEFAULT_AI_SKILLS = [
6
+ { skillKey: 'professional-answer', description: 'Structured analysis with LLM', isLocal: false },
7
+ ];
8
+
9
+ export const DEFAULT_UTILITY_SKILLS = [
10
+ { skillKey: 'scoped-data-reader', description: 'Read scoped data', isLocal: true },
11
+ { skillKey: 'scoped-answer-writer', description: 'Persist results', isLocal: true },
12
+ ];
13
+
14
+ export const DEFAULT_COMPOSER_CONSTRAINTS = { requireFinalizer: true };
15
+
16
+ const SUGGEST_OBJECTIVE_DEFAULT_DESC =
17
+ 'Infer the graph-level primary intent (L1 headline) from structure, titles, and analysisContext.';
18
+
19
+ const REVIEW_CONCEPT_DEFAULT_DESC =
20
+ 'Review whether metadata.graphConcept aligns with the graph structure and skills. Return verdict (coherent + summary), categorized findings with severity, and optional suggestedConceptPatch for primary intent fields.';
21
+
22
+ /**
23
+ * Used when the Concept Edit tab runs reviewConcept for requirements prefill.
24
+ * Points the model at docs/graph-composer-concept-requirements.md (repo) for the expected patch shape.
25
+ */
26
+ export const REVIEW_REQUIREMENTS_DEFAULT_DESC = [
27
+ 'Review metadata.graphConcept against the exellix graph JSON and analysisContext.',
28
+ 'Return: (1) verdict { coherent, summary }; (2) findings[] with category, severity, summary, optional taskIndex (0-based coreTasks index), optional nodeIds, optional suggestedChange.',
29
+ 'Use finding categories when possible: primary_intent, expected_input, output_model, core_task, graph_topology, memory_io, catalog.',
30
+ '(3) In graphConceptPatch OR suggestedConceptPatch, include partial updates: optional string fields (expectedInput, outputDescription, primaryIntentStatement, etc.) and/or coreTasks[] aligned by INDEX with the current concept.',
31
+ 'Each coreTasks[i] may be partial — especially requirements { skill, catalogBinding, memoryIO } and webQueryTemplate on the task slice.',
32
+ 'Orthogonal lanes: (1) Top-level entityBindings = which memorix-entities catalog ids the graph is about (graph subject; catalog binding, not graphEntry.inputs). (2) Per-task requirements.memoryIO = execution-memory reads/writes for that step (runtime handoff). (3) webQueryTemplate on task aiTaskProfile = PRE webScope Rendrix template — not a substitute for (1).',
33
+ 'Graph-level entityBindings (memorix-entities ids; JSON field names still *EntityCollectionId) are the canonical place for “what entity this graph is about”.',
34
+ 'webQueryTemplate uses Rendrix with flat {{input.*}} refs (e.g. "Patch status for {{input.cveId}}?"). Optional webQueryTemplates[] for pack mode.',
35
+ 'Uncertainty and gaps: still populate graphConceptPatch where you can; ALSO emit top-level requirementOptions[] with taskIndex, field (e.g. memoryIO.writes), ranked candidates { value, rationale, source }, and needsNewArtifact when nothing in-graph fits.',
36
+ 'Do not replace entire graph JSON; only return the patch object for graphConcept.',
37
+ ].join(' ');
38
+
39
+ /**
40
+ * @param {Record<string, unknown> | null | undefined} t
41
+ */
42
+ function formatCoreTaskExtras(t) {
43
+ if (!t || typeof t !== 'object') return '';
44
+ const parts = [];
45
+ const req = t.requirements && typeof t.requirements === 'object' ? t.requirements : null;
46
+ const sk = req?.skill && typeof req.skill === 'object' ? req.skill.skillKey : '';
47
+ if (typeof sk === 'string' && sk.trim()) parts.push(`skill: ${sk.trim()}`);
48
+ const mem = req?.memoryIO && typeof req.memoryIO === 'object' ? req.memoryIO : null;
49
+ if (mem && typeof mem.writes === 'string' && mem.writes.trim()) parts.push(`writes: ${mem.writes.trim()}`);
50
+ const webTemplate =
51
+ typeof t.webQueryTemplate === 'string' && t.webQueryTemplate.trim()
52
+ ? t.webQueryTemplate.trim()
53
+ : typeof req?.webQueryTemplate === 'string' && req.webQueryTemplate.trim()
54
+ ? req.webQueryTemplate.trim()
55
+ : '';
56
+ if (webTemplate) parts.push(`webQuery: ${webTemplate.slice(0, 80)}${webTemplate.length > 80 ? '…' : ''}`);
57
+ else if (t.webScoping && typeof t.webScoping === 'object' && t.webScoping.enabled) {
58
+ parts.push('webScope: legacy webScoping (migrate to webQueryTemplate)');
59
+ }
60
+ const syn = t.synthesis && typeof t.synthesis === 'object' ? t.synthesis : null;
61
+ if (syn && syn.enabled) {
62
+ if (typeof syn.strategy === 'string' && syn.strategy.trim()) parts.push(`synthesis: ${syn.strategy.trim()}`);
63
+ const pr = Array.isArray(syn.prompts) ? syn.prompts.map((p) => String(p).trim()).filter(Boolean) : [];
64
+ if (pr.length) parts.push(`synthesis prompts: ${pr.join('; ')}`);
65
+ }
66
+ if (mem && Array.isArray(mem.reads) && mem.reads.some((r) => String(r).trim())) {
67
+ parts.push(`reads: ${mem.reads.map((r) => String(r).trim()).filter(Boolean).join(', ')}`);
68
+ }
69
+ if (mem?.jobContextMappings && typeof mem.jobContextMappings === 'object' && !Array.isArray(mem.jobContextMappings)) {
70
+ const keys = Object.keys(mem.jobContextMappings);
71
+ if (keys.length) parts.push(`jobContextMapping: ${JSON.stringify(mem.jobContextMappings)}`);
72
+ }
73
+ const cb = req?.catalogBinding && typeof req.catalogBinding === 'object' ? req.catalogBinding : null;
74
+ if (cb && (String(cb.catalogId || '').trim() || String(cb.scopingMapId || '').trim())) {
75
+ const idv = [cb.catalogId, cb.version].filter((x) => String(x || '').trim()).join('@');
76
+ const tail = cb.scopingMapId && String(cb.scopingMapId).trim() ? ` map:${String(cb.scopingMapId).trim()}` : '';
77
+ parts.push(`catalog: ${idv || '(binding)'}${tail}`);
78
+ }
79
+ return parts.length ? ` (${parts.join('; ')})` : '';
80
+ }
81
+
82
+ /**
83
+ * @param {{
84
+ * action: string,
85
+ * scope: 'graph' | 'node' | 'edge',
86
+ * description: string,
87
+ * selectedItem: object | null,
88
+ * getExportableGraph: () => object,
89
+ * skillMode: string,
90
+ * getAnalysisContext?: () => object | null,
91
+ * onlyIfEmpty?: string[],
92
+ * catalogPickLists?: { narratives?: Array<{ id: string; label?: string }> } & Record<string, unknown>,
93
+ * }} p
94
+ */
95
+ export function buildGraphComposerInput(p) {
96
+ const {
97
+ action,
98
+ scope,
99
+ description,
100
+ selectedItem,
101
+ getExportableGraph,
102
+ skillMode,
103
+ getAnalysisContext,
104
+ onlyIfEmpty,
105
+ catalogPickLists,
106
+ } = p;
107
+
108
+ const existingGraph = action === 'create' ? undefined : getExportableGraph();
109
+
110
+ let focusNodeIds;
111
+ let desc =
112
+ (action === 'suggestConceptObjective' || action === 'reviewConcept') && !description.trim()
113
+ ? action === 'reviewConcept'
114
+ ? REVIEW_CONCEPT_DEFAULT_DESC
115
+ : SUGGEST_OBJECTIVE_DEFAULT_DESC
116
+ : description;
117
+
118
+ if (scope === 'node' && selectedItem?.obj?.id) {
119
+ focusNodeIds = [selectedItem.obj.id];
120
+ }
121
+
122
+ if (scope === 'edge' && selectedItem?.obj) {
123
+ const { from, to } = selectedItem.obj;
124
+ const prefix = `[Scope: edge from="${from}" to="${to}"] `;
125
+ desc = prefix + description;
126
+ focusNodeIds = [from, to].filter(Boolean);
127
+ }
128
+
129
+ let analysisContext = null;
130
+ if (typeof getAnalysisContext === 'function') {
131
+ try {
132
+ analysisContext = getAnalysisContext();
133
+ } catch {
134
+ analysisContext = null;
135
+ }
136
+ }
137
+
138
+ const intent = {
139
+ action,
140
+ description: desc,
141
+ ...(focusNodeIds?.length ? { focusNodeIds } : {}),
142
+ ...(action === 'suggestConceptObjective' && Array.isArray(onlyIfEmpty) && onlyIfEmpty.length
143
+ ? { onlyIfEmpty }
144
+ : {}),
145
+ };
146
+
147
+ const pickLists = normalizeCatalogPickLists(catalogPickLists);
148
+
149
+ return {
150
+ intent,
151
+ ...(existingGraph !== undefined ? { existingGraph } : {}),
152
+ ...(analysisContext ? { analysisContext } : {}),
153
+ skillMode,
154
+ aiSkills: DEFAULT_AI_SKILLS,
155
+ utilitySkills: DEFAULT_UTILITY_SKILLS,
156
+ constraints: DEFAULT_COMPOSER_CONSTRAINTS,
157
+ ...(pickLists ? { catalogPickLists: pickLists } : {}),
158
+ };
159
+ }
160
+
161
+ /**
162
+ * Normalize Catalox-sourced picklists (currently `narratives`) before sending to the composer.
163
+ * Drops empty lists and invalid entries so the AI sees a clean set or no key at all.
164
+ *
165
+ * @param {{ narratives?: Array<{ id?: string; label?: string }> } | null | undefined} pickLists
166
+ */
167
+ function normalizeCatalogPickLists(pickLists) {
168
+ if (!pickLists || typeof pickLists !== 'object') return null;
169
+ /** @type {Record<string, Array<{ id: string; label?: string }>>} */
170
+ const out = {};
171
+ const narratives = Array.isArray(pickLists.narratives) ? pickLists.narratives : null;
172
+ if (narratives && narratives.length) {
173
+ const cleaned = [];
174
+ for (const n of narratives) {
175
+ const id = typeof n?.id === 'string' ? n.id.trim() : '';
176
+ if (!id) continue;
177
+ const entry = { id };
178
+ if (typeof n?.label === 'string' && n.label.trim() && n.label.trim() !== id) {
179
+ entry.label = n.label.trim();
180
+ }
181
+ cleaned.push(entry);
182
+ }
183
+ if (cleaned.length) out.narratives = cleaned;
184
+ }
185
+ return Object.keys(out).length ? out : null;
186
+ }
187
+
188
+ /**
189
+ * Build a natural-language instruction for create/modify from graphConcept fields.
190
+ * @param {Record<string, unknown>} graphConcept
191
+ * @param {'create' | 'modify'} mode
192
+ */
193
+ export function buildInstructionFromGraphConcept(graphConcept, mode) {
194
+ const c = graphConcept && typeof graphConcept === 'object' ? graphConcept : {};
195
+ const lines = [];
196
+
197
+ if (typeof c.graphType === 'string' && c.graphType.trim()) {
198
+ lines.push(`Graph type: ${c.graphType.trim()}.`);
199
+ }
200
+ if (typeof c.primaryIntentStatement === 'string' && c.primaryIntentStatement.trim()) {
201
+ lines.push(`Primary intent (${c.primaryIntentType || 'objective'}): ${c.primaryIntentStatement.trim()}`);
202
+ }
203
+ if (typeof c.businessObjective === 'string' && c.businessObjective.trim()) {
204
+ lines.push(`Business objective: ${c.businessObjective.trim()}`);
205
+ }
206
+ if (typeof c.expectedInput === 'string' && c.expectedInput.trim()) {
207
+ lines.push(`Expected input from callers: ${c.expectedInput.trim()}`);
208
+ }
209
+ if (typeof c.outputDescription === 'string' && c.outputDescription.trim()) {
210
+ lines.push(`Primary outputs / outcome: ${c.outputDescription.trim()}`);
211
+ }
212
+ if (Array.isArray(c.coreTasks) && c.coreTasks.length) {
213
+ lines.push('Core tasks (implement as logical steps / nodes in order):');
214
+ c.coreTasks.forEach((t, i) => {
215
+ const stmt = typeof t?.statement === 'string' ? t.statement : '';
216
+ const tt = typeof t?.taskType === 'string' ? t.taskType : 'objective';
217
+ if (stmt.trim()) lines.push(` ${i + 1}. [${tt}] ${stmt.trim()}${formatCoreTaskExtras(t)}`);
218
+ });
219
+ }
220
+ if (typeof c.notes === 'string' && c.notes.trim()) {
221
+ lines.push(`Additional notes: ${c.notes.trim()}`);
222
+ }
223
+
224
+ const body = lines.join('\n');
225
+ if (!body.trim()) return '';
226
+
227
+ if (mode === 'create') {
228
+ return `Build a exellix-graph DAG that implements the following product concept. Use a finalizer. Keep the graph focused and coherent.\n\n${body}`;
229
+ }
230
+ return `Update the existing graph so it implements this concept. Preserve unrelated structure where possible; align nodes, edges, and readability with:\n\n${body}`;
231
+ }
232
+
233
+ const traceReviewConceptIo =
234
+ typeof import.meta !== 'undefined' &&
235
+ import.meta.env &&
236
+ String(import.meta.env.VITE_GRAPH_COMPOSER_TRACE_REVIEW || '') === 'true';
237
+
238
+ /** Passed to POST /api/graph-composer → runGraphComposer(input, options). Set VITE_GRAPH_COMPOSER_TRACE_REVIEW=true for redacted reviewConcept I/O in server logs. */
239
+ export const GRAPH_COMPOSER_API_OPTIONS = {
240
+ askTimeoutMs: 120_000,
241
+ maxTokens: 8192,
242
+ ...(traceReviewConceptIo ? { traceReviewConceptIo: true } : {}),
243
+ };
244
+
245
+ const CONCEPT_PATCH_KEYS = ['primaryIntentType', 'primaryIntentStatement', 'businessObjective', 'primaryOutcome'];
246
+
247
+ /**
248
+ * @param {Record<string, unknown> | null | undefined} patch
249
+ * @returns {Record<string, string> | null}
250
+ */
251
+ export function pickNonEmptyConceptPatch(patch) {
252
+ if (!patch || typeof patch !== 'object') return null;
253
+ /** @type {Record<string, string>} */
254
+ const out = {};
255
+ for (const k of CONCEPT_PATCH_KEYS) {
256
+ if (patch[k] === undefined || patch[k] === null) continue;
257
+ const s = typeof patch[k] === 'string' ? patch[k].trim() : String(patch[k]).trim();
258
+ if (s.length) out[k] = typeof patch[k] === 'string' ? patch[k].trim() : s;
259
+ }
260
+ return Object.keys(out).length ? out : null;
261
+ }
262
+
263
+ export const DEFAULT_ONLY_IF_EMPTY_KEYS = [...CONCEPT_PATCH_KEYS];
@@ -0,0 +1,304 @@
1
+ /**
2
+ * Collect suggestion strings from graph node metadata for Concept Requirements editors.
3
+ * Pure — no React.
4
+ */
5
+
6
+ import { requirementsHintsFromNarrative } from '@exellix/graphs-studio-concept';
7
+ import { resolveMemorixItemDescriptorId } from '@exellix/graphs-studio-data-flow/lib/memorixScopedConfig.js';
8
+
9
+ /** @param {unknown} id */
10
+ function isVirtualId(id) {
11
+ return (
12
+ id === '__exellixGraphEntry' ||
13
+ id === '__exellixGraphResponse' ||
14
+ (typeof id === 'string' && id.startsWith('__exellix'))
15
+ );
16
+ }
17
+
18
+ function trimmed(s) {
19
+ return typeof s === 'string' ? s.trim() : '';
20
+ }
21
+
22
+ /**
23
+ * @typedef {{
24
+ * datasetIds: string[],
25
+ * narrativeTypeIds: string[],
26
+ * writePaths: string[],
27
+ * readPaths: string[],
28
+ * catalogIds: string[],
29
+ * scopingMapIds: string[],
30
+ * memorixItemDescriptorIds: string[],
31
+ * jobContextEntries: { key: string; value: string }[],
32
+ * }} GraphSuggestions
33
+ */
34
+
35
+ /**
36
+ * Walk nodes in execution order (when available) and collect unique ids/paths from metadata.
37
+ *
38
+ * @param {object | null | undefined} graphData
39
+ * @param {object | null | undefined} graphAnalysis
40
+ * @returns {GraphSuggestions}
41
+ */
42
+ export function collectGraphSuggestions(graphData, graphAnalysis) {
43
+ /** @type {string[]} */
44
+ const datasetIds = [];
45
+ /** @type {string[]} */
46
+ const narrativeTypeIds = [];
47
+ /** @type {string[]} */
48
+ const writePaths = [];
49
+ /** @type {string[]} */
50
+ const readPaths = [];
51
+ /** @type {string[]} */
52
+ const catalogIds = [];
53
+ /** @type {string[]} */
54
+ const scopingMapIds = [];
55
+ /** @type {string[]} */
56
+ const memorixItemDescriptorIds = [];
57
+ /** @type {{ key: string; value: string }[]} */
58
+ const jobContextEntries = [];
59
+
60
+ const rawNodes = graphData?.nodes;
61
+ const nodes = Array.isArray(rawNodes)
62
+ ? rawNodes
63
+ : rawNodes && typeof rawNodes === 'object'
64
+ ? Object.values(rawNodes)
65
+ : [];
66
+
67
+ const executionOrder =
68
+ Array.isArray(graphAnalysis?.executionOrder) && graphAnalysis.executionOrder.length > 0
69
+ ? graphAnalysis.executionOrder
70
+ : nodes.map((n) => n?.id).filter(Boolean);
71
+
72
+ const nodesById = Object.fromEntries(nodes.map((n) => [n.id, n]));
73
+
74
+ const ordered = [];
75
+ for (const id of executionOrder) {
76
+ const node = nodesById[id];
77
+ if (node && !isVirtualId(node.id)) ordered.push(node);
78
+ }
79
+ for (const node of nodes) {
80
+ if (!node || isVirtualId(node.id)) continue;
81
+ if (!ordered.includes(node)) ordered.push(node);
82
+ }
83
+
84
+ const seenDs = new Set();
85
+ const seenNt = new Set();
86
+ const seenWrite = new Set();
87
+ const seenRead = new Set();
88
+ const seenCat = new Set();
89
+ const seenScope = new Set();
90
+ const seenMemorix = new Set();
91
+ const seenJcm = new Set();
92
+
93
+ const pushUnique = (arr, set, v) => {
94
+ const t = trimmed(v);
95
+ if (!t || set.has(t)) return;
96
+ set.add(t);
97
+ arr.push(t);
98
+ };
99
+
100
+ for (const node of ordered) {
101
+ const tc =
102
+ node.taskConfiguration && typeof node.taskConfiguration === 'object' && !Array.isArray(node.taskConfiguration)
103
+ ? node.taskConfiguration
104
+ : null;
105
+ const nar = tc?.narrix ?? node.metadata?.narrix;
106
+ if (nar && typeof nar === 'object') {
107
+ pushUnique(datasetIds, seenDs, nar.datasetId);
108
+ if (Array.isArray(nar.narrativeTypeIds)) {
109
+ for (const x of nar.narrativeTypeIds) {
110
+ pushUnique(narrativeTypeIds, seenNt, String(x));
111
+ }
112
+ }
113
+ }
114
+
115
+ const path = trimmed(node.executionMapping?.path);
116
+ pushUnique(writePaths, seenWrite, path);
117
+
118
+ const cb = node.metadata?.catalogBinding;
119
+ if (cb && typeof cb === 'object') {
120
+ pushUnique(catalogIds, seenCat, cb.catalogId);
121
+ pushUnique(scopingMapIds, seenScope, cb.scopingMapId);
122
+ }
123
+ pushUnique(scopingMapIds, seenScope, tc?.scopingMapId);
124
+ pushUnique(scopingMapIds, seenScope, node.metadata?.scopingMapId);
125
+ pushUnique(memorixItemDescriptorIds, seenMemorix, resolveMemorixItemDescriptorId(tc));
126
+ pushUnique(memorixItemDescriptorIds, seenMemorix, resolveMemorixItemDescriptorId(node.metadata));
127
+ if (Array.isArray(tc?.pack)) {
128
+ for (const slot of tc.pack) {
129
+ pushUnique(memorixItemDescriptorIds, seenMemorix, resolveMemorixItemDescriptorId(slot));
130
+ pushUnique(scopingMapIds, seenScope, slot?.scopingMapId);
131
+ }
132
+ }
133
+
134
+ const rawJcm = tc?.jobContextMapping ?? node.metadata?.jobContextMapping ?? node.jobContextMapping;
135
+ if (rawJcm && typeof rawJcm === 'object' && !Array.isArray(rawJcm)) {
136
+ for (const [k, v] of Object.entries(rawJcm)) {
137
+ const key = trimmed(k);
138
+ let val = '';
139
+ if (typeof v === 'string') val = trimmed(v);
140
+ else if (v && typeof v === 'object' && typeof v.path === 'string') val = trimmed(v.path);
141
+ if (!key || !val) continue;
142
+ const sig = `${key}\t${val}`;
143
+ if (seenJcm.has(sig)) continue;
144
+ seenJcm.add(sig);
145
+ jobContextEntries.push({ key, value: val });
146
+ pushUnique(readPaths, seenRead, val);
147
+ }
148
+ }
149
+ }
150
+
151
+ return {
152
+ datasetIds,
153
+ narrativeTypeIds,
154
+ writePaths,
155
+ readPaths,
156
+ catalogIds,
157
+ scopingMapIds,
158
+ memorixItemDescriptorIds,
159
+ jobContextEntries,
160
+ };
161
+ }
162
+
163
+ /** Default shape for {@link augmentGraphSuggestionsWithTaskNarrative} when the parent has no graph yet. */
164
+ export function emptyGraphSuggestions() {
165
+ return {
166
+ datasetIds: [],
167
+ narrativeTypeIds: [],
168
+ writePaths: [],
169
+ readPaths: [],
170
+ catalogIds: [],
171
+ scopingMapIds: [],
172
+ memorixItemDescriptorIds: [],
173
+ jobContextEntries: [],
174
+ };
175
+ }
176
+
177
+ /**
178
+ * Merges Exellix-native Catalox catalog ids into {@link collectGraphSuggestions} output (deduped, order preserved).
179
+ * Scoping-question rows come from Catalox (`scopingQuestions`, appId `xmemory`). Optional `datasetIds` /
180
+ * `narrativeTypeIds` keys only merge ids supplied by the caller (e.g. from the graph itself), not legacy narrix catalogs.
181
+ *
182
+ * @param {GraphSuggestions} base
183
+ * @param {{
184
+ * narrativeTypeIds?: string[],
185
+ * datasetIds?: string[],
186
+ * scopedQuestionCatalogIds?: string[],
187
+ * }} catalox
188
+ * @returns {GraphSuggestions}
189
+ */
190
+ export function mergeCataloxIntoGraphSuggestions(base, catalox = {}) {
191
+ const out = {
192
+ datasetIds: [...(base.datasetIds || [])],
193
+ narrativeTypeIds: [...(base.narrativeTypeIds || [])],
194
+ writePaths: [...(base.writePaths || [])],
195
+ readPaths: [...(base.readPaths || [])],
196
+ catalogIds: [...(base.catalogIds || [])],
197
+ scopingMapIds: [...(base.scopingMapIds || [])],
198
+ memorixItemDescriptorIds: [...(base.memorixItemDescriptorIds || [])],
199
+ jobContextEntries: [...(base.jobContextEntries || [])],
200
+ };
201
+ const seenDs = new Set(out.datasetIds);
202
+ const seenNt = new Set(out.narrativeTypeIds);
203
+ const seenCat = new Set(out.catalogIds);
204
+ const pushUnique = (arr, set, v) => {
205
+ const t = trimmed(v);
206
+ if (!t || set.has(t)) return;
207
+ set.add(t);
208
+ arr.push(t);
209
+ };
210
+ for (const x of catalox.datasetIds || []) pushUnique(out.datasetIds, seenDs, x);
211
+ for (const x of catalox.narrativeTypeIds || []) pushUnique(out.narrativeTypeIds, seenNt, x);
212
+ for (const x of catalox.scopedQuestionCatalogIds || []) pushUnique(out.catalogIds, seenCat, x);
213
+ return out;
214
+ }
215
+
216
+ /** @param {unknown} task */
217
+ function taskNarrativeForHints(task) {
218
+ if (!task || typeof task !== 'object') return '';
219
+ const t = /** @type {Record<string, unknown>} */ (task);
220
+ return [t.statement, t.details, t.notes]
221
+ .map((x) => (typeof x === 'string' ? x : ''))
222
+ .join('\n')
223
+ .trim();
224
+ }
225
+
226
+ /**
227
+ * Merges {@link collectGraphSuggestions} with regex hints from this step’s statement / Details / notes
228
+ * (same heuristics as composer prefill). Use when the canvas has little metadata so chips still appear.
229
+ *
230
+ * @param {Partial<GraphSuggestions> | null | undefined} base
231
+ * @param {unknown} task
232
+ * @returns {GraphSuggestions}
233
+ */
234
+ export function augmentGraphSuggestionsWithTaskNarrative(base, task) {
235
+ const b = base && typeof base === 'object' ? base : {};
236
+ /** @type {GraphSuggestions} */
237
+ const out = {
238
+ datasetIds: [...(b.datasetIds || [])],
239
+ narrativeTypeIds: [...(b.narrativeTypeIds || [])],
240
+ writePaths: [...(b.writePaths || [])],
241
+ readPaths: [...(b.readPaths || [])],
242
+ catalogIds: [...(b.catalogIds || [])],
243
+ scopingMapIds: [...(b.scopingMapIds || [])],
244
+ memorixItemDescriptorIds: [...(b.memorixItemDescriptorIds || [])],
245
+ jobContextEntries: [...(b.jobContextEntries || [])],
246
+ };
247
+
248
+ const seenDs = new Set(out.datasetIds);
249
+ const seenNt = new Set(out.narrativeTypeIds);
250
+ const seenW = new Set(out.writePaths);
251
+ const seenR = new Set(out.readPaths);
252
+ const seenCat = new Set(out.catalogIds);
253
+ const seenScope = new Set(out.scopingMapIds);
254
+ const seenJcm = new Set(out.jobContextEntries.map((e) => `${e.key}\t${e.value}`));
255
+
256
+ const pushUnique = (arr, set, v) => {
257
+ const t = trimmed(v);
258
+ if (!t || set.has(t)) return;
259
+ set.add(t);
260
+ arr.push(t);
261
+ };
262
+
263
+ const narrative = taskNarrativeForHints(task);
264
+ const networkDs = narrative.match(/\b(network\.[a-z][a-z0-9_.-]*)\b/i);
265
+ if (networkDs) pushUnique(out.datasetIds, seenDs, networkDs[1]);
266
+
267
+ const hints = requirementsHintsFromNarrative(narrative);
268
+ if (!hints) return out;
269
+
270
+ if (hints.memoryIO && typeof hints.memoryIO === 'object') {
271
+ const m = /** @type {Record<string, unknown>} */ (hints.memoryIO);
272
+ if (typeof m.writes === 'string') pushUnique(out.writePaths, seenW, m.writes);
273
+ if (Array.isArray(m.reads)) {
274
+ for (const r of m.reads) pushUnique(out.readPaths, seenR, r);
275
+ }
276
+ const jcm = m.jobContextMappings;
277
+ if (jcm && typeof jcm === 'object' && !Array.isArray(jcm)) {
278
+ for (const [k, v] of Object.entries(jcm)) {
279
+ const key = trimmed(k);
280
+ const val = trimmed(typeof v === 'string' ? v : '');
281
+ if (!key || !val) continue;
282
+ const sig = `${key}\t${val}`;
283
+ if (seenJcm.has(sig)) continue;
284
+ seenJcm.add(sig);
285
+ out.jobContextEntries.push({ key, value: val });
286
+ pushUnique(out.readPaths, seenR, val);
287
+ }
288
+ }
289
+ }
290
+
291
+ return out;
292
+ }
293
+
294
+ const FINDING_REQ_HINT_RE =
295
+ /\b(narrix|network\.|dataset|layer|subnet|memory\s*io|memory_io|execution\.|scoped\.|executionMapping|outputMapping|writes?|reads?|catalog|scoping|jobContext|artifact|binding|paths?)\b/i;
296
+
297
+ /**
298
+ * Finding summaries from `reviewConcept` that are likely about Narrix / Memory IO / paths — show near requirement fields.
299
+ * @param {unknown[]} lines
300
+ */
301
+ export function filterFindingLinesForRequirementsHints(lines) {
302
+ if (!Array.isArray(lines)) return [];
303
+ return lines.filter((l) => typeof l === 'string' && FINDING_REQ_HINT_RE.test(l));
304
+ }
package/src/index.js ADDED
@@ -0,0 +1,9 @@
1
+ export {
2
+ buildLayerAwareComposerContext,
3
+ mergeLayerContextIntoComposerPayload,
4
+ } from './layerAwareComposerContext.js';
5
+
6
+ export { buildComposerAnalysisContext } from './composerContext.ts';
7
+
8
+ export * from './graphComposerPayload.js';
9
+ export * from './graphSuggestions.js';
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Layer-aware composer context builder.
3
+ * @param {object} input
4
+ * @param {Record<string, unknown> | null} [input.conceptLayer]
5
+ * @param {Record<string, unknown> | null} [input.dataFlowLayer]
6
+ * @param {string} [input.syncState]
7
+ * @param {Record<string, unknown> | null} [input.graphAnalysis]
8
+ */
9
+ export function buildLayerAwareComposerContext(input) {
10
+ const parts = [];
11
+ if (input.syncState && input.syncState !== 'clean') {
12
+ parts.push(`Layer sync state: ${input.syncState}`);
13
+ }
14
+ if (input.conceptLayer?.studioConcept) {
15
+ parts.push(`Concept layer present for graph ${input.conceptLayer.graphId ?? 'unknown'}`);
16
+ }
17
+ if (input.dataFlowLayer?.informationFlow) {
18
+ const nodeCount = input.dataFlowLayer.informationFlow.nodes?.length ?? 0;
19
+ parts.push(`Data-flow layer: ${nodeCount} planning nodes`);
20
+ }
21
+ if (input.graphAnalysis?.status === 'error') {
22
+ parts.push(`Graph analysis error: ${input.graphAnalysis.errorMessage ?? 'unknown'}`);
23
+ }
24
+ return {
25
+ layerSummary: parts.join('\n'),
26
+ hasConceptLayer: Boolean(input.conceptLayer),
27
+ hasDataFlowLayer: Boolean(input.dataFlowLayer),
28
+ syncState: input.syncState ?? 'clean',
29
+ };
30
+ }
31
+
32
+ /**
33
+ * @param {Record<string, unknown>} basePayload
34
+ * @param {ReturnType<typeof buildLayerAwareComposerContext>} layerContext
35
+ */
36
+ export function mergeLayerContextIntoComposerPayload(basePayload, layerContext) {
37
+ return {
38
+ ...basePayload,
39
+ layerContext,
40
+ analysisContext: [
41
+ typeof basePayload.analysisContext === 'string' ? basePayload.analysisContext : '',
42
+ layerContext.layerSummary,
43
+ ]
44
+ .filter(Boolean)
45
+ .join('\n\n'),
46
+ };
47
+ }
package/src/types.ts ADDED
@@ -0,0 +1,246 @@
1
+ import type { GraphCatalogs, GraphInspection } from '@exellix/graph-engine/dist/src/inspection/types.js';
2
+ import type { GraphContractsInspection, NodeContractInspection } from '@exellix/graph-engine/dist/src/inspection/contractTypes.js';
3
+
4
+ export type EdgeFlowType = 'structural' | 'data' | 'control' | 'mixed';
5
+
6
+ export type EdgeAnalysis = {
7
+ edgeKey: string;
8
+ edgeIndex: number;
9
+ from: string;
10
+ to: string;
11
+ when?: unknown;
12
+ flowType: EdgeFlowType;
13
+ writtenPaths: string[];
14
+ readPaths: string[];
15
+ matchedWritesToReads: string[];
16
+ predicatePaths: string[];
17
+ predicatePathTails: string[];
18
+ predicateProviders: string[];
19
+ predicateUnresolved: boolean;
20
+ branchType:
21
+ | 'unconditional'
22
+ | 'conditional-any'
23
+ | 'conditional-all'
24
+ | 'conditional-path'
25
+ | 'conditional';
26
+ hasStructuralRole: boolean;
27
+ };
28
+
29
+ export type GraphIssueSeverity = 'error' | 'warning' | 'info';
30
+
31
+ export type GraphIssueCategory =
32
+ | 'structural'
33
+ | 'contract'
34
+ | 'routing'
35
+ | 'readability'
36
+ | 'validation'
37
+ | 'scoping'
38
+ | 'narrix'
39
+ | 'finalizer'
40
+ | 'graph-hygiene';
41
+
42
+ export type GraphIssueScope = 'graph' | 'node' | 'edge';
43
+
44
+ export type GraphIssue = {
45
+ id: string;
46
+ severity: GraphIssueSeverity;
47
+ scopeType: GraphIssueScope;
48
+ scopeId: string;
49
+ category: GraphIssueCategory;
50
+ title: string;
51
+ description: string;
52
+ evidence?: Record<string, unknown>;
53
+ recommendedAction?: string;
54
+ };
55
+
56
+ export type GraphSummary = {
57
+ nodeCount: number;
58
+ edgeCount: number;
59
+ entryNodeCount: number;
60
+ finalizerCount: number;
61
+ aiTaskCount: number;
62
+ localSkillCount: number;
63
+ conditionalEdgeCount: number;
64
+ routingNodeCount: number;
65
+ issueCounts: { error: number; warning: number; info: number };
66
+ readabilityCoverage: { withTitle: number; withAsks: number; withKind: number };
67
+ };
68
+
69
+ export type AnalysisStatus = 'ok' | 'error';
70
+
71
+ // --- I/O Resolution types ---
72
+
73
+ export type IONodeLayer = 'scope' | 'inference' | 'write-back' | 'finalizer';
74
+
75
+ export type IOEntryTier = 'graph' | 'runtime';
76
+
77
+ export type IOEntry = {
78
+ /** Dot-separated execution memory path, or "xmemory-op:{id}", or "finalOutput" */
79
+ path: string;
80
+ tier: IOEntryTier;
81
+ /** Human-readable source label (e.g. "outputMapping", "narrix preprocessor") */
82
+ source: string;
83
+ /** For writes: target store ("execution", "xmemory-op", etc.) */
84
+ target?: string;
85
+ /** Extra context (collection name, mode, fallback label, etc.) */
86
+ detail?: string;
87
+ /** When true, only show in the inspector, not on the node card */
88
+ _inspectorOnly?: boolean;
89
+ /** Marks the primary narrix read entry */
90
+ _narrixPrimary?: boolean;
91
+ };
92
+
93
+ export type ResolvedNodeIO = {
94
+ layer: IONodeLayer;
95
+ reads: IOEntry[];
96
+ writes: IOEntry[];
97
+ };
98
+
99
+ export type IOLink = {
100
+ writtenBy: string;
101
+ writePath: string;
102
+ readBy: string;
103
+ readPath: string;
104
+ };
105
+
106
+ export type DiagramEdgeKind = 'topology' | 'data' | 'control' | 'mixed';
107
+
108
+ export type DiagramEdge = {
109
+ key: string;
110
+ index: number;
111
+ from: string;
112
+ to: string;
113
+ kind: DiagramEdgeKind;
114
+ hasCondition: boolean;
115
+ dataFlowLinks: IOLink[];
116
+ dataFlowPaths: string[];
117
+ readPaths: string[];
118
+ tooltip: string;
119
+ };
120
+
121
+ export type DiagramModel = {
122
+ topologyEdges: DiagramEdge[];
123
+ dataFlowLinks: IOLink[];
124
+ virtualBoundaryEdges: Array<{ from: string; to: string }>;
125
+ unresolvedReads: DanglingRead[];
126
+ orphanedWrites: OrphanedWrite[];
127
+ edgesByIndex: DiagramEdge[];
128
+ edgesByKey: Record<string, DiagramEdge>;
129
+ };
130
+
131
+ export type RuntimeFinalizerReadStatus = 'connected' | 'no-authored-edge' | 'missing-writer';
132
+
133
+ export type RuntimeFinalizerReadContract = {
134
+ key: string;
135
+ finalizerId: string;
136
+ finalizerTitle: string;
137
+ readPath: string;
138
+ readSource: string;
139
+ authoredSource: string;
140
+ writerNodeId: string;
141
+ writerTitle: string;
142
+ writerPath: string;
143
+ matchingWriterCount: number;
144
+ matchingAuthoredEdgeIndex: number;
145
+ matchingAuthoredEdgeKey: string;
146
+ status: RuntimeFinalizerReadStatus;
147
+ statusLabel: string;
148
+ };
149
+
150
+ export type RuntimeFinalizerContract = {
151
+ finalizerId: string;
152
+ finalizerTitle: string;
153
+ finalizerType: string;
154
+ strategy: string;
155
+ reads: RuntimeFinalizerReadContract[];
156
+ connectedCount: number;
157
+ missingCount: number;
158
+ totalCount: number;
159
+ statusLabel: string;
160
+ };
161
+
162
+ export type RuntimeEdgeContract = {
163
+ key: string;
164
+ index: number;
165
+ from: string;
166
+ to: string;
167
+ kind: DiagramEdgeKind;
168
+ runtimeDataPaths: string[];
169
+ dataFlowLinks: IOLink[];
170
+ sourceWritePaths: string[];
171
+ targetReadPaths: string[];
172
+ explanation: string;
173
+ fixHint: string;
174
+ };
175
+
176
+ export type RuntimeDataContract = {
177
+ finalizerContracts: RuntimeFinalizerContract[];
178
+ edgeContractsByIndex: RuntimeEdgeContract[];
179
+ edgeContractsByKey: Record<string, RuntimeEdgeContract>;
180
+ summary: {
181
+ dataHandoffEdgeCount: number;
182
+ topologyOnlyEdgeCount: number;
183
+ conditionalEdgeCount: number;
184
+ finalizerReadCount: number;
185
+ connectedFinalizerReadCount: number;
186
+ missingFinalizerReadCount: number;
187
+ finalizerInputStatusLabel: string;
188
+ };
189
+ };
190
+
191
+ export type DanglingRead = {
192
+ nodeId: string;
193
+ path: string;
194
+ tier: IOEntryTier;
195
+ source: string;
196
+ };
197
+
198
+ export type OrphanedWrite = {
199
+ nodeId: string;
200
+ path: string;
201
+ tier: IOEntryTier;
202
+ source: string;
203
+ };
204
+
205
+ // --- Simplified filter state ---
206
+
207
+ export type AnalysisFilters = {
208
+ layer: 'all' | IONodeLayer;
209
+ hasIssues: boolean;
210
+ };
211
+
212
+ export const defaultAnalysisFilters: AnalysisFilters = {
213
+ layer: 'all',
214
+ hasIssues: false,
215
+ };
216
+
217
+ // --- Graph analysis result ---
218
+
219
+ export type GraphAnalysisResult = {
220
+ status: AnalysisStatus;
221
+ errorMessage?: string;
222
+ graphInspection: GraphInspection;
223
+ graphContractInspection: GraphContractsInspection;
224
+ graphSummary: GraphSummary;
225
+ /** Same as graphContractInspection.nodes — alias for inspector */
226
+ nodeAnalysisById: Record<string, NodeContractInspection>;
227
+ edgeAnalysisByKey: Record<string, EdgeAnalysis>;
228
+ edgeAnalysisByIndex: EdgeAnalysis[];
229
+ graphIssues: GraphIssue[];
230
+ issuesByNodeId: Record<string, GraphIssue[]>;
231
+ issuesByEdgeKey: Record<string, GraphIssue[]>;
232
+ executionOrder: string[];
233
+ catalogs: GraphCatalogs;
234
+ /** Resolved I/O for each node (Tier 1 + Tier 2) */
235
+ nodeIO: Record<string, ResolvedNodeIO>;
236
+ /** Cross-node write-to-read links */
237
+ ioLinks: IOLink[];
238
+ /** Reads with no matching upstream write */
239
+ danglingReads: DanglingRead[];
240
+ /** Writes no downstream node reads */
241
+ orphanedWrites: OrphanedWrite[];
242
+ /** Runtime-backed diagram semantics consumed by the graph canvas. */
243
+ diagramModel: DiagramModel;
244
+ /** Studio-facing runtime data summary derived from graph-engine inspection. */
245
+ runtimeDataContract: RuntimeDataContract;
246
+ };