macro-agent 0.0.15 → 0.0.17
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/dist/acp/index.d.ts +1 -1
- package/dist/acp/index.d.ts.map +1 -1
- package/dist/acp/index.js.map +1 -1
- package/dist/acp/macro-agent.d.ts +21 -0
- package/dist/acp/macro-agent.d.ts.map +1 -1
- package/dist/acp/macro-agent.js +182 -0
- package/dist/acp/macro-agent.js.map +1 -1
- package/dist/acp/types.d.ts +31 -2
- package/dist/acp/types.d.ts.map +1 -1
- package/dist/acp/types.js.map +1 -1
- package/dist/agent/agent-manager.d.ts.map +1 -1
- package/dist/agent/agent-manager.js +23 -5
- package/dist/agent/agent-manager.js.map +1 -1
- package/dist/map/adapter/acp-over-map.d.ts +15 -0
- package/dist/map/adapter/acp-over-map.d.ts.map +1 -1
- package/dist/map/adapter/acp-over-map.js +204 -9
- package/dist/map/adapter/acp-over-map.js.map +1 -1
- package/dist/store/event-store.d.ts.map +1 -1
- package/dist/store/event-store.js +92 -53
- package/dist/store/event-store.js.map +1 -1
- package/dist/store/instance.d.ts +0 -2
- package/dist/store/instance.d.ts.map +1 -1
- package/dist/store/instance.js +1 -24
- package/dist/store/instance.js.map +1 -1
- package/package.json +3 -3
- package/references/acp-factory-ref/package-lock.json +2 -2
- package/references/acp-factory-ref/package.json +2 -2
- package/references/claude-code-acp/package-lock.json +2 -2
- package/references/claude-code-acp/package.json +1 -1
- package/references/claude-code-acp/src/acp-agent.ts +3 -6
- package/src/acp/__tests__/history.test.ts +526 -0
- package/src/acp/__tests__/integration.test.ts +2 -1
- package/src/acp/index.ts +4 -0
- package/src/acp/macro-agent.ts +329 -85
- package/src/acp/types.ts +39 -2
- package/src/agent/__tests__/agent-manager.test.ts +4 -6
- package/src/agent/agent-manager.ts +24 -5
- package/src/map/adapter/__tests__/acp-over-map-history.test.ts +664 -0
- package/src/map/adapter/__tests__/acp-over-map-persistence.e2e.test.ts +440 -0
- package/src/map/adapter/acp-over-map.ts +246 -7
- package/src/store/__tests__/event-store.test.ts +4 -12
- package/src/store/__tests__/instance.test.ts +5 -7
- package/src/store/event-store.ts +115 -57
- package/src/store/instance.ts +1 -29
|
@@ -117,11 +117,11 @@ export class ACPOverMAPHandler {
|
|
|
117
117
|
break;
|
|
118
118
|
|
|
119
119
|
case "session/new":
|
|
120
|
-
result = await this.handleNewSession(streamState, acp.params);
|
|
120
|
+
result = await this.handleNewSession(streamState, acp.params, emitNotification);
|
|
121
121
|
break;
|
|
122
122
|
|
|
123
123
|
case "session/load":
|
|
124
|
-
result = await this.handleLoadSession(streamState, acp.params);
|
|
124
|
+
result = await this.handleLoadSession(streamState, acp.params, emitNotification);
|
|
125
125
|
break;
|
|
126
126
|
|
|
127
127
|
case "authenticate":
|
|
@@ -201,6 +201,7 @@ export class ACPOverMAPHandler {
|
|
|
201
201
|
private async handleNewSession(
|
|
202
202
|
streamState: StreamState,
|
|
203
203
|
params: unknown,
|
|
204
|
+
emitNotification?: ACPNotificationEmitter,
|
|
204
205
|
): Promise<unknown> {
|
|
205
206
|
if (!streamState.initialized) {
|
|
206
207
|
throw new Error("Must call initialize before newSession");
|
|
@@ -224,12 +225,16 @@ export class ACPOverMAPHandler {
|
|
|
224
225
|
|
|
225
226
|
console.error(`[ACP-over-MAP] Created session ${sessionId} -> agent ${spawned.id}`);
|
|
226
227
|
|
|
228
|
+
// Emit session_info_update so client has title/timestamps
|
|
229
|
+
this.emitSessionInfo(streamState, sessionId, emitNotification);
|
|
230
|
+
|
|
227
231
|
return { sessionId };
|
|
228
232
|
}
|
|
229
233
|
|
|
230
234
|
private async handleLoadSession(
|
|
231
235
|
streamState: StreamState,
|
|
232
236
|
params: unknown,
|
|
237
|
+
emitNotification?: ACPNotificationEmitter,
|
|
233
238
|
): Promise<unknown> {
|
|
234
239
|
if (!streamState.initialized) {
|
|
235
240
|
throw new Error("Must call initialize before loadSession");
|
|
@@ -271,7 +276,8 @@ export class ACPOverMAPHandler {
|
|
|
271
276
|
streamState.sessionId = sessionId;
|
|
272
277
|
streamState.agentId = existing.id;
|
|
273
278
|
this.sessionMapper.createMapping(sessionId as ACPSessionId, existing.id);
|
|
274
|
-
|
|
279
|
+
this.emitSessionInfo(streamState, sessionId, emitNotification);
|
|
280
|
+
return { sessionId };
|
|
275
281
|
}
|
|
276
282
|
|
|
277
283
|
// Agent exists but no active session - resume it
|
|
@@ -280,7 +286,8 @@ export class ACPOverMAPHandler {
|
|
|
280
286
|
streamState.sessionId = sessionId;
|
|
281
287
|
streamState.agentId = spawned.id;
|
|
282
288
|
this.sessionMapper.createMapping(sessionId as ACPSessionId, spawned.id);
|
|
283
|
-
|
|
289
|
+
this.emitSessionInfo(streamState, sessionId, emitNotification);
|
|
290
|
+
return { sessionId };
|
|
284
291
|
}
|
|
285
292
|
|
|
286
293
|
// No existing agent found - create new with the specified session ID
|
|
@@ -293,8 +300,9 @@ export class ACPOverMAPHandler {
|
|
|
293
300
|
streamState.sessionId = sessionId;
|
|
294
301
|
streamState.agentId = spawned.id;
|
|
295
302
|
this.sessionMapper.createMapping(sessionId as ACPSessionId, spawned.id);
|
|
303
|
+
this.emitSessionInfo(streamState, sessionId, emitNotification);
|
|
296
304
|
|
|
297
|
-
return {};
|
|
305
|
+
return { sessionId };
|
|
298
306
|
}
|
|
299
307
|
|
|
300
308
|
private async handleAuthenticate(_params: unknown): Promise<unknown> {
|
|
@@ -314,7 +322,9 @@ export class ACPOverMAPHandler {
|
|
|
314
322
|
messages?: Array<{ role: string; content: string }>;
|
|
315
323
|
}) ?? {};
|
|
316
324
|
|
|
317
|
-
|
|
325
|
+
// Prefer the server's resolved session ID (set during loadSession) over the
|
|
326
|
+
// client's acpContext.sessionId which may be stale (e.g., "_resolve_" sentinel)
|
|
327
|
+
const sessionId = streamState.sessionId ?? paramSessionId ?? sessionIdFromContext;
|
|
318
328
|
if (!sessionId) {
|
|
319
329
|
throw new Error("No session - call newSession or loadSession first");
|
|
320
330
|
}
|
|
@@ -372,6 +382,15 @@ export class ACPOverMAPHandler {
|
|
|
372
382
|
emitNotification(notification);
|
|
373
383
|
};
|
|
374
384
|
|
|
385
|
+
// Ensure conversation exists in EventStore for history persistence
|
|
386
|
+
this.ensureConversation(sessionId as ACPSessionId, agentId);
|
|
387
|
+
|
|
388
|
+
// Accumulate response content for history recording
|
|
389
|
+
const buffer: { textChunks: string[]; toolCalls: Record<string, unknown>[] } = {
|
|
390
|
+
textChunks: [],
|
|
391
|
+
toolCalls: [],
|
|
392
|
+
};
|
|
393
|
+
|
|
375
394
|
try {
|
|
376
395
|
// Stream responses from the agent
|
|
377
396
|
let updateCount = 0;
|
|
@@ -381,6 +400,27 @@ export class ACPOverMAPHandler {
|
|
|
381
400
|
return { stopReason: "cancelled" };
|
|
382
401
|
}
|
|
383
402
|
|
|
403
|
+
// Accumulate content for history persistence
|
|
404
|
+
const u = update as Record<string, unknown>;
|
|
405
|
+
const updateType = u.sessionUpdate as string ?? u.type as string;
|
|
406
|
+
if (updateType === "agent_message_chunk") {
|
|
407
|
+
const content = u.content as { type?: string; text?: string } | undefined;
|
|
408
|
+
if (content?.text) {
|
|
409
|
+
buffer.textChunks.push(content.text);
|
|
410
|
+
}
|
|
411
|
+
} else if (updateType === "tool_call" || updateType === "tool_call_update") {
|
|
412
|
+
const status = u.status as string | undefined;
|
|
413
|
+
if (status === "completed" || (updateType === "tool_call" && status !== "running")) {
|
|
414
|
+
buffer.toolCalls.push({
|
|
415
|
+
toolCallId: u.toolCallId,
|
|
416
|
+
title: u.title,
|
|
417
|
+
status: u.status,
|
|
418
|
+
input: u.rawInput,
|
|
419
|
+
output: u.output,
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
384
424
|
// Stream the update back to the client
|
|
385
425
|
emitSessionUpdate(update);
|
|
386
426
|
updateCount++;
|
|
@@ -388,6 +428,12 @@ export class ACPOverMAPHandler {
|
|
|
388
428
|
|
|
389
429
|
console.error(`[ACP-over-MAP] Prompt completed for agent ${agentId}, ${updateCount} updates`);
|
|
390
430
|
|
|
431
|
+
// Persist conversation turns for history
|
|
432
|
+
this.recordPromptTurns(sessionId as ACPSessionId, agentId, messageContent, buffer);
|
|
433
|
+
|
|
434
|
+
// Emit updated session info after prompt completes
|
|
435
|
+
this.emitSessionInfo(streamState, sessionId, emitNotification);
|
|
436
|
+
|
|
391
437
|
return { stopReason: "end_turn" };
|
|
392
438
|
} catch (error) {
|
|
393
439
|
console.error(`[ACP-over-MAP] Prompt error:`, error);
|
|
@@ -406,7 +452,8 @@ export class ACPOverMAPHandler {
|
|
|
406
452
|
sessionIdFromContext?: string,
|
|
407
453
|
): Promise<unknown> {
|
|
408
454
|
const { sessionId: paramSessionId } = (params as { sessionId?: string }) ?? {};
|
|
409
|
-
|
|
455
|
+
// Prefer server's resolved session ID over client's potentially stale one
|
|
456
|
+
const sessionId = streamState.sessionId ?? paramSessionId ?? sessionIdFromContext;
|
|
410
457
|
|
|
411
458
|
// Signal cancellation
|
|
412
459
|
streamState.abortController.abort();
|
|
@@ -527,11 +574,203 @@ export class ACPOverMAPHandler {
|
|
|
527
574
|
};
|
|
528
575
|
}
|
|
529
576
|
|
|
577
|
+
case "_macro/getHistory": {
|
|
578
|
+
const { sessionId, agentId: historyAgentId, limit } = methodParams as {
|
|
579
|
+
sessionId?: string;
|
|
580
|
+
agentId?: string;
|
|
581
|
+
limit?: number;
|
|
582
|
+
};
|
|
583
|
+
|
|
584
|
+
// Resolve conversationId: prefer agentId lookup (resolves to the
|
|
585
|
+
// original session_id where turns were recorded), fall back to
|
|
586
|
+
// explicit sessionId. This allows history to survive across server
|
|
587
|
+
// restarts even when the ACP session ID changes (e.g., resume()
|
|
588
|
+
// fails → TUI creates new session with different ID).
|
|
589
|
+
let conversationId: string | undefined;
|
|
590
|
+
if (historyAgentId) {
|
|
591
|
+
const agent = this.eventStore.getAgent(historyAgentId as AgentId);
|
|
592
|
+
if (agent) {
|
|
593
|
+
conversationId = agent.session_id;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
if (!conversationId) {
|
|
597
|
+
conversationId = sessionId;
|
|
598
|
+
}
|
|
599
|
+
if (!conversationId) {
|
|
600
|
+
return { turns: [] };
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const turns = this.eventStore.listTurns({
|
|
604
|
+
conversationId,
|
|
605
|
+
order: "asc",
|
|
606
|
+
limit: limit ?? 200,
|
|
607
|
+
});
|
|
608
|
+
return {
|
|
609
|
+
turns: turns.map((turn) => ({
|
|
610
|
+
role:
|
|
611
|
+
turn.contentType === "user_prompt"
|
|
612
|
+
? ("user" as const)
|
|
613
|
+
: ("assistant" as const),
|
|
614
|
+
timestamp: turn.timestamp,
|
|
615
|
+
content: turn.content,
|
|
616
|
+
})),
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
|
|
530
620
|
default:
|
|
531
621
|
throw new Error(`Unknown extension method: ${method}`);
|
|
532
622
|
}
|
|
533
623
|
}
|
|
534
624
|
|
|
625
|
+
// ─────────────────────────────────────────────────────────────────
|
|
626
|
+
// History Persistence
|
|
627
|
+
// ─────────────────────────────────────────────────────────────────
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* Ensure a conversation exists in the EventStore for a given session.
|
|
631
|
+
* This must be called before recording turns.
|
|
632
|
+
*/
|
|
633
|
+
private ensureConversation(
|
|
634
|
+
acpSessionId: ACPSessionId,
|
|
635
|
+
agentId: AgentId,
|
|
636
|
+
): void {
|
|
637
|
+
if (typeof this.eventStore.getConversation !== "function") {
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
const existing = this.eventStore.getConversation(acpSessionId);
|
|
642
|
+
if (existing) return;
|
|
643
|
+
|
|
644
|
+
try {
|
|
645
|
+
this.eventStore.emit({
|
|
646
|
+
type: "conversation",
|
|
647
|
+
source: { agent_id: agentId },
|
|
648
|
+
payload: {
|
|
649
|
+
action: "created",
|
|
650
|
+
conversation_id: acpSessionId,
|
|
651
|
+
conversation_type: "session",
|
|
652
|
+
subject: `ACP session ${acpSessionId}`,
|
|
653
|
+
},
|
|
654
|
+
});
|
|
655
|
+
} catch (error) {
|
|
656
|
+
console.warn(
|
|
657
|
+
`[ACP-over-MAP] Failed to create conversation for session ${acpSessionId}:`,
|
|
658
|
+
error instanceof Error ? error.message : String(error),
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Record user and assistant turns after a prompt completes.
|
|
665
|
+
* Mirrors MacroAgent.recordPromptTurns() for the ACP-over-MAP path.
|
|
666
|
+
*/
|
|
667
|
+
private recordPromptTurns(
|
|
668
|
+
acpSessionId: ACPSessionId,
|
|
669
|
+
agentId: AgentId,
|
|
670
|
+
userMessage: string,
|
|
671
|
+
buffer: { textChunks: string[]; toolCalls: Record<string, unknown>[] },
|
|
672
|
+
): void {
|
|
673
|
+
const now = Date.now();
|
|
674
|
+
|
|
675
|
+
try {
|
|
676
|
+
// Record user turn
|
|
677
|
+
if (userMessage) {
|
|
678
|
+
this.eventStore.emit({
|
|
679
|
+
type: "turn",
|
|
680
|
+
source: { agent_id: agentId },
|
|
681
|
+
payload: {
|
|
682
|
+
action: "recorded",
|
|
683
|
+
turn_id: `turn_user_${now}_${Math.random().toString(36).slice(2, 8)}`,
|
|
684
|
+
conversation_id: acpSessionId,
|
|
685
|
+
participant: "user",
|
|
686
|
+
timestamp: now,
|
|
687
|
+
content_type: "user_prompt",
|
|
688
|
+
content: userMessage,
|
|
689
|
+
source_type: "explicit",
|
|
690
|
+
},
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// Record assistant turn with accumulated content
|
|
695
|
+
const assistantText = buffer.textChunks.join("");
|
|
696
|
+
const parts: unknown[] = [];
|
|
697
|
+
|
|
698
|
+
if (assistantText) {
|
|
699
|
+
parts.push({ type: "text", text: assistantText });
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
for (const tool of buffer.toolCalls) {
|
|
703
|
+
parts.push({ type: "tool", ...tool });
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
if (parts.length > 0) {
|
|
707
|
+
this.eventStore.emit({
|
|
708
|
+
type: "turn",
|
|
709
|
+
source: { agent_id: agentId },
|
|
710
|
+
payload: {
|
|
711
|
+
action: "recorded",
|
|
712
|
+
turn_id: `turn_asst_${now}_${Math.random().toString(36).slice(2, 8)}`,
|
|
713
|
+
conversation_id: acpSessionId,
|
|
714
|
+
participant: agentId,
|
|
715
|
+
timestamp: now + 1,
|
|
716
|
+
content_type: "assistant_response",
|
|
717
|
+
content: { parts },
|
|
718
|
+
source_type: "explicit",
|
|
719
|
+
},
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
} catch (error) {
|
|
723
|
+
console.warn(
|
|
724
|
+
`[ACP-over-MAP] Failed to record turns for session ${acpSessionId}:`,
|
|
725
|
+
error instanceof Error ? error.message : String(error),
|
|
726
|
+
);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// ─────────────────────────────────────────────────────────────────
|
|
731
|
+
// Session Info
|
|
732
|
+
// ─────────────────────────────────────────────────────────────────
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* Emit a session_info_update notification with title and timestamps.
|
|
736
|
+
* Uses agent task as title and session mapping timestamps.
|
|
737
|
+
*/
|
|
738
|
+
private emitSessionInfo(
|
|
739
|
+
streamState: StreamState,
|
|
740
|
+
sessionId: string,
|
|
741
|
+
emitNotification?: ACPNotificationEmitter,
|
|
742
|
+
): void {
|
|
743
|
+
if (!emitNotification) return;
|
|
744
|
+
|
|
745
|
+
const mapping = this.sessionMapper.getMapping(sessionId as ACPSessionId);
|
|
746
|
+
const agentId = streamState.agentId;
|
|
747
|
+
const agent = agentId ? this.eventStore.getAgent(agentId as AgentId) : null;
|
|
748
|
+
|
|
749
|
+
const title = agent?.task ?? null;
|
|
750
|
+
const updatedAt = new Date(mapping?.updatedAt ?? Date.now()).toISOString();
|
|
751
|
+
|
|
752
|
+
const notification: ACPEnvelope = {
|
|
753
|
+
acp: {
|
|
754
|
+
jsonrpc: "2.0",
|
|
755
|
+
method: "session/update",
|
|
756
|
+
params: {
|
|
757
|
+
sessionId,
|
|
758
|
+
update: {
|
|
759
|
+
sessionUpdate: "session_info_update",
|
|
760
|
+
title,
|
|
761
|
+
updatedAt,
|
|
762
|
+
},
|
|
763
|
+
},
|
|
764
|
+
},
|
|
765
|
+
acpContext: {
|
|
766
|
+
streamId: streamState.streamId,
|
|
767
|
+
sessionId,
|
|
768
|
+
direction: "agent-to-client",
|
|
769
|
+
},
|
|
770
|
+
};
|
|
771
|
+
emitNotification(notification);
|
|
772
|
+
}
|
|
773
|
+
|
|
535
774
|
// ─────────────────────────────────────────────────────────────────
|
|
536
775
|
// Cleanup
|
|
537
776
|
// ─────────────────────────────────────────────────────────────────
|
|
@@ -674,8 +674,7 @@ describe('Event Archival', () => {
|
|
|
674
674
|
beforeEach(async () => {
|
|
675
675
|
// Create a temporary directory for testing
|
|
676
676
|
testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'event-store-test-'));
|
|
677
|
-
|
|
678
|
-
store = await createEventStore({ path: storePath });
|
|
677
|
+
store = await createEventStore({ baseDir: testDir, instanceId: 'archive-test' });
|
|
679
678
|
});
|
|
680
679
|
|
|
681
680
|
afterEach(async () => {
|
|
@@ -987,18 +986,11 @@ describe('Event Archival', () => {
|
|
|
987
986
|
}
|
|
988
987
|
});
|
|
989
988
|
|
|
990
|
-
it('should
|
|
989
|
+
it('should throw when using removed legacy path option', async () => {
|
|
991
990
|
const legacyPath = path.join(testDir, 'legacy-store.json');
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
|
995
|
-
expect.stringContaining('DEPRECATION WARNING')
|
|
996
|
-
);
|
|
997
|
-
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
|
998
|
-
expect.stringContaining('path` option is deprecated')
|
|
991
|
+
await expect(createEventStore({ path: legacyPath })).rejects.toThrow(
|
|
992
|
+
'The `path` option has been removed'
|
|
999
993
|
);
|
|
1000
|
-
|
|
1001
|
-
await legacyStore.close();
|
|
1002
994
|
});
|
|
1003
995
|
|
|
1004
996
|
it('should not emit deprecation warning for new-style configuration', async () => {
|
|
@@ -90,7 +90,6 @@ describe('Path Resolution', () => {
|
|
|
90
90
|
|
|
91
91
|
expect(resolved.instancePath).toBe(':memory:');
|
|
92
92
|
expect(resolved.isNew).toBe(true);
|
|
93
|
-
expect(resolved.isLegacy).toBe(false);
|
|
94
93
|
expect(resolved.backendType).toBe('memory');
|
|
95
94
|
expect(resolved.instanceId).toMatch(/^inst_/);
|
|
96
95
|
});
|
|
@@ -142,15 +141,14 @@ describe('Path Resolution', () => {
|
|
|
142
141
|
expect(resolved.namespace).toBe('my-project');
|
|
143
142
|
});
|
|
144
143
|
|
|
145
|
-
it('should
|
|
144
|
+
it('should ignore legacy path option and use defaults', () => {
|
|
146
145
|
const legacyPath = path.join(testBaseDir, 'legacy-store.json');
|
|
147
|
-
const config: StoreConfig = { path: legacyPath };
|
|
146
|
+
const config: StoreConfig = { path: legacyPath, baseDir: testBaseDir };
|
|
148
147
|
const resolved = resolveInstancePath(config);
|
|
149
148
|
|
|
150
|
-
|
|
151
|
-
expect(resolved.backendType).toBe(
|
|
152
|
-
expect(resolved.
|
|
153
|
-
expect(resolved.instanceId).toBe('legacy-store');
|
|
149
|
+
// path option is no longer handled — resolveInstancePath ignores it
|
|
150
|
+
expect(resolved.backendType).toBe(DEFAULT_BACKEND_TYPE);
|
|
151
|
+
expect(resolved.instanceId).toMatch(/^inst_/);
|
|
154
152
|
});
|
|
155
153
|
|
|
156
154
|
it('should throw on invalid instance ID', () => {
|
package/src/store/event-store.ts
CHANGED
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { createStore, Store } from 'tinybase';
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
12
|
+
import { createCustomPersister, createCustomSqlitePersister } from 'tinybase/persisters';
|
|
13
|
+
import type { DatabasePersisterConfig } from 'tinybase/persisters';
|
|
14
14
|
import Database from 'better-sqlite3';
|
|
15
15
|
import { nanoid } from 'nanoid';
|
|
16
16
|
import * as path from 'path';
|
|
@@ -65,38 +65,80 @@ import type { StorageBackend, ExportedEvent } from './backends/types.js';
|
|
|
65
65
|
import { createTinyBaseBackend } from './backends/tinybase-backend.js';
|
|
66
66
|
|
|
67
67
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
68
|
-
//
|
|
68
|
+
// Tabular better-sqlite3 Persister
|
|
69
69
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
70
70
|
|
|
71
71
|
/**
|
|
72
|
-
*
|
|
73
|
-
* TinyBase
|
|
74
|
-
* but we use better-sqlite3 (sync API). This custom persister bridges the gap.
|
|
72
|
+
* Build tabular config for the 10 TinyBase tables.
|
|
73
|
+
* Identity mapping: TinyBase table name === SQLite table name.
|
|
75
74
|
*/
|
|
76
|
-
function
|
|
75
|
+
function getTabularConfig(): DatabasePersisterConfig {
|
|
76
|
+
const tableNames = [
|
|
77
|
+
'events', 'agents', 'tasks', 'messages',
|
|
78
|
+
'sessions', 'conversations', 'turns',
|
|
79
|
+
'threads', 'subscriptions', 'participants',
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
const load: Record<string, string> = {};
|
|
83
|
+
const save: Record<string, string> = {};
|
|
84
|
+
for (const t of tableNames) {
|
|
85
|
+
load[t] = t; // SQLite table -> TinyBase table (same name)
|
|
86
|
+
save[t] = t; // TinyBase table -> SQLite table (same name)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
mode: 'tabular',
|
|
91
|
+
tables: { load, save },
|
|
92
|
+
autoLoadIntervalSeconds: 1,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Creates a tabular TinyBase persister backed by better-sqlite3.
|
|
98
|
+
*
|
|
99
|
+
* Unlike the old JSON blob approach (entire store as one JSON string in a
|
|
100
|
+
* single row), tabular mode maps each TinyBase table to a real SQLite table
|
|
101
|
+
* and only writes changed rows on autoSave — making persistence O(delta)
|
|
102
|
+
* instead of O(total).
|
|
103
|
+
*/
|
|
104
|
+
function createTabularBetterSqlite3Persister(
|
|
77
105
|
store: Store,
|
|
78
106
|
db: ReturnType<typeof Database>,
|
|
79
|
-
tableName: string = 'tinybase_store'
|
|
80
107
|
) {
|
|
81
|
-
//
|
|
82
|
-
|
|
108
|
+
// Wrap better-sqlite3's sync API as the async DatabaseExecuteCommand.
|
|
109
|
+
// TinyBase generates SQL with $1, $2, ... placeholders (PostgreSQL-style),
|
|
110
|
+
// but better-sqlite3 uses ? for positional array binding. Convert them.
|
|
111
|
+
const executeCommand = async (sql: string, params?: any[]): Promise<Record<string, any>[]> => {
|
|
112
|
+
const convertedSql = sql.replace(/\$\d+/g, '?');
|
|
113
|
+
const trimmed = convertedSql.trimStart().toUpperCase();
|
|
114
|
+
const stmt = db.prepare(convertedSql);
|
|
115
|
+
if (trimmed.startsWith('SELECT') || trimmed.startsWith('PRAGMA')) {
|
|
116
|
+
return (params ? stmt.all(...params) : stmt.all()) as Record<string, any>[];
|
|
117
|
+
}
|
|
118
|
+
params ? stmt.run(...params) : stmt.run();
|
|
119
|
+
return [];
|
|
120
|
+
};
|
|
83
121
|
|
|
84
|
-
return
|
|
122
|
+
return createCustomSqlitePersister(
|
|
85
123
|
store,
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
//
|
|
97
|
-
(
|
|
98
|
-
//
|
|
99
|
-
|
|
124
|
+
getTabularConfig(),
|
|
125
|
+
executeCommand,
|
|
126
|
+
// addChangeListener — better-sqlite3 has no native change events
|
|
127
|
+
(_listener: (tableName: string) => void) => null as any,
|
|
128
|
+
// delChangeListener — no-op
|
|
129
|
+
(_handle: any) => {},
|
|
130
|
+
// onSqlCommand
|
|
131
|
+
undefined,
|
|
132
|
+
// onIgnoredError
|
|
133
|
+
(error: any) => console.warn('[EventStore] Persister error:', error),
|
|
134
|
+
// destroy
|
|
135
|
+
() => db.close(),
|
|
136
|
+
// persist mode (1 = StoreOnly)
|
|
137
|
+
1 as any,
|
|
138
|
+
// thing (the db instance)
|
|
139
|
+
db,
|
|
140
|
+
// getThing accessor name
|
|
141
|
+
'getDb',
|
|
100
142
|
);
|
|
101
143
|
}
|
|
102
144
|
|
|
@@ -252,7 +294,15 @@ export function parseDuration(duration: string): number {
|
|
|
252
294
|
export async function createEventStore(config: StoreConfig = {}): Promise<EventStore> {
|
|
253
295
|
// Resolve instance configuration
|
|
254
296
|
const resolved = resolveInstancePath(config);
|
|
255
|
-
const { instanceId, instancePath, namespace, isNew,
|
|
297
|
+
const { instanceId, instancePath, namespace, isNew, backendType } = resolved;
|
|
298
|
+
|
|
299
|
+
// Reject deprecated legacy path option
|
|
300
|
+
if (config.path) {
|
|
301
|
+
throw new Error(
|
|
302
|
+
'[macro-agent] The `path` option has been removed. ' +
|
|
303
|
+
'Use `instanceId` and `baseDir` instead for per-instance isolation.'
|
|
304
|
+
);
|
|
305
|
+
}
|
|
256
306
|
|
|
257
307
|
// Track baseDir for MCP subprocess communication
|
|
258
308
|
const baseDir = config.baseDir ?? path.join(os.homedir(), '.multiagent');
|
|
@@ -261,15 +311,6 @@ export async function createEventStore(config: StoreConfig = {}): Promise<EventS
|
|
|
261
311
|
const peerVisibility: PeerVisibilityConfig =
|
|
262
312
|
config.peerVisibility ?? DEFAULT_PEER_VISIBILITY;
|
|
263
313
|
|
|
264
|
-
// Emit deprecation warning for legacy path option
|
|
265
|
-
if (isLegacy) {
|
|
266
|
-
console.warn(
|
|
267
|
-
'[macro-agent] DEPRECATION WARNING: The `path` option is deprecated and will be removed in a future version. ' +
|
|
268
|
-
'Use `instanceId` and `baseDir` instead for per-instance isolation. ' +
|
|
269
|
-
'See documentation for migration guide.'
|
|
270
|
-
);
|
|
271
|
-
}
|
|
272
|
-
|
|
273
314
|
// Emit warning for in-memory mode (only in non-test environments)
|
|
274
315
|
if (config.inMemory && process.env.NODE_ENV !== 'test') {
|
|
275
316
|
console.warn(
|
|
@@ -282,31 +323,53 @@ export async function createEventStore(config: StoreConfig = {}): Promise<EventS
|
|
|
282
323
|
const store = createStore();
|
|
283
324
|
|
|
284
325
|
// Set up persister based on backend type
|
|
285
|
-
let persister: ReturnType<typeof
|
|
326
|
+
let persister: ReturnType<typeof createTabularBetterSqlite3Persister> | null = null;
|
|
286
327
|
let db: ReturnType<typeof Database> | null = null;
|
|
287
328
|
|
|
288
329
|
if (!config.inMemory) {
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
330
|
+
ensureInstanceDir(instancePath);
|
|
331
|
+
const dbPath = path.join(instancePath, 'store.sqlite');
|
|
332
|
+
db = new Database(dbPath);
|
|
333
|
+
db.pragma('journal_mode = WAL');
|
|
334
|
+
db.pragma('busy_timeout = 5000');
|
|
335
|
+
|
|
336
|
+
// Migration: if old JSON blob table exists, load data from it first
|
|
337
|
+
const oldTableExists = db.prepare(
|
|
338
|
+
"SELECT name FROM sqlite_master WHERE type='table' AND name='tinybase_store'"
|
|
339
|
+
).get();
|
|
340
|
+
|
|
341
|
+
if (oldTableExists) {
|
|
342
|
+
const oldPersister = createCustomPersister(
|
|
343
|
+
store,
|
|
344
|
+
async () => {
|
|
345
|
+
const row = db!.prepare("SELECT store FROM tinybase_store WHERE _id = '_'").get() as { store: string } | undefined;
|
|
346
|
+
return row ? JSON.parse(row.store) : undefined;
|
|
347
|
+
},
|
|
348
|
+
async () => {},
|
|
349
|
+
(listener) => setInterval(listener, 1000),
|
|
350
|
+
(interval: ReturnType<typeof setInterval>) => clearInterval(interval),
|
|
351
|
+
);
|
|
352
|
+
await oldPersister.load();
|
|
353
|
+
oldPersister.destroy();
|
|
304
354
|
}
|
|
355
|
+
|
|
356
|
+
persister = createTabularBetterSqlite3Persister(store, db);
|
|
357
|
+
|
|
358
|
+
if (oldTableExists) {
|
|
359
|
+
// Save migrated data to new tabular format, then drop old table
|
|
360
|
+
await persister.save();
|
|
361
|
+
db.exec('DROP TABLE IF EXISTS tinybase_store');
|
|
362
|
+
}
|
|
363
|
+
|
|
305
364
|
await persister.load();
|
|
365
|
+
// Auto-save: persist to disk whenever the in-memory store changes.
|
|
366
|
+
// Without this, emit() only writes to TinyBase's in-memory store and
|
|
367
|
+
// data is lost if the server is killed before an explicit persist().
|
|
368
|
+
await persister.startAutoSave();
|
|
306
369
|
}
|
|
307
370
|
|
|
308
371
|
// Initialize/update instance metadata
|
|
309
|
-
if (!config.inMemory
|
|
372
|
+
if (!config.inMemory) {
|
|
310
373
|
if (isNew) {
|
|
311
374
|
const meta = createInstanceMeta(resolved, config);
|
|
312
375
|
writeInstanceMeta(instancePath, meta);
|
|
@@ -876,11 +939,6 @@ export async function createEventStore(config: StoreConfig = {}): Promise<EventS
|
|
|
876
939
|
* Get archives directory path
|
|
877
940
|
*/
|
|
878
941
|
function getArchivesDir(): string {
|
|
879
|
-
// For legacy mode, use parent of the file path
|
|
880
|
-
// For new mode, use the instance directory
|
|
881
|
-
if (isLegacy) {
|
|
882
|
-
return path.join(path.dirname(instancePath), 'archives');
|
|
883
|
-
}
|
|
884
942
|
return path.join(instancePath, 'archives');
|
|
885
943
|
}
|
|
886
944
|
|