@makefinks/daemon 0.1.2 → 0.1.4
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 +21 -6
- package/package.json +1 -1
- package/src/ai/model-config.ts +1 -1
- package/src/ai/tools/run-bash.ts +4 -115
- package/src/ai/tools/todo-manager.ts +45 -47
- package/src/app/App.tsx +3 -3
- package/src/app/components/AppOverlays.tsx +2 -0
- package/src/components/OnboardingOverlay.tsx +27 -6
- package/src/hooks/keyboard-handlers.ts +8 -13
- package/src/hooks/use-conversation-manager.ts +1 -1
- package/src/hooks/use-playwright-notification.ts +22 -4
- package/src/hooks/use-voice-dependencies-notification.ts +34 -9
- package/src/security/bash-security-policy.ts +250 -0
- package/src/state/migrations/001-init.ts +12 -0
- package/src/state/session-store.ts +45 -1
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
|
|
4
|
-
|
|
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
|
+

|
|
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`
|
|
123
|
+
Voice input requires `sox` or other platform-specific audio libraries:
|
|
109
124
|
|
|
110
125
|
### macOS
|
|
111
126
|
```bash
|
package/package.json
CHANGED
package/src/ai/model-config.ts
CHANGED
|
@@ -20,7 +20,7 @@ export const AVAILABLE_MODELS: ModelOption[] = [
|
|
|
20
20
|
];
|
|
21
21
|
|
|
22
22
|
// Default model ID
|
|
23
|
-
export const DEFAULT_MODEL_ID = "
|
|
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;
|
package/src/ai/tools/run-bash.ts
CHANGED
|
@@ -1,123 +1,12 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
1
2
|
import { tool } from "ai";
|
|
2
3
|
import { z } from "zod";
|
|
3
|
-
import {
|
|
4
|
+
import { isDangerousCommand, isSensitivePathAccess } from "../../security/bash-security-policy";
|
|
4
5
|
import { getDaemonManager } from "../../state/daemon-state";
|
|
5
6
|
|
|
6
|
-
const DEFAULT_TIMEOUT_MS = 30000;
|
|
7
|
+
const DEFAULT_TIMEOUT_MS = 30000;
|
|
7
8
|
const MAX_OUTPUT_LENGTH = 50000;
|
|
8
9
|
|
|
9
|
-
const DANGEROUS_COMMANDS = [
|
|
10
|
-
"rm",
|
|
11
|
-
"rmdir",
|
|
12
|
-
"mv",
|
|
13
|
-
"kill",
|
|
14
|
-
"killall",
|
|
15
|
-
"pkill",
|
|
16
|
-
"shutdown",
|
|
17
|
-
"reboot",
|
|
18
|
-
"halt",
|
|
19
|
-
"poweroff",
|
|
20
|
-
"init",
|
|
21
|
-
"systemctl",
|
|
22
|
-
"chmod",
|
|
23
|
-
"chown",
|
|
24
|
-
"chgrp",
|
|
25
|
-
"mkfs",
|
|
26
|
-
"fdisk",
|
|
27
|
-
"dd",
|
|
28
|
-
"format",
|
|
29
|
-
"sudo",
|
|
30
|
-
"su",
|
|
31
|
-
"doas",
|
|
32
|
-
"passwd",
|
|
33
|
-
"useradd",
|
|
34
|
-
"userdel",
|
|
35
|
-
"usermod",
|
|
36
|
-
"groupadd",
|
|
37
|
-
"groupdel",
|
|
38
|
-
"visudo",
|
|
39
|
-
"crontab",
|
|
40
|
-
"iptables",
|
|
41
|
-
"ufw",
|
|
42
|
-
"firewall-cmd",
|
|
43
|
-
"mount",
|
|
44
|
-
"umount",
|
|
45
|
-
"fstab",
|
|
46
|
-
"apt-get remove",
|
|
47
|
-
"apt-get purge",
|
|
48
|
-
"apt remove",
|
|
49
|
-
"apt purge",
|
|
50
|
-
"yum remove",
|
|
51
|
-
"yum erase",
|
|
52
|
-
"dnf remove",
|
|
53
|
-
"pacman -R",
|
|
54
|
-
"brew uninstall",
|
|
55
|
-
"npm uninstall -g",
|
|
56
|
-
"pip uninstall",
|
|
57
|
-
"truncate",
|
|
58
|
-
"shred",
|
|
59
|
-
"wipefs",
|
|
60
|
-
">",
|
|
61
|
-
">>",
|
|
62
|
-
"git push --force",
|
|
63
|
-
"git push -f",
|
|
64
|
-
"git reset --hard",
|
|
65
|
-
"git clean -fd",
|
|
66
|
-
"docker rm",
|
|
67
|
-
"docker rmi",
|
|
68
|
-
"docker system prune",
|
|
69
|
-
"kubectl delete",
|
|
70
|
-
"terraform destroy",
|
|
71
|
-
"drop database",
|
|
72
|
-
"drop table",
|
|
73
|
-
"delete from",
|
|
74
|
-
"truncate table",
|
|
75
|
-
];
|
|
76
|
-
|
|
77
|
-
const DANGEROUS_PATTERNS = [
|
|
78
|
-
/\brm\s+(-[a-zA-Z]*r[a-zA-Z]*|-[a-zA-Z]*f[a-zA-Z]*|\s).*\//i,
|
|
79
|
-
/\brm\s+-rf?\s/i,
|
|
80
|
-
/\bkill\s+-9\b/i,
|
|
81
|
-
/\bsudo\s/i,
|
|
82
|
-
/\bsu\s+-?\s*$/i,
|
|
83
|
-
/\bchmod\s+[0-7]{3,4}\s/i,
|
|
84
|
-
/\bchown\s/i,
|
|
85
|
-
/\bdd\s+if=/i,
|
|
86
|
-
/>\s*\/dev\//i,
|
|
87
|
-
/\|.*\bsh\b/i,
|
|
88
|
-
/\|.*\bbash\b/i,
|
|
89
|
-
/curl.*\|\s*(ba)?sh/i,
|
|
90
|
-
/wget.*\|\s*(ba)?sh/i,
|
|
91
|
-
/eval\s*\$/i,
|
|
92
|
-
/\$\(.*\)/,
|
|
93
|
-
/`.*`/,
|
|
94
|
-
];
|
|
95
|
-
|
|
96
|
-
function isDangerousCommand(command: string): boolean {
|
|
97
|
-
const normalizedCmd = command.toLowerCase().trim();
|
|
98
|
-
|
|
99
|
-
for (const dangerous of DANGEROUS_COMMANDS) {
|
|
100
|
-
if (dangerous.includes(" ")) {
|
|
101
|
-
if (normalizedCmd.includes(dangerous.toLowerCase())) {
|
|
102
|
-
return true;
|
|
103
|
-
}
|
|
104
|
-
} else {
|
|
105
|
-
const wordBoundaryRegex = new RegExp(`\\b${dangerous}\\b`, "i");
|
|
106
|
-
if (wordBoundaryRegex.test(command)) {
|
|
107
|
-
return true;
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
for (const pattern of DANGEROUS_PATTERNS) {
|
|
113
|
-
if (pattern.test(command)) {
|
|
114
|
-
return true;
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
return false;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
10
|
export const runBash = tool({
|
|
122
11
|
description:
|
|
123
12
|
"Execute a bash command on the user's system. Use this to run shell commands, scripts, install packages, manage files, or perform any terminal operation. Commands run in the current working directory by default.",
|
|
@@ -152,7 +41,7 @@ export const runBash = tool({
|
|
|
152
41
|
return true;
|
|
153
42
|
}
|
|
154
43
|
|
|
155
|
-
return isDangerousCommand(command);
|
|
44
|
+
return isDangerousCommand(command) || isSensitivePathAccess(command);
|
|
156
45
|
},
|
|
157
46
|
execute: async ({ command, workdir, timeout }) => {
|
|
158
47
|
return new Promise((resolve) => {
|
|
@@ -1,23 +1,11 @@
|
|
|
1
1
|
import { tool } from "ai";
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import
|
|
3
|
+
import { getRuntimeContext } from "../../state/runtime-context";
|
|
4
|
+
import { loadLatestTodoList, saveTodoList } from "../../state/session-store";
|
|
5
|
+
import type { TodoItem } from "../../types";
|
|
4
6
|
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
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
|
|
71
|
-
const
|
|
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
|
-
|
|
82
|
+
currentTodos = [];
|
|
83
|
+
await saveTodoList(context.sessionId, currentTodos);
|
|
77
84
|
return {
|
|
78
85
|
success: true,
|
|
79
|
-
|
|
80
|
-
list: "No todos.",
|
|
86
|
+
todos: formatTodoList(currentTodos),
|
|
81
87
|
};
|
|
82
88
|
}
|
|
83
|
-
|
|
89
|
+
currentTodos = newTodos.map((t) => ({
|
|
84
90
|
content: t.content,
|
|
85
91
|
status: t.status || "pending",
|
|
86
92
|
}));
|
|
87
|
-
|
|
93
|
+
await saveTodoList(context.sessionId, currentTodos);
|
|
88
94
|
return {
|
|
89
95
|
success: true,
|
|
90
|
-
|
|
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
|
|
103
|
-
|
|
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-${
|
|
111
|
+
error: `Invalid index ${index}. Valid range: 1-${currentTodos.length}`,
|
|
108
112
|
};
|
|
109
113
|
}
|
|
110
|
-
const todo =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
141
|
+
export function clearAllTodos(): void {
|
|
142
|
+
currentTodos = [];
|
|
143
|
+
lastSessionId = null;
|
|
145
144
|
}
|
|
146
145
|
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
85
|
-
|
|
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}
|
|
246
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
10
|
+
SpeechSpeed,
|
|
9
11
|
VoiceInteractionType,
|
|
10
|
-
AppPreferences,
|
|
11
12
|
} from "../types";
|
|
12
|
-
import {
|
|
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 {
|
|
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: (
|
|
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,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
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
|
|
3
|
+
const SENSITIVE_PATHS = [
|
|
4
|
+
"~/.ssh",
|
|
5
|
+
"~/.gnupg",
|
|
6
|
+
"~/.gpg",
|
|
7
|
+
"~/.aws",
|
|
8
|
+
"~/.azure",
|
|
9
|
+
"~/.config/gcloud",
|
|
10
|
+
"~/.kube",
|
|
11
|
+
"~/Library/Application Support/Google/Chrome",
|
|
12
|
+
"~/Library/Application Support/Firefox",
|
|
13
|
+
"~/Library/Application Support/Microsoft Edge",
|
|
14
|
+
"~/Library/Safari",
|
|
15
|
+
"~/.config/google-chrome",
|
|
16
|
+
"~/.config/chromium",
|
|
17
|
+
"~/.mozilla/firefox",
|
|
18
|
+
"~/Library/Keychains",
|
|
19
|
+
"~/.password-store",
|
|
20
|
+
"~/.local/share/keyrings",
|
|
21
|
+
"~/.env",
|
|
22
|
+
"~/.envrc",
|
|
23
|
+
"~/.netrc",
|
|
24
|
+
"~/Downloads",
|
|
25
|
+
"~/Documents",
|
|
26
|
+
"~/Desktop",
|
|
27
|
+
"~/Pictures",
|
|
28
|
+
"~/Movies",
|
|
29
|
+
"~/Music",
|
|
30
|
+
"~/Library/Messages",
|
|
31
|
+
"~/Library/Mail",
|
|
32
|
+
"~/Library/Calendars",
|
|
33
|
+
"~/Library/Contacts",
|
|
34
|
+
"~/Library/Cookies",
|
|
35
|
+
"~/.docker/config.json",
|
|
36
|
+
"~/.npmrc",
|
|
37
|
+
"~/.pypirc",
|
|
38
|
+
"~/.gem/credentials",
|
|
39
|
+
"~/.config/gh",
|
|
40
|
+
"~/.config/hub",
|
|
41
|
+
"~/.bash_history",
|
|
42
|
+
"~/.zsh_history",
|
|
43
|
+
"~/.node_repl_history",
|
|
44
|
+
"~/.python_history",
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
const SENSITIVE_PATH_PATTERNS = [
|
|
48
|
+
/\bid_rsa\b/i,
|
|
49
|
+
/\bid_ed25519\b/i,
|
|
50
|
+
/\bid_ecdsa\b/i,
|
|
51
|
+
/\bid_dsa\b/i,
|
|
52
|
+
/\bauthorized_keys\b/i,
|
|
53
|
+
/\bknown_hosts\b/i,
|
|
54
|
+
/\.pem\b/i,
|
|
55
|
+
/\.key\b/i,
|
|
56
|
+
/private.*key/i,
|
|
57
|
+
/\.env(\.|$)/i,
|
|
58
|
+
/\.envrc\b/i,
|
|
59
|
+
/aws.*credentials/i,
|
|
60
|
+
/aws.*config/i,
|
|
61
|
+
/\bkeychain\b/i,
|
|
62
|
+
/\bkeyring\b/i,
|
|
63
|
+
/\bLogin Data\b/i,
|
|
64
|
+
/\bCookies\b/i,
|
|
65
|
+
/\bWeb Data\b/i,
|
|
66
|
+
/\bsecurity\s+(find|dump|export)/i,
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
const DANGEROUS_COMMANDS = [
|
|
70
|
+
"rm",
|
|
71
|
+
"rmdir",
|
|
72
|
+
"mv",
|
|
73
|
+
"kill",
|
|
74
|
+
"killall",
|
|
75
|
+
"pkill",
|
|
76
|
+
"shutdown",
|
|
77
|
+
"reboot",
|
|
78
|
+
"halt",
|
|
79
|
+
"poweroff",
|
|
80
|
+
"init",
|
|
81
|
+
"systemctl",
|
|
82
|
+
"chmod",
|
|
83
|
+
"chown",
|
|
84
|
+
"chgrp",
|
|
85
|
+
"mkfs",
|
|
86
|
+
"fdisk",
|
|
87
|
+
"dd",
|
|
88
|
+
"format",
|
|
89
|
+
"sudo",
|
|
90
|
+
"su",
|
|
91
|
+
"doas",
|
|
92
|
+
"env",
|
|
93
|
+
"printenv",
|
|
94
|
+
"export",
|
|
95
|
+
"passwd",
|
|
96
|
+
"useradd",
|
|
97
|
+
"userdel",
|
|
98
|
+
"usermod",
|
|
99
|
+
"groupadd",
|
|
100
|
+
"groupdel",
|
|
101
|
+
"visudo",
|
|
102
|
+
"crontab",
|
|
103
|
+
"iptables",
|
|
104
|
+
"ufw",
|
|
105
|
+
"firewall-cmd",
|
|
106
|
+
"mount",
|
|
107
|
+
"umount",
|
|
108
|
+
"fstab",
|
|
109
|
+
"apt-get remove",
|
|
110
|
+
"apt-get purge",
|
|
111
|
+
"apt remove",
|
|
112
|
+
"apt purge",
|
|
113
|
+
"yum remove",
|
|
114
|
+
"yum erase",
|
|
115
|
+
"dnf remove",
|
|
116
|
+
"pacman -R",
|
|
117
|
+
"brew uninstall",
|
|
118
|
+
"npm uninstall -g",
|
|
119
|
+
"pip uninstall",
|
|
120
|
+
"truncate",
|
|
121
|
+
"shred",
|
|
122
|
+
"wipefs",
|
|
123
|
+
">",
|
|
124
|
+
">>",
|
|
125
|
+
"git push --force",
|
|
126
|
+
"git push -f",
|
|
127
|
+
"git reset --hard",
|
|
128
|
+
"git clean -fd",
|
|
129
|
+
"docker rm",
|
|
130
|
+
"docker rmi",
|
|
131
|
+
"docker system prune",
|
|
132
|
+
"kubectl delete",
|
|
133
|
+
"terraform destroy",
|
|
134
|
+
"drop database",
|
|
135
|
+
"drop table",
|
|
136
|
+
"delete from",
|
|
137
|
+
"truncate table",
|
|
138
|
+
];
|
|
139
|
+
|
|
140
|
+
const DANGEROUS_PATTERNS = [
|
|
141
|
+
/\brm\s+(-[a-zA-Z]*r[a-zA-Z]*|-[a-zA-Z]*f[a-zA-Z]*|\s).*\//i,
|
|
142
|
+
/\brm\s+-rf?\s/i,
|
|
143
|
+
/\bkill\s+-9\b/i,
|
|
144
|
+
/\bsudo\s/i,
|
|
145
|
+
/\bsu\s+-?\s*$/i,
|
|
146
|
+
/\bchmod\s+[0-7]{3,4}\s/i,
|
|
147
|
+
/\bchown\s/i,
|
|
148
|
+
/\bdd\s+if=/i,
|
|
149
|
+
/>\s*\/dev\//i,
|
|
150
|
+
/\|.*\bsh\b/i,
|
|
151
|
+
/\|.*\bbash\b/i,
|
|
152
|
+
/curl.*\|\s*(ba)?sh/i,
|
|
153
|
+
/wget.*\|\s*(ba)?sh/i,
|
|
154
|
+
/eval\s*\$/i,
|
|
155
|
+
/\$\(.*\)/,
|
|
156
|
+
/`.*`/,
|
|
157
|
+
/\benv\s*$/i,
|
|
158
|
+
/\bprintenv\s*$/i,
|
|
159
|
+
/\bexport\s+-p/i,
|
|
160
|
+
/\bset\s*\|/i,
|
|
161
|
+
/echo\s+\$\w*_?(KEY|TOKEN|SECRET|PASSWORD|CREDENTIALS)/i,
|
|
162
|
+
];
|
|
163
|
+
|
|
164
|
+
function expandPath(path: string): string {
|
|
165
|
+
if (path.startsWith("~/")) {
|
|
166
|
+
return path.replace("~", homedir());
|
|
167
|
+
}
|
|
168
|
+
if (path === "~") {
|
|
169
|
+
return homedir();
|
|
170
|
+
}
|
|
171
|
+
return path;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function isSensitivePathAccess(command: string): boolean {
|
|
175
|
+
const normalizedCmd = command.trim();
|
|
176
|
+
const home = homedir();
|
|
177
|
+
|
|
178
|
+
for (const sensitivePath of SENSITIVE_PATHS) {
|
|
179
|
+
const expandedPath = expandPath(sensitivePath);
|
|
180
|
+
if (normalizedCmd.includes(expandedPath)) {
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
if (sensitivePath.startsWith("~/") && normalizedCmd.includes(sensitivePath)) {
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
if (normalizedCmd.includes(sensitivePath.replace("~", "$HOME"))) {
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
for (const pattern of SENSITIVE_PATH_PATTERNS) {
|
|
192
|
+
if (pattern.test(normalizedCmd)) {
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const homeAccessPattern = new RegExp(
|
|
198
|
+
`(cat|less|head|tail|more|bat|grep|rg|awk|sed|find|ls|tree|du)\\s+[^|;]*?(~(?:/[^\\s/]+)?(?:\\s|$)|${home.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(?:/[^\\s/]+)?(?:\\s|$))`
|
|
199
|
+
);
|
|
200
|
+
if (homeAccessPattern.test(normalizedCmd)) {
|
|
201
|
+
const allowedHomePaths = [
|
|
202
|
+
"~/projects",
|
|
203
|
+
"~/code",
|
|
204
|
+
"~/dev",
|
|
205
|
+
"~/src",
|
|
206
|
+
"~/repos",
|
|
207
|
+
"~/workspace",
|
|
208
|
+
"~/work",
|
|
209
|
+
"~/.local/bin",
|
|
210
|
+
"~/go",
|
|
211
|
+
"~/bin",
|
|
212
|
+
];
|
|
213
|
+
const isAllowedPath = allowedHomePaths.some((allowed) => {
|
|
214
|
+
const expanded = expandPath(allowed);
|
|
215
|
+
return normalizedCmd.includes(expanded) || normalizedCmd.includes(allowed);
|
|
216
|
+
});
|
|
217
|
+
if (!isAllowedPath) {
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function isDangerousCommand(command: string): boolean {
|
|
226
|
+
const normalizedCmd = command.toLowerCase().trim();
|
|
227
|
+
|
|
228
|
+
for (const dangerous of DANGEROUS_COMMANDS) {
|
|
229
|
+
if (dangerous.includes(" ")) {
|
|
230
|
+
if (normalizedCmd.includes(dangerous.toLowerCase())) {
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
233
|
+
} else {
|
|
234
|
+
const wordBoundaryRegex = new RegExp(`\\b${dangerous}\\b`, "i");
|
|
235
|
+
if (wordBoundaryRegex.test(command)) {
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
for (const pattern of DANGEROUS_PATTERNS) {
|
|
242
|
+
if (pattern.test(command)) {
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return false;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export { isDangerousCommand, isSensitivePathAccess };
|
|
@@ -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 {
|
|
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
|
+
}
|