@vellumai/assistant 0.4.11 → 0.4.13

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 (111) hide show
  1. package/ARCHITECTURE.md +401 -385
  2. package/package.json +1 -1
  3. package/src/__tests__/guardian-verify-setup-skill-regression.test.ts +75 -61
  4. package/src/__tests__/registry.test.ts +235 -187
  5. package/src/__tests__/secure-keys.test.ts +27 -0
  6. package/src/__tests__/session-agent-loop.test.ts +521 -256
  7. package/src/__tests__/session-surfaces-task-progress.test.ts +1 -0
  8. package/src/__tests__/session-tool-setup-app-refresh.test.ts +1 -0
  9. package/src/__tests__/session-tool-setup-memory-scope.test.ts +1 -0
  10. package/src/__tests__/session-tool-setup-side-effect-flag.test.ts +1 -0
  11. package/src/__tests__/skills.test.ts +334 -276
  12. package/src/__tests__/slack-skill.test.ts +124 -0
  13. package/src/__tests__/starter-task-flow.test.ts +7 -17
  14. package/src/agent/loop.ts +10 -3
  15. package/src/config/bundled-skills/chatgpt-import/tools/chatgpt-import.ts +449 -0
  16. package/src/config/bundled-skills/doordash/SKILL.md +171 -0
  17. package/src/config/bundled-skills/doordash/__tests__/doordash-client.test.ts +203 -0
  18. package/src/config/bundled-skills/doordash/__tests__/doordash-session.test.ts +164 -0
  19. package/src/config/bundled-skills/doordash/doordash-cli.ts +1193 -0
  20. package/src/config/bundled-skills/doordash/doordash-entry.ts +22 -0
  21. package/src/config/bundled-skills/doordash/lib/cart-queries.ts +787 -0
  22. package/src/config/bundled-skills/doordash/lib/client.ts +1071 -0
  23. package/src/config/bundled-skills/doordash/lib/order-queries.ts +85 -0
  24. package/src/config/bundled-skills/doordash/lib/queries.ts +28 -0
  25. package/src/config/bundled-skills/doordash/lib/query-extractor.ts +94 -0
  26. package/src/config/bundled-skills/doordash/lib/search-queries.ts +203 -0
  27. package/src/config/bundled-skills/doordash/lib/session.ts +93 -0
  28. package/src/config/bundled-skills/doordash/lib/shared/errors.ts +61 -0
  29. package/src/config/bundled-skills/doordash/lib/shared/ipc.ts +32 -0
  30. package/src/config/bundled-skills/doordash/lib/shared/network-recorder.ts +380 -0
  31. package/src/config/bundled-skills/doordash/lib/shared/platform.ts +35 -0
  32. package/src/config/bundled-skills/doordash/lib/shared/recording-store.ts +43 -0
  33. package/src/config/bundled-skills/doordash/lib/shared/recording-types.ts +49 -0
  34. package/src/config/bundled-skills/doordash/lib/shared/truncate.ts +6 -0
  35. package/src/config/bundled-skills/doordash/lib/store-queries.ts +246 -0
  36. package/src/config/bundled-skills/doordash/lib/types.ts +367 -0
  37. package/src/config/bundled-skills/google-calendar/SKILL.md +4 -5
  38. package/src/config/bundled-skills/google-oauth-setup/SKILL.md +41 -41
  39. package/src/config/bundled-skills/messaging/SKILL.md +59 -42
  40. package/src/config/bundled-skills/messaging/TOOLS.json +14 -92
  41. package/src/config/bundled-skills/messaging/tools/gmail-archive-by-query.ts +5 -1
  42. package/src/config/bundled-skills/messaging/tools/gmail-batch-archive.ts +11 -2
  43. package/src/config/bundled-skills/messaging/tools/gmail-outreach-scan.ts +8 -1
  44. package/src/config/bundled-skills/messaging/tools/gmail-sender-digest.ts +12 -4
  45. package/src/config/bundled-skills/messaging/tools/gmail-unsubscribe.ts +5 -1
  46. package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +5 -1
  47. package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +5 -2
  48. package/src/config/bundled-skills/notion/SKILL.md +240 -0
  49. package/src/config/bundled-skills/notion-oauth-setup/SKILL.md +127 -0
  50. package/src/config/bundled-skills/oauth-setup/SKILL.md +144 -0
  51. package/src/config/bundled-skills/phone-calls/SKILL.md +76 -45
  52. package/src/config/bundled-skills/skills-catalog/SKILL.md +32 -29
  53. package/src/config/bundled-skills/slack/SKILL.md +49 -0
  54. package/src/config/bundled-skills/slack/TOOLS.json +167 -0
  55. package/src/config/bundled-skills/slack/tools/shared.ts +23 -0
  56. package/src/config/bundled-skills/{messaging → slack}/tools/slack-add-reaction.ts +2 -5
  57. package/src/config/bundled-skills/slack/tools/slack-channel-details.ts +33 -0
  58. package/src/config/bundled-skills/slack/tools/slack-configure-channels.ts +75 -0
  59. package/src/config/bundled-skills/{messaging → slack}/tools/slack-delete-message.ts +2 -5
  60. package/src/config/bundled-skills/{messaging → slack}/tools/slack-leave-channel.ts +2 -5
  61. package/src/config/bundled-skills/slack/tools/slack-scan-digest.ts +193 -0
  62. package/src/config/{vellum-skills → bundled-skills}/sms-setup/SKILL.md +29 -22
  63. package/src/config/{vellum-skills → bundled-skills}/telegram-setup/SKILL.md +17 -14
  64. package/src/config/{vellum-skills → bundled-skills}/twilio-setup/SKILL.md +20 -5
  65. package/src/config/bundled-tool-registry.ts +292 -267
  66. package/src/config/schema.ts +1 -1
  67. package/src/daemon/handlers/skills.ts +334 -234
  68. package/src/daemon/ipc-contract/messages.ts +2 -0
  69. package/src/daemon/ipc-contract/surfaces.ts +2 -0
  70. package/src/daemon/lifecycle.ts +358 -221
  71. package/src/daemon/response-tier.ts +2 -0
  72. package/src/daemon/server.ts +453 -193
  73. package/src/daemon/session-agent-loop-handlers.ts +43 -2
  74. package/src/daemon/session-agent-loop.ts +3 -0
  75. package/src/daemon/session-lifecycle.ts +3 -0
  76. package/src/daemon/session-process.ts +1 -0
  77. package/src/daemon/session-surfaces.ts +22 -20
  78. package/src/daemon/session-tool-setup.ts +1 -0
  79. package/src/daemon/session.ts +5 -2
  80. package/src/messaging/outreach-classifier.ts +12 -5
  81. package/src/messaging/provider-types.ts +5 -0
  82. package/src/messaging/provider.ts +1 -1
  83. package/src/messaging/providers/gmail/adapter.ts +11 -5
  84. package/src/messaging/providers/gmail/client.ts +2 -0
  85. package/src/messaging/providers/slack/adapter.ts +1 -0
  86. package/src/messaging/providers/slack/client.ts +8 -0
  87. package/src/messaging/providers/slack/types.ts +5 -0
  88. package/src/runtime/http-errors.ts +33 -20
  89. package/src/runtime/http-server.ts +706 -291
  90. package/src/runtime/http-types.ts +26 -16
  91. package/src/runtime/routes/secret-routes.ts +57 -2
  92. package/src/runtime/routes/surface-action-routes.ts +66 -0
  93. package/src/runtime/routes/trust-rules-routes.ts +140 -0
  94. package/src/security/keychain-to-encrypted-migration.ts +59 -0
  95. package/src/security/secure-keys.ts +17 -0
  96. package/src/skills/frontmatter.ts +9 -7
  97. package/src/tools/apps/executors.ts +2 -1
  98. package/src/tools/tool-manifest.ts +44 -42
  99. package/src/tools/types.ts +9 -0
  100. package/src/__tests__/skill-mirror-parity.test.ts +0 -176
  101. package/src/config/vellum-skills/catalog.json +0 -63
  102. package/src/config/vellum-skills/chatgpt-import/tools/chatgpt-import.ts +0 -295
  103. package/src/skills/vellum-catalog-remote.ts +0 -166
  104. package/src/tools/skills/vellum-catalog.ts +0 -168
  105. /package/src/config/{vellum-skills → bundled-skills}/chatgpt-import/SKILL.md +0 -0
  106. /package/src/config/{vellum-skills → bundled-skills}/chatgpt-import/TOOLS.json +0 -0
  107. /package/src/config/{vellum-skills → bundled-skills}/deploy-fullstack-vercel/SKILL.md +0 -0
  108. /package/src/config/{vellum-skills → bundled-skills}/document-writer/SKILL.md +0 -0
  109. /package/src/config/{vellum-skills → bundled-skills}/guardian-verify-setup/SKILL.md +0 -0
  110. /package/src/config/{vellum-skills → bundled-skills}/slack-oauth-setup/SKILL.md +0 -0
  111. /package/src/config/{vellum-skills → bundled-skills}/trusted-contacts/SKILL.md +0 -0
@@ -0,0 +1,124 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ import { describe, expect, test } from 'bun:test';
5
+
6
+ import type { SlackConversation } from '../messaging/providers/slack/types.js';
7
+
8
+ const BUNDLED_SKILLS_DIR = join(import.meta.dir, '..', 'config', 'bundled-skills');
9
+
10
+ describe('slack adapter isPrivate mapping', () => {
11
+ // Inline the mapping logic to test it independently of the adapter module
12
+ function mapIsPrivate(conv: Partial<SlackConversation>): boolean {
13
+ return conv.is_private ?? conv.is_group ?? false;
14
+ }
15
+
16
+ test('public channel is not private', () => {
17
+ expect(mapIsPrivate({ is_channel: true, is_private: false })).toBe(false);
18
+ });
19
+
20
+ test('private channel with is_private flag', () => {
21
+ expect(mapIsPrivate({ is_channel: true, is_private: true })).toBe(true);
22
+ });
23
+
24
+ test('private channel via is_group (legacy)', () => {
25
+ expect(mapIsPrivate({ is_group: true })).toBe(true);
26
+ });
27
+
28
+ test('is_private takes precedence over is_group', () => {
29
+ expect(mapIsPrivate({ is_private: false, is_group: true })).toBe(false);
30
+ });
31
+
32
+ test('DM defaults to not private when flags absent', () => {
33
+ expect(mapIsPrivate({ is_im: true })).toBe(false);
34
+ });
35
+
36
+ test('mpim (group DM) defaults to not private when is_private absent', () => {
37
+ expect(mapIsPrivate({ is_mpim: true })).toBe(false);
38
+ });
39
+
40
+ test('undefined flags default to false', () => {
41
+ expect(mapIsPrivate({})).toBe(false);
42
+ });
43
+ });
44
+
45
+ describe('slack skill TOOLS.json', () => {
46
+ const toolsPath = join(BUNDLED_SKILLS_DIR, 'slack', 'TOOLS.json');
47
+ const toolsJson = JSON.parse(readFileSync(toolsPath, 'utf-8'));
48
+
49
+ test('is valid JSON with correct version', () => {
50
+ expect(toolsJson.version).toBe(1);
51
+ expect(Array.isArray(toolsJson.tools)).toBe(true);
52
+ });
53
+
54
+ test('has expected tools', () => {
55
+ const names = toolsJson.tools.map((t: { name: string }) => t.name);
56
+ expect(names).toContain('slack_scan_digest');
57
+ expect(names).toContain('slack_channel_details');
58
+ expect(names).toContain('slack_configure_channels');
59
+ expect(names).toContain('slack_add_reaction');
60
+ expect(names).toContain('slack_delete_message');
61
+ expect(names).toContain('slack_leave_channel');
62
+ });
63
+
64
+ test('has 6 tools total', () => {
65
+ expect(toolsJson.tools.length).toBe(6);
66
+ });
67
+
68
+ test('all tools have required fields', () => {
69
+ for (const tool of toolsJson.tools) {
70
+ expect(tool.name).toBeDefined();
71
+ expect(tool.description).toBeDefined();
72
+ expect(tool.category).toBeDefined();
73
+ expect(tool.risk).toBeDefined();
74
+ expect(tool.input_schema).toBeDefined();
75
+ expect(tool.executor).toBeDefined();
76
+ expect(tool.execution_target).toBeDefined();
77
+ }
78
+ });
79
+
80
+ test('all executor files exist', () => {
81
+ const slackSkillDir = join(BUNDLED_SKILLS_DIR, 'slack');
82
+ for (const tool of toolsJson.tools) {
83
+ const executorPath = join(slackSkillDir, tool.executor);
84
+ expect(() => readFileSync(executorPath)).not.toThrow();
85
+ }
86
+ });
87
+ });
88
+
89
+ describe('messaging skill no longer has Slack tools', () => {
90
+ const messagingToolsPath = join(BUNDLED_SKILLS_DIR, 'messaging', 'TOOLS.json');
91
+ const messagingToolsJson = JSON.parse(readFileSync(messagingToolsPath, 'utf-8'));
92
+
93
+ test('slack_add_reaction not in messaging TOOLS.json', () => {
94
+ const names = messagingToolsJson.tools.map((t: { name: string }) => t.name);
95
+ expect(names).not.toContain('slack_add_reaction');
96
+ });
97
+
98
+ test('slack_delete_message not in messaging TOOLS.json', () => {
99
+ const names = messagingToolsJson.tools.map((t: { name: string }) => t.name);
100
+ expect(names).not.toContain('slack_delete_message');
101
+ });
102
+
103
+ test('slack_leave_channel not in messaging TOOLS.json', () => {
104
+ const names = messagingToolsJson.tools.map((t: { name: string }) => t.name);
105
+ expect(names).not.toContain('slack_leave_channel');
106
+ });
107
+ });
108
+
109
+ describe('slack skill SKILL.md', () => {
110
+ const skillMd = readFileSync(join(BUNDLED_SKILLS_DIR, 'slack', 'SKILL.md'), 'utf-8');
111
+
112
+ test('has correct frontmatter name', () => {
113
+ expect(skillMd).toContain('name: "Slack"');
114
+ });
115
+
116
+ test('is user-invocable', () => {
117
+ expect(skillMd).toContain('user-invocable: true');
118
+ });
119
+
120
+ test('mentions privacy rules', () => {
121
+ expect(skillMd).toContain('isPrivate');
122
+ expect(skillMd).toContain('MUST NEVER be shared');
123
+ });
124
+ });
@@ -46,6 +46,7 @@ function makeContext(): SurfaceSessionContext {
46
46
  lastSurfaceAction: new Map<string, { actionId: string; data?: Record<string, unknown> }>(),
47
47
  surfaceState: new Map(),
48
48
  surfaceUndoStacks: new Map(),
49
+ surfaceActionRequestIds: new Set<string>(),
49
50
  currentTurnSurfaces: [],
50
51
  isProcessing: () => false,
51
52
  enqueueMessage: () => ({ queued: false, requestId: 'req-1' }),
@@ -74,7 +75,7 @@ describe('starter task surface actions', () => {
74
75
  expect(ctx.pendingSurfaceActions.has('surf-1')).toBe(true);
75
76
  });
76
77
 
77
- test('falls back to structured payload when prompt is absent', () => {
78
+ test('falls back to human-readable summary with action data when prompt is absent', () => {
78
79
  const forwarded: string[] = [];
79
80
  const ctx = makeContext();
80
81
  ctx.processMessage = async (content) => {
@@ -86,16 +87,9 @@ describe('starter task surface actions', () => {
86
87
  handleSurfaceAction(ctx, 'surf-2', 'relay_prompt', { topic: 'weather in sf' });
87
88
 
88
89
  expect(forwarded).toHaveLength(1);
89
- const parsed = JSON.parse(forwarded[0]) as {
90
- surfaceAction: boolean;
91
- surfaceId: string;
92
- actionId: string;
93
- data: Record<string, unknown>;
94
- };
95
- expect(parsed.surfaceAction).toBe(true);
96
- expect(parsed.surfaceId).toBe('surf-2');
97
- expect(parsed.actionId).toBe('relay_prompt');
98
- expect(parsed.data.topic).toBe('weather in sf');
90
+ expect(forwarded[0]).toContain('[User action on dynamic_page surface:');
91
+ expect(forwarded[0]).toContain('Action data:');
92
+ expect(forwarded[0]).toContain('"topic":"weather in sf"');
99
93
  expect(ctx.pendingSurfaceActions.has('surf-2')).toBe(true);
100
94
  });
101
95
 
@@ -111,12 +105,8 @@ describe('starter task surface actions', () => {
111
105
  handleSurfaceAction(ctx, 'surf-3', 'save_filters', { prompt: 'keep this literal field' });
112
106
 
113
107
  expect(forwarded).toHaveLength(1);
114
- const parsed = JSON.parse(forwarded[0]) as {
115
- actionId: string;
116
- data: Record<string, unknown>;
117
- };
118
- expect(parsed.actionId).toBe('save_filters');
119
- expect(parsed.data.prompt).toBe('keep this literal field');
108
+ expect(forwarded[0]).toContain('[User action on dynamic_page surface:');
109
+ expect(forwarded[0]).toContain('"prompt":"keep this literal field"');
120
110
  });
121
111
 
122
112
  test('consumes non-dynamic pending actions after forwarding', () => {
package/src/agent/loop.ts CHANGED
@@ -41,7 +41,7 @@ export type AgentEvent =
41
41
 
42
42
  const DEFAULT_CONFIG: AgentLoopConfig = {
43
43
  maxTokens: 16000,
44
- maxToolUseTurns: 0,
44
+ maxToolUseTurns: 40,
45
45
  minTurnIntervalMs: 150,
46
46
  };
47
47
 
@@ -65,14 +65,14 @@ export class AgentLoop {
65
65
  private tools: ToolDefinition[];
66
66
  private resolveTools: ((history: Message[]) => ToolDefinition[]) | null;
67
67
  private resolveSystemPrompt: ((history: Message[]) => ResolvedSystemPrompt) | null;
68
- private toolExecutor: ((name: string, input: Record<string, unknown>, onOutput?: (chunk: string) => void) => Promise<{ content: string; isError: boolean; diff?: { filePath: string; oldContent: string; newContent: string; isNewFile: boolean }; status?: string; contentBlocks?: ContentBlock[]; sensitiveBindings?: SensitiveOutputBinding[] }>) | null;
68
+ private toolExecutor: ((name: string, input: Record<string, unknown>, onOutput?: (chunk: string) => void) => Promise<{ content: string; isError: boolean; diff?: { filePath: string; oldContent: string; newContent: string; isNewFile: boolean }; status?: string; contentBlocks?: ContentBlock[]; sensitiveBindings?: SensitiveOutputBinding[]; yieldToUser?: boolean }>) | null;
69
69
 
70
70
  constructor(
71
71
  provider: Provider,
72
72
  systemPrompt: string,
73
73
  config?: Partial<AgentLoopConfig>,
74
74
  tools?: ToolDefinition[],
75
- toolExecutor?: (name: string, input: Record<string, unknown>, onOutput?: (chunk: string) => void) => Promise<{ content: string; isError: boolean; diff?: { filePath: string; oldContent: string; newContent: string; isNewFile: boolean }; status?: string; contentBlocks?: ContentBlock[]; sensitiveBindings?: SensitiveOutputBinding[] }>,
75
+ toolExecutor?: (name: string, input: Record<string, unknown>, onOutput?: (chunk: string) => void) => Promise<{ content: string; isError: boolean; diff?: { filePath: string; oldContent: string; newContent: string; isNewFile: boolean }; status?: string; contentBlocks?: ContentBlock[]; sensitiveBindings?: SensitiveOutputBinding[]; yieldToUser?: boolean }>,
76
76
  resolveTools?: (history: Message[]) => ToolDefinition[],
77
77
  resolveSystemPrompt?: (history: Message[]) => ResolvedSystemPrompt,
78
78
  ) {
@@ -479,6 +479,13 @@ export class AgentLoop {
479
479
  break;
480
480
  }
481
481
 
482
+ // If any tool result requests yielding to the user (e.g. interactive
483
+ // surface awaiting a button click), push results and stop the loop.
484
+ if (toolResults.some(({ result }) => result.yieldToUser)) {
485
+ history.push({ role: 'user', content: resultBlocks });
486
+ break;
487
+ }
488
+
482
489
  // Track tool-use turns and inject progress reminder every N turns
483
490
  toolUseTurns++;
484
491
  if (this.config.maxToolUseTurns && this.config.maxToolUseTurns > 0 && toolUseTurns >= this.config.maxToolUseTurns) {
@@ -0,0 +1,449 @@
1
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { inflateRawSync } from "node:zlib";
5
+
6
+ import { Database } from "bun:sqlite";
7
+ import { eq } from "drizzle-orm";
8
+ import { drizzle } from "drizzle-orm/bun-sqlite";
9
+ import { integer, real, sqliteTable, text } from "drizzle-orm/sqlite-core";
10
+ import { v4 as uuid } from "uuid";
11
+
12
+ import type {
13
+ ToolContext,
14
+ ToolExecutionResult,
15
+ } from "../../../../tools/types.js";
16
+
17
+ // -- Inline schema (only the tables this tool touches) --
18
+
19
+ const conversations = sqliteTable("conversations", {
20
+ id: text("id").primaryKey(),
21
+ title: text("title"),
22
+ createdAt: integer("created_at").notNull(),
23
+ updatedAt: integer("updated_at").notNull(),
24
+ totalInputTokens: integer("total_input_tokens").notNull().default(0),
25
+ totalOutputTokens: integer("total_output_tokens").notNull().default(0),
26
+ totalEstimatedCost: real("total_estimated_cost").notNull().default(0),
27
+ contextSummary: text("context_summary"),
28
+ contextCompactedMessageCount: integer("context_compacted_message_count")
29
+ .notNull()
30
+ .default(0),
31
+ contextCompactedAt: integer("context_compacted_at"),
32
+ threadType: text("thread_type").notNull().default("standard"),
33
+ memoryScopeId: text("memory_scope_id").notNull().default("default"),
34
+ });
35
+
36
+ const messagesTable = sqliteTable("messages", {
37
+ id: text("id").primaryKey(),
38
+ conversationId: text("conversation_id")
39
+ .notNull()
40
+ .references(() => conversations.id),
41
+ role: text("role").notNull(),
42
+ content: text("content").notNull(),
43
+ createdAt: integer("created_at").notNull(),
44
+ metadata: text("metadata"),
45
+ });
46
+
47
+ const conversationKeys = sqliteTable("conversation_keys", {
48
+ id: text("id").primaryKey(),
49
+ conversationKey: text("conversation_key").notNull(),
50
+ conversationId: text("conversation_id")
51
+ .notNull()
52
+ .references(() => conversations.id, { onDelete: "cascade" }),
53
+ createdAt: integer("created_at").notNull(),
54
+ });
55
+
56
+ // -- Inline DB access --
57
+
58
+ const schema = { conversations, messages: messagesTable, conversationKeys };
59
+
60
+ function getDbPath(): string {
61
+ const baseDir = process.env.BASE_DATA_DIR?.trim() || homedir();
62
+ return join(baseDir, ".vellum", "workspace", "data", "db", "assistant.db");
63
+ }
64
+
65
+ let db: ReturnType<typeof drizzle<typeof schema>> | null = null;
66
+
67
+ function getDb() {
68
+ if (!db) {
69
+ const dbPath = getDbPath();
70
+ const dbDir = join(dbPath, "..");
71
+ mkdirSync(dbDir, { recursive: true });
72
+ const sqlite = new Database(dbPath);
73
+ sqlite.exec("PRAGMA journal_mode=WAL");
74
+ sqlite.exec("PRAGMA foreign_keys = ON");
75
+ db = drizzle(sqlite, { schema });
76
+ }
77
+ return db;
78
+ }
79
+
80
+ // -- Inline conversation helpers --
81
+
82
+ let lastTimestamp = 0;
83
+ function monotonicNow(): number {
84
+ const now = Date.now();
85
+ lastTimestamp = Math.max(now, lastTimestamp + 1);
86
+ return lastTimestamp;
87
+ }
88
+
89
+ function createConversation(title: string) {
90
+ const database = getDb();
91
+ const now = Date.now();
92
+ const id = uuid();
93
+ const conversation = {
94
+ id,
95
+ title,
96
+ createdAt: now,
97
+ updatedAt: now,
98
+ totalInputTokens: 0,
99
+ totalOutputTokens: 0,
100
+ totalEstimatedCost: 0,
101
+ contextSummary: null as string | null,
102
+ contextCompactedMessageCount: 0,
103
+ contextCompactedAt: null as number | null,
104
+ threadType: "standard" as const,
105
+ memoryScopeId: "default",
106
+ };
107
+ database.insert(conversations).values(conversation).run();
108
+ return conversation;
109
+ }
110
+
111
+ function addMessage(conversationId: string, role: string, content: string) {
112
+ const database = getDb();
113
+ const now = monotonicNow();
114
+ const message = {
115
+ id: uuid(),
116
+ conversationId,
117
+ role,
118
+ content,
119
+ createdAt: now,
120
+ };
121
+ database.transaction((tx) => {
122
+ tx.insert(messagesTable).values(message).run();
123
+ tx.update(conversations)
124
+ .set({ updatedAt: now })
125
+ .where(eq(conversations.id, conversationId))
126
+ .run();
127
+ });
128
+ return message;
129
+ }
130
+
131
+ // -- ChatGPT export format types --
132
+
133
+ interface ChatGPTContent {
134
+ content_type: string;
135
+ parts?: (string | null | Record<string, unknown>)[];
136
+ }
137
+
138
+ interface ChatGPTNode {
139
+ message: {
140
+ author: { role: string };
141
+ content: ChatGPTContent;
142
+ create_time?: number | null;
143
+ } | null;
144
+ parent: string | null;
145
+ children: string[];
146
+ }
147
+
148
+ interface ChatGPTConversation {
149
+ id?: string;
150
+ title: string;
151
+ create_time: number;
152
+ update_time: number;
153
+ current_node: string;
154
+ mapping: Record<string, ChatGPTNode>;
155
+ }
156
+
157
+ interface ImportedMessage {
158
+ role: string;
159
+ content: Array<{ type: string; text: string }>;
160
+ createdAt: number;
161
+ }
162
+
163
+ interface ImportedConversation {
164
+ sourceId: string;
165
+ title: string;
166
+ createdAt: number;
167
+ updatedAt: number;
168
+ messages: ImportedMessage[];
169
+ }
170
+
171
+ // -- Tool entry point --
172
+
173
+ export async function run(
174
+ input: Record<string, unknown>,
175
+ _context: ToolContext,
176
+ ): Promise<ToolExecutionResult> {
177
+ const filePath = input.file_path as string;
178
+
179
+ if (!filePath) {
180
+ return { content: "Error: file_path is required", isError: true };
181
+ }
182
+
183
+ if (!filePath.endsWith(".zip")) {
184
+ return {
185
+ content:
186
+ "Error: Only ZIP files are accepted. Please provide the ChatGPT export ZIP file.",
187
+ isError: true,
188
+ };
189
+ }
190
+
191
+ if (!existsSync(filePath)) {
192
+ return { content: `Error: File not found: ${filePath}`, isError: true };
193
+ }
194
+
195
+ let imported: ImportedConversation[];
196
+ try {
197
+ imported = parseChatGPTExport(filePath);
198
+ } catch (err) {
199
+ return {
200
+ content: `Error parsing export file: ${err instanceof Error ? err.message : String(err)}`,
201
+ isError: true,
202
+ };
203
+ }
204
+
205
+ if (imported.length === 0) {
206
+ return {
207
+ content: "No conversations found in the export file.",
208
+ isError: false,
209
+ };
210
+ }
211
+
212
+ const database = getDb();
213
+ let importedCount = 0;
214
+ let skippedCount = 0;
215
+ let messageCount = 0;
216
+
217
+ for (const conv of imported) {
218
+ const convKey = `chatgpt:${conv.sourceId}`;
219
+
220
+ const existing = database
221
+ .select()
222
+ .from(conversationKeys)
223
+ .where(eq(conversationKeys.conversationKey, convKey))
224
+ .get();
225
+
226
+ if (existing) {
227
+ skippedCount++;
228
+ continue;
229
+ }
230
+
231
+ const conversation = createConversation(conv.title);
232
+
233
+ for (const msg of conv.messages) {
234
+ addMessage(conversation.id, msg.role, JSON.stringify(msg.content));
235
+ }
236
+
237
+ // Override timestamps to match ChatGPT originals
238
+ database
239
+ .update(conversations)
240
+ .set({ createdAt: conv.createdAt, updatedAt: conv.updatedAt })
241
+ .where(eq(conversations.id, conversation.id))
242
+ .run();
243
+
244
+ // Update message timestamps to match ChatGPT originals
245
+ const dbMessages = database
246
+ .select({ id: messagesTable.id })
247
+ .from(messagesTable)
248
+ .where(eq(messagesTable.conversationId, conversation.id))
249
+ .orderBy(messagesTable.createdAt)
250
+ .all();
251
+
252
+ for (let i = 0; i < dbMessages.length && i < conv.messages.length; i++) {
253
+ database
254
+ .update(messagesTable)
255
+ .set({ createdAt: conv.messages[i].createdAt })
256
+ .where(eq(messagesTable.id, dbMessages[i].id))
257
+ .run();
258
+ }
259
+
260
+ database
261
+ .insert(conversationKeys)
262
+ .values({
263
+ id: uuid(),
264
+ conversationKey: convKey,
265
+ conversationId: conversation.id,
266
+ createdAt: Date.now(),
267
+ })
268
+ .run();
269
+
270
+ importedCount++;
271
+ messageCount += conv.messages.length;
272
+ }
273
+
274
+ const lines = [
275
+ `Imported ${importedCount} conversation(s) with ${messageCount} message(s).`,
276
+ ];
277
+ if (skippedCount > 0) {
278
+ lines.push(`Skipped ${skippedCount} already-imported conversation(s).`);
279
+ }
280
+ return { content: lines.join("\n"), isError: false };
281
+ }
282
+
283
+ // -- Parser --
284
+
285
+ function parseChatGPTExport(zipPath: string): ImportedConversation[] {
286
+ const jsonContent = extractConversationsJsonFromZip(zipPath);
287
+
288
+ const raw = JSON.parse(jsonContent);
289
+ if (!Array.isArray(raw)) {
290
+ throw new Error("Expected conversations.json to contain a JSON array");
291
+ }
292
+
293
+ const results: ImportedConversation[] = [];
294
+ for (const conv of raw as ChatGPTConversation[]) {
295
+ const imported = parseConversation(conv);
296
+ if (imported) {
297
+ results.push(imported);
298
+ }
299
+ }
300
+ return results;
301
+ }
302
+
303
+ function parseConversation(
304
+ conv: ChatGPTConversation,
305
+ ): ImportedConversation | null {
306
+ const { mapping, current_node } = conv;
307
+ if (!mapping || !current_node || !mapping[current_node]) return null;
308
+
309
+ // Walk from current_node to root via parent pointers, then reverse for chronological order
310
+ const nodeIds: string[] = [];
311
+ let nodeId: string | null = current_node;
312
+ while (nodeId) {
313
+ nodeIds.push(nodeId);
314
+ nodeId = mapping[nodeId]?.parent ?? null;
315
+ }
316
+ nodeIds.reverse();
317
+
318
+ const messages: ImportedMessage[] = [];
319
+ for (const id of nodeIds) {
320
+ const node = mapping[id];
321
+ if (!node?.message) continue;
322
+
323
+ const { author, content, create_time } = node.message;
324
+ const role = author?.role;
325
+ if (role !== "user" && role !== "assistant") continue;
326
+
327
+ const text = extractText(content);
328
+ if (!text) continue;
329
+
330
+ messages.push({
331
+ role,
332
+ content: [{ type: "text", text }],
333
+ createdAt: create_time
334
+ ? Math.round(create_time * 1000)
335
+ : Math.round(conv.create_time * 1000),
336
+ });
337
+ }
338
+
339
+ if (messages.length === 0) return null;
340
+
341
+ return {
342
+ sourceId: conv.id ?? `${conv.title}-${conv.create_time}`,
343
+ title: conv.title || "Untitled",
344
+ createdAt: Math.round(conv.create_time * 1000),
345
+ updatedAt: Math.round(conv.update_time * 1000),
346
+ messages,
347
+ };
348
+ }
349
+
350
+ function extractText(content: ChatGPTContent): string {
351
+ if (!content?.parts) return "";
352
+ return content.parts
353
+ .filter((p): p is string => typeof p === "string")
354
+ .join("");
355
+ }
356
+
357
+ // -- ZIP extraction --
358
+
359
+ function extractConversationsJsonFromZip(zipPath: string): string {
360
+ const buffer = readFileSync(zipPath);
361
+
362
+ // Find end of central directory record (EOCD signature: 0x06054b50)
363
+ let eocdOffset = -1;
364
+ for (let i = buffer.length - 22; i >= 0; i--) {
365
+ if (
366
+ buffer[i] === 0x50 &&
367
+ buffer[i + 1] === 0x4b &&
368
+ buffer[i + 2] === 0x05 &&
369
+ buffer[i + 3] === 0x06
370
+ ) {
371
+ eocdOffset = i;
372
+ break;
373
+ }
374
+ }
375
+ if (eocdOffset === -1) {
376
+ throw new Error(
377
+ "Invalid ZIP file: could not find end of central directory",
378
+ );
379
+ }
380
+
381
+ const centralDirOffset = buffer.readUInt32LE(eocdOffset + 16);
382
+ const centralDirEntries = buffer.readUInt16LE(eocdOffset + 10);
383
+
384
+ // Walk central directory to find conversations.json
385
+ let offset = centralDirOffset;
386
+ for (let i = 0; i < centralDirEntries; i++) {
387
+ if (
388
+ buffer[offset] !== 0x50 ||
389
+ buffer[offset + 1] !== 0x4b ||
390
+ buffer[offset + 2] !== 0x01 ||
391
+ buffer[offset + 3] !== 0x02
392
+ ) {
393
+ throw new Error("Invalid ZIP central directory entry");
394
+ }
395
+
396
+ const cdCompressedSize = buffer.readUInt32LE(offset + 20);
397
+ const fileNameLength = buffer.readUInt16LE(offset + 28);
398
+ const extraLength = buffer.readUInt16LE(offset + 30);
399
+ const commentLength = buffer.readUInt16LE(offset + 32);
400
+ const localHeaderOffset = buffer.readUInt32LE(offset + 42);
401
+ const fileName = buffer
402
+ .subarray(offset + 46, offset + 46 + fileNameLength)
403
+ .toString("utf-8");
404
+
405
+ if (
406
+ fileName === "conversations.json" ||
407
+ fileName.endsWith("/conversations.json")
408
+ ) {
409
+ return extractLocalFile(buffer, localHeaderOffset, cdCompressedSize);
410
+ }
411
+
412
+ offset += 46 + fileNameLength + extraLength + commentLength;
413
+ }
414
+
415
+ throw new Error("conversations.json not found in ZIP file");
416
+ }
417
+
418
+ function extractLocalFile(
419
+ buffer: Buffer,
420
+ offset: number,
421
+ cdCompressedSize: number,
422
+ ): string {
423
+ if (
424
+ buffer[offset] !== 0x50 ||
425
+ buffer[offset + 1] !== 0x4b ||
426
+ buffer[offset + 2] !== 0x03 ||
427
+ buffer[offset + 3] !== 0x04
428
+ ) {
429
+ throw new Error("Invalid ZIP local file header");
430
+ }
431
+
432
+ const compressionMethod = buffer.readUInt16LE(offset + 8);
433
+ const localCompressedSize = buffer.readUInt32LE(offset + 18);
434
+ const compressedSize =
435
+ cdCompressedSize > 0 ? cdCompressedSize : localCompressedSize;
436
+ const fileNameLength = buffer.readUInt16LE(offset + 26);
437
+ const extraLength = buffer.readUInt16LE(offset + 28);
438
+
439
+ const dataOffset = offset + 30 + fileNameLength + extraLength;
440
+ const fileData = buffer.subarray(dataOffset, dataOffset + compressedSize);
441
+
442
+ if (compressionMethod === 0) {
443
+ return fileData.toString("utf-8");
444
+ } else if (compressionMethod === 8) {
445
+ return inflateRawSync(fileData).toString("utf-8");
446
+ } else {
447
+ throw new Error(`Unsupported ZIP compression method: ${compressionMethod}`);
448
+ }
449
+ }