@makefinks/daemon 0.1.2 → 0.1.3

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/README.md CHANGED
@@ -1,7 +1,8 @@
1
1
  # DAEMON
2
2
  **DAEMON** (pronounced "day-mon") is an opinionated **terminal-based AI agent** with distinct sci-fi theming,
3
- delivered through a well-thought-out TUI powered by [OpenTUI](https://github.com/anomalyco/opentui).
4
- It supports **text and voice interaction** and can be fully controlled through **hotkeys**.
3
+ delivered through a highly performant TUI powered by [OpenTUI](https://github.com/anomalyco/opentui).
4
+
5
+ It supports **text and voice interaction**, can be fully controlled through **hotkeys** and offers **vim-like controls**.
5
6
 
6
7
  DAEMON is focused on **information-gathering workflows** that benefit from **grounded responses**
7
8
  but can also interact with and **control** your system through the terminal with scoped permissions.
@@ -36,7 +37,7 @@ See full installation details below for configuration and system dependencies.
36
37
  At the core of the TUI is DAEMON's **animated avatar**, reacting to what it's doing in real time:
37
38
  listening to audio input, reasoning about questions, calling tools, and generating an answer.
38
39
 
39
- The avatar was deliberately designed to feel slightly ominous and alien-like playing into
40
+ The avatar was deliberately designed to feel slightly ominous and alien-like playing into sci-fi depictions.
40
41
 
41
42
  ### 🧠 LLMs
42
43
  DAEMON can be powered by **any** model available on [OpenRouter](https://openrouter.ai/models).
@@ -55,8 +56,22 @@ It features a large vocabulary and can transcribe multilingual inputs with compl
55
56
 
56
57
  OpenAI's TTS model `gpt-4o-mini-tts-2025-03-20` is used to generate voice output with as little latency as possible.
57
58
 
58
- ### 🔎 Web Search
59
- DAEMON uses the [Exa](https://exa.ai/) search and fetch API for retrieving accurate and up-to-date information.
59
+ ### 🔎 Web Search with Grounding
60
+ DAEMON uses the [Exa](https://exa.ai/) search and fetch API for retrieving **accurate** and **up-to-date information**.
61
+
62
+ After fetching relevant information, DAEMON has the ability to **ground** statements with **source links** that contain **highlightable fragments**.
63
+ The TUI comes with a menu for reading, verifying and opening sources for the current session.
64
+
65
+ ![grounding-menu](img/grounding-menu.png)
66
+ For most statements, pressing Enter opens the source in your browser and **highlights the passage that supports the claim**.
67
+
68
+ <p align="center">
69
+ <img src="img/grounding-highlight.png" alt="grounding-highlight" width="320" />
70
+ <img src="img/grounding-highlight-2.png" alt="grounding-highlight" width="320" />
71
+ </p>
72
+ While DAEMON is encouraged to always cite sources you can always prompt to get groundings:
73
+
74
+ > "Use the grounding tool" / "Ground your answers"
60
75
 
61
76
  ### 💾 Session Persistence
62
77
  DAEMON stores chat sessions locally (SQLite) and lets you resume past conversations.
@@ -105,7 +120,7 @@ Configuration is done via environment variables (or the onboarding UI):
105
120
 
106
121
  ## 🛠️ System dependencies
107
122
 
108
- Voice input requires `sox` and platform-specific audio libraries:
123
+ Voice input requires `sox` or other platform-specific audio libraries:
109
124
 
110
125
  ### macOS
111
126
  ```bash
package/package.json CHANGED
@@ -28,7 +28,7 @@
28
28
  },
29
29
  "module": "src/index.tsx",
30
30
  "type": "module",
31
- "version": "0.1.2",
31
+ "version": "0.1.3",
32
32
  "bin": {
33
33
  "daemon": "dist/cli.js"
34
34
  },
@@ -20,7 +20,7 @@ export const AVAILABLE_MODELS: ModelOption[] = [
20
20
  ];
21
21
 
22
22
  // Default model ID
23
- export const DEFAULT_MODEL_ID = "google/gemini-3-flash-preview";
23
+ export const DEFAULT_MODEL_ID = "z-ai/glm-4.7";
24
24
 
25
25
  // Current selected model (mutable)
26
26
  let currentModelId = DEFAULT_MODEL_ID;
@@ -1,23 +1,11 @@
1
1
  import { tool } from "ai";
2
2
  import { z } from "zod";
3
- import type { TodoItem, TodoStatus } from "../../types";
3
+ import { getRuntimeContext } from "../../state/runtime-context";
4
+ import { loadLatestTodoList, saveTodoList } from "../../state/session-store";
5
+ import type { TodoItem } from "../../types";
4
6
 
5
- // Session-based todo storage
6
- const todoSessions = new Map<string, TodoItem[]>();
7
-
8
- // Default session for single-user mode
9
- const DEFAULT_SESSION = "default";
10
-
11
- function getTodos(sessionId: string = DEFAULT_SESSION): TodoItem[] {
12
- if (!todoSessions.has(sessionId)) {
13
- todoSessions.set(sessionId, []);
14
- }
15
- return todoSessions.get(sessionId)!;
16
- }
17
-
18
- function setTodos(sessionId: string, todos: TodoItem[]): void {
19
- todoSessions.set(sessionId, todos);
20
- }
7
+ let currentTodos: TodoItem[] = [];
8
+ let lastSessionId: string | null = null;
21
9
 
22
10
  function formatTodoList(todos: TodoItem[]): string {
23
11
  if (todos.length === 0) {
@@ -38,7 +26,17 @@ function formatTodoList(todos: TodoItem[]): string {
38
26
  return lines.join("\n");
39
27
  }
40
28
 
41
- // Schema for a single todo item in the write action
29
+ async function ensureTodosLoaded(sessionId: string | null): Promise<void> {
30
+ if (sessionId === lastSessionId) return;
31
+
32
+ lastSessionId = sessionId;
33
+ if (sessionId) {
34
+ currentTodos = await loadLatestTodoList(sessionId);
35
+ } else {
36
+ currentTodos = [];
37
+ }
38
+ }
39
+
42
40
  const todoItemSchema = z.object({
43
41
  content: z.string().describe("The todo item description"),
44
42
  status: z
@@ -65,30 +63,37 @@ Example write with status:
65
63
  .enum(["pending", "in_progress", "completed", "cancelled"])
66
64
  .optional()
67
65
  .describe("New status for the todo (used with 'update')"),
68
- sessionId: z.string().optional().describe("Session ID for isolation. Defaults to 'default'."),
69
66
  }),
70
- execute: async ({ action, todos: newTodos, index, status, sessionId }) => {
71
- const session = sessionId || DEFAULT_SESSION;
67
+ execute: async ({ action, todos: newTodos, index, status }) => {
68
+ const context = getRuntimeContext();
69
+
70
+ if (!context.sessionId) {
71
+ return {
72
+ success: false,
73
+ error: "No active session for todos",
74
+ };
75
+ }
76
+
77
+ await ensureTodosLoaded(context.sessionId);
72
78
 
73
79
  switch (action) {
74
80
  case "write": {
75
81
  if (!newTodos || newTodos.length === 0) {
76
- setTodos(session, []);
82
+ currentTodos = [];
83
+ await saveTodoList(context.sessionId, currentTodos);
77
84
  return {
78
85
  success: true,
79
- message: "Cleared all todos",
80
- list: "No todos.",
86
+ todos: formatTodoList(currentTodos),
81
87
  };
82
88
  }
83
- const items: TodoItem[] = newTodos.map((t) => ({
89
+ currentTodos = newTodos.map((t) => ({
84
90
  content: t.content,
85
91
  status: t.status || "pending",
86
92
  }));
87
- setTodos(session, items);
93
+ await saveTodoList(context.sessionId, currentTodos);
88
94
  return {
89
95
  success: true,
90
- message: `Set ${items.length} todos`,
91
- list: formatTodoList(items),
96
+ todos: formatTodoList(currentTodos),
92
97
  };
93
98
  }
94
99
 
@@ -99,34 +104,28 @@ Example write with status:
99
104
  error: "Index is required for 'update'",
100
105
  };
101
106
  }
102
- const todos = getTodos(session);
103
- const idx = index - 1; // Convert to 0-based
104
- if (idx < 0 || idx >= todos.length) {
107
+ const idx = index - 1;
108
+ if (idx < 0 || idx >= currentTodos.length) {
105
109
  return {
106
110
  success: false,
107
- error: `Invalid index ${index}. Valid range: 1-${todos.length}`,
111
+ error: `Invalid index ${index}. Valid range: 1-${currentTodos.length}`,
108
112
  };
109
113
  }
110
- const todo = todos[idx]!;
114
+ const todo = currentTodos[idx]!;
111
115
  if (status) {
112
116
  todo.status = status;
113
117
  }
118
+ await saveTodoList(context.sessionId, currentTodos);
114
119
  return {
115
120
  success: true,
116
- message: `Updated #${index}: ${todo.content} -> ${todo.status}`,
117
- list: formatTodoList(todos),
121
+ todos: formatTodoList(currentTodos),
118
122
  };
119
123
  }
120
124
 
121
125
  case "list": {
122
- const todos = getTodos(session);
123
126
  return {
124
127
  success: true,
125
- count: todos.length,
126
- pending: todos.filter((t) => t.status === "pending").length,
127
- inProgress: todos.filter((t) => t.status === "in_progress").length,
128
- completed: todos.filter((t) => t.status === "completed").length,
129
- list: formatTodoList(todos),
128
+ todos: formatTodoList(currentTodos),
130
129
  };
131
130
  }
132
131
 
@@ -139,12 +138,11 @@ Example write with status:
139
138
  },
140
139
  });
141
140
 
142
- // Export a function to clear all sessions (useful for testing)
143
- export function clearAllTodoSessions(): void {
144
- todoSessions.clear();
141
+ export function clearAllTodos(): void {
142
+ currentTodos = [];
143
+ lastSessionId = null;
145
144
  }
146
145
 
147
- // Export function to get current todos for UI display
148
- export function getCurrentTodos(sessionId: string = DEFAULT_SESSION): TodoItem[] {
149
- return [...getTodos(sessionId)];
146
+ export function getCurrentTodos(): TodoItem[] {
147
+ return [...currentTodos];
150
148
  }
package/src/app/App.tsx CHANGED
@@ -81,8 +81,9 @@ extend({
81
81
  export function App() {
82
82
  const renderer = useRenderer();
83
83
 
84
- usePlaywrightNotification();
85
- useVoiceDependenciesNotification();
84
+ const [onboardingActive, setOnboardingActive] = useState(false);
85
+ usePlaywrightNotification({ enabled: !onboardingActive });
86
+ useVoiceDependenciesNotification({ enabled: !onboardingActive });
86
87
  const { handleCopyOnSelectMouseUp } = useCopyOnSelect();
87
88
 
88
89
  const {
@@ -223,7 +224,6 @@ export function App() {
223
224
  const { responseElapsedMs } = useResponseTimer({ daemonState });
224
225
 
225
226
  const [loadedPreferences, setLoadedPreferences] = useState<AppPreferences | null>(null);
226
- const [onboardingActive, setOnboardingActive] = useState(false);
227
227
  const [onboardingStep, setOnboardingStep] = useState<OnboardingStep>("intro");
228
228
  const [devices, setDevices] = useState<AudioDevice[]>([]);
229
229
  const [currentDevice, setCurrentDevice] = useState<string | undefined>(undefined);
@@ -114,6 +114,8 @@ function AppOverlaysImpl() {
114
114
  models={model.curatedModels}
115
115
  currentModelId={model.currentModelId}
116
116
  deviceLoadTimedOut={device.deviceLoadTimedOut}
117
+ soxAvailable={device.soxAvailable}
118
+ soxInstallHint={device.soxInstallHint}
117
119
  setCurrentDevice={device.setCurrentDevice}
118
120
  setCurrentOutputDevice={device.setCurrentOutputDevice}
119
121
  setCurrentModelId={model.setCurrentModelId}
@@ -2,11 +2,11 @@ import type { KeyEvent, TextareaRenderable } from "@opentui/core";
2
2
  import { useKeyboard } from "@opentui/react";
3
3
  import { useCallback, useEffect, useMemo, useState } from "react";
4
4
  import type { RefObject } from "react";
5
- import type { AppPreferences, AudioDevice, ModelOption, OnboardingStep } from "../types";
6
- import { ApiKeyStep } from "./ApiKeyStep";
7
5
  import { handleOnboardingKey } from "../hooks/keyboard-handlers";
6
+ import type { AppPreferences, AudioDevice, ModelOption, OnboardingStep } from "../types";
8
7
  import { COLORS } from "../ui/constants";
9
8
  import { formatContextWindowK, formatPrice } from "../utils/formatters";
9
+ import { ApiKeyStep } from "./ApiKeyStep";
10
10
 
11
11
  const MODEL_COL_WIDTH = {
12
12
  CTX: 6,
@@ -53,6 +53,8 @@ interface OnboardingOverlayProps {
53
53
  models: ModelOption[];
54
54
  currentModelId: string;
55
55
  deviceLoadTimedOut?: boolean;
56
+ soxAvailable: boolean;
57
+ soxInstallHint: string;
56
58
  setCurrentDevice: (deviceName: string | undefined) => void;
57
59
  setCurrentOutputDevice: (deviceName: string | undefined) => void;
58
60
  setCurrentModelId: (modelId: string) => void;
@@ -74,6 +76,8 @@ export function OnboardingOverlay({
74
76
  models,
75
77
  currentModelId,
76
78
  deviceLoadTimedOut,
79
+ soxAvailable,
80
+ soxInstallHint,
77
81
  setCurrentDevice,
78
82
  setCurrentOutputDevice,
79
83
  setCurrentModelId,
@@ -200,7 +204,6 @@ export function OnboardingOverlay({
200
204
  <box justifyContent="center">
201
205
  <text>
202
206
  <span fg={COLORS.DAEMON_LABEL}>Press ENTER to begin</span>
203
- <span fg={COLORS.REASONING_DIM}> · ESC to skip</span>
204
207
  </text>
205
208
  </box>
206
209
  </>
@@ -242,11 +245,29 @@ export function OnboardingOverlay({
242
245
  </box>
243
246
  <box marginBottom={1}>
244
247
  <text>
245
- <span fg={COLORS.USER_LABEL}>↑/↓ navigate, ENTER select</span>
246
- <span fg={COLORS.REASONING_DIM}> · TAB continue · ESC skip</span>
248
+ <span fg={COLORS.USER_LABEL}>
249
+ {soxAvailable ? "↑/↓ navigate, ENTER select" : "ESC to skip"}
250
+ </span>
251
+ {soxAvailable && <span fg={COLORS.REASONING_DIM}> · TAB continue · ESC skip</span>}
247
252
  </text>
248
253
  </box>
249
- {devices.length === 0 ? (
254
+ {!soxAvailable ? (
255
+ <box flexDirection="column" paddingTop={1}>
256
+ <text>
257
+ <span fg={COLORS.ERROR}>sox is not installed</span>
258
+ </text>
259
+ <box marginTop={1}>
260
+ <text>
261
+ <span fg={COLORS.USER_LABEL}>Voice input requires sox for audio capture.</span>
262
+ </text>
263
+ </box>
264
+ <box marginTop={1}>
265
+ <text>
266
+ <span fg={COLORS.MENU_TEXT}>{soxInstallHint}</span>
267
+ </text>
268
+ </box>
269
+ </box>
270
+ ) : devices.length === 0 ? (
250
271
  <box flexDirection="column">
251
272
  <box>
252
273
  <text>
@@ -1,19 +1,19 @@
1
1
  import type { KeyEvent } from "@opentui/core";
2
+ import { setResponseModel } from "../ai/model-config";
2
3
  import type {
4
+ AppPreferences,
3
5
  AudioDevice,
6
+ BashApprovalLevel,
4
7
  ModelOption,
5
8
  OnboardingStep,
6
- SpeechSpeed,
7
9
  ReasoningEffort,
8
- BashApprovalLevel,
10
+ SpeechSpeed,
9
11
  VoiceInteractionType,
10
- AppPreferences,
11
12
  } from "../types";
12
- import { REASONING_EFFORT_LEVELS, BASH_APPROVAL_LEVELS } from "../types";
13
- import { setAudioDevice } from "../voice/audio-recorder";
14
- import { setResponseModel } from "../ai/model-config";
13
+ import { BASH_APPROVAL_LEVELS, REASONING_EFFORT_LEVELS } from "../types";
15
14
  import { openUrlInBrowser } from "../utils/preferences";
16
- import { isNavigateUpKey, isNavigateDownKey } from "./menu-navigation";
15
+ import { setAudioDevice } from "../voice/audio-recorder";
16
+ import { isNavigateDownKey, isNavigateUpKey } from "./menu-navigation";
17
17
 
18
18
  export type KeyHandler = (key: KeyEvent) => boolean;
19
19
 
@@ -103,12 +103,7 @@ export function determineNextStep(
103
103
  type EscapeHandler = (ctx: OnboardingContext) => void;
104
104
 
105
105
  const ESCAPE_HANDLERS: Partial<Record<OnboardingStep, EscapeHandler>> = {
106
- intro: (ctx) => {
107
- if (process.env.OPENROUTER_API_KEY) {
108
- ctx.persistPreferences({ onboardingCompleted: true });
109
- ctx.completeOnboarding();
110
- }
111
- },
106
+ intro: () => {},
112
107
  openrouter_key: () => {},
113
108
  openai_key: (ctx) => {
114
109
  const nextStep = determineNextStep("openai_key", ctx.preferences);
@@ -1,5 +1,5 @@
1
- import { useCallback } from "react";
2
1
  import { toast } from "@opentui-ui/toast/react";
2
+ import { useCallback } from "react";
3
3
  import { getDaemonManager } from "../state/daemon-state";
4
4
  import {
5
5
  buildModelHistoryFromConversation,
@@ -1,8 +1,16 @@
1
- import { useEffect } from "react";
2
1
  import { toast } from "@opentui-ui/toast/react";
2
+ import { useEffect, useRef } from "react";
3
3
  import { detectLocalPlaywrightChromium } from "../utils/js-rendering";
4
4
 
5
- export function usePlaywrightNotification(): void {
5
+ export interface UsePlaywrightNotificationParams {
6
+ enabled: boolean;
7
+ }
8
+
9
+ export function usePlaywrightNotification(params: UsePlaywrightNotificationParams): void {
10
+ const { enabled } = params;
11
+ const hasNotifiedRef = useRef(false);
12
+ const pendingNotificationRef = useRef<{ reason: string; hint?: string } | null>(null);
13
+
6
14
  useEffect(() => {
7
15
  let cancelled = false;
8
16
 
@@ -12,12 +20,22 @@ export function usePlaywrightNotification(): void {
12
20
 
13
21
  if (capability.available) return;
14
22
 
15
- const description = capability.hint ? `${capability.reason}\n\n${capability.hint}` : capability.reason;
16
- toast.warning("JS-rendered pages unavailable", { description });
23
+ pendingNotificationRef.current = { reason: capability.reason, hint: capability.hint };
17
24
  })();
18
25
 
19
26
  return () => {
20
27
  cancelled = true;
21
28
  };
22
29
  }, []);
30
+
31
+ useEffect(() => {
32
+ if (!enabled || hasNotifiedRef.current) return;
33
+ const pending = pendingNotificationRef.current;
34
+ if (!pending) return;
35
+
36
+ hasNotifiedRef.current = true;
37
+
38
+ const description = pending.hint ? `${pending.reason}\n\n${pending.hint}` : pending.reason;
39
+ toast.warning("JS-rendered pages unavailable", { description });
40
+ }, [enabled]);
23
41
  }
@@ -1,8 +1,19 @@
1
- import { useEffect } from "react";
2
1
  import { toast } from "@opentui-ui/toast/react";
3
- import { detectVoiceDependencies, type VoiceDependencies } from "../utils/voice-dependencies";
2
+ import { useEffect, useRef } from "react";
3
+ import { type VoiceDependencies, detectVoiceDependencies } from "../utils/voice-dependencies";
4
+
5
+ export interface UseVoiceDependenciesNotificationParams {
6
+ /** When false, the notification is deferred until enabled becomes true */
7
+ enabled: boolean;
8
+ }
9
+
10
+ export function useVoiceDependenciesNotification(params: UseVoiceDependenciesNotificationParams): void {
11
+ const { enabled } = params;
12
+ const hasNotifiedRef = useRef(false);
13
+ const pendingNotificationRef = useRef<
14
+ { type: "error"; message: string } | { type: "sox"; hint: string } | null
15
+ >(null);
4
16
 
5
- export function useVoiceDependenciesNotification(): void {
6
17
  useEffect(() => {
7
18
  let cancelled = false;
8
19
 
@@ -13,9 +24,7 @@ export function useVoiceDependenciesNotification(): void {
13
24
  } catch (error) {
14
25
  if (cancelled) return;
15
26
  const err = error instanceof Error ? error : new Error(String(error));
16
- toast.warning("Voice dependency check failed", {
17
- description: err.message,
18
- });
27
+ pendingNotificationRef.current = { type: "error", message: err.message };
19
28
  return;
20
29
  }
21
30
 
@@ -24,9 +33,7 @@ export function useVoiceDependenciesNotification(): void {
24
33
  if (!deps.sox.available) {
25
34
  const hint =
26
35
  deps.sox.hint ?? (process.platform === "darwin" ? "Run: brew install sox" : "Install sox");
27
- toast.warning("Voice features unavailable", {
28
- description: `sox is not installed. ${hint}`,
29
- });
36
+ pendingNotificationRef.current = { type: "sox", hint };
30
37
  }
31
38
  })();
32
39
 
@@ -34,4 +41,22 @@ export function useVoiceDependenciesNotification(): void {
34
41
  cancelled = true;
35
42
  };
36
43
  }, []);
44
+
45
+ useEffect(() => {
46
+ if (!enabled || hasNotifiedRef.current) return;
47
+ const pending = pendingNotificationRef.current;
48
+ if (!pending) return;
49
+
50
+ hasNotifiedRef.current = true;
51
+
52
+ if (pending.type === "error") {
53
+ toast.warning("Voice dependency check failed", {
54
+ description: pending.message,
55
+ });
56
+ } else {
57
+ toast.warning("Voice features unavailable", {
58
+ description: `sox is not installed. ${pending.hint}`,
59
+ });
60
+ }
61
+ }, [enabled]);
37
62
  }
@@ -29,5 +29,17 @@ export function createMigration001Init(defaultUsageJson: string): (db: Database)
29
29
  CREATE INDEX IF NOT EXISTS idx_grounding_maps_session_message
30
30
  ON grounding_maps(session_id, message_id);
31
31
  `);
32
+ db.exec(`
33
+ CREATE TABLE IF NOT EXISTS todo_lists (
34
+ id TEXT PRIMARY KEY,
35
+ session_id TEXT NOT NULL,
36
+ created_at TEXT NOT NULL,
37
+ items_json TEXT NOT NULL
38
+ );
39
+ `);
40
+ db.exec(`
41
+ CREATE INDEX IF NOT EXISTS idx_todo_lists_session_created
42
+ ON todo_lists(session_id, created_at DESC);
43
+ `);
32
44
  };
33
45
  }
@@ -12,11 +12,12 @@ import type {
12
12
  ModelMessage,
13
13
  SessionInfo,
14
14
  SessionSnapshot,
15
+ TodoItem,
15
16
  TokenUsage,
16
17
  } from "../types";
17
18
  import { debug } from "../utils/debug-logger";
18
19
  import { getAppConfigDir } from "../utils/preferences";
19
- import { ensureWorkspaceExists, deleteWorkspace } from "../utils/workspace-manager";
20
+ import { deleteWorkspace, ensureWorkspaceExists } from "../utils/workspace-manager";
20
21
  import { getSessionMigrations } from "./migrations";
21
22
 
22
23
  const SESSION_DB_FILE = "sessions.sqlite";
@@ -357,3 +358,46 @@ export async function loadLatestGroundingMap(sessionId: string): Promise<Groundi
357
358
  return null;
358
359
  }
359
360
  }
361
+
362
+ function parseTodoItems(raw: string): TodoItem[] {
363
+ try {
364
+ const parsed = JSON.parse(raw) as unknown;
365
+ if (!Array.isArray(parsed)) return [];
366
+ return parsed as TodoItem[];
367
+ } catch {
368
+ return [];
369
+ }
370
+ }
371
+
372
+ export async function saveTodoList(sessionId: string, items: TodoItem[]): Promise<void> {
373
+ try {
374
+ const database = await getDb();
375
+ const now = new Date().toISOString();
376
+ const id = crypto.randomUUID();
377
+ const itemsJson = JSON.stringify(items);
378
+
379
+ database
380
+ .prepare("INSERT INTO todo_lists (id, session_id, created_at, items_json) VALUES (?, ?, ?, ?)")
381
+ .run(id, sessionId, now, itemsJson);
382
+ } catch (error) {
383
+ const err = error instanceof Error ? error : new Error(String(error));
384
+ debug.error("todo-save-failed", { message: err.message });
385
+ }
386
+ }
387
+
388
+ export async function loadLatestTodoList(sessionId: string): Promise<TodoItem[]> {
389
+ try {
390
+ const database = await getDb();
391
+ const row = database
392
+ .prepare("SELECT items_json FROM todo_lists WHERE session_id = ? ORDER BY created_at DESC LIMIT 1")
393
+ .get(sessionId) as { items_json: string } | undefined;
394
+
395
+ if (!row) return [];
396
+
397
+ return parseTodoItems(row.items_json);
398
+ } catch (error) {
399
+ const err = error instanceof Error ? error : new Error(String(error));
400
+ debug.error("todo-load-failed", { message: err.message });
401
+ return [];
402
+ }
403
+ }