tana-mcp-codemode 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts ADDED
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Tana MCP Server - Entry Point
4
+ *
5
+ * Codemode MCP server for Tana knowledge management.
6
+ * AI writes TypeScript code that executes against the Tana Local API.
7
+ */
8
+
9
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
11
+ import { z } from "zod";
12
+
13
+ import { createClient } from "./api/client";
14
+ import { createTanaAPI } from "./api/tana";
15
+ import { executeSandbox } from "./sandbox/executor";
16
+ import { cleanupOldRuns, initDb } from "./storage/history";
17
+ import { TOOL_DESCRIPTION } from "./prompts";
18
+
19
+ async function main() {
20
+ // Initialize database and cleanup old runs
21
+ initDb();
22
+ const cleaned = cleanupOldRuns(30);
23
+ if (cleaned > 0) {
24
+ console.error(`Cleaned up ${cleaned} old script runs`);
25
+ }
26
+
27
+ // Create Tana client and API
28
+ const client = createClient();
29
+ const tana = createTanaAPI(client);
30
+
31
+ // Create MCP server using the modern McpServer API
32
+ const server = new McpServer({
33
+ name: "tana-mcp-codemode",
34
+ version: "0.1.0",
35
+ });
36
+
37
+ // Register the execute tool using registerTool (non-deprecated API)
38
+ server.registerTool(
39
+ "execute",
40
+ {
41
+ description: TOOL_DESCRIPTION,
42
+ inputSchema: {
43
+ code: z.string().describe("TypeScript code to execute"),
44
+ sessionId: z
45
+ .string()
46
+ .optional()
47
+ .describe("Optional session ID for grouping script runs"),
48
+ input: z
49
+ .string()
50
+ .optional()
51
+ .describe("Data to pass to script via stdin() helper"),
52
+ },
53
+ },
54
+ async ({ code, sessionId, input }) => {
55
+ const result = await executeSandbox(code, tana, sessionId, input);
56
+
57
+ // Format response
58
+ let responseText = "";
59
+ if (result.output) {
60
+ responseText += result.output;
61
+ }
62
+ if (result.error) {
63
+ responseText += responseText ? "\n\n" : "";
64
+ responseText += `Error: ${result.error}`;
65
+ }
66
+ responseText += `\n\n[Executed in ${result.durationMs}ms]`;
67
+
68
+ return {
69
+ content: [
70
+ {
71
+ type: "text" as const,
72
+ text: responseText,
73
+ },
74
+ ],
75
+ isError: !result.success,
76
+ };
77
+ }
78
+ );
79
+
80
+ // Start server with stdio transport
81
+ const transport = new StdioServerTransport();
82
+ await server.connect(transport);
83
+
84
+ console.error("Tana MCP server started");
85
+ }
86
+
87
+ main().catch((error) => {
88
+ console.error("Fatal error:", error);
89
+ process.exit(1);
90
+ });
package/src/prompts.ts ADDED
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Tool Description - Markdown Documentation
3
+ */
4
+
5
+ export const TOOL_DESCRIPTION = `Execute TypeScript code to interact with Tana.
6
+
7
+ ## APIs
8
+
9
+ tana.health() → { status }
10
+ tana.workspaces.list() → Workspace[] { id, name, homeNodeId }
11
+
12
+ ### Nodes
13
+ tana.nodes.search(query, options?) → SearchResult[]
14
+ tana.nodes.read(nodeId, maxDepth?) → string (markdown)
15
+ tana.nodes.getChildren(nodeId, { limit?, offset? }) → { children, total, hasMore }
16
+ tana.nodes.edit({ nodeId, name?, description? }) → { success }
17
+ tana.nodes.trash(nodeId) → { success }
18
+ tana.nodes.check(nodeId) / uncheck(nodeId) → { success }
19
+
20
+ ### Tags
21
+ tana.tags.list(workspaceId, limit?) → Tag[]
22
+ tana.tags.getSchema(tagId, includeEditInstructions?) → string
23
+ tana.tags.modify(nodeId, "add"|"remove", tagIds[])
24
+ tana.tags.create({ workspaceId, name, description?, extendsTagIds?, showCheckbox? })
25
+ tana.tags.addField({ tagId, name, dataType: "plain"|"number"|"date"|"url"|"email"|"checkbox"|"user"|"instance"|"options", ... })
26
+ tana.tags.setCheckbox({ tagId, showCheckbox, doneStateMapping? })
27
+
28
+ ### Fields
29
+ tana.fields.setOption(nodeId, attributeId, optionId)
30
+ tana.fields.setContent(nodeId, attributeId, content)
31
+
32
+ ### Calendar
33
+ tana.calendar.getOrCreate(workspaceId, "day"|"week"|"month"|"year", date?)
34
+
35
+ ### Import
36
+ tana.import(parentNodeId, tanaPasteContent) → { success, nodeIds? }
37
+
38
+ ### Entry Points
39
+ inbox: \`\${workspaceId}_CAPTURE_INBOX\`
40
+ library: \`\${workspaceId}_STASH\`
41
+
42
+ ### Helpers
43
+ stdin().json<T>() | .lines() | .text() | .hasInput()
44
+ workflow.start(msg) | .step(msg) | .progress(n, total, msg) | .complete(msg) | .abort(err)
45
+
46
+ ## Edit (search-and-replace)
47
+
48
+ tana.nodes.edit({ nodeId, name: { old_string, new_string }, description: { old_string, new_string } })
49
+ Empty old_string matches absent field.
50
+
51
+ ## Search Query Operators
52
+
53
+ { textContains: string } | { textMatches: "/regex/i" }
54
+ { hasType: tagId } | { field: { fieldId, stringValue?, numberValue?, nodeId?, state? } }
55
+ { compare: { fieldId, operator: "gt"|"lt"|"eq", value, type } }
56
+ { childOf: { nodeIds[], recursive?, includeRefs? } } | { ownedBy: { nodeId, recursive?, includeSelf? } }
57
+ { linksTo: nodeIds[] }
58
+ { is: "done"|"todo"|"template"|"entity"|"calendarNode"|"search"|"inLibrary" }
59
+ { has: "tag"|"field"|"media"|"audio"|"video"|"image" }
60
+ { created: { last: N } } | { edited: { last?, by?, since? } } | { done: { last: N } }
61
+ { onDate: "YYYY-MM-DD" | { date, fieldId?, overlaps? } }
62
+ { overdue: true } | { inLibrary: true }
63
+ { and: [...] } | { or: [...] } | { not: {...} }
64
+
65
+ ## Examples
66
+
67
+ Search: await tana.nodes.search({ and: [{ hasType: "tagId" }, { is: "todo" }] })
68
+
69
+ Import:
70
+ \`\`\`
71
+ await tana.import(parentNodeId, \`
72
+ - Node #[[^tagId]]
73
+ - [[^fieldId]]:: value
74
+ - [ ] Todo item
75
+ \`)
76
+ \`\`\`
77
+
78
+ ## Output
79
+
80
+ console.log() to return data.
81
+ `;
@@ -0,0 +1,270 @@
1
+ /**
2
+ * Sandbox - Code Execution Engine
3
+ *
4
+ * Executes arbitrary TypeScript code with injected `tana` API object.
5
+ * Features:
6
+ * - AsyncFunction for top-level await support
7
+ * - 10s timeout via Promise.race
8
+ * - Duration tracking
9
+ * - Automatic history persistence with API call tracking
10
+ */
11
+
12
+ import type { SandboxResult } from "../types";
13
+ import type { TanaAPI } from "../api/tana";
14
+ import { saveScriptRun } from "../storage/history";
15
+ import { createStdinHelper } from "./stdin";
16
+ import { createWorkflowHelper } from "./workflow";
17
+
18
+ const EXECUTION_TIMEOUT = 10_000; // 10 seconds
19
+
20
+ /**
21
+ * Wraps the tana API to track which methods are called
22
+ */
23
+ function createTrackedTanaAPI(
24
+ tana: TanaAPI,
25
+ tracker: { calls: string[]; nodeIds: Set<string>; workspaceId: string | null }
26
+ ): TanaAPI {
27
+ const track = <T>(method: string, fn: () => Promise<T>): Promise<T> => {
28
+ tracker.calls.push(method);
29
+ return fn();
30
+ };
31
+
32
+ const trackNodeId = (id: string) => tracker.nodeIds.add(id);
33
+
34
+ return {
35
+ health: () => track("health", () => tana.health()),
36
+
37
+ workspaces: {
38
+ list: () => track("workspaces.list", () => tana.workspaces.list()),
39
+ },
40
+
41
+ nodes: {
42
+ search: (query, options) => {
43
+ if (options?.workspaceIds?.[0]) {
44
+ tracker.workspaceId = options.workspaceIds[0];
45
+ }
46
+ return track("nodes.search", () => tana.nodes.search(query, options));
47
+ },
48
+ read: (nodeId, maxDepth) => {
49
+ trackNodeId(nodeId);
50
+ return track("nodes.read", () => tana.nodes.read(nodeId, maxDepth));
51
+ },
52
+ getChildren: (nodeId, options) => {
53
+ trackNodeId(nodeId);
54
+ return track("nodes.getChildren", () => tana.nodes.getChildren(nodeId, options));
55
+ },
56
+ edit: (options) => {
57
+ trackNodeId(options.nodeId);
58
+ return track("nodes.edit", () => tana.nodes.edit(options));
59
+ },
60
+ trash: (nodeId) => {
61
+ trackNodeId(nodeId);
62
+ return track("nodes.trash", () => tana.nodes.trash(nodeId));
63
+ },
64
+ check: (nodeId) => {
65
+ trackNodeId(nodeId);
66
+ return track("nodes.check", () => tana.nodes.check(nodeId));
67
+ },
68
+ uncheck: (nodeId) => {
69
+ trackNodeId(nodeId);
70
+ return track("nodes.uncheck", () => tana.nodes.uncheck(nodeId));
71
+ },
72
+ },
73
+
74
+ tags: {
75
+ list: (workspaceId, limit) => {
76
+ tracker.workspaceId = workspaceId;
77
+ return track("tags.list", () => tana.tags.list(workspaceId, limit));
78
+ },
79
+ getSchema: (tagId, includeEditInstructions) =>
80
+ track("tags.getSchema", () => tana.tags.getSchema(tagId, includeEditInstructions)),
81
+ modify: (nodeId, action, tagIds) => {
82
+ trackNodeId(nodeId);
83
+ return track("tags.modify", () => tana.tags.modify(nodeId, action, tagIds));
84
+ },
85
+ create: (options) => {
86
+ tracker.workspaceId = options.workspaceId;
87
+ return track("tags.create", () => tana.tags.create(options));
88
+ },
89
+ addField: (options) =>
90
+ track("tags.addField", () => tana.tags.addField(options)),
91
+ setCheckbox: (options) =>
92
+ track("tags.setCheckbox", () => tana.tags.setCheckbox(options)),
93
+ },
94
+
95
+ fields: {
96
+ setOption: (nodeId, attributeId, optionId) => {
97
+ trackNodeId(nodeId);
98
+ return track("fields.setOption", () => tana.fields.setOption(nodeId, attributeId, optionId));
99
+ },
100
+ setContent: (nodeId, attributeId, content) => {
101
+ trackNodeId(nodeId);
102
+ return track("fields.setContent", () => tana.fields.setContent(nodeId, attributeId, content));
103
+ },
104
+ },
105
+
106
+ calendar: {
107
+ getOrCreate: (workspaceId, granularity, date) => {
108
+ tracker.workspaceId = workspaceId;
109
+ return track("calendar.getOrCreate", () =>
110
+ tana.calendar.getOrCreate(workspaceId, granularity, date)
111
+ );
112
+ },
113
+ },
114
+
115
+ import: (parentNodeId, content) => {
116
+ trackNodeId(parentNodeId);
117
+ return track("import", () => tana.import(parentNodeId, content));
118
+ },
119
+ };
120
+ }
121
+
122
+ export async function executeSandbox(
123
+ code: string,
124
+ tana: TanaAPI,
125
+ sessionId?: string,
126
+ input?: string
127
+ ): Promise<SandboxResult> {
128
+ const startTime = performance.now();
129
+ const logs: string[] = [];
130
+
131
+ // Track API usage
132
+ const tracker = {
133
+ calls: [] as string[],
134
+ nodeIds: new Set<string>(),
135
+ workspaceId: null as string | null,
136
+ };
137
+
138
+ // Custom console that captures output
139
+ const sandboxConsole = {
140
+ log: (...args: unknown[]) => {
141
+ logs.push(args.map(formatArg).join(" "));
142
+ },
143
+ error: (...args: unknown[]) => {
144
+ logs.push(`[ERROR] ${args.map(formatArg).join(" ")}`);
145
+ },
146
+ warn: (...args: unknown[]) => {
147
+ logs.push(`[WARN] ${args.map(formatArg).join(" ")}`);
148
+ },
149
+ info: (...args: unknown[]) => {
150
+ logs.push(args.map(formatArg).join(" "));
151
+ },
152
+ };
153
+
154
+ // Create helpers for script execution
155
+ const stdin = createStdinHelper(input);
156
+ const effectiveSessionId = sessionId ?? crypto.randomUUID();
157
+ const workflow = createWorkflowHelper(effectiveSessionId);
158
+
159
+ // Wrap tana to track API calls
160
+ const trackedTana = createTrackedTanaAPI(tana, tracker);
161
+
162
+ try {
163
+ // Create async function to support top-level await
164
+ // eslint-disable-next-line @typescript-eslint/no-implied-eval
165
+ const AsyncFunction = Object.getPrototypeOf(
166
+ async function () {}
167
+ ).constructor;
168
+
169
+ // Globals to shadow (will receive undefined to prevent accidental access)
170
+ // Note: This is defense-in-depth, not a security sandbox
171
+ const shadowedGlobals = [
172
+ "process",
173
+ "require",
174
+ "Bun",
175
+ "Deno",
176
+ "globalThis",
177
+ "global",
178
+ "eval",
179
+ "Function",
180
+ "__dirname",
181
+ "__filename",
182
+ "module",
183
+ "exports",
184
+ ];
185
+
186
+ const fn = new AsyncFunction(
187
+ "tana",
188
+ "console",
189
+ "stdin",
190
+ "workflow",
191
+ ...shadowedGlobals,
192
+ code
193
+ );
194
+
195
+ // Race between execution and timeout
196
+ const timeoutPromise = new Promise<never>((_, reject) => {
197
+ setTimeout(() => {
198
+ reject(new Error(`Execution timed out after ${EXECUTION_TIMEOUT}ms`));
199
+ }, EXECUTION_TIMEOUT);
200
+ });
201
+
202
+ // Pass undefined for all shadowed globals
203
+ const undefinedArgs = shadowedGlobals.map(() => undefined);
204
+ await Promise.race([
205
+ fn(trackedTana, sandboxConsole, stdin, workflow, ...undefinedArgs),
206
+ timeoutPromise,
207
+ ]);
208
+
209
+ const durationMs = Math.round(performance.now() - startTime);
210
+ const output = logs.join("\n");
211
+
212
+ // Fire-and-forget save to history
213
+ saveScriptRun({
214
+ script: code,
215
+ success: true,
216
+ output,
217
+ error: null,
218
+ durationMs,
219
+ sessionId: effectiveSessionId,
220
+ input: input ?? null,
221
+ apiCalls: tracker.calls.length > 0 ? tracker.calls : null,
222
+ nodeIdsAffected: tracker.nodeIds.size > 0 ? Array.from(tracker.nodeIds) : null,
223
+ workspaceId: tracker.workspaceId,
224
+ });
225
+
226
+ return {
227
+ success: true,
228
+ output,
229
+ durationMs,
230
+ };
231
+ } catch (error) {
232
+ const durationMs = Math.round(performance.now() - startTime);
233
+ const errorMessage =
234
+ error instanceof Error ? error.message : String(error);
235
+ const output = logs.join("\n");
236
+
237
+ // Fire-and-forget save to history
238
+ saveScriptRun({
239
+ script: code,
240
+ success: false,
241
+ output,
242
+ error: errorMessage,
243
+ durationMs,
244
+ sessionId: effectiveSessionId,
245
+ input: input ?? null,
246
+ apiCalls: tracker.calls.length > 0 ? tracker.calls : null,
247
+ nodeIdsAffected: tracker.nodeIds.size > 0 ? Array.from(tracker.nodeIds) : null,
248
+ workspaceId: tracker.workspaceId,
249
+ });
250
+
251
+ return {
252
+ success: false,
253
+ output,
254
+ error: errorMessage,
255
+ durationMs,
256
+ };
257
+ }
258
+ }
259
+
260
+ function formatArg(arg: unknown): string {
261
+ if (arg === null) return "null";
262
+ if (arg === undefined) return "undefined";
263
+ if (typeof arg === "string") return arg;
264
+ if (typeof arg === "number" || typeof arg === "boolean") return String(arg);
265
+ try {
266
+ return JSON.stringify(arg, null, 2);
267
+ } catch {
268
+ return String(arg);
269
+ }
270
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * stdin() Helper
3
+ *
4
+ * Provides convenient methods for handling input data passed to scripts.
5
+ * In MCP context, this handles data passed via the `input` parameter.
6
+ *
7
+ * Usage in scripts:
8
+ * const data = stdin().json(); // Parse as JSON
9
+ * const lines = stdin().lines(); // Split into lines
10
+ * const text = stdin().text(); // Raw trimmed text
11
+ */
12
+
13
+ export interface StdinHelper {
14
+ /** Get raw input as trimmed string */
15
+ text(): string;
16
+ /** Split input into array of lines (empty lines filtered) */
17
+ lines(): string[];
18
+ /** Parse input as JSON (throws if invalid) */
19
+ json<T = unknown>(): T;
20
+ /** Parse input as JSON, return null if invalid */
21
+ jsonSafe<T = unknown>(): T | null;
22
+ /** Check if any input was provided */
23
+ hasInput(): boolean;
24
+ }
25
+
26
+ export function createStdinHelper(input: string | undefined): () => StdinHelper {
27
+ const rawInput = input ?? "";
28
+
29
+ return () => ({
30
+ text(): string {
31
+ return rawInput.trim();
32
+ },
33
+
34
+ lines(): string[] {
35
+ return rawInput
36
+ .split("\n")
37
+ .map((line) => line.trim())
38
+ .filter((line) => line.length > 0);
39
+ },
40
+
41
+ json<T = unknown>(): T {
42
+ const text = rawInput.trim();
43
+ if (!text) {
44
+ throw new Error("stdin is empty - no JSON to parse");
45
+ }
46
+ try {
47
+ return JSON.parse(text) as T;
48
+ } catch (e) {
49
+ throw new Error(
50
+ `Failed to parse stdin as JSON: ${e instanceof Error ? e.message : String(e)}`
51
+ );
52
+ }
53
+ },
54
+
55
+ jsonSafe<T = unknown>(): T | null {
56
+ const text = rawInput.trim();
57
+ if (!text) return null;
58
+ try {
59
+ return JSON.parse(text) as T;
60
+ } catch {
61
+ return null;
62
+ }
63
+ },
64
+
65
+ hasInput(): boolean {
66
+ return rawInput.trim().length > 0;
67
+ },
68
+ });
69
+ }
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Workflow Event Logging
3
+ *
4
+ * Tracks execution progress through multi-step operations.
5
+ * Provides timeline view instead of flat pass/fail history.
6
+ *
7
+ * Usage in scripts:
8
+ * workflow.start("Processing nodes");
9
+ * workflow.step("Fetching workspaces");
10
+ * workflow.step("Found 3 workspaces");
11
+ * workflow.complete("Done!");
12
+ */
13
+
14
+ import { Database } from "bun:sqlite";
15
+ import { initDb } from "../storage/history";
16
+
17
+ export type WorkflowEventType =
18
+ | "start"
19
+ | "step"
20
+ | "progress"
21
+ | "complete"
22
+ | "abort";
23
+
24
+ export interface WorkflowEvent {
25
+ id: number;
26
+ sessionId: string;
27
+ timestamp: number;
28
+ eventType: WorkflowEventType;
29
+ message: string;
30
+ metadata?: Record<string, unknown>;
31
+ }
32
+
33
+ export interface WorkflowHelper {
34
+ /** Start a new workflow with description */
35
+ start(message: string): void;
36
+ /** Log an intermediate step */
37
+ step(message: string): void;
38
+ /** Log progress (e.g., "25/100 processed") */
39
+ progress(current: number, total: number, message?: string): void;
40
+ /** Mark workflow as successfully completed */
41
+ complete(message?: string): void;
42
+ /** Mark workflow as aborted/failed */
43
+ abort(reason: string): void;
44
+ }
45
+
46
+ let workflowTableInitialized = false;
47
+
48
+ function ensureWorkflowTable(db: Database): void {
49
+ if (workflowTableInitialized) return;
50
+
51
+ db.run(`
52
+ CREATE TABLE IF NOT EXISTS workflow_events (
53
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
54
+ session_id TEXT NOT NULL,
55
+ timestamp INTEGER NOT NULL,
56
+ event_type TEXT NOT NULL,
57
+ message TEXT NOT NULL,
58
+ metadata TEXT
59
+ )
60
+ `);
61
+
62
+ db.run(`
63
+ CREATE INDEX IF NOT EXISTS idx_workflow_session
64
+ ON workflow_events(session_id, timestamp)
65
+ `);
66
+
67
+ workflowTableInitialized = true;
68
+ }
69
+
70
+ function saveWorkflowEvent(
71
+ sessionId: string,
72
+ eventType: WorkflowEventType,
73
+ message: string,
74
+ metadata?: Record<string, unknown>
75
+ ): void {
76
+ try {
77
+ const db = initDb();
78
+ ensureWorkflowTable(db);
79
+
80
+ const stmt = db.prepare(`
81
+ INSERT INTO workflow_events (session_id, timestamp, event_type, message, metadata)
82
+ VALUES (?, ?, ?, ?, ?)
83
+ `);
84
+ stmt.run(
85
+ sessionId,
86
+ Date.now(),
87
+ eventType,
88
+ message,
89
+ metadata ? JSON.stringify(metadata) : null
90
+ );
91
+ } catch (e) {
92
+ // Fire-and-forget - don't disrupt execution
93
+ console.error("Failed to save workflow event:", e);
94
+ }
95
+ }
96
+
97
+ export function createWorkflowHelper(sessionId: string): WorkflowHelper {
98
+ return {
99
+ start(message: string): void {
100
+ saveWorkflowEvent(sessionId, "start", message);
101
+ },
102
+
103
+ step(message: string): void {
104
+ saveWorkflowEvent(sessionId, "step", message);
105
+ },
106
+
107
+ progress(current: number, total: number, message?: string): void {
108
+ const progressMsg = message
109
+ ? `${message} (${current}/${total})`
110
+ : `${current}/${total}`;
111
+ saveWorkflowEvent(sessionId, "progress", progressMsg, { current, total });
112
+ },
113
+
114
+ complete(message?: string): void {
115
+ saveWorkflowEvent(sessionId, "complete", message ?? "Completed");
116
+ },
117
+
118
+ abort(reason: string): void {
119
+ saveWorkflowEvent(sessionId, "abort", reason);
120
+ },
121
+ };
122
+ }
123
+
124
+ export function getWorkflowEvents(
125
+ sessionId: string,
126
+ limit = 100
127
+ ): WorkflowEvent[] {
128
+ try {
129
+ const db = initDb();
130
+ ensureWorkflowTable(db);
131
+
132
+ const stmt = db.prepare(`
133
+ SELECT
134
+ id,
135
+ session_id as sessionId,
136
+ timestamp,
137
+ event_type as eventType,
138
+ message,
139
+ metadata
140
+ FROM workflow_events
141
+ WHERE session_id = ?
142
+ ORDER BY timestamp ASC
143
+ LIMIT ?
144
+ `);
145
+
146
+ const rows = stmt.all(sessionId, limit) as Array<{
147
+ id: number;
148
+ sessionId: string;
149
+ timestamp: number;
150
+ eventType: WorkflowEventType;
151
+ message: string;
152
+ metadata: string | null;
153
+ }>;
154
+
155
+ return rows.map((row) => ({
156
+ ...row,
157
+ metadata: row.metadata ? JSON.parse(row.metadata) : undefined,
158
+ }));
159
+ } catch {
160
+ return [];
161
+ }
162
+ }
163
+
164
+ export function getRecentWorkflows(limit = 20): Array<{
165
+ sessionId: string;
166
+ startTime: number;
167
+ eventCount: number;
168
+ lastEvent: string;
169
+ }> {
170
+ try {
171
+ const db = initDb();
172
+ ensureWorkflowTable(db);
173
+
174
+ const stmt = db.prepare(`
175
+ SELECT
176
+ session_id as sessionId,
177
+ MIN(timestamp) as startTime,
178
+ COUNT(*) as eventCount,
179
+ (SELECT message FROM workflow_events w2
180
+ WHERE w2.session_id = workflow_events.session_id
181
+ ORDER BY timestamp DESC LIMIT 1) as lastEvent
182
+ FROM workflow_events
183
+ GROUP BY session_id
184
+ ORDER BY startTime DESC
185
+ LIMIT ?
186
+ `);
187
+
188
+ return stmt.all(limit) as Array<{
189
+ sessionId: string;
190
+ startTime: number;
191
+ eventCount: number;
192
+ lastEvent: string;
193
+ }>;
194
+ } catch {
195
+ return [];
196
+ }
197
+ }