@ziggs-ai/agent-sdk 0.1.3

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 (40) hide show
  1. package/README.md +82 -0
  2. package/package.json +26 -0
  3. package/src/ConnectionPool.js +133 -0
  4. package/src/adapters/OpenAIAdapter.js +73 -0
  5. package/src/adapters/index.js +1 -0
  6. package/src/agent/Agent.js +121 -0
  7. package/src/agent/EventQueue.js +68 -0
  8. package/src/agent/OutboxBuffer.js +62 -0
  9. package/src/cognition/PromptBuilder.js +312 -0
  10. package/src/cognition/resolveActionTool.js +12 -0
  11. package/src/cognition/runTurn.js +578 -0
  12. package/src/context/applyEffects.js +133 -0
  13. package/src/context/batch.js +25 -0
  14. package/src/context/classifyEnvelope.js +82 -0
  15. package/src/context/routingLabels.js +54 -0
  16. package/src/createHealthServer.js +28 -0
  17. package/src/formatters/HistoryFormatter.js +257 -0
  18. package/src/formatters/TaskFormatter.js +180 -0
  19. package/src/formatters/index.js +9 -0
  20. package/src/index.js +76 -0
  21. package/src/ingress/normalizeIncoming.js +70 -0
  22. package/src/runLauncher.js +159 -0
  23. package/src/shared/ids.js +7 -0
  24. package/src/shared/types.js +86 -0
  25. package/src/tasks/TaskService.js +247 -0
  26. package/src/tasks/index.js +9 -0
  27. package/src/tasks/taskCore.js +229 -0
  28. package/src/tasks/taskProtocolRegistry.js +22 -0
  29. package/src/tasks/taskProtocolRunner.js +107 -0
  30. package/src/tasks/taskProtocolTools.js +87 -0
  31. package/src/tools/ToolManager.js +79 -0
  32. package/src/tools/ToolProvider.js +29 -0
  33. package/src/tools/defineTool.js +82 -0
  34. package/src/tools/index.js +11 -0
  35. package/src/utils/jsonExtractor.js +139 -0
  36. package/src/workflow/AgentMachine.js +250 -0
  37. package/src/workflow/WorkflowRuntime.js +63 -0
  38. package/src/workflow/dsl.js +287 -0
  39. package/src/workflow/motifs.js +435 -0
  40. package/src/ziggs/runtime.js +192 -0
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Maps action results into context fields for transition conditions, and holds CONTEXT_RESET.
3
+ */
4
+
5
+ import { isTaskProtocolToolName, mapTaskProtocolToolToOperation } from '../tasks/taskProtocolRegistry.js';
6
+
7
+ function applyTaskOperationResultToContext(updates, operation, result) {
8
+ const r = result || {};
9
+ if (operation === 'make-task' || operation === 'make-sub-tasks') {
10
+ const isProposal = r.state === 'proposal' || r.proposal?.status === 'pending';
11
+ if (isProposal) {
12
+ // executorIdIsYou is a backend read-time field — absent from creation responses.
13
+ // Fallback: proposeToDoWork sets agentId === executorId (you are both owner and executor);
14
+ // delegateToAgent sets agentId !== executorId (specialist is the executor).
15
+ const isOwnProposal = r.executorIdIsYou || (r.agentId && r.agentId === r.executorId);
16
+ if (isOwnProposal) {
17
+ updates.proposal = r;
18
+ } else if (r.executorId) {
19
+ updates.delegatedTask = r;
20
+ if (r.taskId) {
21
+ if (!updates._delegatedTaskIds) updates._delegatedTaskIds = [];
22
+ updates._delegatedTaskIds.push(r.taskId);
23
+ }
24
+ }
25
+ } else if (r.executorId) {
26
+ updates.delegatedTask = r;
27
+ // Accumulate all delegated task IDs for parallel batch delegation
28
+ if (r.taskId) {
29
+ if (!updates._delegatedTaskIds) updates._delegatedTaskIds = [];
30
+ updates._delegatedTaskIds.push(r.taskId);
31
+ }
32
+ }
33
+ }
34
+ if (operation === 'update-task') {
35
+ const status = r.state || r.status;
36
+ if (status === 'completed') updates.taskCompleted = true;
37
+ if (status === 'failed') updates.taskFailed = true;
38
+ }
39
+ if (operation === 'respond-proposal') {
40
+ updates.respondedProposal = r;
41
+ }
42
+ }
43
+
44
+ export const CONTEXT_RESET = Object.freeze({
45
+ messageSent: false,
46
+ activeWait: false,
47
+ proposal: null,
48
+ delegatedTask: null,
49
+ taskCompleted: false,
50
+ taskFailed: false,
51
+ subtaskResult: null,
52
+ subtaskFailed: false,
53
+ toolResults: null,
54
+ lastError: null,
55
+ lastAction: null,
56
+ lastActionResult: null,
57
+ approval: false,
58
+ rejection: false,
59
+ taskAssignment: null,
60
+ /** User-approved orchestration root: specialist id from task.contract (see AgentMachine). */
61
+ orchestrationSpecialistAgentId: null,
62
+ /** When true, a delegated subtask finished and another specialist in the chain should run next. */
63
+ orchestrationContinueChain: false,
64
+ incomingMessage: false,
65
+ respondedProposal: null,
66
+ searchCompleted: false,
67
+ boardResults: null,
68
+ taskPublished: false,
69
+ });
70
+
71
+ export function buildContextUpdates(actionName, emittedEvents) {
72
+ const updates = { ...CONTEXT_RESET, lastAction: actionName };
73
+
74
+ if (!Array.isArray(emittedEvents)) return updates;
75
+
76
+ const toolResults = [];
77
+
78
+ for (const ev of emittedEvents) {
79
+ if (!ev || typeof ev !== 'object') continue;
80
+ const events = Array.isArray(ev) ? ev : [ev];
81
+
82
+ for (const e of events) {
83
+ switch (e.type) {
84
+ case 'tool_result':
85
+ toolResults.push({ tool: e.tool, result: e.result });
86
+ updates.lastActionResult = e;
87
+ if (isTaskProtocolToolName(e.tool)) {
88
+ applyTaskOperationResultToContext(updates, mapTaskProtocolToolToOperation(e.tool), e.result);
89
+ }
90
+ break;
91
+
92
+ case 'tool_error':
93
+ toolResults.push({ tool: e.tool, error: e.error });
94
+ updates.lastActionResult = e;
95
+ updates.lastError = e.error;
96
+ break;
97
+
98
+ case 'message_sent':
99
+ updates.messageSent = true;
100
+ updates.lastActionResult = e;
101
+ break;
102
+
103
+ case 'message_duplicate_skipped':
104
+ updates.messageSent = true;
105
+ break;
106
+
107
+ case 'waited':
108
+ updates.activeWait = true;
109
+ break;
110
+
111
+ case 'task_result': {
112
+ const result = e.result || {};
113
+ const operation = e.operation;
114
+ updates.lastActionResult = e;
115
+ applyTaskOperationResultToContext(updates, operation, result);
116
+ break;
117
+ }
118
+
119
+ case 'task_error':
120
+ updates.lastError = e.error;
121
+ updates.lastActionResult = e;
122
+ break;
123
+
124
+ default:
125
+ break;
126
+ }
127
+ }
128
+ }
129
+
130
+ if (toolResults.length > 0) updates.toolResults = toolResults;
131
+
132
+ return updates;
133
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Unwrap batched events and task-result relevance for routing / workflow.
3
+ */
4
+
5
+ export function getBatchEvents(rawEvent) {
6
+ if (!rawEvent) return [];
7
+ if (rawEvent.type === 'batch' && Array.isArray(rawEvent.events)) {
8
+ return rawEvent.events;
9
+ }
10
+ return [rawEvent];
11
+ }
12
+
13
+ /**
14
+ * Whether a task_result payload is relevant to the current agent (routing / workflow).
15
+ */
16
+ export function isTaskResultRelevantToAgent(result, ownAgentId) {
17
+ if (!ownAgentId) return true;
18
+ return (
19
+ result.executorIdIsYou || result.agentIdIsYou || result.proposedToIsYou
20
+ || result.payerIdIsYou || result.createdByIsYou
21
+ || result.executorId === ownAgentId || result.agentId === ownAgentId
22
+ || result.payerId === ownAgentId || result.proposedTo === ownAgentId
23
+ || result.createdBy === ownAgentId || result.proposedTo === 'everyone'
24
+ );
25
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Classifies incoming events (routing states) into flags merged into machine context.
3
+ */
4
+
5
+ import { CONTEXT_RESET } from './applyEffects.js';
6
+ import { getBatchEvents, isTaskResultRelevantToAgent } from './batch.js';
7
+
8
+ function isTaskAssignment(result, ownAgentId) {
9
+ return (
10
+ (result.executorIdIsYou || result.executorId === ownAgentId)
11
+ && (result.state === 'active' || result.state === 'in-progress')
12
+ );
13
+ }
14
+
15
+ export function classifyIncomingEvent(rawEvent, ownAgentId) {
16
+ const flags = { ...CONTEXT_RESET };
17
+
18
+ if (!rawEvent) return flags;
19
+
20
+ const events = getBatchEvents(rawEvent);
21
+
22
+ for (const ev of events) {
23
+ if (!ev) continue;
24
+
25
+ if (ev.type === 'task_result') {
26
+ const result = ev.result || {};
27
+ if (
28
+ ownAgentId &&
29
+ !isTaskResultRelevantToAgent(result, ownAgentId) &&
30
+ ev.receiverId !== ownAgentId
31
+ ) {
32
+ continue;
33
+ }
34
+
35
+ const state = result.state;
36
+ const proposalStatus = result.proposal?.status;
37
+
38
+ // User approved your orchestration proposal: you remain agentId + executorId on the task.
39
+ // Keep this before isTaskAssignment so we still classify if IsYou flags were computed for another viewer.
40
+ if (
41
+ state === 'active' &&
42
+ proposalStatus === 'approved' &&
43
+ ownAgentId &&
44
+ result.agentId === ownAgentId
45
+ ) {
46
+ flags.approval = true;
47
+ flags.taskAssignment = result;
48
+ continue;
49
+ }
50
+
51
+ if (isTaskAssignment(result, ownAgentId)) {
52
+ flags.taskAssignment = result;
53
+ flags.approval = true;
54
+ continue;
55
+ }
56
+ if (proposalStatus === 'approved' || (state === 'active' && proposalStatus === 'pending')) {
57
+ flags.approval = true;
58
+ flags.taskAssignment = result;
59
+ continue;
60
+ }
61
+ if (proposalStatus === 'rejected' || state === 'cancelled') {
62
+ flags.rejection = true;
63
+ continue;
64
+ }
65
+ if (state === 'completed') {
66
+ flags.subtaskResult = result;
67
+ continue;
68
+ }
69
+ if (state === 'failed') {
70
+ flags.subtaskResult = result;
71
+ flags.subtaskFailed = true;
72
+ continue;
73
+ }
74
+ flags.subtaskResult = result;
75
+ continue;
76
+ }
77
+
78
+ flags.incomingMessage = true;
79
+ }
80
+
81
+ return flags;
82
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Higher-level workflow labels derived from batched incoming events.
3
+ */
4
+
5
+ import { isTaskProtocolToolName } from '../tasks/taskProtocolRegistry.js';
6
+ import { getBatchEvents, isTaskResultRelevantToAgent } from './batch.js';
7
+
8
+ export function unwrapBatchEvent(event) {
9
+ if (event?.type === 'batch' && event.events?.[0]) return event.events[0];
10
+ return event;
11
+ }
12
+
13
+ export function classifyWorkflowEvent(event, ownAgentId = null) {
14
+ const events = getBatchEvents(event);
15
+ if (events.length === 0) return 'message';
16
+
17
+ let sawTaskResult = false;
18
+ let sawOwnTaskResult = false;
19
+
20
+ for (const ev of events) {
21
+ if (ev?.type !== 'task_result') continue;
22
+ sawTaskResult = true;
23
+
24
+ const result = ev.result || {};
25
+ if (!isTaskResultRelevantToAgent(result, ownAgentId)) continue;
26
+ sawOwnTaskResult = true;
27
+
28
+ const state = result.state;
29
+ const proposalStatus = result.proposal?.status;
30
+
31
+ if (
32
+ (result.executorIdIsYou || (!result.executorIdIsYou && result.executorId === ownAgentId))
33
+ && (state === 'active' || state === 'in-progress')
34
+ ) {
35
+ return 'task_assignment';
36
+ }
37
+ if (proposalStatus === 'approved' || (state === 'active' && proposalStatus === 'pending')) return 'task_approved';
38
+ if (proposalStatus === 'rejected' || state === 'cancelled') return 'task_rejected';
39
+ }
40
+
41
+ if (sawOwnTaskResult || sawTaskResult) return 'task_update';
42
+ return 'message';
43
+ }
44
+
45
+ export function findTaskResult(output) {
46
+ return output?.emittedEvents?.find(e =>
47
+ e.type === 'task_result' || (e.type === 'tool_result' && isTaskProtocolToolName(e.tool))
48
+ ) ?? null;
49
+ }
50
+
51
+ export function findIncomingTaskResult(incomingEvent) {
52
+ const events = getBatchEvents(incomingEvent);
53
+ return events.find(e => e?.type === 'task_result') ?? null;
54
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * createHealthServer — minimal HTTP server for platform health checks.
3
+ *
4
+ * Previously this file also served a wake webhook; wake events now travel over
5
+ * the launcher's control WebSocket (see ConnectionPool.startControl), so this
6
+ * server only answers 200 OK for anything. Keep it for Cloud Run-style
7
+ * platforms that require a listening HTTP port on the launcher container.
8
+ *
9
+ * Usage:
10
+ * createHealthServer({ label: 'launcher' });
11
+ */
12
+
13
+ import { createServer } from 'http';
14
+
15
+ /**
16
+ * @param {object} [opts]
17
+ * @param {number} [opts.port] - defaults to process.env.PORT || 8080
18
+ * @param {string} [opts.label] - log prefix
19
+ * @returns {import('http').Server}
20
+ */
21
+ export function createHealthServer({ port = process.env.PORT || 8080, label = 'agents' } = {}) {
22
+ const server = createServer((_req, res) => {
23
+ res.writeHead(200);
24
+ res.end('ok');
25
+ });
26
+ server.listen(port, () => console.log(`[${label}] health server on port ${port}`));
27
+ return server;
28
+ }
@@ -0,0 +1,257 @@
1
+ /**
2
+ * HistoryFormatter - Pluggable formatter for converting raw MongoDB history data to readable text.
3
+ *
4
+ * This formatter is external and pluggable - can be swapped with custom implementations
5
+ * by passing a different formatter to PromptBuilder.
6
+ */
7
+ export class HistoryFormatter {
8
+ constructor(options = {}) {
9
+ this.options = options;
10
+ this.showTimestamps = options.showTimestamps ?? true;
11
+ this.shortIds = options.shortIds ?? false; // Always show full IDs by default
12
+ }
13
+
14
+ /**
15
+ * Format an array of history entries into readable text
16
+ * @param {Array} history - Array of history entries from context
17
+ * @param {string} agentId - The current agent's ID (for "You" labels)
18
+ * @param {Object} options - Optional: { mode } - when mode is 'mission', prefixes each line with [CHAT], [TOOL], or [TASK]
19
+ * @returns {string} Formatted history text
20
+ */
21
+ format(history, agentId, options = {}) {
22
+ if (!history?.length) return 'No previous activity.';
23
+
24
+ const labelTypes = options.mode === 'mission';
25
+ const sorted = [...history].sort((a, b) => {
26
+ const tA = a.timestamp ? new Date(a.timestamp).getTime() : 0;
27
+ const tB = b.timestamp ? new Date(b.timestamp).getTime() : 0;
28
+ return tA - tB;
29
+ });
30
+ return sorted.map(entry => this.formatEntry(entry, agentId, labelTypes)).join('\n');
31
+ }
32
+
33
+ /**
34
+ * Format a single history entry based on its type
35
+ * @param {boolean} labelTypes - When true, prefix with [CHAT], [TOOL], or [TASK]
36
+ */
37
+ formatEntry(entry, agentId, labelTypes = false) {
38
+ const timestamp = this.showTimestamps ? this.formatTime(entry.timestamp) : '';
39
+ const timePrefix = timestamp ? `[${timestamp}] ` : '';
40
+ let typeLabel = '';
41
+
42
+ // Message entries
43
+ if (entry.entryType === 'message' || !entry.entryType) {
44
+ const line = this.formatMessage(entry, agentId, timePrefix);
45
+ typeLabel = labelTypes ? '[CHAT] ' : '';
46
+ return `${typeLabel}${line}`;
47
+ }
48
+
49
+ // Task history entries
50
+ if (entry.entryType === 'task_history') {
51
+ const line = this.formatTaskHistory(entry, agentId, timePrefix);
52
+ typeLabel = labelTypes ? '[TASK] ' : '';
53
+ return `${typeLabel}${line}`;
54
+ }
55
+
56
+ // Artifact entries (operation tracking, or agent thought)
57
+ if (entry.entryType === 'artifact') {
58
+ const line = this.formatArtifact(entry, agentId, timePrefix);
59
+ if (labelTypes) {
60
+ if (entry.content_type === 'thought') {
61
+ typeLabel = '[THOUGHT] ';
62
+ } else {
63
+ const text = entry.text || '';
64
+ const json = text.replace(/^operation_(?:started|completed|error):/, '');
65
+ const data = json ? this.safeParseJSON(json) : null;
66
+ typeLabel = (data?.type === 'tool' || text.includes('operation_error')) ? '[TOOL] ' : '[TASK] ';
67
+ }
68
+ }
69
+ return `${typeLabel}${line}`;
70
+ }
71
+
72
+ return `${timePrefix}${entry.text || 'Unknown entry'}`;
73
+ }
74
+
75
+ /**
76
+ * Format a message entry
77
+ */
78
+ formatMessage(entry, agentId, timePrefix) {
79
+ const from = this.formatParticipant(entry.sender, agentId);
80
+ const to = entry.receiver ? ` → ${this.formatParticipant(entry.receiver, agentId)}` : '';
81
+ const text = entry.text || '';
82
+
83
+ return `${timePrefix}${from}${to}: ${text}`;
84
+ }
85
+
86
+ /**
87
+ * Format a task history entry (created, state_changed, proposal_responded, etc.)
88
+ */
89
+ formatTaskHistory(entry, agentId, timePrefix) {
90
+ const task = entry.service?.task;
91
+ if (!task) return `${timePrefix}Task update`;
92
+
93
+ const changeType = entry.service?.changeType;
94
+ const taskId = this.shortIds ? this.shortId(task.taskId) : task.taskId;
95
+ const desc = task.description || 'No description';
96
+
97
+ switch (changeType) {
98
+ case 'created':
99
+ return `${timePrefix}Task created: "${desc}" (${taskId})
100
+ Owner: ${this.formatOwner(task, agentId)}
101
+ Executor: ${this.formatExecutor(task, agentId)}
102
+ Proposed to: ${task.proposal?.proposedTo || 'N/A'}`;
103
+
104
+ case 'state_changed': {
105
+ const oldState = entry.service?.previousState?.state || '?';
106
+ const newState = entry.service?.newState?.state || '?';
107
+ return `${timePrefix}Task "${desc}" (${taskId}): ${oldState} → ${newState}`;
108
+ }
109
+
110
+ case 'proposal_responded': {
111
+ const action = entry.service?.metadata?.action;
112
+ return `${timePrefix}Proposal ${action}: "${desc}" (${taskId})`;
113
+ }
114
+
115
+ case 'processing_changed': {
116
+ const processing = entry.service?.newState?.processing;
117
+ return `${timePrefix}Task "${desc}" (${taskId}): processing ${processing ? 'started' : 'stopped'}`;
118
+ }
119
+
120
+ default:
121
+ return `${timePrefix}Task ${changeType}: "${desc}" (${taskId})`;
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Format an artifact entry (operation tracking, or agent thought)
127
+ */
128
+ formatArtifact(entry, agentId, timePrefix) {
129
+ const text = entry.text || '';
130
+
131
+ // Agent thought (chain-of-thought reasoning)
132
+ if (entry.content_type === 'thought') {
133
+ return `${timePrefix}Thought: ${text}`;
134
+ }
135
+
136
+ // Parse and simplify operation_started messages
137
+ if (text.startsWith('operation_started:')) {
138
+ const data = this.safeParseJSON(text.replace('operation_started:', ''));
139
+ if (data?.type === 'tool') {
140
+ const argsStr = this.formatArgs(data.args);
141
+ return `${timePrefix}Tool starting: ${data.tool}(${argsStr})`;
142
+ }
143
+ if (data?.type === 'task') {
144
+ return `${timePrefix}Task operation: ${data.operation}`;
145
+ }
146
+ return `${timePrefix}Operation started: ${data?.type || 'unknown'}`;
147
+ }
148
+
149
+ // Parse and simplify operation_completed messages
150
+ if (text.startsWith('operation_completed:')) {
151
+ const data = this.safeParseJSON(text.replace('operation_completed:', ''));
152
+ if (data?.type === 'tool') {
153
+ return `${timePrefix}Tool completed: ${data.tool}`;
154
+ }
155
+ if (data?.type === 'task') {
156
+ return `${timePrefix}Task operation completed: ${data.operation}`;
157
+ }
158
+ return `${timePrefix}Operation completed: ${data?.type || 'unknown'}`;
159
+ }
160
+
161
+ // Parse and simplify operation_error messages
162
+ if (text.startsWith('operation_error:')) {
163
+ const data = this.safeParseJSON(text.replace('operation_error:', ''));
164
+ return `${timePrefix}ERROR: ${data?.error || 'Unknown error'}`;
165
+ }
166
+
167
+ // Default: return text as-is
168
+ return `${timePrefix}${text}`;
169
+ }
170
+
171
+ /**
172
+ * Format a participant (sender/receiver) with "You" label if applicable
173
+ */
174
+ formatParticipant(participant, agentId) {
175
+ if (!participant) return 'unknown';
176
+ const id = participant.id || 'unknown';
177
+ const type = participant.type || 'unknown';
178
+ const isYou = id === agentId;
179
+
180
+ if (type === 'system') return 'System';
181
+ if (isYou) return 'You';
182
+ return `${id} (${type})`;
183
+ }
184
+
185
+ /**
186
+ * Format task owner with "You" label if applicable
187
+ */
188
+ formatOwner(task, agentId) {
189
+ return task.agentId === agentId ? 'You' : task.agentId;
190
+ }
191
+
192
+ /**
193
+ * Format task executor with "You" label if applicable
194
+ */
195
+ formatExecutor(task, agentId) {
196
+ return task.executorId === agentId ? 'You' : task.executorId;
197
+ }
198
+
199
+ /**
200
+ * Return full task ID (or shortened if shortIds option is enabled)
201
+ */
202
+ shortId(taskId) {
203
+ if (!taskId) return '?';
204
+
205
+ // If shortIds is disabled, always return full ID
206
+ if (!this.shortIds) return taskId;
207
+
208
+ // Otherwise, shorten it
209
+ const parts = taskId.split('_');
210
+ if (parts.length >= 3) {
211
+ return `task_...${parts[parts.length - 1]}`;
212
+ }
213
+ return taskId.length > 20 ? taskId.slice(0, 20) + '...' : taskId;
214
+ }
215
+
216
+ /**
217
+ * Format timestamp as readable time
218
+ */
219
+ formatTime(timestamp) {
220
+ if (!timestamp) return '??:??';
221
+ const date = new Date(timestamp);
222
+ if (isNaN(date.getTime())) return '??:??';
223
+ return date.toLocaleTimeString('en-US', {
224
+ hour: '2-digit',
225
+ minute: '2-digit',
226
+ second: '2-digit',
227
+ hour12: false
228
+ });
229
+ }
230
+
231
+ /**
232
+ * Format args object for display
233
+ */
234
+ formatArgs(args) {
235
+ if (!args || typeof args !== 'object') return '';
236
+ const entries = Object.entries(args);
237
+ if (entries.length === 0) return '';
238
+ if (entries.length <= 3) {
239
+ return entries.map(([k, v]) => `${k}=${JSON.stringify(v)}`).join(', ');
240
+ }
241
+ return JSON.stringify(args);
242
+ }
243
+
244
+ /**
245
+ * Safely parse JSON, returning null on failure
246
+ */
247
+ safeParseJSON(str) {
248
+ try {
249
+ return JSON.parse(str);
250
+ } catch {
251
+ return null;
252
+ }
253
+ }
254
+ }
255
+
256
+ // Default instance for easy import
257
+ export const historyFormatter = new HistoryFormatter();