newo 3.6.2 → 3.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +44 -3
  2. package/README.md +61 -0
  3. package/dist/cli/commands/check.d.ts +3 -0
  4. package/dist/cli/commands/check.js +15 -0
  5. package/dist/cli/commands/format.d.ts +3 -0
  6. package/dist/cli/commands/format.js +105 -0
  7. package/dist/cli/commands/help.js +13 -0
  8. package/dist/cli/commands/lint.d.ts +3 -0
  9. package/dist/cli/commands/lint.js +195 -0
  10. package/dist/cli-new/di/tokens.d.ts +1 -1
  11. package/dist/cli.js +45 -9
  12. package/dist/domain/strategies/sync/AttributeSyncStrategy.js +38 -8
  13. package/dist/lint/config.d.ts +4 -0
  14. package/dist/lint/config.js +14 -0
  15. package/dist/lint/discovery.d.ts +34 -0
  16. package/dist/lint/discovery.js +112 -0
  17. package/dist/lint/live-schema.d.ts +20 -0
  18. package/dist/lint/live-schema.js +52 -0
  19. package/dist/lint/reporters/index.d.ts +4 -0
  20. package/dist/lint/reporters/index.js +19 -0
  21. package/dist/lint/reporters/json.d.ts +3 -0
  22. package/dist/lint/reporters/json.js +6 -0
  23. package/dist/lint/reporters/sarif.d.ts +3 -0
  24. package/dist/lint/reporters/sarif.js +47 -0
  25. package/dist/lint/reporters/text.d.ts +3 -0
  26. package/dist/lint/reporters/text.js +51 -0
  27. package/dist/lint/reporters/types.d.ts +6 -0
  28. package/dist/lint/reporters/types.js +2 -0
  29. package/dist/sync/attributes.js +38 -12
  30. package/dist/sync/conversations.d.ts +1 -1
  31. package/dist/sync/conversations.js +240 -193
  32. package/dist/sync/json-attr-utils.d.ts +67 -0
  33. package/dist/sync/json-attr-utils.js +98 -0
  34. package/package.json +3 -1
  35. package/src/cli/commands/check.ts +21 -0
  36. package/src/cli/commands/format.ts +131 -0
  37. package/src/cli/commands/help.ts +13 -0
  38. package/src/cli/commands/lint.ts +246 -0
  39. package/src/cli.ts +50 -9
  40. package/src/domain/strategies/sync/AttributeSyncStrategy.ts +45 -8
  41. package/src/lint/config.ts +17 -0
  42. package/src/lint/discovery.ts +148 -0
  43. package/src/lint/live-schema.ts +62 -0
  44. package/src/lint/reporters/index.ts +22 -0
  45. package/src/lint/reporters/json.ts +12 -0
  46. package/src/lint/reporters/sarif.ts +59 -0
  47. package/src/lint/reporters/text.ts +58 -0
  48. package/src/lint/reporters/types.ts +7 -0
  49. package/src/sync/attributes.ts +43 -14
  50. package/src/sync/conversations.ts +265 -212
  51. package/src/sync/json-attr-utils.ts +95 -0
@@ -1,218 +1,265 @@
1
1
  /**
2
2
  * Conversations synchronization module
3
+ *
4
+ * Incremental/resumable conversation pull:
5
+ * - Writes per-persona JSON files to newo_customers/<idn>/conversations/<persona_id>.json as they arrive
6
+ * - Updates conversations.yaml aggregate after each persona finishes
7
+ * - Skips personas already fully fetched (resume support) unless --force passed via env NEWO_CONV_FORCE=1
8
+ * - Graceful on partial failure: individual persona errors do not abort the batch, state is preserved
3
9
  */
4
10
  import { listUserPersonas, getChatHistory } from '../api.js';
5
11
  import { writeFileSafe } from '../fsutil.js';
12
+ import fs from 'fs-extra';
13
+ import path from 'path';
6
14
  import yaml from 'js-yaml';
7
15
  import pLimit from 'p-limit';
8
- // Concurrency limit for API calls
9
16
  const concurrencyLimit = pLimit(5);
10
- /**
11
- * Pull conversations for a customer and save to YAML
12
- */
13
- export async function pullConversations(client, customer, options = {}, verbose = false) {
14
- if (verbose)
15
- console.log(`💬 Fetching conversations for customer ${customer.idn}...`);
17
+ function personaFilePath(customerIdn, personaId) {
18
+ return path.join('newo_customers', customerIdn, 'conversations', `${personaId}.json`);
19
+ }
20
+ function aggregateYamlPath(customerIdn) {
21
+ return path.join('newo_customers', customerIdn, 'conversations.yaml');
22
+ }
23
+ async function readPersonaState(customerIdn, personaId) {
24
+ const p = personaFilePath(customerIdn, personaId);
25
+ if (!(await fs.pathExists(p)))
26
+ return null;
16
27
  try {
17
- // Get all user personas with pagination
18
- const allPersonas = [];
19
- let page = 1;
20
- const perPage = 50;
21
- let hasMore = true;
22
- while (hasMore) {
23
- const response = await listUserPersonas(client, page, perPage);
24
- allPersonas.push(...response.items);
25
- if (verbose)
26
- console.log(`📋 Page ${page}: Found ${response.items.length} personas (${allPersonas.length}/${response.metadata.total} total)`);
27
- hasMore = response.items.length === perPage && allPersonas.length < response.metadata.total;
28
- page++;
28
+ return JSON.parse(await fs.readFile(p, 'utf8'));
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ }
34
+ async function writePersonaState(customerIdn, state) {
35
+ await writeFileSafe(personaFilePath(customerIdn, state.id), JSON.stringify(state, null, 2));
36
+ }
37
+ async function writeAggregateYaml(customerIdn) {
38
+ const dir = path.join('newo_customers', customerIdn, 'conversations');
39
+ const files = (await fs.pathExists(dir)) ? await fs.readdir(dir) : [];
40
+ const personas = [];
41
+ for (const f of files) {
42
+ if (!f.endsWith('.json'))
43
+ continue;
44
+ try {
45
+ const state = JSON.parse(await fs.readFile(path.join(dir, f), 'utf8'));
46
+ personas.push({
47
+ id: state.id,
48
+ name: state.name,
49
+ phone: state.phone,
50
+ act_count: state.act_count,
51
+ acts: state.acts
52
+ });
29
53
  }
30
- if (options.maxPersonas && allPersonas.length > options.maxPersonas) {
31
- allPersonas.splice(options.maxPersonas);
32
- if (verbose)
33
- console.log(`⚠️ Limited to ${options.maxPersonas} personas as requested`);
54
+ catch {
55
+ // skip corrupted file
34
56
  }
57
+ }
58
+ personas.sort((a, b) => {
59
+ const aLatestTime = a.acts.length > 0 ? a.acts[a.acts.length - 1].datetime : '1970-01-01T00:00:00.000Z';
60
+ const bLatestTime = b.acts.length > 0 ? b.acts[b.acts.length - 1].datetime : '1970-01-01T00:00:00.000Z';
61
+ return new Date(bLatestTime).getTime() - new Date(aLatestTime).getTime();
62
+ });
63
+ const totalActs = personas.reduce((sum, p) => sum + p.acts.length, 0);
64
+ const data = {
65
+ personas,
66
+ total_personas: personas.length,
67
+ total_acts: totalActs,
68
+ generated_at: new Date().toISOString()
69
+ };
70
+ const yamlContent = yaml.dump(data, {
71
+ indent: 2,
72
+ quotingType: '"',
73
+ forceQuotes: false,
74
+ lineWidth: 120,
75
+ noRefs: true,
76
+ sortKeys: false,
77
+ flowLevel: -1
78
+ });
79
+ await writeFileSafe(aggregateYamlPath(customerIdn), yamlContent);
80
+ return { personas: personas.length, acts: totalActs };
81
+ }
82
+ function buildProcessedActs(raw) {
83
+ const sorted = [...raw].sort((a, b) => new Date(a.datetime).getTime() - new Date(b.datetime).getTime());
84
+ return sorted.map(act => {
85
+ const processedAct = {
86
+ datetime: act.datetime,
87
+ type: act.reference_idn,
88
+ message: act.source_text
89
+ };
90
+ if (act.contact_information)
91
+ processedAct.contact_information = act.contact_information;
92
+ if (act.flow_idn && act.flow_idn !== 'unknown')
93
+ processedAct.flow_idn = act.flow_idn;
94
+ if (act.skill_idn && act.skill_idn !== 'unknown')
95
+ processedAct.skill_idn = act.skill_idn;
96
+ if (act.session_id && act.session_id !== 'unknown')
97
+ processedAct.session_id = act.session_id;
98
+ return processedAct;
99
+ });
100
+ }
101
+ /**
102
+ * Pull conversations for a customer and save incrementally.
103
+ */
104
+ export async function pullConversations(client, customer, options = {}, verbose = false) {
105
+ const force = process.env.NEWO_CONV_FORCE === '1';
106
+ console.log(`💬 Fetching conversations for ${customer.idn}${force ? ' (force re-fetch)' : ' (resume mode)'}...`);
107
+ // Ensure output dirs exist
108
+ await fs.ensureDir(path.join('newo_customers', customer.idn, 'conversations'));
109
+ // 1. Enumerate all personas
110
+ const allPersonas = [];
111
+ let page = 1;
112
+ const perPage = 50;
113
+ let hasMore = true;
114
+ while (hasMore) {
115
+ const response = await listUserPersonas(client, page, perPage);
116
+ allPersonas.push(...response.items);
35
117
  if (verbose)
36
- console.log(`👥 Processing ${allPersonas.length} personas...`);
37
- // Process personas concurrently with limited concurrency
38
- const processedPersonas = [];
39
- await Promise.all(allPersonas.map(persona => concurrencyLimit(async () => {
40
- try {
41
- // Extract phone number from actors
42
- const phoneActor = persona.actors.find(actor => actor.integration_idn === 'newo_voice' &&
43
- actor.connector_idn === 'newo_voice_connector' &&
44
- actor.contact_information?.startsWith('+'));
45
- const phone = phoneActor?.contact_information || null;
46
- // Get acts for this persona
47
- const allActs = [];
48
- let actPage = 1;
49
- const actsPerPage = 100; // Higher limit for acts
50
- let hasMoreActs = true;
51
- // Get user actor IDs from persona actors first
52
- const userActors = persona.actors.filter(actor => actor.integration_idn === 'newo_voice' &&
53
- actor.connector_idn === 'newo_voice_connector');
54
- if (userActors.length === 0) {
55
- if (verbose)
56
- console.log(` 👤 ${persona.name}: No voice actors found, skipping`);
57
- // No voice actors, can't get chat history - add persona with empty acts
58
- processedPersonas.push({
59
- id: persona.id,
60
- name: persona.name,
61
- phone,
62
- act_count: persona.act_count,
63
- acts: []
118
+ console.log(`📋 Page ${page}: ${response.items.length} personas (${allPersonas.length}/${response.metadata.total})`);
119
+ hasMore = response.items.length === perPage && allPersonas.length < response.metadata.total;
120
+ page++;
121
+ }
122
+ if (options.maxPersonas && allPersonas.length > options.maxPersonas) {
123
+ allPersonas.splice(options.maxPersonas);
124
+ }
125
+ const total = allPersonas.length;
126
+ console.log(`👥 Found ${total} personas. Processing with concurrency=5...`);
127
+ let done = 0;
128
+ let skipped = 0;
129
+ let failed = 0;
130
+ await Promise.all(allPersonas.map(persona => concurrencyLimit(async () => {
131
+ try {
132
+ // Resume: skip if already complete
133
+ const existing = await readPersonaState(customer.idn, persona.id);
134
+ if (!force && existing && existing.complete) {
135
+ skipped++;
136
+ done++;
137
+ if (verbose)
138
+ console.log(`⏭️ [${done}/${total}] ${persona.name}: already complete (${existing.acts.length} acts)`);
139
+ return;
140
+ }
141
+ const phoneActor = persona.actors.find(actor => actor.integration_idn === 'newo_voice' &&
142
+ actor.connector_idn === 'newo_voice_connector' &&
143
+ actor.contact_information?.startsWith('+'));
144
+ const phone = phoneActor?.contact_information || null;
145
+ const userActors = persona.actors.filter(actor => actor.integration_idn === 'newo_voice' &&
146
+ actor.connector_idn === 'newo_voice_connector');
147
+ if (userActors.length === 0) {
148
+ const state = {
149
+ id: persona.id,
150
+ name: persona.name,
151
+ phone,
152
+ act_count: persona.act_count,
153
+ acts: [],
154
+ fetched_at: new Date().toISOString(),
155
+ complete: true
156
+ };
157
+ await writePersonaState(customer.idn, state);
158
+ done++;
159
+ if (verbose)
160
+ console.log(`✓ [${done}/${total}] ${persona.name}: no voice actors`);
161
+ return;
162
+ }
163
+ // Fetch acts paginated
164
+ const allActs = [];
165
+ let actPage = 1;
166
+ const actsPerPage = 100;
167
+ let hasMoreActs = true;
168
+ const maxPages = 50;
169
+ let lastError;
170
+ while (hasMoreActs && actPage <= maxPages) {
171
+ try {
172
+ const chatResponse = await getChatHistory(client, {
173
+ user_actor_id: userActors[0].id,
174
+ page: actPage,
175
+ per: actsPerPage
64
176
  });
65
- if (verbose)
66
- console.log(` ✓ Processed ${persona.name}: 0 acts (no voice actors)`);
67
- return; // Return from the concurrency function
68
- }
69
- // Safety mechanism to prevent infinite loops
70
- const maxPages = 50; // Limit to 50 pages (5000 acts max per persona)
71
- while (hasMoreActs && actPage <= maxPages) {
72
- try {
73
- const chatHistoryParams = {
177
+ if (chatResponse.items && chatResponse.items.length > 0) {
178
+ const convertedActs = chatResponse.items.map((item) => ({
179
+ id: item.id || `chat_${Math.random()}`,
180
+ command_act_id: null,
181
+ external_event_id: item.external_event_id || 'chat_history',
182
+ arguments: [],
183
+ reference_idn: (item.is_agent === true) ? 'agent_message' : 'user_message',
184
+ runtime_context_id: item.runtime_context_id || 'chat_history',
185
+ source_text: item.payload?.text || item.message || item.content || item.text || '',
186
+ original_text: item.payload?.text || item.message || item.content || item.text || '',
187
+ datetime: item.datetime || item.created_at || item.timestamp || new Date().toISOString(),
74
188
  user_actor_id: userActors[0].id,
75
- page: actPage,
76
- per: actsPerPage
189
+ agent_actor_id: null,
190
+ user_persona_id: persona.id,
191
+ user_persona_name: persona.name,
192
+ agent_persona_id: item.agent_persona_id || 'unknown',
193
+ external_id: item.external_id || null,
194
+ integration_idn: 'newo_voice',
195
+ connector_idn: 'newo_voice_connector',
196
+ to_integration_idn: null,
197
+ to_connector_idn: null,
198
+ is_agent: Boolean(item.is_agent === true),
199
+ project_idn: null,
200
+ flow_idn: item.flow_idn || 'unknown',
201
+ skill_idn: item.skill_idn || 'unknown',
202
+ session_id: item.session_id || 'unknown',
203
+ recordings: item.recordings || [],
204
+ contact_information: item.contact_information || null
205
+ }));
206
+ allActs.push(...convertedActs);
207
+ // Save partial progress every page
208
+ const partialState = {
209
+ id: persona.id,
210
+ name: persona.name,
211
+ phone,
212
+ act_count: persona.act_count,
213
+ acts: buildProcessedActs(allActs),
214
+ fetched_at: new Date().toISOString(),
215
+ complete: false
77
216
  };
78
- if (verbose)
79
- console.log(` 📄 ${persona.name}: Fetching page ${actPage}...`);
80
- const chatResponse = await getChatHistory(client, chatHistoryParams);
81
- if (chatResponse.items && chatResponse.items.length > 0) {
82
- // Convert chat history format to acts format - create minimal ConversationAct objects
83
- const convertedActs = chatResponse.items.map((item) => ({
84
- id: item.id || `chat_${Math.random()}`,
85
- command_act_id: null,
86
- external_event_id: item.external_event_id || 'chat_history',
87
- arguments: [],
88
- reference_idn: (item.is_agent === true) ? 'agent_message' : 'user_message',
89
- runtime_context_id: item.runtime_context_id || 'chat_history',
90
- source_text: item.payload?.text || item.message || item.content || item.text || '',
91
- original_text: item.payload?.text || item.message || item.content || item.text || '',
92
- datetime: item.datetime || item.created_at || item.timestamp || new Date().toISOString(),
93
- user_actor_id: userActors[0].id,
94
- agent_actor_id: null,
95
- user_persona_id: persona.id,
96
- user_persona_name: persona.name,
97
- agent_persona_id: item.agent_persona_id || 'unknown',
98
- external_id: item.external_id || null,
99
- integration_idn: 'newo_voice',
100
- connector_idn: 'newo_voice_connector',
101
- to_integration_idn: null,
102
- to_connector_idn: null,
103
- is_agent: Boolean(item.is_agent === true),
104
- project_idn: null,
105
- flow_idn: item.flow_idn || 'unknown',
106
- skill_idn: item.skill_idn || 'unknown',
107
- session_id: item.session_id || 'unknown',
108
- recordings: item.recordings || [],
109
- contact_information: item.contact_information || null
110
- }));
111
- allActs.push(...convertedActs);
112
- if (verbose && convertedActs.length > 0) {
113
- console.log(` 👤 ${persona.name}: Chat History - ${convertedActs.length} messages (${allActs.length} total)`);
114
- }
115
- // Check if we should continue paginating
116
- const hasMetadata = chatResponse.metadata?.total !== undefined;
117
- const currentTotal = chatResponse.metadata?.total || 0;
118
- hasMoreActs = chatResponse.items.length === actsPerPage &&
119
- hasMetadata &&
120
- allActs.length < currentTotal;
121
- actPage++;
122
- if (verbose)
123
- console.log(` 📊 ${persona.name}: Page ${actPage - 1} done, ${allActs.length}/${currentTotal} total acts`);
124
- }
125
- else {
126
- // No more items
127
- hasMoreActs = false;
128
- if (verbose)
129
- console.log(` 📊 ${persona.name}: No more chat history items`);
130
- }
217
+ await writePersonaState(customer.idn, partialState);
218
+ const currentTotal = chatResponse.metadata?.total || 0;
219
+ hasMoreActs = chatResponse.items.length === actsPerPage && allActs.length < currentTotal;
220
+ actPage++;
131
221
  }
132
- catch (chatError) {
133
- if (verbose)
134
- console.log(` ⚠️ Chat history failed for ${persona.name}: ${chatError instanceof Error ? chatError.message : String(chatError)}`);
222
+ else {
135
223
  hasMoreActs = false;
136
224
  }
137
225
  }
138
- if (actPage > maxPages) {
226
+ catch (chatError) {
227
+ lastError = chatError instanceof Error ? chatError.message : String(chatError);
139
228
  if (verbose)
140
- console.log(` ⚠️ ${persona.name}: Reached max pages limit (${maxPages}), stopping pagination`);
229
+ console.log(`⚠️ ${persona.name} page ${actPage}: ${lastError}`);
230
+ hasMoreActs = false;
141
231
  }
142
- // Sort acts by datetime ascending (chronological order)
143
- allActs.sort((a, b) => new Date(a.datetime).getTime() - new Date(b.datetime).getTime());
144
- // Process acts into simplified format - exclude redundant fields
145
- const processedActs = allActs.map(act => {
146
- const processedAct = {
147
- datetime: act.datetime,
148
- type: act.reference_idn,
149
- message: act.source_text
150
- };
151
- // Only include non-redundant fields
152
- if (act.contact_information) {
153
- processedAct.contact_information = act.contact_information;
154
- }
155
- if (act.flow_idn && act.flow_idn !== 'unknown') {
156
- processedAct.flow_idn = act.flow_idn;
157
- }
158
- if (act.skill_idn && act.skill_idn !== 'unknown') {
159
- processedAct.skill_idn = act.skill_idn;
160
- }
161
- if (act.session_id && act.session_id !== 'unknown') {
162
- processedAct.session_id = act.session_id;
163
- }
164
- return processedAct;
165
- });
166
- processedPersonas.push({
167
- id: persona.id,
168
- name: persona.name,
169
- phone,
170
- act_count: persona.act_count,
171
- acts: processedActs
172
- });
173
- if (verbose)
174
- console.log(` ✓ Processed ${persona.name}: ${processedActs.length} acts`);
175
232
  }
176
- catch (error) {
177
- console.error(`❌ Failed to process persona ${persona.name}:`, error);
178
- // Continue with other personas
179
- }
180
- })));
181
- // Sort personas by most recent act time (descending) - use latest act from acts array
182
- processedPersonas.sort((a, b) => {
183
- const aLatestTime = a.acts.length > 0 ? a.acts[a.acts.length - 1].datetime : '1970-01-01T00:00:00.000Z';
184
- const bLatestTime = b.acts.length > 0 ? b.acts[b.acts.length - 1].datetime : '1970-01-01T00:00:00.000Z';
185
- return new Date(bLatestTime).getTime() - new Date(aLatestTime).getTime();
186
- });
187
- // Calculate totals
188
- const totalActs = processedPersonas.reduce((sum, persona) => sum + persona.acts.length, 0);
189
- // Create final conversations data
190
- const conversationsData = {
191
- personas: processedPersonas,
192
- total_personas: processedPersonas.length,
193
- total_acts: totalActs,
194
- generated_at: new Date().toISOString()
195
- };
196
- // Save to YAML file
197
- const conversationsPath = `newo_customers/${customer.idn}/conversations.yaml`;
198
- const yamlContent = yaml.dump(conversationsData, {
199
- indent: 2,
200
- quotingType: '"',
201
- forceQuotes: false,
202
- lineWidth: 120,
203
- noRefs: true,
204
- sortKeys: false,
205
- flowLevel: -1
206
- });
207
- await writeFileSafe(conversationsPath, yamlContent);
208
- if (verbose) {
209
- console.log(`✓ Saved conversations to ${conversationsPath}`);
210
- console.log(`📊 Summary: ${processedPersonas.length} personas, ${totalActs} total acts`);
233
+ const finalState = {
234
+ id: persona.id,
235
+ name: persona.name,
236
+ phone,
237
+ act_count: persona.act_count,
238
+ acts: buildProcessedActs(allActs),
239
+ fetched_at: new Date().toISOString(),
240
+ complete: !lastError
241
+ };
242
+ if (lastError)
243
+ finalState.last_error = lastError;
244
+ await writePersonaState(customer.idn, finalState);
245
+ // Incremental YAML aggregate every persona
246
+ const agg = await writeAggregateYaml(customer.idn);
247
+ done++;
248
+ if (lastError)
249
+ failed++;
250
+ console.log(`${lastError ? '⚠️ ' : '✓'} [${done}/${total}] ${persona.name}: ${finalState.acts.length} acts${lastError ? ` (partial: ${lastError})` : ''} | total so far: ${agg.personas} personas / ${agg.acts} acts`);
211
251
  }
212
- }
213
- catch (error) {
214
- console.error(`❌ Failed to pull conversations for ${customer.idn}:`, error);
215
- throw error;
216
- }
252
+ catch (error) {
253
+ failed++;
254
+ done++;
255
+ const msg = error instanceof Error ? error.message : String(error);
256
+ console.error(`❌ [${done}/${total}] ${persona.name}: ${msg}`);
257
+ }
258
+ })));
259
+ // Final aggregate write
260
+ const final = await writeAggregateYaml(customer.idn);
261
+ console.log(`\n✅ Done. ${final.personas} personas, ${final.acts} acts. Skipped ${skipped} (already cached), ${failed} had errors.`);
262
+ console.log(` Aggregate: ${aggregateYamlPath(customer.idn)}`);
263
+ console.log(` Per-persona: newo_customers/${customer.idn}/conversations/<id>.json`);
217
264
  }
218
265
  //# sourceMappingURL=conversations.js.map
@@ -0,0 +1,67 @@
1
+ /**
2
+ * JSON-typed attribute helpers.
3
+ *
4
+ * Why this exists:
5
+ *
6
+ * The NEWO platform stores some attributes (e.g.
7
+ * `project_attributes_private_dynamic_workflow_builder_canvas`) as
8
+ * `value_type: json`. The API may return the `value` field as either a
9
+ * STRING containing JSON or as an already-parsed OBJECT.
10
+ *
11
+ * Without normalization, two bugs leak through:
12
+ *
13
+ * 1. When the API returns the value as an OBJECT, `yaml.dump` serializes
14
+ * it as a YAML structure (mappings/sequences). Pushing back then sends
15
+ * `{"value": {...}}` instead of `{"value": "..."}`, breaking the
16
+ * Workflow Builder which expects the canvas as a JSON STRING.
17
+ *
18
+ * 2. The push-time change check used `String(localAttr.value)` for
19
+ * comparison. With objects this collapses to `"[object Object]"` on
20
+ * both sides — silently masking real changes — and with mismatched
21
+ * string vs object representations it triggers spurious pushes that
22
+ * overwrite the canvas with the wrong shape (Builder shows blank).
23
+ *
24
+ * The fix is conservative: for `value_type: json` only, always coerce the
25
+ * value to a STRING when persisting and when pushing, and use canonical
26
+ * JSON for comparisons. String-typed values in the wild are left
27
+ * untouched, so no churn for the majority of attributes.
28
+ */
29
+ /**
30
+ * True if the attribute is a JSON-typed attribute (case- and
31
+ * format-insensitive: handles `json`, `JSON`, `AttributeValueTypes.json`,
32
+ * `ValueType.JSON`, etc.).
33
+ */
34
+ export declare function isJsonValueType(valueType: unknown): boolean;
35
+ /**
36
+ * Coerce a JSON-typed attribute's value to a STRING suitable for storage
37
+ * in attributes.yaml and for sending to the platform.
38
+ *
39
+ * - `null` / `undefined` → `''`
40
+ * - object → compact JSON string (`JSON.stringify(value)`)
41
+ * - string → returned as-is (we trust the platform's existing format)
42
+ * - other → `String(value)`
43
+ *
44
+ * We deliberately do NOT re-format string values, even when they look
45
+ * like JSON. Many existing canvases are stored pretty-printed and
46
+ * reformatting would create huge spurious diffs in users' repos.
47
+ */
48
+ export declare function normalizeJsonValueForStorage(value: unknown): string;
49
+ /**
50
+ * Canonical comparison for JSON-typed attribute values.
51
+ *
52
+ * Returns the canonical form (compact JSON if parseable, otherwise the
53
+ * raw string). Use this on both sides of a comparison so that pretty- vs
54
+ * compact-printed JSON does not register as a change, and so that an
55
+ * object on one side equals its stringified form on the other side.
56
+ */
57
+ export declare function canonicalJsonValue(value: unknown): string;
58
+ /**
59
+ * True if two JSON-typed attribute values are semantically equal.
60
+ *
61
+ * Handles the four mismatched representations that can occur during a
62
+ * pull/push cycle:
63
+ * string vs string (different whitespace/indent), object vs string,
64
+ * string vs object, object vs object.
65
+ */
66
+ export declare function jsonValuesEqual(a: unknown, b: unknown): boolean;
67
+ //# sourceMappingURL=json-attr-utils.d.ts.map
@@ -0,0 +1,98 @@
1
+ /**
2
+ * JSON-typed attribute helpers.
3
+ *
4
+ * Why this exists:
5
+ *
6
+ * The NEWO platform stores some attributes (e.g.
7
+ * `project_attributes_private_dynamic_workflow_builder_canvas`) as
8
+ * `value_type: json`. The API may return the `value` field as either a
9
+ * STRING containing JSON or as an already-parsed OBJECT.
10
+ *
11
+ * Without normalization, two bugs leak through:
12
+ *
13
+ * 1. When the API returns the value as an OBJECT, `yaml.dump` serializes
14
+ * it as a YAML structure (mappings/sequences). Pushing back then sends
15
+ * `{"value": {...}}` instead of `{"value": "..."}`, breaking the
16
+ * Workflow Builder which expects the canvas as a JSON STRING.
17
+ *
18
+ * 2. The push-time change check used `String(localAttr.value)` for
19
+ * comparison. With objects this collapses to `"[object Object]"` on
20
+ * both sides — silently masking real changes — and with mismatched
21
+ * string vs object representations it triggers spurious pushes that
22
+ * overwrite the canvas with the wrong shape (Builder shows blank).
23
+ *
24
+ * The fix is conservative: for `value_type: json` only, always coerce the
25
+ * value to a STRING when persisting and when pushing, and use canonical
26
+ * JSON for comparisons. String-typed values in the wild are left
27
+ * untouched, so no churn for the majority of attributes.
28
+ */
29
+ /**
30
+ * True if the attribute is a JSON-typed attribute (case- and
31
+ * format-insensitive: handles `json`, `JSON`, `AttributeValueTypes.json`,
32
+ * `ValueType.JSON`, etc.).
33
+ */
34
+ export function isJsonValueType(valueType) {
35
+ if (typeof valueType !== 'string')
36
+ return false;
37
+ const lower = valueType.toLowerCase();
38
+ return lower === 'json' || lower.endsWith('.json');
39
+ }
40
+ /**
41
+ * Coerce a JSON-typed attribute's value to a STRING suitable for storage
42
+ * in attributes.yaml and for sending to the platform.
43
+ *
44
+ * - `null` / `undefined` → `''`
45
+ * - object → compact JSON string (`JSON.stringify(value)`)
46
+ * - string → returned as-is (we trust the platform's existing format)
47
+ * - other → `String(value)`
48
+ *
49
+ * We deliberately do NOT re-format string values, even when they look
50
+ * like JSON. Many existing canvases are stored pretty-printed and
51
+ * reformatting would create huge spurious diffs in users' repos.
52
+ */
53
+ export function normalizeJsonValueForStorage(value) {
54
+ if (value == null)
55
+ return '';
56
+ if (typeof value === 'string')
57
+ return value;
58
+ if (typeof value === 'object') {
59
+ try {
60
+ return JSON.stringify(value);
61
+ }
62
+ catch {
63
+ return String(value);
64
+ }
65
+ }
66
+ return String(value);
67
+ }
68
+ /**
69
+ * Canonical comparison for JSON-typed attribute values.
70
+ *
71
+ * Returns the canonical form (compact JSON if parseable, otherwise the
72
+ * raw string). Use this on both sides of a comparison so that pretty- vs
73
+ * compact-printed JSON does not register as a change, and so that an
74
+ * object on one side equals its stringified form on the other side.
75
+ */
76
+ export function canonicalJsonValue(value) {
77
+ const stringified = normalizeJsonValueForStorage(value);
78
+ if (stringified === '')
79
+ return '';
80
+ try {
81
+ return JSON.stringify(JSON.parse(stringified));
82
+ }
83
+ catch {
84
+ return stringified;
85
+ }
86
+ }
87
+ /**
88
+ * True if two JSON-typed attribute values are semantically equal.
89
+ *
90
+ * Handles the four mismatched representations that can occur during a
91
+ * pull/push cycle:
92
+ * string vs string (different whitespace/indent), object vs string,
93
+ * string vs object, object vs object.
94
+ */
95
+ export function jsonValuesEqual(a, b) {
96
+ return canonicalJsonValue(a) === canonicalJsonValue(b);
97
+ }
98
+ //# sourceMappingURL=json-attr-utils.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "newo",
3
- "version": "3.6.2",
3
+ "version": "3.7.1",
4
4
  "description": "NEWO CLI: Professional command-line tool with modular architecture for NEWO AI Agent development. Features account migration, integration management, webhook automation, AKB knowledge base, project attributes, sandbox testing, IDN-based file management, real-time progress tracking, intelligent sync operations, and comprehensive multi-customer support.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -60,6 +60,8 @@
60
60
  "node": ">=18"
61
61
  },
62
62
  "dependencies": {
63
+ "newo-dsl-analyzer": "^1.0.0",
64
+ "newo-dsl-core": "^1.0.0",
63
65
  "axios": "^1.7.7",
64
66
  "chokidar": "^5.0.0",
65
67
  "dotenv": "^16.4.5",