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.
Files changed (44) hide show
  1. package/dist/acp/index.d.ts +1 -1
  2. package/dist/acp/index.d.ts.map +1 -1
  3. package/dist/acp/index.js.map +1 -1
  4. package/dist/acp/macro-agent.d.ts +21 -0
  5. package/dist/acp/macro-agent.d.ts.map +1 -1
  6. package/dist/acp/macro-agent.js +182 -0
  7. package/dist/acp/macro-agent.js.map +1 -1
  8. package/dist/acp/types.d.ts +31 -2
  9. package/dist/acp/types.d.ts.map +1 -1
  10. package/dist/acp/types.js.map +1 -1
  11. package/dist/agent/agent-manager.d.ts.map +1 -1
  12. package/dist/agent/agent-manager.js +23 -5
  13. package/dist/agent/agent-manager.js.map +1 -1
  14. package/dist/map/adapter/acp-over-map.d.ts +15 -0
  15. package/dist/map/adapter/acp-over-map.d.ts.map +1 -1
  16. package/dist/map/adapter/acp-over-map.js +204 -9
  17. package/dist/map/adapter/acp-over-map.js.map +1 -1
  18. package/dist/store/event-store.d.ts.map +1 -1
  19. package/dist/store/event-store.js +92 -53
  20. package/dist/store/event-store.js.map +1 -1
  21. package/dist/store/instance.d.ts +0 -2
  22. package/dist/store/instance.d.ts.map +1 -1
  23. package/dist/store/instance.js +1 -24
  24. package/dist/store/instance.js.map +1 -1
  25. package/package.json +3 -3
  26. package/references/acp-factory-ref/package-lock.json +2 -2
  27. package/references/acp-factory-ref/package.json +2 -2
  28. package/references/claude-code-acp/package-lock.json +2 -2
  29. package/references/claude-code-acp/package.json +1 -1
  30. package/references/claude-code-acp/src/acp-agent.ts +3 -6
  31. package/src/acp/__tests__/history.test.ts +526 -0
  32. package/src/acp/__tests__/integration.test.ts +2 -1
  33. package/src/acp/index.ts +4 -0
  34. package/src/acp/macro-agent.ts +329 -85
  35. package/src/acp/types.ts +39 -2
  36. package/src/agent/__tests__/agent-manager.test.ts +4 -6
  37. package/src/agent/agent-manager.ts +24 -5
  38. package/src/map/adapter/__tests__/acp-over-map-history.test.ts +664 -0
  39. package/src/map/adapter/__tests__/acp-over-map-persistence.e2e.test.ts +440 -0
  40. package/src/map/adapter/acp-over-map.ts +246 -7
  41. package/src/store/__tests__/event-store.test.ts +4 -12
  42. package/src/store/__tests__/instance.test.ts +5 -7
  43. package/src/store/event-store.ts +115 -57
  44. 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
- return {};
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
- return {};
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
- const sessionId = paramSessionId ?? sessionIdFromContext ?? streamState.sessionId;
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
- const sessionId = paramSessionId ?? sessionIdFromContext ?? streamState.sessionId;
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
- const storePath = path.join(testDir, 'store.json');
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 emit deprecation warning when using legacy path option', async () => {
989
+ it('should throw when using removed legacy path option', async () => {
991
990
  const legacyPath = path.join(testDir, 'legacy-store.json');
992
- const legacyStore = await createEventStore({ path: legacyPath });
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 handle legacy path option', () => {
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
- expect(resolved.isLegacy).toBe(true);
151
- expect(resolved.backendType).toBe('json');
152
- expect(resolved.instancePath).toBe(legacyPath);
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', () => {
@@ -9,8 +9,8 @@
9
9
  */
10
10
 
11
11
  import { createStore, Store } from 'tinybase';
12
- import { createFilePersister } from 'tinybase/persisters/persister-file';
13
- import { createCustomPersister } from 'tinybase/persisters';
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
- // Custom better-sqlite3 Persister
68
+ // Tabular better-sqlite3 Persister
69
69
  // ─────────────────────────────────────────────────────────────────────────────
70
70
 
71
71
  /**
72
- * Creates a custom TinyBase persister for better-sqlite3.
73
- * TinyBase's built-in createSqlite3Persister expects node-sqlite3 (async/callback API),
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 createBetterSqlite3Persister(
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
- // Create table if not exists
82
- db.exec(`CREATE TABLE IF NOT EXISTS ${tableName} (_id TEXT PRIMARY KEY, store TEXT)`);
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 createCustomPersister(
122
+ return createCustomSqlitePersister(
85
123
  store,
86
- // getPersisted - load from SQLite
87
- async () => {
88
- const row = db.prepare(`SELECT store FROM ${tableName} WHERE _id = '_'`).get() as { store: string } | undefined;
89
- return row ? JSON.parse(row.store) : undefined;
90
- },
91
- // setPersisted - save to SQLite
92
- async (getContent) => {
93
- const json = JSON.stringify(getContent());
94
- db.prepare(`INSERT OR REPLACE INTO ${tableName} (_id, store) VALUES ('_', ?)`).run(json);
95
- },
96
- // addPersisterListener - poll for external changes (cross-process)
97
- (listener) => setInterval(listener, 1000),
98
- // delPersisterListener - cleanup polling
99
- (interval: ReturnType<typeof setInterval>) => clearInterval(interval),
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, isLegacy, backendType } = resolved;
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 createFilePersister> | ReturnType<typeof createBetterSqlite3Persister> | null = null;
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
- if (isLegacy) {
290
- // Legacy mode: Use JSON file persister at the specified path
291
- const dir = path.dirname(instancePath);
292
- if (!fs.existsSync(dir)) {
293
- fs.mkdirSync(dir, { recursive: true });
294
- }
295
- persister = createFilePersister(store, instancePath);
296
- } else {
297
- // New instances: Use SQLite with custom better-sqlite3 persister
298
- ensureInstanceDir(instancePath);
299
- const dbPath = path.join(instancePath, 'store.sqlite');
300
- db = new Database(dbPath);
301
- db.pragma('journal_mode = WAL');
302
- db.pragma('busy_timeout = 5000');
303
- persister = createBetterSqlite3Persister(store, db);
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 && !isLegacy) {
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