@agentprojectcontext/apx 1.78.0 → 1.80.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/package.json +1 -1
- package/src/core/agent/run-agent.js +46 -6
- package/src/core/agent/super-agent.js +3 -0
- package/src/core/agent/tool-summary.js +65 -0
- package/src/core/agent/tools/handlers/list-commitments.js +80 -0
- package/src/core/agent/tools/handlers/list-tasks.js +66 -27
- package/src/core/agent/tools/handlers/record-commitment.js +68 -0
- package/src/core/agent/tools/handlers/send-telegram.js +68 -2
- package/src/core/agent/tools/names.js +6 -0
- package/src/core/agent/tools/registry.js +9 -0
- package/src/core/agent/tools/tool-call-parser.js +123 -6
- package/src/core/channels/telegram/ask-callbacks.js +121 -6
- package/src/core/channels/telegram/dispatch.js +21 -4
- package/src/core/channels/telegram/inbound/file.js +108 -0
- package/src/core/channels/telegram/inbound/photo.js +42 -11
- package/src/core/channels/telegram/media.js +31 -3
- package/src/core/channels/telegram/reply.js +32 -5
- package/src/core/config/paths.js +3 -0
- package/src/core/config/redact.js +22 -0
- package/src/core/daemon/service.js +238 -0
- package/src/core/engines/gemini.js +343 -63
- package/src/core/engines/openai-compatible.js +21 -2
- package/src/core/memory/consolidate.js +225 -0
- package/src/core/nudge/index.js +192 -0
- package/src/core/nudge/policy.js +143 -0
- package/src/core/nudge/store.js +141 -0
- package/src/core/profiles/bundled/secretary/PROFILE.md +8 -9
- package/src/core/profiles/bundled/secretary/config.schema.json +33 -3
- package/src/core/profiles/bundled/secretary/routines/day-close.json +7 -3
- package/src/core/profiles/bundled/secretary/routines/day-open.json +7 -3
- package/src/core/profiles/bundled/secretary/routines/watch.json +13 -0
- package/src/core/routines/runner.js +102 -3
- package/src/core/routines/signals.js +270 -0
- package/src/core/stores/commitments.js +331 -0
- package/src/core/stores/messages.js +10 -1
- package/src/core/stores/routines.js +17 -3
- package/src/core/util/thinking.js +51 -0
- package/src/host/daemon/api/commitments.js +135 -0
- package/src/host/daemon/api/nudges.js +112 -0
- package/src/host/daemon/api/routines.js +24 -0
- package/src/host/daemon/api/self-memory.js +50 -0
- package/src/host/daemon/api/telegram.js +42 -4
- package/src/host/daemon/api/voice.js +3 -1
- package/src/host/daemon/api.js +6 -0
- package/src/host/daemon/callback-reconciler.js +16 -0
- package/src/host/daemon/plugins/desktop/index.js +7 -1
- package/src/host/daemon/plugins/telegram/index.js +7 -2
- package/src/host/daemon/wakeup.js +17 -3
- package/src/interfaces/cli/commands/commitment.js +154 -0
- package/src/interfaces/cli/commands/daemon.js +57 -0
- package/src/interfaces/cli/commands/memory.js +73 -0
- package/src/interfaces/cli/commands/nudge.js +130 -0
- package/src/interfaces/cli/help/index.js +2 -2
- package/src/interfaces/cli/routes/commitment.js +19 -0
- package/src/interfaces/cli/routes/daemon.js +7 -1
- package/src/interfaces/cli/routes/index.js +4 -0
- package/src/interfaces/cli/routes/memory.js +10 -2
- package/src/interfaces/cli/routes/nudge.js +17 -0
- package/src/interfaces/web/dist/assets/index-CvEoGtTf.js +849 -0
- package/src/interfaces/web/dist/assets/index-CvEoGtTf.js.map +1 -0
- package/src/interfaces/web/dist/assets/index-DzBBXFaO.css +1 -0
- package/src/interfaces/web/dist/index.html +2 -2
- package/src/interfaces/web/package-lock.json +11 -10
- package/src/interfaces/web/src/components/Section.tsx +18 -3
- package/src/interfaces/web/src/components/chat/MessageBubble.tsx +13 -0
- package/src/interfaces/web/src/components/cron/CronPicker.tsx +196 -0
- package/src/interfaces/web/src/components/inbox/InboxList.tsx +145 -0
- package/src/interfaces/web/src/components/memory/MemoryBrowser.tsx +34 -4
- package/src/interfaces/web/src/components/routines/RoutineDetail.tsx +16 -4
- package/src/interfaces/web/src/components/routines/RoutineEditor.tsx +12 -2
- package/src/interfaces/web/src/components/routines/shared.ts +14 -5
- package/src/interfaces/web/src/components/settings/NudgePanel.tsx +183 -0
- package/src/interfaces/web/src/components/settings/ProfilePanel.tsx +36 -12
- package/src/interfaces/web/src/components/ui/filter-chips.tsx +47 -0
- package/src/interfaces/web/src/components/ui.tsx +1 -0
- package/src/interfaces/web/src/constants/index.ts +1 -0
- package/src/interfaces/web/src/hooks/useChat.ts +5 -1
- package/src/interfaces/web/src/hooks/useNudges.ts +38 -0
- package/src/interfaces/web/src/i18n/en.ts +127 -0
- package/src/interfaces/web/src/i18n/es.ts +127 -0
- package/src/interfaces/web/src/lib/api/commitments.ts +57 -0
- package/src/interfaces/web/src/lib/api/notebook.ts +23 -0
- package/src/interfaces/web/src/lib/api/nudges.ts +53 -0
- package/src/interfaces/web/src/lib/cron.ts +196 -0
- package/src/interfaces/web/src/lib/when.ts +32 -0
- package/src/interfaces/web/src/screens/InboxScreen.tsx +107 -77
- package/src/interfaces/web/src/screens/ProjectScreen.tsx +5 -2
- package/src/interfaces/web/src/screens/SettingsScreen.tsx +17 -3
- package/src/interfaces/web/src/screens/base/CommitmentsTab.tsx +239 -0
- package/src/interfaces/web/src/screens/base/GlobalTasksTab.tsx +102 -19
- package/src/interfaces/web/src/screens/base/LogsTab.tsx +15 -0
- package/src/interfaces/web/src/screens/project/ChatTab.tsx +21 -3
- package/src/interfaces/web/src/screens/project/RoutinesTab.tsx +13 -11
- package/src/interfaces/web/src/types/daemon.ts +10 -1
- package/src/interfaces/web/dist/assets/index-CBR_-QyA.js +0 -824
- package/src/interfaces/web/dist/assets/index-CBR_-QyA.js.map +0 -1
- package/src/interfaces/web/dist/assets/index-D_EJEA1n.css +0 -1
|
@@ -1,103 +1,133 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
1
2
|
import { useNavigate } from "react-router-dom";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
3
|
+
import { ArrowUpRight, EyeOff, Eye } from "lucide-react";
|
|
4
|
+
import { Button, Loading } from "../components/ui";
|
|
5
|
+
import { Tip } from "../components/ui/tip";
|
|
6
|
+
import { InboxList, rowKey } from "../components/inbox/InboxList";
|
|
7
|
+
import { ChatTab } from "./project/ChatTab";
|
|
4
8
|
import { useInbox } from "../hooks/useInbox";
|
|
5
9
|
import type { InboxRow } from "../lib/api/inbox";
|
|
10
|
+
import type { ChatKey } from "../components/chat/ChatList";
|
|
6
11
|
import { t } from "../i18n";
|
|
7
|
-
import { useState } from "react";
|
|
8
12
|
|
|
9
13
|
/**
|
|
10
14
|
* The agent inbox — every agent as a conversation, most recent first.
|
|
11
15
|
*
|
|
12
16
|
* A SECOND AXIS over the same data, not a replacement for project navigation:
|
|
13
17
|
* this is the conversational way in, the project rail is the structural one.
|
|
14
|
-
*
|
|
15
|
-
*
|
|
18
|
+
*
|
|
19
|
+
* Two panes, like any messaging app: pick on the left, read and reply on the
|
|
20
|
+
* right. The list used to fill the width and NAVIGATE AWAY on click, so
|
|
21
|
+
* reading one conversation meant losing the list — which defeats the point of
|
|
22
|
+
* having an inbox at all. The right pane embeds the real chat surface rather
|
|
23
|
+
* than a read-only rendering of it: if you can see what an agent said, you can
|
|
24
|
+
* answer it there and then.
|
|
16
25
|
*/
|
|
17
26
|
export function InboxScreen() {
|
|
18
27
|
const navigate = useNavigate();
|
|
19
28
|
const [includeEmpty, setIncludeEmpty] = useState(false);
|
|
20
29
|
const { rows, isLoading } = useInbox(includeEmpty);
|
|
30
|
+
const [selected, setSelected] = useState<InboxRow | null>(null);
|
|
31
|
+
|
|
32
|
+
// Open the most recent conversation on arrival. An inbox that lands on an
|
|
33
|
+
// empty pane makes you click twice to see the thing you came for.
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (!selected && rows.length) setSelected(rows[0]);
|
|
36
|
+
}, [rows, selected]);
|
|
37
|
+
|
|
38
|
+
if (isLoading) return <Loading />;
|
|
39
|
+
|
|
40
|
+
const pid = selected ? String(selected.project_id ?? 0) : null;
|
|
41
|
+
const title = selected ? (selected.agent_name || selected.agent_slug) : "";
|
|
21
42
|
|
|
22
|
-
|
|
43
|
+
/**
|
|
44
|
+
* Which conversation the chat pane should open.
|
|
45
|
+
*
|
|
46
|
+
* The super-agent has no per-agent conversation files — it talks on channels,
|
|
47
|
+
* and its history is the cross-channel ledger — so its row addresses a
|
|
48
|
+
* THREAD (channel + date). A project agent addresses a conversation. With
|
|
49
|
+
* neither, fall through to a live session rather than showing nothing.
|
|
50
|
+
*/
|
|
51
|
+
const selectionFor = (row: InboxRow): ChatKey | undefined => {
|
|
23
52
|
if (row.kind === "super_agent") {
|
|
24
|
-
|
|
25
|
-
|
|
53
|
+
return row.channel && row.conversation_id
|
|
54
|
+
? { kind: "thread", channel: row.channel, threadId: row.conversation_id }
|
|
55
|
+
: undefined;
|
|
26
56
|
}
|
|
27
|
-
|
|
57
|
+
return row.conversation_id
|
|
58
|
+
? { kind: "conv", agentSlug: row.agent_slug, convId: row.conversation_id }
|
|
59
|
+
: { kind: "live", agentSlug: row.agent_slug };
|
|
28
60
|
};
|
|
29
61
|
|
|
30
62
|
return (
|
|
31
|
-
<
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
63
|
+
<div className="flex h-full min-h-0 flex-col gap-3" data-testid="inbox-screen">
|
|
64
|
+
<div className="flex shrink-0 items-baseline justify-between gap-3">
|
|
65
|
+
<div>
|
|
66
|
+
<h1 className="text-lg font-semibold">{t("inbox.title")}</h1>
|
|
67
|
+
<p className="text-xs text-muted-fg">{t("inbox.subtitle")}</p>
|
|
68
|
+
</div>
|
|
69
|
+
<Tip content={includeEmpty ? t("inbox.hide_quiet") : t("inbox.show_quiet")}>
|
|
70
|
+
<Button size="sm" variant={includeEmpty ? "primary" : "ghost"} onClick={() => setIncludeEmpty((v) => !v)}>
|
|
71
|
+
{includeEmpty ? <EyeOff size={14} /> : <Eye size={14} />}
|
|
72
|
+
<span className="hidden sm:inline">{t("inbox.show_quiet")}</span>
|
|
73
|
+
</Button>
|
|
74
|
+
</Tip>
|
|
75
|
+
</div>
|
|
43
76
|
|
|
44
|
-
<
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
onClick={() => open(row)}
|
|
51
|
-
className={`flex w-full items-start gap-3 rounded-md border px-3 py-2 text-left transition ${
|
|
52
|
-
row.pinned
|
|
53
|
-
? "border-primary/60 bg-primary/5 hover:bg-primary/10"
|
|
54
|
-
: "border-border bg-muted/30 hover:bg-muted/50"
|
|
55
|
-
}`}
|
|
56
|
-
>
|
|
57
|
-
<span className="mt-0.5 text-lg leading-none">{row.agent_emoji || "🤖"}</span>
|
|
77
|
+
<div className="flex min-h-0 flex-1 overflow-hidden rounded-xl border border-border bg-card/40">
|
|
78
|
+
<InboxList
|
|
79
|
+
rows={rows}
|
|
80
|
+
selectedKey={selected ? rowKey(selected) : null}
|
|
81
|
+
onSelect={setSelected}
|
|
82
|
+
/>
|
|
58
83
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
84
|
+
<section className="flex min-w-0 flex-1 flex-col">
|
|
85
|
+
{!selected ? (
|
|
86
|
+
<div className="flex h-full items-center justify-center p-8">
|
|
87
|
+
<p className="text-sm text-muted-fg">{t("inbox.empty")}</p>
|
|
88
|
+
</div>
|
|
89
|
+
) : (
|
|
90
|
+
<>
|
|
91
|
+
<header className="flex shrink-0 items-center justify-between gap-3 border-b border-border px-4 py-2.5">
|
|
92
|
+
<div className="min-w-0">
|
|
93
|
+
<h2 className="truncate text-sm font-semibold">{title}</h2>
|
|
94
|
+
<p className="truncate text-[11px] text-muted-fg">
|
|
95
|
+
{selected.project_name || t("inbox.super_agent_scope")}
|
|
96
|
+
{selected.channel ? ` · ${selected.channel}` : ""}
|
|
97
|
+
</p>
|
|
98
|
+
</div>
|
|
99
|
+
{/* The structural way out. The inbox is a second axis over the
|
|
100
|
+
same data, so getting from a conversation to its project
|
|
101
|
+
must always be one click. */}
|
|
102
|
+
<Button
|
|
103
|
+
size="sm"
|
|
104
|
+
variant="ghost"
|
|
105
|
+
onClick={() =>
|
|
106
|
+
navigate(
|
|
107
|
+
selected.kind === "super_agent"
|
|
108
|
+
? "/p/0/chat"
|
|
109
|
+
: `/p/${selected.project_id}/agents/${encodeURIComponent(selected.agent_slug)}`,
|
|
110
|
+
)}
|
|
111
|
+
>
|
|
112
|
+
{t("inbox.open_in_project")} <ArrowUpRight size={13} />
|
|
113
|
+
</Button>
|
|
114
|
+
</header>
|
|
69
115
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
</button>
|
|
87
|
-
</li>
|
|
88
|
-
))}
|
|
89
|
-
</ul>
|
|
90
|
-
</Section>
|
|
116
|
+
<div className="min-h-0 flex-1">
|
|
117
|
+
{/* Remounted per selection: the chat surface holds its own
|
|
118
|
+
session state, and carrying one agent's stream into another
|
|
119
|
+
agent's pane would be worse than a moment's reload. */}
|
|
120
|
+
<ChatTab
|
|
121
|
+
key={rowKey(selected)}
|
|
122
|
+
pid={pid as string}
|
|
123
|
+
hideSidebar
|
|
124
|
+
initialSelection={selectionFor(selected)}
|
|
125
|
+
/>
|
|
126
|
+
</div>
|
|
127
|
+
</>
|
|
128
|
+
)}
|
|
129
|
+
</section>
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
91
132
|
);
|
|
92
133
|
}
|
|
93
|
-
|
|
94
|
-
/** Today → time of day; older → date. Same idea as a messaging app. */
|
|
95
|
-
function shortTime(iso: string): string {
|
|
96
|
-
if (!iso) return "";
|
|
97
|
-
const d = new Date(iso);
|
|
98
|
-
if (Number.isNaN(d.getTime())) return "";
|
|
99
|
-
const sameDay = new Date().toDateString() === d.toDateString();
|
|
100
|
-
return sameDay
|
|
101
|
-
? d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
|
102
|
-
: d.toLocaleDateString();
|
|
103
|
-
}
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
Bot, Heart, Zap, Puzzle, Settings,
|
|
6
6
|
MessagesSquare, KeyRound,
|
|
7
7
|
LayoutDashboard, Boxes, Cpu, ScrollText, History, Brain, FileCode2, Cable,
|
|
8
|
-
Building2, FileText, FolderTree, Sparkles,
|
|
8
|
+
Building2, FileText, FolderTree, Sparkles, Handshake,
|
|
9
9
|
} from "lucide-react";
|
|
10
10
|
import { useNavCollapse, type TabSection } from "../components/common/TabNav";
|
|
11
11
|
import { TabLayout } from "../components/common/TabLayout";
|
|
@@ -23,6 +23,7 @@ import { ModelsTab } from "./base/ModelsTab";
|
|
|
23
23
|
import { AgentDefaultsTab } from "./base/AgentDefaultsTab";
|
|
24
24
|
import { SessionsTab } from "./base/SessionsTab";
|
|
25
25
|
import { GlobalTasksTab } from "./base/GlobalTasksTab";
|
|
26
|
+
import { CommitmentsTab } from "./base/CommitmentsTab";
|
|
26
27
|
import { ConfigTab } from "./project/ConfigTab";
|
|
27
28
|
import { AgentsTab } from "./project/AgentsTab";
|
|
28
29
|
import { RoutinesTab } from "./project/RoutinesTab";
|
|
@@ -42,7 +43,7 @@ import { SkillsTab } from "./project/SkillsTab";
|
|
|
42
43
|
|
|
43
44
|
type NavKey =
|
|
44
45
|
| "" | "chat" | "config" | "telegram"
|
|
45
|
-
| "agents" | "routines" | "tasks" | "mcps" | "integrations" | "vars" | "logs" | "memories" | "artifacts"
|
|
46
|
+
| "agents" | "routines" | "tasks" | "commitments" | "mcps" | "integrations" | "vars" | "logs" | "memories" | "artifacts"
|
|
46
47
|
| "structure" | "docs" | "files" | "skills" | "sessions";
|
|
47
48
|
|
|
48
49
|
export function ProjectScreen() {
|
|
@@ -115,6 +116,7 @@ export function ProjectScreen() {
|
|
|
115
116
|
items: [
|
|
116
117
|
{ key: "routines", label: t("project.nav.routines"), icon: Heart },
|
|
117
118
|
{ key: "tasks", label: t("project.nav.tasks"), icon: Zap },
|
|
119
|
+
{ key: "commitments", label: t("project.nav.commitments"), icon: Handshake },
|
|
118
120
|
{ key: "mcps", label: t("project.nav.mcps"), icon: Puzzle },
|
|
119
121
|
{ key: "integrations", label: "Integrations", icon: Cable },
|
|
120
122
|
{ key: "vars", label: t("project.nav.vars"), icon: KeyRound },
|
|
@@ -184,6 +186,7 @@ export function ProjectScreen() {
|
|
|
184
186
|
<Route path="skills" element={<SkillsTab pid={pid} />} />
|
|
185
187
|
<Route path="routines" element={<RoutinesTab pid={pid} />} />
|
|
186
188
|
<Route path="tasks" element={isBase ? <GlobalTasksTab /> : <TasksTab pid={pid} />} />
|
|
189
|
+
<Route path="commitments" element={<CommitmentsTab pid={isBase ? undefined : pid} />} />
|
|
187
190
|
<Route path="mcps" element={<McpsTab pid={pid} />} />
|
|
188
191
|
<Route path="integrations" element={<IntegrationsTab pid={pid} />} />
|
|
189
192
|
<Route path="artifacts" element={<ArtifactsTab pid={pid} />} />
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { type ReactElement } from "react";
|
|
2
2
|
import { useLocation, useNavigate } from "react-router-dom";
|
|
3
3
|
import {
|
|
4
|
-
Bot, Cpu, Database, Globe, IdCard, KeyRound, LayoutGrid, MessageCircle, Mic, Monitor, ScrollText, Send, Smartphone, Sparkles, User,
|
|
4
|
+
BellRing, Bot, Cpu, Database, Globe, IdCard, KeyRound, LayoutGrid, MessageCircle, Mic, Monitor, ScrollText, Send, Smartphone, Sparkles, User,
|
|
5
5
|
} from "lucide-react";
|
|
6
6
|
import { useNavCollapse, type TabSection } from "../components/common/TabNav";
|
|
7
7
|
import { TabLayout } from "../components/common/TabLayout";
|
|
8
8
|
import { IdentityPanel } from "../components/settings/IdentityPanel";
|
|
9
9
|
import { SuperAgentPanel } from "../components/settings/SuperAgentPanel";
|
|
10
10
|
import { ProfilePanel } from "../components/settings/ProfilePanel";
|
|
11
|
+
import { NudgePanel } from "../components/settings/NudgePanel";
|
|
11
12
|
import { MemoryPanel } from "../components/settings/MemoryPanel";
|
|
12
13
|
import { SkillsSettings } from "../components/settings/SkillsSettings";
|
|
13
14
|
import { ModelsTab } from "./base/ModelsTab";
|
|
@@ -22,7 +23,7 @@ import { STORAGE } from "../constants";
|
|
|
22
23
|
import { t } from "../i18n";
|
|
23
24
|
|
|
24
25
|
type TabKey =
|
|
25
|
-
| "identity" | "super_agent" | "profile" | "engines" | "memory" | "skills" | "telegram" | "devices"
|
|
26
|
+
| "identity" | "super_agent" | "profile" | "nudge" | "engines" | "memory" | "skills" | "telegram" | "devices"
|
|
26
27
|
| "voice" | "deck" | "desktop" | "web" | "advanced";
|
|
27
28
|
|
|
28
29
|
const SECTIONS: TabSection[] = [
|
|
@@ -32,11 +33,22 @@ const SECTIONS: TabSection[] = [
|
|
|
32
33
|
{ key: "identity", label: t("settings.tabs.identity"), icon: User },
|
|
33
34
|
],
|
|
34
35
|
},
|
|
36
|
+
// The super-agent and the three screens that only describe IT: who it is,
|
|
37
|
+
// what line of work it has, and how often it may speak. The budget is core
|
|
38
|
+
// rather than profile-owned — it works with no profile installed — but it
|
|
39
|
+
// governs this agent's outbound messages, so it belongs next to them.
|
|
35
40
|
{
|
|
36
|
-
title: t("settings.
|
|
41
|
+
title: t("settings.super_agent_section"),
|
|
37
42
|
items: [
|
|
38
43
|
{ key: "super_agent", label: t("settings.tabs.super_agent"), icon: Bot },
|
|
39
44
|
{ key: "profile", label: t("settings.tabs.profile"), icon: IdCard },
|
|
45
|
+
{ key: "nudge", label: t("settings.tabs.nudge"), icon: BellRing },
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
// Shared substrate: every agent uses these, not just the super-agent.
|
|
49
|
+
{
|
|
50
|
+
title: t("settings.knowledge_section"),
|
|
51
|
+
items: [
|
|
40
52
|
{ key: "engines", label: t("settings.tabs.engines"), icon: Cpu },
|
|
41
53
|
{ key: "memory", label: "Memory (RAG)", icon: Database },
|
|
42
54
|
{ key: "skills", label: t("skills_page.title"), icon: Sparkles },
|
|
@@ -76,6 +88,7 @@ const PANELS: Record<TabKey, () => ReactElement> = {
|
|
|
76
88
|
identity: () => <IdentityPanel />,
|
|
77
89
|
super_agent: () => <SuperAgentPanel />,
|
|
78
90
|
profile: () => <ProfilePanel />,
|
|
91
|
+
nudge: () => <NudgePanel />,
|
|
79
92
|
engines: () => <ModelsTab />,
|
|
80
93
|
memory: () => <MemoryPanel />,
|
|
81
94
|
skills: () => <SkillsSettings />,
|
|
@@ -117,6 +130,7 @@ function tabFromPath(pathname: string): TabKey {
|
|
|
117
130
|
switch (raw) {
|
|
118
131
|
case "super-agent": return "super_agent";
|
|
119
132
|
case "profile": return "profile";
|
|
133
|
+
case "nudge": return "nudge";
|
|
120
134
|
case "engines": return "engines";
|
|
121
135
|
case "memory": return "memory";
|
|
122
136
|
case "skills": return "skills";
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { useNavigate } from "react-router-dom";
|
|
3
|
+
import { Plus } from "lucide-react";
|
|
4
|
+
import { Commitments, type CommitmentState } from "../../lib/api/commitments";
|
|
5
|
+
import { Section } from "../../components/Section";
|
|
6
|
+
import { PagedList, usePagedQuery } from "../../components/Pager";
|
|
7
|
+
import { Badge, Button, Dialog, Empty, Field, FilterChips, Input, Loading } from "../../components/ui";
|
|
8
|
+
import { UiSelect } from "../../components/UiSelect";
|
|
9
|
+
import { useProjects } from "../../hooks/useProjects";
|
|
10
|
+
import { useToast } from "../../components/Toast";
|
|
11
|
+
import { t } from "../../i18n";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Commitments — what was promised, to whom, by when.
|
|
15
|
+
*
|
|
16
|
+
* Sits next to Tasks rather than inside it. The two answer different
|
|
17
|
+
* questions: a task is work, a commitment is someone waiting. Overdue is the
|
|
18
|
+
* default filter for exactly that reason — the expensive failure here is
|
|
19
|
+
* social, and it is silent unless something surfaces it.
|
|
20
|
+
*
|
|
21
|
+
* `pid` omitted = every project, which is the view that matches how promises
|
|
22
|
+
* are actually made.
|
|
23
|
+
*/
|
|
24
|
+
export function CommitmentsTab({ pid }: { pid?: string }) {
|
|
25
|
+
const navigate = useNavigate();
|
|
26
|
+
const toast = useToast();
|
|
27
|
+
const [state, setState] = useState<CommitmentState | "all">("open");
|
|
28
|
+
const [overdueOnly, setOverdueOnly] = useState(false);
|
|
29
|
+
|
|
30
|
+
// Capturing a promise by hand matters even though the agent captures them
|
|
31
|
+
// from conversation: the moment you remember one is rarely the moment you
|
|
32
|
+
// are talking to it.
|
|
33
|
+
const [adding, setAdding] = useState(false);
|
|
34
|
+
const [who, setWho] = useState("");
|
|
35
|
+
const [what, setWhat] = useState("");
|
|
36
|
+
const [due, setDue] = useState("");
|
|
37
|
+
const [target, setTarget] = useState(pid ?? "");
|
|
38
|
+
const [busy, setBusy] = useState(false);
|
|
39
|
+
const { projects } = useProjects();
|
|
40
|
+
|
|
41
|
+
const paged = usePagedQuery({
|
|
42
|
+
key: `/api/commitments?pid=${pid ?? "all"}&state=${state}&overdue=${overdueOnly}`,
|
|
43
|
+
fetchPage: (limit, offset) =>
|
|
44
|
+
pid
|
|
45
|
+
? Commitments.listPage(pid, { state, overdue: overdueOnly, limit, offset })
|
|
46
|
+
: Commitments.globalPage({ state, overdue: overdueOnly, limit, offset }),
|
|
47
|
+
resetKey: `${pid ?? "all"}|${state}|${overdueOnly}`,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const close = async (
|
|
51
|
+
projectId: string,
|
|
52
|
+
id: string,
|
|
53
|
+
how: "kept" | "missed",
|
|
54
|
+
) => {
|
|
55
|
+
try {
|
|
56
|
+
await Commitments[how](projectId, id);
|
|
57
|
+
await paged.mutate();
|
|
58
|
+
} catch (e) {
|
|
59
|
+
toast.error((e as Error).message);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const isOverdue = (c: { state: string; due: string | null }) =>
|
|
64
|
+
c.state === "open" && !!c.due && c.due < new Date().toISOString();
|
|
65
|
+
|
|
66
|
+
const resetForm = () => { setWho(""); setWhat(""); setDue(""); setTarget(pid ?? ""); };
|
|
67
|
+
|
|
68
|
+
const create = async () => {
|
|
69
|
+
const projectId = pid ?? target;
|
|
70
|
+
if (!who.trim() || !what.trim() || !projectId) return;
|
|
71
|
+
setBusy(true);
|
|
72
|
+
try {
|
|
73
|
+
await Commitments.add(String(projectId), {
|
|
74
|
+
counterparty: who.trim(),
|
|
75
|
+
body: what.trim(),
|
|
76
|
+
// A date is optional here even though it is the useful part — refusing
|
|
77
|
+
// to record "I promised Ana the quote, no date yet" would mean the
|
|
78
|
+
// promise goes unrecorded, which is strictly worse.
|
|
79
|
+
due: due ? new Date(due).toISOString() : null,
|
|
80
|
+
});
|
|
81
|
+
await paged.mutate();
|
|
82
|
+
setAdding(false);
|
|
83
|
+
resetForm();
|
|
84
|
+
} catch (e) {
|
|
85
|
+
toast.error((e as Error).message);
|
|
86
|
+
} finally {
|
|
87
|
+
setBusy(false);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<Section
|
|
93
|
+
fullHeight
|
|
94
|
+
title={t("project.commitments.title")}
|
|
95
|
+
description={t("project.commitments.subtitle")}
|
|
96
|
+
action={
|
|
97
|
+
<Button size="sm" variant="primary" onClick={() => setAdding(true)}>
|
|
98
|
+
<Plus size={14} /> {t("project.commitments.add")}
|
|
99
|
+
</Button>
|
|
100
|
+
}
|
|
101
|
+
filters={
|
|
102
|
+
<>
|
|
103
|
+
<FilterChips
|
|
104
|
+
value={state}
|
|
105
|
+
onChange={setState}
|
|
106
|
+
label={t("project.commitments.title")}
|
|
107
|
+
options={(["open", "kept", "missed", "all"] as const).map((s) => ({
|
|
108
|
+
value: s, label: t(`project.commitments.state.${s}`),
|
|
109
|
+
}))}
|
|
110
|
+
/>
|
|
111
|
+
<Button
|
|
112
|
+
size="sm"
|
|
113
|
+
variant={overdueOnly ? "primary" : "ghost"}
|
|
114
|
+
onClick={() => setOverdueOnly((v) => !v)}
|
|
115
|
+
>
|
|
116
|
+
{t("project.commitments.overdue_only")}
|
|
117
|
+
</Button>
|
|
118
|
+
</>
|
|
119
|
+
}
|
|
120
|
+
>
|
|
121
|
+
{paged.isLoading && <Loading />}
|
|
122
|
+
{!paged.isLoading && paged.total === 0 && <Empty>{t("project.commitments.empty")}</Empty>}
|
|
123
|
+
|
|
124
|
+
<PagedList paged={paged} fullHeight>
|
|
125
|
+
<ul className="space-y-2 text-sm" data-testid="commitments-list">
|
|
126
|
+
{paged.items.map((c) => {
|
|
127
|
+
const projectId = String(
|
|
128
|
+
(c as { project_id?: string | number }).project_id ?? pid ?? "",
|
|
129
|
+
);
|
|
130
|
+
const projectName = (c as { project_name?: string }).project_name;
|
|
131
|
+
return (
|
|
132
|
+
<li
|
|
133
|
+
key={`${projectId}-${c.id}`}
|
|
134
|
+
className={`flex items-start gap-3 rounded-md border px-3 py-2 ${
|
|
135
|
+
isOverdue(c) ? "border-red-500/40 bg-red-500/5" : "border-border bg-muted/30"
|
|
136
|
+
}`}
|
|
137
|
+
>
|
|
138
|
+
{projectName ? (
|
|
139
|
+
<button
|
|
140
|
+
type="button"
|
|
141
|
+
onClick={() => navigate(`/p/${projectId}/commitments`)}
|
|
142
|
+
title={t("project.global_tasks.go_project")}
|
|
143
|
+
>
|
|
144
|
+
<Badge tone="info">{projectName.split("/").pop() || projectId}</Badge>
|
|
145
|
+
</button>
|
|
146
|
+
) : null}
|
|
147
|
+
|
|
148
|
+
<div className="min-w-0 flex-1">
|
|
149
|
+
<div className="flex flex-wrap items-baseline gap-2">
|
|
150
|
+
{/* The counterparty leads. "Who is waiting" is the whole
|
|
151
|
+
reason this is not a task. */}
|
|
152
|
+
<span className="font-medium">{c.counterparty}</span>
|
|
153
|
+
<span className="opacity-80">— {c.body}</span>
|
|
154
|
+
</div>
|
|
155
|
+
<div className="mt-0.5 flex flex-wrap items-center gap-2 text-xs text-muted-fg">
|
|
156
|
+
{c.due ? (
|
|
157
|
+
<span className={isOverdue(c) ? "font-medium text-red-500" : ""}>
|
|
158
|
+
{isOverdue(c) ? t("project.commitments.overdue") : t("project.global_tasks.due")}{" "}
|
|
159
|
+
{c.due.slice(0, 10)}
|
|
160
|
+
</span>
|
|
161
|
+
) : (
|
|
162
|
+
<span className="opacity-60">{t("project.commitments.no_date")}</span>
|
|
163
|
+
)}
|
|
164
|
+
{c.origin_channel ? <span>· {c.origin_channel}</span> : null}
|
|
165
|
+
{c.renegotiated_count ? (
|
|
166
|
+
<Badge tone="warning">
|
|
167
|
+
{t("project.commitments.moved")} ×{c.renegotiated_count}
|
|
168
|
+
</Badge>
|
|
169
|
+
) : null}
|
|
170
|
+
{c.state !== "open" ? <Badge>{t(`project.commitments.state.${c.state}`)}</Badge> : null}
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
|
|
174
|
+
{c.state === "open" && projectId ? (
|
|
175
|
+
<div className="flex shrink-0 gap-1">
|
|
176
|
+
<Button size="sm" variant="ghost" onClick={() => close(projectId, c.id, "kept")}>
|
|
177
|
+
{t("project.commitments.mark_kept")}
|
|
178
|
+
</Button>
|
|
179
|
+
<Button size="sm" variant="ghost" onClick={() => close(projectId, c.id, "missed")}>
|
|
180
|
+
{t("project.commitments.mark_missed")}
|
|
181
|
+
</Button>
|
|
182
|
+
</div>
|
|
183
|
+
) : null}
|
|
184
|
+
</li>
|
|
185
|
+
);
|
|
186
|
+
})}
|
|
187
|
+
</ul>
|
|
188
|
+
</PagedList>
|
|
189
|
+
|
|
190
|
+
<Dialog
|
|
191
|
+
open={adding}
|
|
192
|
+
onClose={() => { setAdding(false); resetForm(); }}
|
|
193
|
+
title={t("project.commitments.add_title")}
|
|
194
|
+
description={t("project.commitments.add_hint")}
|
|
195
|
+
footer={
|
|
196
|
+
<>
|
|
197
|
+
<Button onClick={() => { setAdding(false); resetForm(); }}>{t("common.cancel")}</Button>
|
|
198
|
+
<Button
|
|
199
|
+
variant="primary"
|
|
200
|
+
loading={busy}
|
|
201
|
+
disabled={!who.trim() || !what.trim() || !(pid ?? target)}
|
|
202
|
+
onClick={create}
|
|
203
|
+
>
|
|
204
|
+
{t("project.commitments.add")}
|
|
205
|
+
</Button>
|
|
206
|
+
</>
|
|
207
|
+
}
|
|
208
|
+
>
|
|
209
|
+
<div className="flex flex-col gap-3 p-5">
|
|
210
|
+
{/* Counterparty first — it is what makes this a commitment and not a
|
|
211
|
+
task, so the form asks for it before anything else. */}
|
|
212
|
+
<Field label={t("project.commitments.field_who")} hint={t("project.commitments.field_who_hint")}>
|
|
213
|
+
<Input value={who} onChange={(e) => setWho(e.target.value)} placeholder="Ana" autoFocus />
|
|
214
|
+
</Field>
|
|
215
|
+
<Field label={t("project.commitments.field_what")}>
|
|
216
|
+
<Input
|
|
217
|
+
value={what}
|
|
218
|
+
onChange={(e) => setWhat(e.target.value)}
|
|
219
|
+
placeholder={t("project.commitments.field_what_ph")}
|
|
220
|
+
onKeyDown={(e) => { if (e.key === "Enter" && who.trim() && what.trim()) create(); }}
|
|
221
|
+
/>
|
|
222
|
+
</Field>
|
|
223
|
+
<Field label={t("project.commitments.field_due")} hint={t("project.commitments.field_due_hint")}>
|
|
224
|
+
<Input type="date" value={due} onChange={(e) => setDue(e.target.value)} />
|
|
225
|
+
</Field>
|
|
226
|
+
{!pid ? (
|
|
227
|
+
<Field label={t("project.commitments.field_project")}>
|
|
228
|
+
<UiSelect
|
|
229
|
+
value={String(target)}
|
|
230
|
+
onChange={setTarget}
|
|
231
|
+
options={projects.map((p) => ({ value: String(p.id), label: p.name || p.path }))}
|
|
232
|
+
/>
|
|
233
|
+
</Field>
|
|
234
|
+
) : null}
|
|
235
|
+
</div>
|
|
236
|
+
</Dialog>
|
|
237
|
+
</Section>
|
|
238
|
+
);
|
|
239
|
+
}
|