newo 3.6.1 → 3.7.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.
Files changed (48) hide show
  1. package/CHANGELOG.md +41 -0
  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 +7 -4
  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 +14 -14
  30. package/dist/sync/conversations.d.ts +1 -1
  31. package/dist/sync/conversations.js +240 -193
  32. package/package.json +3 -1
  33. package/src/cli/commands/check.ts +21 -0
  34. package/src/cli/commands/format.ts +131 -0
  35. package/src/cli/commands/help.ts +13 -0
  36. package/src/cli/commands/lint.ts +246 -0
  37. package/src/cli.ts +50 -9
  38. package/src/domain/strategies/sync/AttributeSyncStrategy.ts +7 -4
  39. package/src/lint/config.ts +17 -0
  40. package/src/lint/discovery.ts +148 -0
  41. package/src/lint/live-schema.ts +62 -0
  42. package/src/lint/reporters/index.ts +22 -0
  43. package/src/lint/reporters/json.ts +12 -0
  44. package/src/lint/reporters/sarif.ts +59 -0
  45. package/src/lint/reporters/text.ts +58 -0
  46. package/src/lint/reporters/types.ts +7 -0
  47. package/src/sync/attributes.ts +14 -14
  48. package/src/sync/conversations.ts +265 -212
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Human-readable terminal reporter. Mirrors the ESLint 'stylish' layout:
3
+ *
4
+ * path/to/file.jinja
5
+ * 12:5 error Unknown skill: foo. Did you mean: bar? E100
6
+ * ...
7
+ *
8
+ * 2 problems (1 error, 1 warning)
9
+ */
10
+ import path from 'path';
11
+ import type { ProjectLintReport, LintResult } from 'newo-dsl-analyzer';
12
+ import type { Reporter } from './types.js';
13
+
14
+ const RED = '\x1b[31m';
15
+ const YELLOW = '\x1b[33m';
16
+ const CYAN = '\x1b[36m';
17
+ const GREY = '\x1b[90m';
18
+ const RESET = '\x1b[0m';
19
+ const BOLD = '\x1b[1m';
20
+
21
+ export const textReporter: Reporter = {
22
+ write(report: ProjectLintReport): string {
23
+ const lines: string[] = [];
24
+ const filesWithIssues = report.results.filter(r => r.diagnostics.length > 0);
25
+
26
+ for (const result of filesWithIssues) {
27
+ lines.push(renderFile(result));
28
+ lines.push('');
29
+ }
30
+
31
+ lines.push(renderSummary(report));
32
+ return lines.join('\n');
33
+ },
34
+ };
35
+
36
+ function renderFile(result: LintResult): string {
37
+ const rel = path.relative(process.cwd(), result.filePath);
38
+ const header = `${BOLD}${CYAN}${rel}${RESET}`;
39
+ const rows = result.diagnostics.map(d => {
40
+ const loc = `${d.range.start.line}:${d.range.start.column}`;
41
+ const sev = d.severity === 'error'
42
+ ? `${RED}error${RESET}`
43
+ : d.severity === 'warning'
44
+ ? `${YELLOW}warning${RESET}`
45
+ : `${GREY}${d.severity}${RESET}`;
46
+ return ` ${loc.padEnd(7)} ${sev.padEnd(16)} ${d.message} ${GREY}${d.code}${RESET}`;
47
+ });
48
+ return [header, ...rows].join('\n');
49
+ }
50
+
51
+ function renderSummary(report: ProjectLintReport): string {
52
+ const total = report.errorCount + report.warningCount;
53
+ if (total === 0) {
54
+ return `${GREY}No issues found.${RESET}`;
55
+ }
56
+ const color = report.errorCount > 0 ? RED : YELLOW;
57
+ return `${color}${BOLD}${total} problems${RESET} (${report.errorCount} error${report.errorCount === 1 ? '' : 's'}, ${report.warningCount} warning${report.warningCount === 1 ? '' : 's'})`;
58
+ }
@@ -0,0 +1,7 @@
1
+ import type { ProjectLintReport } from 'newo-dsl-analyzer';
2
+
3
+ export type ReporterName = 'text' | 'json' | 'sarif';
4
+
5
+ export interface Reporter {
6
+ write(report: ProjectLintReport): string;
7
+ }
@@ -11,6 +11,7 @@ import {
11
11
  import path from 'path';
12
12
  import fs from 'fs-extra';
13
13
  import yaml from 'js-yaml';
14
+ import { patchYamlToPyyaml } from '../format/yaml-patch.js';
14
15
  import type { AxiosInstance } from 'axios';
15
16
  import type { CustomerConfig } from '../types.js';
16
17
 
@@ -72,25 +73,24 @@ export async function saveCustomerAttributes(
72
73
  attributes: cleanAttributes
73
74
  };
74
75
 
75
- // Configure YAML output to match reference format exactly
76
+ // Emit YAML without folding/wrapping; patchYamlToPyyaml handles long-line
77
+ // wrapping and converts JSON-like double-quoted values to single-quoted
78
+ // (so strings containing `"` stay valid YAML on reload).
76
79
  let yamlContent = yaml.dump(attributesYaml, {
77
80
  indent: 2,
78
81
  quotingType: '"',
79
82
  forceQuotes: false,
80
- lineWidth: 80, // Wrap long lines to match reference format
83
+ lineWidth: -1,
81
84
  noRefs: true,
82
85
  sortKeys: false,
83
- flowLevel: -1, // Never use flow syntax
84
- styles: {
85
- '!!str': 'folded' // Use folded style for better line wrapping of long strings
86
- }
86
+ flowLevel: -1,
87
87
  });
88
88
 
89
- // Post-process to fix enum format and improve JSON string formatting
89
+ // Post-process to fix enum format
90
90
  yamlContent = yamlContent.replace(/__ENUM_PLACEHOLDER_(\w+)__/g, '!enum "AttributeValueTypes.$1"');
91
91
 
92
- // Fix JSON string formatting to match reference (remove escape characters)
93
- yamlContent = yamlContent.replace(/\\"/g, '"');
92
+ // Convert JSON-like double-quoted values to single-quoted and wrap long lines
93
+ yamlContent = patchYamlToPyyaml(yamlContent);
94
94
 
95
95
  // Save all files: attributes.yaml, ID mapping, and backup for diff tracking
96
96
  await writeFileSafe(customerAttributesPath(customer.idn), yamlContent);
@@ -172,20 +172,20 @@ export async function saveProjectAttributes(
172
172
  attributes: cleanAttributes
173
173
  };
174
174
 
175
- // Configure YAML output
175
+ // Emit YAML without folding/wrapping; patchYamlToPyyaml handles long-line
176
+ // wrapping and converts JSON-like double-quoted values to single-quoted.
176
177
  let yamlContent = yaml.dump(attributesYaml, {
177
178
  indent: 2,
178
179
  quotingType: '"',
179
180
  forceQuotes: false,
180
- lineWidth: 80,
181
+ lineWidth: -1,
181
182
  noRefs: true,
182
183
  sortKeys: false,
183
- flowLevel: -1
184
+ flowLevel: -1,
184
185
  });
185
186
 
186
- // Post-process to fix enum format
187
187
  yamlContent = yamlContent.replace(/__ENUM_PLACEHOLDER_(\w+)__/g, '!enum "AttributeValueTypes.$1"');
188
- yamlContent = yamlContent.replace(/\\"/g, '"');
188
+ yamlContent = patchYamlToPyyaml(yamlContent);
189
189
 
190
190
  // Save to project directory
191
191
  const customerDir = path.join(process.cwd(), 'newo_customers', customer.idn);
@@ -1,8 +1,16 @@
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
16
  import type { AxiosInstance } from 'axios';
@@ -16,11 +24,108 @@ import type {
16
24
  ConversationsData
17
25
  } from '../types.js';
18
26
 
19
- // Concurrency limit for API calls
20
27
  const concurrencyLimit = pLimit(5);
21
28
 
29
+ type PersonaState = {
30
+ id: string;
31
+ name: string;
32
+ phone: string | null;
33
+ act_count: number;
34
+ acts: ProcessedAct[];
35
+ fetched_at: string;
36
+ complete: boolean;
37
+ last_error?: string;
38
+ };
39
+
40
+ function personaFilePath(customerIdn: string, personaId: string): string {
41
+ return path.join('newo_customers', customerIdn, 'conversations', `${personaId}.json`);
42
+ }
43
+
44
+ function aggregateYamlPath(customerIdn: string): string {
45
+ return path.join('newo_customers', customerIdn, 'conversations.yaml');
46
+ }
47
+
48
+ async function readPersonaState(customerIdn: string, personaId: string): Promise<PersonaState | null> {
49
+ const p = personaFilePath(customerIdn, personaId);
50
+ if (!(await fs.pathExists(p))) return null;
51
+ try {
52
+ return JSON.parse(await fs.readFile(p, 'utf8')) as PersonaState;
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ async function writePersonaState(customerIdn: string, state: PersonaState): Promise<void> {
59
+ await writeFileSafe(personaFilePath(customerIdn, state.id), JSON.stringify(state, null, 2));
60
+ }
61
+
62
+ async function writeAggregateYaml(customerIdn: string): Promise<{ personas: number; acts: number }> {
63
+ const dir = path.join('newo_customers', customerIdn, 'conversations');
64
+ const files = (await fs.pathExists(dir)) ? await fs.readdir(dir) : [];
65
+ const personas: ProcessedPersona[] = [];
66
+ for (const f of files) {
67
+ if (!f.endsWith('.json')) continue;
68
+ try {
69
+ const state = JSON.parse(await fs.readFile(path.join(dir, f), 'utf8')) as PersonaState;
70
+ personas.push({
71
+ id: state.id,
72
+ name: state.name,
73
+ phone: state.phone,
74
+ act_count: state.act_count,
75
+ acts: state.acts
76
+ });
77
+ } catch {
78
+ // skip corrupted file
79
+ }
80
+ }
81
+
82
+ personas.sort((a, b) => {
83
+ const aLatestTime = a.acts.length > 0 ? a.acts[a.acts.length - 1]!.datetime : '1970-01-01T00:00:00.000Z';
84
+ const bLatestTime = b.acts.length > 0 ? b.acts[b.acts.length - 1]!.datetime : '1970-01-01T00:00:00.000Z';
85
+ return new Date(bLatestTime).getTime() - new Date(aLatestTime).getTime();
86
+ });
87
+
88
+ const totalActs = personas.reduce((sum, p) => sum + p.acts.length, 0);
89
+
90
+ const data: ConversationsData = {
91
+ personas,
92
+ total_personas: personas.length,
93
+ total_acts: totalActs,
94
+ generated_at: new Date().toISOString()
95
+ };
96
+
97
+ const yamlContent = yaml.dump(data, {
98
+ indent: 2,
99
+ quotingType: '"',
100
+ forceQuotes: false,
101
+ lineWidth: 120,
102
+ noRefs: true,
103
+ sortKeys: false,
104
+ flowLevel: -1
105
+ });
106
+
107
+ await writeFileSafe(aggregateYamlPath(customerIdn), yamlContent);
108
+ return { personas: personas.length, acts: totalActs };
109
+ }
110
+
111
+ function buildProcessedActs(raw: ConversationAct[]): ProcessedAct[] {
112
+ const sorted = [...raw].sort((a, b) => new Date(a.datetime).getTime() - new Date(b.datetime).getTime());
113
+ return sorted.map(act => {
114
+ const processedAct: ProcessedAct = {
115
+ datetime: act.datetime,
116
+ type: act.reference_idn,
117
+ message: act.source_text
118
+ };
119
+ if (act.contact_information) (processedAct as any).contact_information = act.contact_information;
120
+ if (act.flow_idn && act.flow_idn !== 'unknown') (processedAct as any).flow_idn = act.flow_idn;
121
+ if (act.skill_idn && act.skill_idn !== 'unknown') (processedAct as any).skill_idn = act.skill_idn;
122
+ if (act.session_id && act.session_id !== 'unknown') (processedAct as any).session_id = act.session_id;
123
+ return processedAct;
124
+ });
125
+ }
126
+
22
127
  /**
23
- * Pull conversations for a customer and save to YAML
128
+ * Pull conversations for a customer and save incrementally.
24
129
  */
25
130
  export async function pullConversations(
26
131
  client: AxiosInstance,
@@ -28,230 +133,178 @@ export async function pullConversations(
28
133
  options: ConversationOptions = {},
29
134
  verbose: boolean = false
30
135
  ): Promise<void> {
31
- if (verbose) console.log(`šŸ’¬ Fetching conversations for customer ${customer.idn}...`);
136
+ const force = process.env.NEWO_CONV_FORCE === '1';
137
+ console.log(`šŸ’¬ Fetching conversations for ${customer.idn}${force ? ' (force re-fetch)' : ' (resume mode)'}...`);
138
+
139
+ // Ensure output dirs exist
140
+ await fs.ensureDir(path.join('newo_customers', customer.idn, 'conversations'));
141
+
142
+ // 1. Enumerate all personas
143
+ const allPersonas: UserPersona[] = [];
144
+ let page = 1;
145
+ const perPage = 50;
146
+ let hasMore = true;
147
+
148
+ while (hasMore) {
149
+ const response = await listUserPersonas(client, page, perPage);
150
+ allPersonas.push(...response.items);
151
+ if (verbose) console.log(`šŸ“‹ Page ${page}: ${response.items.length} personas (${allPersonas.length}/${response.metadata.total})`);
152
+ hasMore = response.items.length === perPage && allPersonas.length < response.metadata.total;
153
+ page++;
154
+ }
32
155
 
33
- try {
34
- // Get all user personas with pagination
35
- const allPersonas: UserPersona[] = [];
36
- let page = 1;
37
- const perPage = 50;
38
- let hasMore = true;
156
+ if (options.maxPersonas && allPersonas.length > options.maxPersonas) {
157
+ allPersonas.splice(options.maxPersonas);
158
+ }
39
159
 
40
- while (hasMore) {
41
- const response = await listUserPersonas(client, page, perPage);
42
- allPersonas.push(...response.items);
160
+ const total = allPersonas.length;
161
+ console.log(`šŸ‘„ Found ${total} personas. Processing with concurrency=5...`);
162
+
163
+ let done = 0;
164
+ let skipped = 0;
165
+ let failed = 0;
166
+
167
+ await Promise.all(allPersonas.map(persona => concurrencyLimit(async () => {
168
+ try {
169
+ // Resume: skip if already complete
170
+ const existing = await readPersonaState(customer.idn, persona.id);
171
+ if (!force && existing && existing.complete) {
172
+ skipped++;
173
+ done++;
174
+ if (verbose) console.log(`ā­ļø [${done}/${total}] ${persona.name}: already complete (${existing.acts.length} acts)`);
175
+ return;
176
+ }
43
177
 
44
- if (verbose) console.log(`šŸ“‹ Page ${page}: Found ${response.items.length} personas (${allPersonas.length}/${response.metadata.total} total)`);
178
+ const phoneActor = persona.actors.find(actor =>
179
+ actor.integration_idn === 'newo_voice' &&
180
+ actor.connector_idn === 'newo_voice_connector' &&
181
+ actor.contact_information?.startsWith('+')
182
+ );
183
+ const phone = phoneActor?.contact_information || null;
45
184
 
46
- hasMore = response.items.length === perPage && allPersonas.length < response.metadata.total;
47
- page++;
48
- }
185
+ const userActors = persona.actors.filter(actor =>
186
+ actor.integration_idn === 'newo_voice' &&
187
+ actor.connector_idn === 'newo_voice_connector'
188
+ );
49
189
 
50
- if (options.maxPersonas && allPersonas.length > options.maxPersonas) {
51
- allPersonas.splice(options.maxPersonas);
52
- if (verbose) console.log(`āš ļø Limited to ${options.maxPersonas} personas as requested`);
53
- }
190
+ if (userActors.length === 0) {
191
+ const state: PersonaState = {
192
+ id: persona.id,
193
+ name: persona.name,
194
+ phone,
195
+ act_count: persona.act_count,
196
+ acts: [],
197
+ fetched_at: new Date().toISOString(),
198
+ complete: true
199
+ };
200
+ await writePersonaState(customer.idn, state);
201
+ done++;
202
+ if (verbose) console.log(`āœ“ [${done}/${total}] ${persona.name}: no voice actors`);
203
+ return;
204
+ }
54
205
 
55
- if (verbose) console.log(`šŸ‘„ Processing ${allPersonas.length} personas...`);
56
-
57
- // Process personas concurrently with limited concurrency
58
- const processedPersonas: ProcessedPersona[] = [];
59
-
60
- await Promise.all(allPersonas.map(persona => concurrencyLimit(async () => {
61
- try {
62
- // Extract phone number from actors
63
- const phoneActor = persona.actors.find(actor =>
64
- actor.integration_idn === 'newo_voice' &&
65
- actor.connector_idn === 'newo_voice_connector' &&
66
- actor.contact_information?.startsWith('+')
67
- );
68
- const phone = phoneActor?.contact_information || null;
69
-
70
- // Get acts for this persona
71
- const allActs: ConversationAct[] = [];
72
- let actPage = 1;
73
- const actsPerPage = 100; // Higher limit for acts
74
- let hasMoreActs = true;
75
-
76
- // Get user actor IDs from persona actors first
77
- const userActors = persona.actors.filter(actor =>
78
- actor.integration_idn === 'newo_voice' &&
79
- actor.connector_idn === 'newo_voice_connector'
80
- );
81
-
82
- if (userActors.length === 0) {
83
- if (verbose) console.log(` šŸ‘¤ ${persona.name}: No voice actors found, skipping`);
84
- // No voice actors, can't get chat history - add persona with empty acts
85
- processedPersonas.push({
86
- id: persona.id,
87
- name: persona.name,
88
- phone,
89
- act_count: persona.act_count,
90
- acts: []
206
+ // Fetch acts paginated
207
+ const allActs: ConversationAct[] = [];
208
+ let actPage = 1;
209
+ const actsPerPage = 100;
210
+ let hasMoreActs = true;
211
+ const maxPages = 50;
212
+ let lastError: string | undefined;
213
+
214
+ while (hasMoreActs && actPage <= maxPages) {
215
+ try {
216
+ const chatResponse = await getChatHistory(client, {
217
+ user_actor_id: userActors[0]!.id,
218
+ page: actPage,
219
+ per: actsPerPage
91
220
  });
92
- if (verbose) console.log(` āœ“ Processed ${persona.name}: 0 acts (no voice actors)`);
93
- return; // Return from the concurrency function
94
- }
95
-
96
- // Safety mechanism to prevent infinite loops
97
- const maxPages = 50; // Limit to 50 pages (5000 acts max per persona)
98
221
 
99
- while (hasMoreActs && actPage <= maxPages) {
100
- try {
101
- const chatHistoryParams = {
222
+ if (chatResponse.items && chatResponse.items.length > 0) {
223
+ const convertedActs: ConversationAct[] = chatResponse.items.map((item: any) => ({
224
+ id: item.id || `chat_${Math.random()}`,
225
+ command_act_id: null,
226
+ external_event_id: item.external_event_id || 'chat_history',
227
+ arguments: [],
228
+ reference_idn: (item.is_agent === true) ? 'agent_message' : 'user_message',
229
+ runtime_context_id: item.runtime_context_id || 'chat_history',
230
+ source_text: item.payload?.text || item.message || item.content || item.text || '',
231
+ original_text: item.payload?.text || item.message || item.content || item.text || '',
232
+ datetime: item.datetime || item.created_at || item.timestamp || new Date().toISOString(),
102
233
  user_actor_id: userActors[0]!.id,
103
- page: actPage,
104
- per: actsPerPage
234
+ agent_actor_id: null,
235
+ user_persona_id: persona.id,
236
+ user_persona_name: persona.name,
237
+ agent_persona_id: item.agent_persona_id || 'unknown',
238
+ external_id: item.external_id || null,
239
+ integration_idn: 'newo_voice',
240
+ connector_idn: 'newo_voice_connector',
241
+ to_integration_idn: null,
242
+ to_connector_idn: null,
243
+ is_agent: Boolean(item.is_agent === true),
244
+ project_idn: null,
245
+ flow_idn: item.flow_idn || 'unknown',
246
+ skill_idn: item.skill_idn || 'unknown',
247
+ session_id: item.session_id || 'unknown',
248
+ recordings: item.recordings || [],
249
+ contact_information: item.contact_information || null
250
+ }));
251
+
252
+ allActs.push(...convertedActs);
253
+
254
+ // Save partial progress every page
255
+ const partialState: PersonaState = {
256
+ id: persona.id,
257
+ name: persona.name,
258
+ phone,
259
+ act_count: persona.act_count,
260
+ acts: buildProcessedActs(allActs),
261
+ fetched_at: new Date().toISOString(),
262
+ complete: false
105
263
  };
264
+ await writePersonaState(customer.idn, partialState);
106
265
 
107
- if (verbose) console.log(` šŸ“„ ${persona.name}: Fetching page ${actPage}...`);
108
- const chatResponse = await getChatHistory(client, chatHistoryParams);
109
-
110
- if (chatResponse.items && chatResponse.items.length > 0) {
111
- // Convert chat history format to acts format - create minimal ConversationAct objects
112
- const convertedActs: ConversationAct[] = chatResponse.items.map((item: any) => ({
113
- id: item.id || `chat_${Math.random()}`,
114
- command_act_id: null,
115
- external_event_id: item.external_event_id || 'chat_history',
116
- arguments: [],
117
- reference_idn: (item.is_agent === true) ? 'agent_message' : 'user_message',
118
- runtime_context_id: item.runtime_context_id || 'chat_history',
119
- source_text: item.payload?.text || item.message || item.content || item.text || '',
120
- original_text: item.payload?.text || item.message || item.content || item.text || '',
121
- datetime: item.datetime || item.created_at || item.timestamp || new Date().toISOString(),
122
- user_actor_id: userActors[0]!.id,
123
- agent_actor_id: null,
124
- user_persona_id: persona.id,
125
- user_persona_name: persona.name,
126
- agent_persona_id: item.agent_persona_id || 'unknown',
127
- external_id: item.external_id || null,
128
- integration_idn: 'newo_voice',
129
- connector_idn: 'newo_voice_connector',
130
- to_integration_idn: null,
131
- to_connector_idn: null,
132
- is_agent: Boolean(item.is_agent === true),
133
- project_idn: null,
134
- flow_idn: item.flow_idn || 'unknown',
135
- skill_idn: item.skill_idn || 'unknown',
136
- session_id: item.session_id || 'unknown',
137
- recordings: item.recordings || [],
138
- contact_information: item.contact_information || null
139
- }));
140
-
141
- allActs.push(...convertedActs);
142
-
143
- if (verbose && convertedActs.length > 0) {
144
- console.log(` šŸ‘¤ ${persona.name}: Chat History - ${convertedActs.length} messages (${allActs.length} total)`);
145
- }
146
-
147
- // Check if we should continue paginating
148
- const hasMetadata = chatResponse.metadata?.total !== undefined;
149
- const currentTotal = chatResponse.metadata?.total || 0;
150
-
151
- hasMoreActs = chatResponse.items.length === actsPerPage &&
152
- hasMetadata &&
153
- allActs.length < currentTotal;
154
-
155
- actPage++;
156
-
157
- if (verbose) console.log(` šŸ“Š ${persona.name}: Page ${actPage - 1} done, ${allActs.length}/${currentTotal} total acts`);
158
- } else {
159
- // No more items
160
- hasMoreActs = false;
161
- if (verbose) console.log(` šŸ“Š ${persona.name}: No more chat history items`);
162
- }
163
- } catch (chatError) {
164
- if (verbose) console.log(` āš ļø Chat history failed for ${persona.name}: ${chatError instanceof Error ? chatError.message : String(chatError)}`);
266
+ const currentTotal = chatResponse.metadata?.total || 0;
267
+ hasMoreActs = chatResponse.items.length === actsPerPage && allActs.length < currentTotal;
268
+ actPage++;
269
+ } else {
165
270
  hasMoreActs = false;
166
271
  }
272
+ } catch (chatError) {
273
+ lastError = chatError instanceof Error ? chatError.message : String(chatError);
274
+ if (verbose) console.log(`āš ļø ${persona.name} page ${actPage}: ${lastError}`);
275
+ hasMoreActs = false;
167
276
  }
168
-
169
- if (actPage > maxPages) {
170
- if (verbose) console.log(` āš ļø ${persona.name}: Reached max pages limit (${maxPages}), stopping pagination`);
171
- }
172
-
173
- // Sort acts by datetime ascending (chronological order)
174
- allActs.sort((a, b) => new Date(a.datetime).getTime() - new Date(b.datetime).getTime());
175
-
176
- // Process acts into simplified format - exclude redundant fields
177
- const processedActs: ProcessedAct[] = allActs.map(act => {
178
- const processedAct: ProcessedAct = {
179
- datetime: act.datetime,
180
- type: act.reference_idn,
181
- message: act.source_text
182
- };
183
-
184
- // Only include non-redundant fields
185
- if (act.contact_information) {
186
- (processedAct as any).contact_information = act.contact_information;
187
- }
188
- if (act.flow_idn && act.flow_idn !== 'unknown') {
189
- (processedAct as any).flow_idn = act.flow_idn;
190
- }
191
- if (act.skill_idn && act.skill_idn !== 'unknown') {
192
- (processedAct as any).skill_idn = act.skill_idn;
193
- }
194
- if (act.session_id && act.session_id !== 'unknown') {
195
- (processedAct as any).session_id = act.session_id;
196
- }
197
-
198
- return processedAct;
199
- });
200
-
201
- processedPersonas.push({
202
- id: persona.id,
203
- name: persona.name,
204
- phone,
205
- act_count: persona.act_count,
206
- acts: processedActs
207
- });
208
-
209
- if (verbose) console.log(` āœ“ Processed ${persona.name}: ${processedActs.length} acts`);
210
- } catch (error) {
211
- console.error(`āŒ Failed to process persona ${persona.name}:`, error);
212
- // Continue with other personas
213
277
  }
214
- })));
215
-
216
- // Sort personas by most recent act time (descending) - use latest act from acts array
217
- processedPersonas.sort((a, b) => {
218
- const aLatestTime = a.acts.length > 0 ? a.acts[a.acts.length - 1]!.datetime : '1970-01-01T00:00:00.000Z';
219
- const bLatestTime = b.acts.length > 0 ? b.acts[b.acts.length - 1]!.datetime : '1970-01-01T00:00:00.000Z';
220
- return new Date(bLatestTime).getTime() - new Date(aLatestTime).getTime();
221
- });
222
-
223
- // Calculate totals
224
- const totalActs = processedPersonas.reduce((sum, persona) => sum + persona.acts.length, 0);
225
-
226
- // Create final conversations data
227
- const conversationsData: ConversationsData = {
228
- personas: processedPersonas,
229
- total_personas: processedPersonas.length,
230
- total_acts: totalActs,
231
- generated_at: new Date().toISOString()
232
- };
233
278
 
234
- // Save to YAML file
235
- const conversationsPath = `newo_customers/${customer.idn}/conversations.yaml`;
236
- const yamlContent = yaml.dump(conversationsData, {
237
- indent: 2,
238
- quotingType: '"',
239
- forceQuotes: false,
240
- lineWidth: 120,
241
- noRefs: true,
242
- sortKeys: false,
243
- flowLevel: -1
244
- });
245
-
246
- await writeFileSafe(conversationsPath, yamlContent);
247
-
248
- if (verbose) {
249
- console.log(`āœ“ Saved conversations to ${conversationsPath}`);
250
- console.log(`šŸ“Š Summary: ${processedPersonas.length} personas, ${totalActs} total acts`);
279
+ const finalState: PersonaState = {
280
+ id: persona.id,
281
+ name: persona.name,
282
+ phone,
283
+ act_count: persona.act_count,
284
+ acts: buildProcessedActs(allActs),
285
+ fetched_at: new Date().toISOString(),
286
+ complete: !lastError
287
+ };
288
+ if (lastError) finalState.last_error = lastError;
289
+ await writePersonaState(customer.idn, finalState);
290
+
291
+ // Incremental YAML aggregate every persona
292
+ const agg = await writeAggregateYaml(customer.idn);
293
+
294
+ done++;
295
+ if (lastError) failed++;
296
+ console.log(`${lastError ? 'āš ļø ' : 'āœ“'} [${done}/${total}] ${persona.name}: ${finalState.acts.length} acts${lastError ? ` (partial: ${lastError})` : ''} | total so far: ${agg.personas} personas / ${agg.acts} acts`);
297
+ } catch (error) {
298
+ failed++;
299
+ done++;
300
+ const msg = error instanceof Error ? error.message : String(error);
301
+ console.error(`āŒ [${done}/${total}] ${persona.name}: ${msg}`);
251
302
  }
252
-
253
- } catch (error) {
254
- console.error(`āŒ Failed to pull conversations for ${customer.idn}:`, error);
255
- throw error;
256
- }
257
- }
303
+ })));
304
+
305
+ // Final aggregate write
306
+ const final = await writeAggregateYaml(customer.idn);
307
+ console.log(`\nāœ… Done. ${final.personas} personas, ${final.acts} acts. Skipped ${skipped} (already cached), ${failed} had errors.`);
308
+ console.log(` Aggregate: ${aggregateYamlPath(customer.idn)}`);
309
+ console.log(` Per-persona: newo_customers/${customer.idn}/conversations/<id>.json`);
310
+ }