@lakitu/sdk 0.1.63 → 0.1.65

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.
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * Sandbox Compiler
3
- *
3
+ *
4
4
  * Manages compilation manifest for sandbox definitions.
5
5
  */
6
6
 
@@ -27,11 +27,11 @@ export const saveManifest = internalMutation({
27
27
  .query("compiledSandbox")
28
28
  .withIndex("by_version", (q) => q.eq("version", args.version))
29
29
  .collect();
30
-
30
+
31
31
  for (const entry of existing) {
32
32
  await ctx.db.delete(entry._id);
33
33
  }
34
-
34
+
35
35
  // Insert new entries
36
36
  for (const item of args.manifest) {
37
37
  await ctx.db.insert("compiledSandbox", {
@@ -43,7 +43,7 @@ export const saveManifest = internalMutation({
43
43
  createdAt: Date.now(),
44
44
  });
45
45
  }
46
-
46
+
47
47
  return { saved: args.manifest.length };
48
48
  },
49
49
  });
@@ -60,15 +60,15 @@ export const getManifest = query({
60
60
  .withIndex("by_version", (q) => q.eq("version", args.version!))
61
61
  .collect();
62
62
  }
63
-
63
+
64
64
  // Get latest version
65
65
  const latest = await ctx.db
66
66
  .query("compiledSandbox")
67
67
  .order("desc")
68
68
  .first();
69
-
69
+
70
70
  if (!latest) return [];
71
-
71
+
72
72
  return await ctx.db
73
73
  .query("compiledSandbox")
74
74
  .withIndex("by_version", (q) => q.eq("version", latest.version))
@@ -84,7 +84,7 @@ export const getLatestVersion = query({
84
84
  .query("compiledSandbox")
85
85
  .order("desc")
86
86
  .first();
87
-
87
+
88
88
  return latest?.version ?? null;
89
89
  },
90
90
  });
@@ -112,35 +112,7 @@ export const getToolImplementation = query({
112
112
  .query("customTools")
113
113
  .withIndex("by_toolId", (q) => q.eq("toolId", args.toolId))
114
114
  .first();
115
-
115
+
116
116
  return tool?.implementation || null;
117
117
  },
118
118
  });
119
-
120
- // ============================================
121
- // Built-in Metadata Queries (deprecated - metadata now in Lakitu)
122
- // ============================================
123
-
124
- /** Get all built-in tool metadata */
125
- export const getBuiltInTools = query({
126
- args: {},
127
- handler: async () => [],
128
- });
129
-
130
- /** Get all built-in skill metadata */
131
- export const getBuiltInSkills = query({
132
- args: {},
133
- handler: async () => [],
134
- });
135
-
136
- /** Get all built-in deliverable metadata */
137
- export const getBuiltInDeliverables = query({
138
- args: {},
139
- handler: async () => [],
140
- });
141
-
142
- /** Get all agent definitions */
143
- export const getAgents = query({
144
- args: {},
145
- handler: async () => [],
146
- });
@@ -11,7 +11,7 @@
11
11
  * 4. Response returns through the chain
12
12
  */
13
13
 
14
- import { action, internalAction, query, mutation } from "../_generated/server";
14
+ import { action, query, mutation } from "../_generated/server";
15
15
  import { api, internal } from "../_generated/api";
16
16
  import { v } from "convex/values";
17
17
  import type { ChainOfThoughtStep, StepStatus } from "../../../shared/chain-of-thought";
@@ -68,7 +68,7 @@ interface GatewayConfig {
68
68
  jwt: string;
69
69
  }
70
70
 
71
- // Module-level gateway config (set by startThread/continueThread)
71
+ // Module-level gateway config (set by startCodeExecThread)
72
72
  let gatewayConfig: GatewayConfig | null = null;
73
73
 
74
74
  // Module-level chain-of-thought steps for real-time UI (in-memory per sandbox session)
@@ -105,7 +105,7 @@ function createToolStep(
105
105
  status: StepStatus
106
106
  ): Omit<ChainOfThoughtStep, "id" | "timestamp"> {
107
107
  const stepType = getStepTypeForTool(toolName);
108
-
108
+
109
109
  switch (stepType) {
110
110
  case "search": {
111
111
  // Extract URLs from search results
@@ -123,14 +123,14 @@ function createToolStep(
123
123
  results: urls.length > 0 ? urls : undefined,
124
124
  };
125
125
  }
126
-
126
+
127
127
  case "browser": {
128
128
  const action = toolName.replace("browser_", "") as any;
129
129
  return {
130
130
  type: "browser",
131
131
  status,
132
132
  action: action === "open" ? "navigate" : action,
133
- label: toolName === "browser_open"
133
+ label: toolName === "browser_open"
134
134
  ? `Navigating to ${(args as any).url}`
135
135
  : toolName === "browser_screenshot"
136
136
  ? "Taking screenshot"
@@ -139,9 +139,9 @@ function createToolStep(
139
139
  screenshot: toolName === "browser_screenshot" ? (result as any)?.screenshot : undefined,
140
140
  };
141
141
  }
142
-
142
+
143
143
  case "file": {
144
- const operation = toolName.includes("read") ? "read"
144
+ const operation = toolName.includes("read") ? "read"
145
145
  : toolName.includes("edit") ? "edit"
146
146
  : toolName.includes("pdf") ? "save"
147
147
  : "write";
@@ -156,7 +156,7 @@ function createToolStep(
156
156
  : `Saving ${path}`,
157
157
  };
158
158
  }
159
-
159
+
160
160
  default:
161
161
  return {
162
162
  type: "tool",
@@ -306,83 +306,6 @@ function createThreadId(): string {
306
306
  return `thread_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
307
307
  }
308
308
 
309
- // ============================================
310
- // Legacy Tool Execution Loop (DEPRECATED)
311
- // ============================================
312
-
313
- /**
314
- * @deprecated Use startCodeExecThread instead. Legacy JSON tool calling is no longer supported.
315
- */
316
- async function runAgentLoop(
317
- _ctx: any,
318
- _systemPrompt: string,
319
- _userPrompt: string,
320
- _maxSteps: number = 10,
321
- _threadId?: string
322
- ): Promise<{ text: string; toolCalls: ToolCall[] }> {
323
- throw new Error(
324
- "Legacy tool calling mode is deprecated. Use startCodeExecThread instead, " +
325
- "which uses the new KSA (Knowledge, Skills, Abilities) architecture with code execution."
326
- );
327
- }
328
-
329
- // ============================================
330
- // Agent Actions
331
- // ============================================
332
-
333
- /**
334
- * Start a new agent thread
335
- * @deprecated Use startCodeExecThread instead. Legacy JSON tool calling is no longer supported.
336
- */
337
- export const startThread = action({
338
- args: {
339
- prompt: v.string(),
340
- context: v.optional(v.any()),
341
- },
342
- handler: async (_ctx, _args): Promise<AgentResult> => {
343
- throw new Error(
344
- "startThread is deprecated. Use startCodeExecThread instead, " +
345
- "which uses the new KSA (Knowledge, Skills, Abilities) architecture with code execution."
346
- );
347
- },
348
- });
349
-
350
- /**
351
- * Continue an existing thread
352
- * @deprecated Use startCodeExecThread instead. Legacy JSON tool calling is no longer supported.
353
- */
354
- export const continueThread = action({
355
- args: {
356
- threadId: v.string(),
357
- prompt: v.string(),
358
- },
359
- handler: async (_ctx, _args): Promise<AgentResult> => {
360
- throw new Error(
361
- "continueThread is deprecated. Use startCodeExecThread instead, " +
362
- "which uses the new KSA (Knowledge, Skills, Abilities) architecture with code execution."
363
- );
364
- },
365
- });
366
-
367
- /**
368
- * Run agent with timeout for chained execution
369
- * @deprecated Use startCodeExecThread instead. Legacy JSON tool calling is no longer supported.
370
- */
371
- export const runWithTimeout = internalAction({
372
- args: {
373
- prompt: v.string(),
374
- context: v.optional(v.any()),
375
- timeoutMs: v.number(),
376
- checkpointId: v.optional(v.id("checkpoints")),
377
- },
378
- handler: async (_ctx, _args) => {
379
- throw new Error(
380
- "runWithTimeout is deprecated. Use startCodeExecThread instead, " +
381
- "which uses the new KSA (Knowledge, Skills, Abilities) architecture with code execution."
382
- );
383
- },
384
- });
385
-
386
309
  // ============================================
387
310
  // Queries
388
311
  // ============================================
@@ -412,7 +335,7 @@ export const getChainOfThoughtSteps = query({
412
335
  });
413
336
 
414
337
  // ============================================
415
- // Code Execution Mode (NEW ARCHITECTURE)
338
+ // Code Execution Mode
416
339
  // ============================================
417
340
 
418
341
  import { runCodeExecLoop, getSteps } from "./codeExecLoop";
@@ -421,12 +344,10 @@ import { getCodeExecSystemPrompt, generateKSAInstructions } from "../prompts/cod
421
344
  /**
422
345
  * Start a thread using code execution mode.
423
346
  *
424
- * This is the NEW architecture:
347
+ * This is the primary agent entry point:
425
348
  * - LLM generates TypeScript code
426
- * - Code imports from skills/ and executes
349
+ * - Code imports from KSA modules and executes
427
350
  * - No JSON tool calls
428
- *
429
- * Use this instead of startThread for the new code execution model.
430
351
  */
431
352
  export const startCodeExecThread = action({
432
353
  args: {
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Sandbox HTTP Endpoints
3
+ *
4
+ * Provides observability endpoints for the E2B sandbox.
5
+ * These run on port 3211 (site port) alongside Convex backend.
6
+ */
7
+
8
+ import { httpRouter } from "convex/server";
9
+ import { httpAction } from "./_generated/server";
10
+
11
+ const http = httpRouter();
12
+
13
+ /**
14
+ * Metrics endpoint - returns sandbox health and resource usage.
15
+ * Used by pool health checks and Claude Code observability.
16
+ *
17
+ * GET /metrics
18
+ *
19
+ * Response:
20
+ * {
21
+ * uptime: number, // Process uptime in seconds
22
+ * cpu: { usage: number, cores: number },
23
+ * memory: { total, free, used, heapUsed, heapTotal },
24
+ * timestamp: number
25
+ * }
26
+ */
27
+ http.route({
28
+ path: "/metrics",
29
+ method: "GET",
30
+ handler: httpAction(async () => {
31
+ // Dynamic imports for Node.js APIs (required for Convex bundling)
32
+ const os = await import("os");
33
+
34
+ const metrics = {
35
+ uptime: process.uptime(),
36
+ cpu: {
37
+ usage: os.loadavg()[0], // 1-minute load average
38
+ cores: os.cpus().length,
39
+ },
40
+ memory: {
41
+ total: os.totalmem(),
42
+ free: os.freemem(),
43
+ used: os.totalmem() - os.freemem(),
44
+ heapUsed: process.memoryUsage().heapUsed,
45
+ heapTotal: process.memoryUsage().heapTotal,
46
+ },
47
+ services: {
48
+ convex: "running", // We're responding, so Convex is up
49
+ },
50
+ timestamp: Date.now(),
51
+ };
52
+
53
+ return new Response(JSON.stringify(metrics), {
54
+ status: 200,
55
+ headers: {
56
+ "Content-Type": "application/json",
57
+ "Cache-Control": "no-cache",
58
+ },
59
+ });
60
+ }),
61
+ });
62
+
63
+ /**
64
+ * Health check endpoint - simple ping for pool management.
65
+ * Used by E2B pool to verify sandbox is responsive.
66
+ *
67
+ * GET /health
68
+ */
69
+ http.route({
70
+ path: "/health",
71
+ method: "GET",
72
+ handler: httpAction(async () => {
73
+ return new Response(JSON.stringify({ status: "ok", timestamp: Date.now() }), {
74
+ status: 200,
75
+ headers: { "Content-Type": "application/json" },
76
+ });
77
+ }),
78
+ });
79
+
80
+ /**
81
+ * Version endpoint - returns SDK version for debugging.
82
+ * Used by pool health checks.
83
+ *
84
+ * GET /version
85
+ */
86
+ http.route({
87
+ path: "/version",
88
+ method: "GET",
89
+ handler: httpAction(async () => {
90
+ // Read version from package.json at runtime
91
+ const fs = await import("fs/promises");
92
+ let version = "unknown";
93
+ try {
94
+ const pkg = JSON.parse(await fs.readFile("/home/user/lakitu/package.json", "utf-8"));
95
+ version = pkg.version || "unknown";
96
+ } catch {
97
+ // Fallback if file not found
98
+ }
99
+
100
+ return new Response(JSON.stringify({ version, timestamp: Date.now() }), {
101
+ status: 200,
102
+ headers: { "Content-Type": "application/json" },
103
+ });
104
+ }),
105
+ });
106
+
107
+ export default http;
@@ -8,11 +8,10 @@
8
8
  // Agent
9
9
  // ============================================
10
10
  export {
11
- startThread,
12
- continueThread,
13
- runWithTimeout,
11
+ startCodeExecThread,
14
12
  getThreadMessages,
15
13
  getStreamDeltas,
14
+ getChainOfThoughtSteps,
16
15
  } from "./agent";
17
16
  export * as decisions from "./agent/decisions";
18
17
 
@@ -38,12 +37,6 @@ export * as sync from "./planning/sync";
38
37
  export * as context from "./context";
39
38
  export * as session from "./context/session";
40
39
 
41
- // ============================================
42
- // Tools (DEPRECATED - Use KSAs instead)
43
- // ============================================
44
- // Legacy tool system has been removed. Use the KSA (Knowledge, Skills, Abilities)
45
- // architecture with code execution mode instead. See packages/lakitu/ksa/
46
-
47
40
  // ============================================
48
41
  // Prompts
49
42
  // ============================================
@@ -1,11 +1,14 @@
1
1
  "use node";
2
2
 
3
3
  /**
4
- * Code Execution Action
4
+ * Code Execution Action (with Streaming Support)
5
5
  *
6
6
  * Executes TypeScript code generated by the LLM.
7
7
  * This is the core of the code execution model - instead of JSON tool calls,
8
8
  * the agent writes code that imports from ksa/ and we execute it.
9
+ *
10
+ * Streaming: Output chunks are forwarded to cloud via fire-and-forget
11
+ * for real-time visibility during long-running executions.
9
12
  */
10
13
 
11
14
  import { internalAction } from "../_generated/server";
@@ -15,11 +18,14 @@ import { v } from "convex/values";
15
18
  const MAX_TIMEOUT_MS = 120_000; // 2 minutes
16
19
  const MAX_OUTPUT_LENGTH = 50_000; // 50KB
17
20
 
21
+ // Chunk size for streaming (balance between latency and overhead)
22
+ const STREAM_CHUNK_INTERVAL_MS = 100; // Batch output every 100ms
23
+
18
24
  /**
19
- * Execute TypeScript code in the sandbox.
25
+ * Execute TypeScript code in the sandbox with streaming output.
20
26
  *
21
27
  * The code can import from /home/user/ksa/ to use available capabilities.
22
- * Output is captured from stdout/stderr.
28
+ * Output is captured and optionally streamed to the cloud.
23
29
  */
24
30
  export const execute = internalAction({
25
31
  args: {
@@ -33,20 +39,25 @@ export const execute = internalAction({
33
39
  CARD_ID: v.optional(v.string()),
34
40
  THREAD_ID: v.optional(v.string()),
35
41
  })),
42
+ // Streaming configuration
43
+ streamConfig: v.optional(v.object({
44
+ sessionId: v.optional(v.string()),
45
+ stepId: v.optional(v.string()),
46
+ cloudUrl: v.optional(v.string()),
47
+ })),
36
48
  },
37
49
  handler: async (_ctx, args): Promise<{
38
50
  success: boolean;
39
51
  output: string;
40
52
  error?: string;
41
53
  exitCode: number;
54
+ streamedChunks?: number; // Track how many chunks were streamed
42
55
  }> => {
43
56
  // Dynamic imports for Node.js modules (required for Convex bundling)
44
- const { exec } = await import("child_process");
45
- const { promisify } = await import("util");
57
+ const { spawn } = await import("child_process");
46
58
  const fs = await import("fs/promises");
47
59
  const crypto = await import("crypto");
48
60
 
49
- const execAsync = promisify(exec);
50
61
  const timeout = Math.min(args.timeoutMs || 60_000, MAX_TIMEOUT_MS);
51
62
 
52
63
  // Generate unique filename for this execution
@@ -54,32 +65,140 @@ export const execute = internalAction({
54
65
  const hash = crypto.createHash("md5").update(args.code).digest("hex").slice(0, 8);
55
66
  const filename = `/home/user/agent_exec_${Date.now()}_${hash}.ts`;
56
67
 
68
+ // Streaming state
69
+ let streamBuffer = "";
70
+ let streamedChunks = 0;
71
+ let streamInterval: NodeJS.Timeout | null = null;
72
+
73
+ // Fire-and-forget stream chunk to cloud
74
+ const flushStreamBuffer = () => {
75
+ if (!streamBuffer || !args.streamConfig?.cloudUrl) return;
76
+
77
+ const chunk = streamBuffer;
78
+ streamBuffer = "";
79
+ streamedChunks++;
80
+
81
+ // Non-blocking POST to cloud
82
+ fetch(`${args.streamConfig.cloudUrl}/agent/stream`, {
83
+ method: "POST",
84
+ headers: {
85
+ "Content-Type": "application/json",
86
+ Authorization: `Bearer ${args.env?.SANDBOX_JWT || ""}`,
87
+ },
88
+ body: JSON.stringify({
89
+ sessionId: args.streamConfig.sessionId,
90
+ stepId: args.streamConfig.stepId,
91
+ chunk,
92
+ chunkIndex: streamedChunks,
93
+ timestamp: Date.now(),
94
+ }),
95
+ }).catch(() => {
96
+ // Fire-and-forget - ignore errors
97
+ });
98
+ };
99
+
57
100
  try {
58
101
  // Write code to temp file
59
102
  await fs.writeFile(filename, args.code, "utf-8");
60
103
 
61
- // Execute with bun (use full path since PATH may not be set in Convex process)
104
+ // Execute with bun using spawn (for streaming)
62
105
  const bunPath = "/home/user/.bun/bin/bun";
63
- const { stdout, stderr } = await execAsync(`${bunPath} run ${filename}`, {
64
- timeout,
106
+ const proc = spawn(bunPath, ["run", filename], {
65
107
  cwd: "/home/user/workspace",
66
108
  env: {
67
109
  ...process.env,
68
- // Ensure PATH includes bun
69
110
  PATH: "/home/user/.bun/bin:/usr/local/bin:/usr/bin:/bin",
70
111
  HOME: "/home/user",
71
- // Make KSAs available via import path
72
112
  NODE_PATH: "/home/user",
73
- // Gateway config for KSAs to call cloud services
74
113
  ...(args.env?.CONVEX_URL && { CONVEX_URL: args.env.CONVEX_URL }),
75
114
  ...(args.env?.GATEWAY_URL && { GATEWAY_URL: args.env.GATEWAY_URL }),
76
115
  ...(args.env?.SANDBOX_JWT && { SANDBOX_JWT: args.env.SANDBOX_JWT }),
77
116
  ...(args.env?.CARD_ID && { CARD_ID: args.env.CARD_ID }),
78
117
  ...(args.env?.THREAD_ID && { THREAD_ID: args.env.THREAD_ID }),
79
118
  },
80
- maxBuffer: MAX_OUTPUT_LENGTH * 2,
81
119
  });
82
120
 
121
+ // Collect output
122
+ let stdout = "";
123
+ let stderr = "";
124
+
125
+ // Start streaming interval if configured
126
+ if (args.streamConfig?.cloudUrl) {
127
+ streamInterval = setInterval(flushStreamBuffer, STREAM_CHUNK_INTERVAL_MS);
128
+ }
129
+
130
+ // Handle stdout
131
+ proc.stdout?.on("data", (data: Buffer) => {
132
+ const text = data.toString();
133
+ stdout += text;
134
+
135
+ // Add to stream buffer if streaming enabled
136
+ if (args.streamConfig?.cloudUrl) {
137
+ streamBuffer += text;
138
+ }
139
+ });
140
+
141
+ // Handle stderr
142
+ proc.stderr?.on("data", (data: Buffer) => {
143
+ const text = data.toString();
144
+ stderr += text;
145
+
146
+ // Add to stream buffer if streaming enabled
147
+ if (args.streamConfig?.cloudUrl) {
148
+ streamBuffer += `[stderr] ${text}`;
149
+ }
150
+ });
151
+
152
+ // Wait for process to complete with timeout
153
+ const result = await new Promise<{ success: boolean; exitCode: number; error?: string }>((resolve) => {
154
+ let resolved = false;
155
+
156
+ // Timeout handler
157
+ const timeoutId = setTimeout(() => {
158
+ if (!resolved) {
159
+ resolved = true;
160
+ proc.kill("SIGTERM");
161
+ resolve({
162
+ success: false,
163
+ exitCode: 124,
164
+ error: `Execution timed out after ${timeout}ms`,
165
+ });
166
+ }
167
+ }, timeout);
168
+
169
+ // Normal completion
170
+ proc.on("close", (code) => {
171
+ if (!resolved) {
172
+ resolved = true;
173
+ clearTimeout(timeoutId);
174
+ resolve({
175
+ success: code === 0,
176
+ exitCode: code ?? 1,
177
+ error: code !== 0 ? `Process exited with code ${code}` : undefined,
178
+ });
179
+ }
180
+ });
181
+
182
+ // Error handler
183
+ proc.on("error", (err) => {
184
+ if (!resolved) {
185
+ resolved = true;
186
+ clearTimeout(timeoutId);
187
+ resolve({
188
+ success: false,
189
+ exitCode: 1,
190
+ error: err.message,
191
+ });
192
+ }
193
+ });
194
+ });
195
+
196
+ // Stop streaming interval and flush remaining buffer
197
+ if (streamInterval) {
198
+ clearInterval(streamInterval);
199
+ flushStreamBuffer();
200
+ }
201
+
83
202
  // Combine and truncate output
84
203
  let output = stdout || "";
85
204
  if (stderr) {
@@ -90,34 +209,27 @@ export const execute = internalAction({
90
209
  }
91
210
 
92
211
  return {
93
- success: true,
212
+ success: result.success,
94
213
  output: output.trim(),
95
- exitCode: 0,
214
+ error: result.error,
215
+ exitCode: result.exitCode,
216
+ streamedChunks: args.streamConfig?.cloudUrl ? streamedChunks : undefined,
96
217
  };
97
218
  } catch (error: unknown) {
98
- const err = error as { message?: string; stdout?: string; stderr?: string; code?: number };
99
- const message = err.message || String(error);
100
- let output = "";
101
-
102
- // Capture any partial output
103
- if (err.stdout) output += err.stdout;
104
- if (err.stderr) output += `\n[stderr]\n${err.stderr}`;
105
-
106
- // Check for timeout
107
- if (message.includes("TIMEOUT") || message.includes("timed out")) {
108
- return {
109
- success: false,
110
- output: output.trim(),
111
- error: `Execution timed out after ${timeout}ms`,
112
- exitCode: 124,
113
- };
219
+ // Stop streaming on error
220
+ if (streamInterval) {
221
+ clearInterval(streamInterval);
114
222
  }
115
223
 
224
+ const err = error as { message?: string };
225
+ const message = err.message || String(error);
226
+
116
227
  return {
117
228
  success: false,
118
- output: output.trim(),
229
+ output: "",
119
230
  error: message,
120
- exitCode: err.code || 1,
231
+ exitCode: 1,
232
+ streamedChunks: args.streamConfig?.cloudUrl ? streamedChunks : undefined,
121
233
  };
122
234
  } finally {
123
235
  // Clean up temp file
@@ -476,7 +476,7 @@ export default defineSchema({
476
476
  .index("by_thread", ["threadId"]),
477
477
 
478
478
  // ============================================
479
- // Sync Queue - Items to sync to cloud
479
+ // Sync Queue - Items to sync to cloud (legacy)
480
480
  // ============================================
481
481
 
482
482
  syncQueue: defineTable({
@@ -507,4 +507,135 @@ export default defineSchema({
507
507
  })
508
508
  .index("by_status", ["status"])
509
509
  .index("by_type", ["type"]),
510
+
511
+ // ============================================
512
+ // Sync Outbox - Transactional outbox for cloud sync
513
+ // ============================================
514
+ // Records all local writes that need to sync to cloud.
515
+ // Uses idempotency keys for at-least-once delivery without duplicates.
516
+
517
+ syncOutbox: defineTable({
518
+ // Idempotency key - UUID generated at write time, ensures no duplicates
519
+ idempotencyKey: v.string(),
520
+
521
+ // Entity reference
522
+ entityType: v.string(), // "brands.products", "brands.assets", "artifacts", etc.
523
+ entityId: v.string(), // Local ID of the entity
524
+
525
+ // Operation
526
+ operation: v.union(
527
+ v.literal("create"),
528
+ v.literal("update"),
529
+ v.literal("delete"),
530
+ v.literal("upsert")
531
+ ),
532
+
533
+ // Payload to send to cloud
534
+ payload: v.any(),
535
+
536
+ // Cloud target (gateway path)
537
+ cloudPath: v.string(), // e.g., "features.brands.core.products.saveProducts"
538
+
539
+ // Status tracking
540
+ status: v.union(
541
+ v.literal("pending"),
542
+ v.literal("syncing"),
543
+ v.literal("synced"),
544
+ v.literal("failed"),
545
+ v.literal("skipped")
546
+ ),
547
+
548
+ // Retry tracking
549
+ attempts: v.number(),
550
+ maxAttempts: v.number(),
551
+ lastAttemptAt: v.optional(v.number()),
552
+ lastError: v.optional(v.string()),
553
+
554
+ // Result tracking
555
+ cloudId: v.optional(v.string()), // ID returned from cloud
556
+ syncedAt: v.optional(v.number()),
557
+
558
+ // Metadata
559
+ priority: v.number(), // 0=highest, 10=lowest
560
+ createdAt: v.number(),
561
+ sessionId: v.optional(v.string()),
562
+ threadId: v.optional(v.string()),
563
+ })
564
+ .index("by_status", ["status"])
565
+ .index("by_idempotency", ["idempotencyKey"])
566
+ .index("by_entity", ["entityType", "entityId"])
567
+ .index("by_session", ["sessionId", "status"])
568
+ .index("by_priority", ["status", "priority", "createdAt"]),
569
+
570
+ // ============================================
571
+ // Marketing Copy - Extracted marketing content
572
+ // ============================================
573
+
574
+ discoveredMarketingCopy: defineTable({
575
+ domain: v.string(),
576
+ sourceUrl: v.string(),
577
+ copyType: v.union(
578
+ v.literal("headline"),
579
+ v.literal("tagline"),
580
+ v.literal("value_prop"),
581
+ v.literal("cta"),
582
+ v.literal("testimonial"),
583
+ v.literal("feature_description"),
584
+ v.literal("other")
585
+ ),
586
+ content: v.string(),
587
+ context: v.optional(v.string()),
588
+ extractedAt: v.number(),
589
+ threadId: v.optional(v.string()),
590
+ syncedToCloud: v.boolean(),
591
+ })
592
+ .index("by_domain", ["domain"])
593
+ .index("by_type", ["copyType"])
594
+ .index("by_synced", ["syncedToCloud"]),
595
+
596
+ // ============================================
597
+ // Services - Extracted service offerings
598
+ // ============================================
599
+
600
+ discoveredServices: defineTable({
601
+ domain: v.string(),
602
+ sourceUrl: v.string(),
603
+ name: v.string(),
604
+ serviceType: v.union(
605
+ v.literal("consulting"),
606
+ v.literal("implementation"),
607
+ v.literal("support"),
608
+ v.literal("training"),
609
+ v.literal("managed_service"),
610
+ v.literal("professional_service"),
611
+ v.literal("other")
612
+ ),
613
+ description: v.optional(v.string()),
614
+ pricing: v.optional(v.string()),
615
+ extractedAt: v.number(),
616
+ threadId: v.optional(v.string()),
617
+ syncedToCloud: v.boolean(),
618
+ })
619
+ .index("by_domain", ["domain"])
620
+ .index("by_type", ["serviceType"])
621
+ .index("by_synced", ["syncedToCloud"]),
622
+
623
+ // ============================================
624
+ // Reviews - Extracted reviews/testimonials
625
+ // ============================================
626
+
627
+ discoveredReviews: defineTable({
628
+ domain: v.string(),
629
+ sourceUrl: v.string(),
630
+ reviewerName: v.optional(v.string()),
631
+ rating: v.optional(v.number()),
632
+ content: v.string(),
633
+ date: v.optional(v.string()),
634
+ platform: v.optional(v.string()),
635
+ extractedAt: v.number(),
636
+ threadId: v.optional(v.string()),
637
+ syncedToCloud: v.boolean(),
638
+ })
639
+ .index("by_domain", ["domain"])
640
+ .index("by_synced", ["syncedToCloud"]),
510
641
  });
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Sync Outbox - Local to Cloud Sync
3
+ *
4
+ * Transactional outbox pattern for syncing local sandbox data to cloud.
5
+ * All local writes are recorded atomically, then synced via gateway.
6
+ */
7
+
8
+ import { v } from "convex/values";
9
+ import { mutation, query } from "../_generated/server";
10
+
11
+ // ============================================================================
12
+ // Outbox Operations
13
+ // ============================================================================
14
+
15
+ /**
16
+ * Add an item to the sync outbox.
17
+ * Called atomically with the local write.
18
+ */
19
+ export const addToOutbox = mutation({
20
+ args: {
21
+ idempotencyKey: v.string(),
22
+ entityType: v.string(),
23
+ entityId: v.string(),
24
+ operation: v.union(
25
+ v.literal("create"),
26
+ v.literal("update"),
27
+ v.literal("delete"),
28
+ v.literal("upsert")
29
+ ),
30
+ payload: v.any(),
31
+ cloudPath: v.string(),
32
+ priority: v.optional(v.number()),
33
+ sessionId: v.optional(v.string()),
34
+ threadId: v.optional(v.string()),
35
+ },
36
+ handler: async (ctx, args) => {
37
+ // Check for existing entry with same idempotency key
38
+ const existing = await ctx.db
39
+ .query("syncOutbox")
40
+ .withIndex("by_idempotency", q => q.eq("idempotencyKey", args.idempotencyKey))
41
+ .first();
42
+
43
+ if (existing) {
44
+ // Already in outbox, skip
45
+ return existing._id;
46
+ }
47
+
48
+ return await ctx.db.insert("syncOutbox", {
49
+ idempotencyKey: args.idempotencyKey,
50
+ entityType: args.entityType,
51
+ entityId: args.entityId,
52
+ operation: args.operation,
53
+ payload: args.payload,
54
+ cloudPath: args.cloudPath,
55
+ status: "pending",
56
+ attempts: 0,
57
+ maxAttempts: 3,
58
+ priority: args.priority ?? 5,
59
+ createdAt: Date.now(),
60
+ sessionId: args.sessionId,
61
+ threadId: args.threadId,
62
+ });
63
+ },
64
+ });
65
+
66
+ /**
67
+ * Get pending items from outbox, ordered by priority and creation time.
68
+ */
69
+ export const getPendingItems = query({
70
+ args: {
71
+ limit: v.optional(v.number()),
72
+ sessionId: v.optional(v.string()),
73
+ },
74
+ handler: async (ctx, args) => {
75
+ const limit = args.limit ?? 50;
76
+
77
+ if (args.sessionId) {
78
+ return await ctx.db
79
+ .query("syncOutbox")
80
+ .withIndex("by_session", q => q.eq("sessionId", args.sessionId).eq("status", "pending"))
81
+ .take(limit);
82
+ }
83
+
84
+ return await ctx.db
85
+ .query("syncOutbox")
86
+ .withIndex("by_priority", q => q.eq("status", "pending"))
87
+ .take(limit);
88
+ },
89
+ });
90
+
91
+ /**
92
+ * Mark item as syncing (in progress).
93
+ */
94
+ export const markSyncing = mutation({
95
+ args: {
96
+ id: v.id("syncOutbox"),
97
+ },
98
+ handler: async (ctx, args) => {
99
+ const item = await ctx.db.get(args.id);
100
+ if (!item) return null;
101
+
102
+ await ctx.db.patch(args.id, {
103
+ status: "syncing",
104
+ lastAttemptAt: Date.now(),
105
+ attempts: item.attempts + 1,
106
+ });
107
+
108
+ return item;
109
+ },
110
+ });
111
+
112
+ /**
113
+ * Mark item as synced (success).
114
+ */
115
+ export const markSynced = mutation({
116
+ args: {
117
+ id: v.id("syncOutbox"),
118
+ cloudId: v.optional(v.string()),
119
+ },
120
+ handler: async (ctx, args) => {
121
+ await ctx.db.patch(args.id, {
122
+ status: "synced",
123
+ syncedAt: Date.now(),
124
+ cloudId: args.cloudId,
125
+ });
126
+ },
127
+ });
128
+
129
+ /**
130
+ * Mark item as failed.
131
+ */
132
+ export const markFailed = mutation({
133
+ args: {
134
+ id: v.id("syncOutbox"),
135
+ error: v.string(),
136
+ },
137
+ handler: async (ctx, args) => {
138
+ const item = await ctx.db.get(args.id);
139
+ if (!item) return;
140
+
141
+ const newStatus = item.attempts >= item.maxAttempts ? "failed" : "pending";
142
+
143
+ await ctx.db.patch(args.id, {
144
+ status: newStatus,
145
+ lastError: args.error,
146
+ lastAttemptAt: Date.now(),
147
+ });
148
+ },
149
+ });
150
+
151
+ /**
152
+ * Get outbox statistics.
153
+ */
154
+ export const getStats = query({
155
+ args: {
156
+ sessionId: v.optional(v.string()),
157
+ },
158
+ handler: async (ctx, args) => {
159
+ let items;
160
+ if (args.sessionId) {
161
+ items = await ctx.db
162
+ .query("syncOutbox")
163
+ .withIndex("by_session", q => q.eq("sessionId", args.sessionId))
164
+ .collect();
165
+ } else {
166
+ items = await ctx.db.query("syncOutbox").collect();
167
+ }
168
+
169
+ return {
170
+ total: items.length,
171
+ pending: items.filter(i => i.status === "pending").length,
172
+ syncing: items.filter(i => i.status === "syncing").length,
173
+ synced: items.filter(i => i.status === "synced").length,
174
+ failed: items.filter(i => i.status === "failed").length,
175
+ byType: items.reduce((acc, i) => {
176
+ acc[i.entityType] = (acc[i.entityType] || 0) + 1;
177
+ return acc;
178
+ }, {} as Record<string, number>),
179
+ };
180
+ },
181
+ });
182
+
183
+ /**
184
+ * Clear synced items (cleanup).
185
+ */
186
+ export const clearSynced = mutation({
187
+ args: {
188
+ olderThanMs: v.optional(v.number()),
189
+ },
190
+ handler: async (ctx, args) => {
191
+ const cutoff = Date.now() - (args.olderThanMs ?? 24 * 60 * 60 * 1000); // 24h default
192
+
193
+ const synced = await ctx.db
194
+ .query("syncOutbox")
195
+ .withIndex("by_status", q => q.eq("status", "synced"))
196
+ .collect();
197
+
198
+ let deleted = 0;
199
+ for (const item of synced) {
200
+ if (item.syncedAt && item.syncedAt < cutoff) {
201
+ await ctx.db.delete(item._id);
202
+ deleted++;
203
+ }
204
+ }
205
+
206
+ return { deleted };
207
+ },
208
+ });
209
+
210
+ /**
211
+ * Retry failed items.
212
+ */
213
+ export const retryFailed = mutation({
214
+ args: {
215
+ entityType: v.optional(v.string()),
216
+ },
217
+ handler: async (ctx, args) => {
218
+ const failed = await ctx.db
219
+ .query("syncOutbox")
220
+ .withIndex("by_status", q => q.eq("status", "failed"))
221
+ .collect();
222
+
223
+ let retried = 0;
224
+ for (const item of failed) {
225
+ if (!args.entityType || item.entityType === args.entityType) {
226
+ await ctx.db.patch(item._id, {
227
+ status: "pending",
228
+ attempts: 0,
229
+ lastError: undefined,
230
+ });
231
+ retried++;
232
+ }
233
+ }
234
+
235
+ return { retried };
236
+ },
237
+ });
@@ -0,0 +1,240 @@
1
+ /**
2
+ * KSA Tracing Module
3
+ *
4
+ * Provides tracing utilities for KSA gateway calls.
5
+ * Enables per-step call tracing for observability into agent execution.
6
+ *
7
+ * Usage:
8
+ * 1. Agent loop calls setStepContext() at start of each step
9
+ * 2. KSAs use tracedGatewayCall() instead of callGateway()
10
+ * 3. Agent loop calls flushTraces() after step completion
11
+ */
12
+
13
+ import { callGateway, fireAndForget } from "./gateway";
14
+
15
+ // =============================================================================
16
+ // Types
17
+ // =============================================================================
18
+
19
+ export interface TraceEntry {
20
+ traceId: string;
21
+ stepId: string | null;
22
+ event: "ksa_call_start" | "ksa_call_end";
23
+ path: string;
24
+ timestamp: number;
25
+ durationMs?: number;
26
+ success?: boolean;
27
+ error?: string;
28
+ }
29
+
30
+ export interface StepContext {
31
+ stepId: string;
32
+ sessionId?: string;
33
+ stepNumber?: number;
34
+ }
35
+
36
+ // =============================================================================
37
+ // Tracing State (per-sandbox)
38
+ // =============================================================================
39
+
40
+ let currentStepContext: StepContext | null = null;
41
+ let traceBuffer: TraceEntry[] = [];
42
+
43
+ /**
44
+ * Generate a unique trace ID for KSA calls.
45
+ * Format: ksa-{timestamp}-{random} for easy identification.
46
+ */
47
+ function generateTraceId(): string {
48
+ const timestamp = Date.now().toString(36);
49
+ const random = Math.random().toString(36).substring(2, 6);
50
+ return `ksa-${timestamp}-${random}`;
51
+ }
52
+
53
+ // =============================================================================
54
+ // Context Management
55
+ // =============================================================================
56
+
57
+ /**
58
+ * Set the current step context for tracing.
59
+ * Call this at the start of each agent step.
60
+ *
61
+ * @param context - Step context with stepId and optional metadata
62
+ *
63
+ * @example
64
+ * setStepContext({ stepId: 'step-001', sessionId: 'sess-123', stepNumber: 1 });
65
+ */
66
+ export function setStepContext(context: StepContext): void {
67
+ currentStepContext = context;
68
+ }
69
+
70
+ /**
71
+ * Clear the current step context.
72
+ * Call this after step completion.
73
+ */
74
+ export function clearStepContext(): void {
75
+ currentStepContext = null;
76
+ }
77
+
78
+ /**
79
+ * Get the current step context (for debugging).
80
+ */
81
+ export function getStepContext(): StepContext | null {
82
+ return currentStepContext;
83
+ }
84
+
85
+ // =============================================================================
86
+ // Traced Gateway Calls
87
+ // =============================================================================
88
+
89
+ /**
90
+ * Make a traced gateway call.
91
+ * Records start/end events with timing for observability.
92
+ *
93
+ * @param path - Service path (e.g., 'services.Valyu.internal.search')
94
+ * @param args - Arguments to pass to the service
95
+ * @param type - Operation type (query, mutation, action)
96
+ * @returns Service response data
97
+ *
98
+ * @example
99
+ * const data = await tracedGatewayCall('features.brands.core.crud.get', { id: 'abc123' });
100
+ */
101
+ export async function tracedGatewayCall<T = unknown>(
102
+ path: string,
103
+ args: Record<string, unknown>,
104
+ type?: "query" | "mutation" | "action"
105
+ ): Promise<T> {
106
+ const traceId = generateTraceId();
107
+ const startTime = Date.now();
108
+ const stepId = currentStepContext?.stepId ?? null;
109
+
110
+ // Record start event
111
+ traceBuffer.push({
112
+ traceId,
113
+ stepId,
114
+ event: "ksa_call_start",
115
+ path,
116
+ timestamp: startTime,
117
+ });
118
+
119
+ try {
120
+ const result = await callGateway<T>(path, args, type);
121
+
122
+ // Record success event
123
+ traceBuffer.push({
124
+ traceId,
125
+ stepId,
126
+ event: "ksa_call_end",
127
+ path,
128
+ timestamp: Date.now(),
129
+ durationMs: Date.now() - startTime,
130
+ success: true,
131
+ });
132
+
133
+ return result;
134
+ } catch (error: unknown) {
135
+ const err = error as { message?: string };
136
+
137
+ // Record failure event
138
+ traceBuffer.push({
139
+ traceId,
140
+ stepId,
141
+ event: "ksa_call_end",
142
+ path,
143
+ timestamp: Date.now(),
144
+ durationMs: Date.now() - startTime,
145
+ success: false,
146
+ error: err.message || String(error),
147
+ });
148
+
149
+ throw error;
150
+ }
151
+ }
152
+
153
+ // =============================================================================
154
+ // Trace Buffer Management
155
+ // =============================================================================
156
+
157
+ /**
158
+ * Get all traces in the buffer without clearing.
159
+ * Useful for debugging.
160
+ */
161
+ export function getTraces(): TraceEntry[] {
162
+ return [...traceBuffer];
163
+ }
164
+
165
+ /**
166
+ * Flush traces from the buffer and return them.
167
+ * Call this after each step to collect traces.
168
+ *
169
+ * @returns Array of trace entries
170
+ *
171
+ * @example
172
+ * const traces = flushTraces();
173
+ * console.log(`Step had ${traces.length} KSA calls`);
174
+ */
175
+ export function flushTraces(): TraceEntry[] {
176
+ const traces = [...traceBuffer];
177
+ traceBuffer = [];
178
+ return traces;
179
+ }
180
+
181
+ /**
182
+ * Flush traces to the cloud via fire-and-forget.
183
+ * Use this for real-time observability without blocking.
184
+ *
185
+ * @param cloudPath - Path to send traces (default: 'components.lakitu.workflows.sandboxConvex.appendTraces')
186
+ *
187
+ * @example
188
+ * flushTracesToCloud();
189
+ */
190
+ export function flushTracesToCloud(
191
+ cloudPath = "components.lakitu.workflows.sandboxConvex.appendTraces"
192
+ ): void {
193
+ const traces = flushTraces();
194
+ if (traces.length === 0) return;
195
+
196
+ const sessionId = currentStepContext?.sessionId;
197
+ if (!sessionId) {
198
+ console.warn("[trace] Cannot flush traces: no sessionId in context");
199
+ return;
200
+ }
201
+
202
+ fireAndForget(cloudPath, {
203
+ sessionId,
204
+ traces,
205
+ });
206
+ }
207
+
208
+ /**
209
+ * Get trace statistics for the current buffer.
210
+ * Useful for debugging and observability.
211
+ */
212
+ export function getTraceStats(): {
213
+ totalCalls: number;
214
+ successfulCalls: number;
215
+ failedCalls: number;
216
+ avgDurationMs: number;
217
+ callsByPath: Record<string, number>;
218
+ } {
219
+ const endTraces = traceBuffer.filter((t) => t.event === "ksa_call_end");
220
+
221
+ const successfulCalls = endTraces.filter((t) => t.success).length;
222
+ const failedCalls = endTraces.filter((t) => !t.success).length;
223
+
224
+ const durations = endTraces.filter((t) => t.durationMs !== undefined).map((t) => t.durationMs!);
225
+ const avgDurationMs =
226
+ durations.length > 0 ? Math.round(durations.reduce((a, b) => a + b, 0) / durations.length) : 0;
227
+
228
+ const callsByPath: Record<string, number> = {};
229
+ for (const trace of endTraces) {
230
+ callsByPath[trace.path] = (callsByPath[trace.path] || 0) + 1;
231
+ }
232
+
233
+ return {
234
+ totalCalls: endTraces.length,
235
+ successfulCalls,
236
+ failedCalls,
237
+ avgDurationMs,
238
+ callsByPath,
239
+ };
240
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lakitu/sdk",
3
- "version": "0.1.63",
3
+ "version": "0.1.65",
4
4
  "description": "Self-hosted AI agent framework for Convex + E2B with code execution",
5
5
  "type": "module",
6
6
  "main": "./dist/sdk/index.js",
@@ -71,6 +71,10 @@
71
71
  "./ksa/browser": {
72
72
  "bun": "./ksa/browser.ts",
73
73
  "default": "./ksa/browser.ts"
74
+ },
75
+ "./ksa/trace": {
76
+ "bun": "./ksa/_shared/trace.ts",
77
+ "default": "./ksa/_shared/trace.ts"
74
78
  }
75
79
  },
76
80
  "files": [
package/template/build.ts CHANGED
@@ -280,7 +280,7 @@ const customTemplate = (baseId: string, buildDir: string, config: TemplateConfig
280
280
  if (aptPackages.length > 0 || pipPackages.length > 0 || npmPackages.length > 0) {
281
281
  console.log("Installing custom packages from template config...");
282
282
  const installCmds: string[] = [];
283
-
283
+
284
284
  if (aptPackages.length > 0) {
285
285
  console.log(` APT: ${aptPackages.join(", ")}`);
286
286
  installCmds.push(generateAptInstall(aptPackages));
@@ -396,20 +396,27 @@ async function buildCustom(baseId = "lakitu-base") {
396
396
  ${LAKITU_DIR}/ ${BUILD_DIR}/lakitu/`.quiet();
397
397
  await $`cp ${import.meta.dir}/e2b/start.sh ${BUILD_DIR}/`;
398
398
 
399
- // Copy project-specific KSAs from convex/lakitu/ksa/ (new location) or lakitu/ (legacy)
400
- const NEW_KSA_DIR = `${LAKITU_DIR}/../../convex/lakitu/ksa`;
401
- const LEGACY_KSA_DIR = `${LAKITU_DIR}/../../lakitu`;
399
+ // Copy project-specific KSAs
400
+ // Supported paths (checked in order):
401
+ // 1. convex/lakitu/ksa/ - Alternative location (colocated with convex backend)
402
+ // 2. lakitu/ - Standard location for project.social (27 KSA modules)
403
+ //
404
+ // Note: project.social uses lakitu/ at the project root as its standard KSA location.
405
+ // The convex/lakitu/ksa/ path is supported for projects that prefer colocating KSAs
406
+ // with their Convex backend code.
407
+ const ALT_KSA_DIR = `${LAKITU_DIR}/../../convex/lakitu/ksa`;
408
+ const STANDARD_KSA_DIR = `${LAKITU_DIR}/../../lakitu`;
402
409
  await $`mkdir -p ${BUILD_DIR}/project-ksa`.quiet();
403
-
404
- // Try new location first, fall back to legacy
410
+
411
+ // Try alternative location first (convex/lakitu/ksa/), fall back to standard (lakitu/)
405
412
  try {
406
- await $`test -d ${NEW_KSA_DIR}`.quiet();
407
- await $`cp -r ${NEW_KSA_DIR}/* ${BUILD_DIR}/project-ksa/`.quiet();
413
+ await $`test -d ${ALT_KSA_DIR}`.quiet();
414
+ await $`cp -r ${ALT_KSA_DIR}/* ${BUILD_DIR}/project-ksa/`.quiet();
408
415
  console.log("Copied project KSAs from convex/lakitu/ksa/");
409
416
  } catch {
410
417
  try {
411
- await $`cp -r ${LEGACY_KSA_DIR}/* ${BUILD_DIR}/project-ksa/`.quiet();
412
- console.log("Copied project KSAs from lakitu/ (legacy location)");
418
+ await $`cp -r ${STANDARD_KSA_DIR}/* ${BUILD_DIR}/project-ksa/`.quiet();
419
+ console.log("Copied project KSAs from lakitu/");
413
420
  } catch {
414
421
  console.log("No project KSAs found");
415
422
  }