@makefinks/daemon 0.1.0 → 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 +43 -10
- package/package.json +8 -2
- package/src/ai/model-config.ts +1 -1
- package/src/ai/tools/grounding-manager.ts +4 -2
- 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/components/tool-layouts/layouts/grounding.tsx +2 -0
- 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/state/migrations/001-init.ts +12 -0
- package/src/state/session-store.ts +45 -1
package/README.md
CHANGED
|
@@ -1,20 +1,43 @@
|
|
|
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.
|
|
8
9
|
|
|
9
10
|

|
|
10
11
|
|
|
12
|
+
## Installation
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
# npm (might throw warnings)
|
|
16
|
+
npm i -g @makefinks/daemon
|
|
17
|
+
|
|
18
|
+
# bun (recommended)
|
|
19
|
+
bun add -g @makefinks/daemon
|
|
20
|
+
|
|
21
|
+
# additional installs (macOS)
|
|
22
|
+
brew install sox # For Audio Input / Output
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Then run with:
|
|
26
|
+
```bash
|
|
27
|
+
daemon
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
> **Note:** DAEMON requires [Bun](https://bun.sh) at runtime. Install Bun first: `curl -fsSL https://bun.com/install | bash`
|
|
31
|
+
|
|
32
|
+
See full installation details below for configuration and system dependencies.
|
|
33
|
+
|
|
11
34
|
## Highlights
|
|
12
35
|
|
|
13
36
|
### 👤 Interactive Avatar
|
|
14
37
|
At the core of the TUI is DAEMON's **animated avatar**, reacting to what it's doing in real time:
|
|
15
38
|
listening to audio input, reasoning about questions, calling tools, and generating an answer.
|
|
16
39
|
|
|
17
|
-
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.
|
|
18
41
|
|
|
19
42
|
### 🧠 LLMs
|
|
20
43
|
DAEMON can be powered by **any** model available on [OpenRouter](https://openrouter.ai/models).
|
|
@@ -33,8 +56,22 @@ It features a large vocabulary and can transcribe multilingual inputs with compl
|
|
|
33
56
|
|
|
34
57
|
OpenAI's TTS model `gpt-4o-mini-tts-2025-03-20` is used to generate voice output with as little latency as possible.
|
|
35
58
|
|
|
36
|
-
### 🔎 Web Search
|
|
37
|
-
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"
|
|
38
75
|
|
|
39
76
|
### 💾 Session Persistence
|
|
40
77
|
DAEMON stores chat sessions locally (SQLite) and lets you resume past conversations.
|
|
@@ -83,7 +120,7 @@ Configuration is done via environment variables (or the onboarding UI):
|
|
|
83
120
|
|
|
84
121
|
## 🛠️ System dependencies
|
|
85
122
|
|
|
86
|
-
Voice input requires `sox`
|
|
123
|
+
Voice input requires `sox` or other platform-specific audio libraries:
|
|
87
124
|
|
|
88
125
|
### macOS
|
|
89
126
|
```bash
|
|
@@ -111,10 +148,6 @@ DAEMON defaults to Exa-based `fetchUrls` for retrieving web page text. For JavaS
|
|
|
111
148
|
|
|
112
149
|
This feature is **optional** and intentionally not installed by default (browser downloads are large). The render tool is not available to DAEMON without the installation below.
|
|
113
150
|
|
|
114
|
-
### For global installs (`npm i -g` / `bun add -g`)
|
|
115
|
-
|
|
116
|
-
Playwright must also be installed globally:
|
|
117
|
-
|
|
118
151
|
```bash
|
|
119
152
|
# 1) Install Playwright globally
|
|
120
153
|
npm i -g playwright
|
package/package.json
CHANGED
|
@@ -28,14 +28,20 @@
|
|
|
28
28
|
},
|
|
29
29
|
"module": "src/index.tsx",
|
|
30
30
|
"type": "module",
|
|
31
|
-
"version": "0.1.
|
|
31
|
+
"version": "0.1.3",
|
|
32
32
|
"bin": {
|
|
33
33
|
"daemon": "dist/cli.js"
|
|
34
34
|
},
|
|
35
35
|
"publishConfig": {
|
|
36
36
|
"access": "public"
|
|
37
37
|
},
|
|
38
|
-
"files": [
|
|
38
|
+
"files": [
|
|
39
|
+
"dist",
|
|
40
|
+
"src",
|
|
41
|
+
"LICENSE",
|
|
42
|
+
"README.md",
|
|
43
|
+
"package.json"
|
|
44
|
+
],
|
|
39
45
|
"scripts": {
|
|
40
46
|
"build:cli": "bun build src/cli.ts --outdir dist --target node",
|
|
41
47
|
"dev": "bun run --watch src/index.tsx",
|
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;
|
|
@@ -14,8 +14,10 @@ const groundingSourceSchema = z.object({
|
|
|
14
14
|
textFragment: z
|
|
15
15
|
.string()
|
|
16
16
|
.min(1)
|
|
17
|
-
.max(
|
|
18
|
-
.describe(
|
|
17
|
+
.max(150)
|
|
18
|
+
.describe(
|
|
19
|
+
"A short phrase or subphrase (MUST BE COPIED VERBATIM) from the source text for deep-linking. Max 150 characters."
|
|
20
|
+
),
|
|
19
21
|
});
|
|
20
22
|
|
|
21
23
|
const groundedStatementSchema = z.object({
|
|
@@ -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>
|
|
@@ -39,6 +39,8 @@ function extractGroundingInput(input: unknown): GroundingInput | null {
|
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
function GroundingBody({ call }: ToolLayoutRenderProps) {
|
|
42
|
+
if (call.status === "failed") return null;
|
|
43
|
+
|
|
42
44
|
const input = extractGroundingInput(call.input);
|
|
43
45
|
if (!input || input.items.length === 0) return null;
|
|
44
46
|
|
|
@@ -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
|
}
|
|
@@ -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
|
+
}
|