@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
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { useEffect, useMemo, useState } from "react";
|
|
2
|
+
import { Clock, Code2 } from "lucide-react";
|
|
3
|
+
import { cn } from "../../lib/cn";
|
|
4
|
+
import { t } from "../../i18n";
|
|
5
|
+
import {
|
|
6
|
+
DAY_KEYS, cronToSelection, selectionToCron, describeCron, isPickerFriendly,
|
|
7
|
+
normalizeCron, type CronSelection,
|
|
8
|
+
} from "../../lib/cron";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Read-only sentence for a cron expression. Falls back to the raw expression
|
|
12
|
+
* when the schedule is beyond what lib/cron understands — a confident wrong
|
|
13
|
+
* reading of when something runs is worse than showing the source.
|
|
14
|
+
*/
|
|
15
|
+
export function CronSummary({ expr, className }: { expr: string; className?: string }) {
|
|
16
|
+
const text = describeCron(expr, t as never);
|
|
17
|
+
return (
|
|
18
|
+
<span className={cn("inline-flex items-center gap-1.5", className)} title={expr}>
|
|
19
|
+
<Clock size={12} className="shrink-0 opacity-60" />
|
|
20
|
+
{text ? <span>{text}</span> : <code className="font-mono text-[11px]">{expr}</code>}
|
|
21
|
+
</span>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Time + day checkboxes, or every-N-hours, with the cron expression as an
|
|
27
|
+
* escape hatch rather than the only interface.
|
|
28
|
+
*
|
|
29
|
+
* The raw field stays reachable on purpose: cron can express things this
|
|
30
|
+
* picker cannot, and a form that silently narrows what the user is allowed to
|
|
31
|
+
* schedule is worse than one that asks them to type. When the current value is
|
|
32
|
+
* beyond the picker, the raw field opens by itself — the alternative is a
|
|
33
|
+
* picker quietly showing something the expression does not say.
|
|
34
|
+
*/
|
|
35
|
+
export function CronPicker({
|
|
36
|
+
value,
|
|
37
|
+
onChange,
|
|
38
|
+
disabled,
|
|
39
|
+
allowEveryHours = true,
|
|
40
|
+
}: {
|
|
41
|
+
value: string;
|
|
42
|
+
onChange: (expr: string) => void;
|
|
43
|
+
disabled?: boolean;
|
|
44
|
+
allowEveryHours?: boolean;
|
|
45
|
+
}) {
|
|
46
|
+
const friendly = useMemo(() => isPickerFriendly(value), [value]);
|
|
47
|
+
const [raw, setRaw] = useState(!friendly);
|
|
48
|
+
const [rawDraft, setRawDraft] = useState(value);
|
|
49
|
+
const sel = useMemo<CronSelection>(() => cronToSelection(value), [value]);
|
|
50
|
+
|
|
51
|
+
// An expression arriving from outside (a profile default, another edit) that
|
|
52
|
+
// the picker cannot represent flips to raw rather than misrepresenting it.
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
setRawDraft(value);
|
|
55
|
+
if (!isPickerFriendly(value)) setRaw(true);
|
|
56
|
+
}, [value]);
|
|
57
|
+
|
|
58
|
+
const patch = (over: Partial<CronSelection>) =>
|
|
59
|
+
onChange(selectionToCron({ ...sel, ...over }));
|
|
60
|
+
|
|
61
|
+
const toggleDay = (d: number) => {
|
|
62
|
+
const next = sel.days.includes(d) ? sel.days.filter((x) => x !== d) : [...sel.days, d];
|
|
63
|
+
patch({ days: next });
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const preset = (days: number[]) => patch({ days });
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<div className="flex flex-col gap-2" data-testid="cron-picker">
|
|
70
|
+
{raw ? (
|
|
71
|
+
<input
|
|
72
|
+
value={rawDraft}
|
|
73
|
+
disabled={disabled}
|
|
74
|
+
onChange={(e) => setRawDraft(e.target.value)}
|
|
75
|
+
onBlur={() => onChange(rawDraft.trim())}
|
|
76
|
+
onKeyDown={(e) => { if (e.key === "Enter") onChange(rawDraft.trim()); }}
|
|
77
|
+
placeholder="30 8 * * 1-5"
|
|
78
|
+
className="w-full rounded-lg border border-border bg-muted/30 px-2.5 py-1.5 font-mono text-sm outline-none focus:border-primary/60"
|
|
79
|
+
data-testid="cron-raw"
|
|
80
|
+
/>
|
|
81
|
+
) : (
|
|
82
|
+
<>
|
|
83
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
84
|
+
<input
|
|
85
|
+
type="time"
|
|
86
|
+
value={sel.everyHours > 0 ? "00:00" : sel.time}
|
|
87
|
+
disabled={disabled || sel.everyHours > 0}
|
|
88
|
+
onChange={(e) => patch({ time: e.target.value, everyHours: 0 })}
|
|
89
|
+
className="rounded-lg border border-border bg-muted/30 px-2.5 py-1.5 text-sm outline-none focus:border-primary/60 disabled:opacity-50"
|
|
90
|
+
data-testid="cron-time"
|
|
91
|
+
/>
|
|
92
|
+
{allowEveryHours ? (
|
|
93
|
+
<label className="flex items-center gap-1.5 text-xs text-muted-fg">
|
|
94
|
+
<input
|
|
95
|
+
type="checkbox"
|
|
96
|
+
checked={sel.everyHours > 0}
|
|
97
|
+
disabled={disabled}
|
|
98
|
+
onChange={(e) => patch({ everyHours: e.target.checked ? 2 : 0 })}
|
|
99
|
+
className="size-3.5 accent-[var(--primary)]"
|
|
100
|
+
/>
|
|
101
|
+
{t("cron.every_label")}
|
|
102
|
+
</label>
|
|
103
|
+
) : null}
|
|
104
|
+
{sel.everyHours > 0 ? (
|
|
105
|
+
<input
|
|
106
|
+
type="number"
|
|
107
|
+
min={1}
|
|
108
|
+
max={23}
|
|
109
|
+
value={sel.everyHours}
|
|
110
|
+
disabled={disabled}
|
|
111
|
+
onChange={(e) => patch({ everyHours: Number(e.target.value) })}
|
|
112
|
+
className="w-16 rounded-lg border border-border bg-muted/30 px-2 py-1.5 text-sm outline-none focus:border-primary/60"
|
|
113
|
+
aria-label={t("cron.every_label")}
|
|
114
|
+
/>
|
|
115
|
+
) : null}
|
|
116
|
+
</div>
|
|
117
|
+
|
|
118
|
+
{sel.everyHours === 0 ? (
|
|
119
|
+
<>
|
|
120
|
+
<div className="flex flex-wrap gap-1" data-testid="cron-days">
|
|
121
|
+
{DAY_KEYS.map((key, d) => {
|
|
122
|
+
const on = sel.days.length === 0 || sel.days.includes(d);
|
|
123
|
+
return (
|
|
124
|
+
<button
|
|
125
|
+
key={key}
|
|
126
|
+
type="button"
|
|
127
|
+
disabled={disabled}
|
|
128
|
+
aria-pressed={sel.days.includes(d)}
|
|
129
|
+
onClick={() => toggleDay(d)}
|
|
130
|
+
className={cn(
|
|
131
|
+
"min-w-9 rounded-md border px-2 py-1 text-[11px] font-medium transition-colors",
|
|
132
|
+
sel.days.includes(d)
|
|
133
|
+
? "border-primary bg-primary/15 text-foreground"
|
|
134
|
+
: on
|
|
135
|
+
// "every day" means all seven are in effect even
|
|
136
|
+
// though none is individually ticked — show that
|
|
137
|
+
// rather than seven blank boxes.
|
|
138
|
+
? "border-border bg-muted/40 text-muted-fg"
|
|
139
|
+
: "border-border text-muted-fg/60 hover:bg-muted/40",
|
|
140
|
+
)}
|
|
141
|
+
>
|
|
142
|
+
{t(`cron.day_short.${key}`)}
|
|
143
|
+
</button>
|
|
144
|
+
);
|
|
145
|
+
})}
|
|
146
|
+
</div>
|
|
147
|
+
<div className="flex flex-wrap gap-1 text-[11px]">
|
|
148
|
+
{([
|
|
149
|
+
["cron.preset_every_day", [] as number[]],
|
|
150
|
+
["cron.preset_weekdays", [1, 2, 3, 4, 5]],
|
|
151
|
+
["cron.preset_weekends", [0, 6]],
|
|
152
|
+
] as const).map(([label, days]) => (
|
|
153
|
+
<button
|
|
154
|
+
key={label}
|
|
155
|
+
type="button"
|
|
156
|
+
disabled={disabled}
|
|
157
|
+
onClick={() => preset([...days])}
|
|
158
|
+
className="rounded-md px-1.5 py-0.5 text-muted-fg underline-offset-2 hover:text-foreground hover:underline"
|
|
159
|
+
>
|
|
160
|
+
{t(label)}
|
|
161
|
+
</button>
|
|
162
|
+
))}
|
|
163
|
+
</div>
|
|
164
|
+
</>
|
|
165
|
+
) : null}
|
|
166
|
+
</>
|
|
167
|
+
)}
|
|
168
|
+
|
|
169
|
+
<div className="flex items-center justify-between gap-2">
|
|
170
|
+
{/* The sentence, always — including in raw mode, where it is the only
|
|
171
|
+
confirmation that what was typed means what was intended. */}
|
|
172
|
+
<CronSummary expr={raw ? rawDraft : value} className="text-[11px] text-muted-fg" />
|
|
173
|
+
<button
|
|
174
|
+
type="button"
|
|
175
|
+
disabled={disabled}
|
|
176
|
+
onClick={() => {
|
|
177
|
+
if (raw) {
|
|
178
|
+
// Leaving raw mode must not silently rewrite a schedule the
|
|
179
|
+
// picker cannot hold.
|
|
180
|
+
const next = rawDraft.trim();
|
|
181
|
+
onChange(next);
|
|
182
|
+
if (isPickerFriendly(next)) setRaw(false);
|
|
183
|
+
} else {
|
|
184
|
+
setRawDraft(normalizeCron(value));
|
|
185
|
+
setRaw(true);
|
|
186
|
+
}
|
|
187
|
+
}}
|
|
188
|
+
className="inline-flex shrink-0 items-center gap-1 text-[11px] text-muted-fg hover:text-foreground"
|
|
189
|
+
data-testid="cron-toggle-raw"
|
|
190
|
+
>
|
|
191
|
+
<Code2 size={11} /> {raw ? t("cron.use_picker") : t("cron.use_cron")}
|
|
192
|
+
</button>
|
|
193
|
+
</div>
|
|
194
|
+
</div>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { useMemo, useState } from "react";
|
|
2
|
+
import { Search } from "lucide-react";
|
|
3
|
+
import type { InboxRow } from "../../lib/api/inbox";
|
|
4
|
+
import { cn } from "../../lib/cn";
|
|
5
|
+
import { t } from "../../i18n";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The conversation rail: every agent as a chat, most recent first.
|
|
9
|
+
*
|
|
10
|
+
* Messaging-app shape rather than a data table, because that is what this is —
|
|
11
|
+
* a list of people-ish things that said something, ordered by when. The
|
|
12
|
+
* previous version was a full-width list of cards that navigated AWAY on
|
|
13
|
+
* click, which meant losing the list to read one row.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Stable colour per agent, so the same agent is the same dot every time. */
|
|
17
|
+
const DOT_COLOURS = [
|
|
18
|
+
"bg-emerald-500", "bg-orange-500", "bg-violet-500", "bg-sky-500",
|
|
19
|
+
"bg-rose-500", "bg-amber-500", "bg-teal-500", "bg-fuchsia-500",
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
function colourFor(seed: string): string {
|
|
23
|
+
let h = 0;
|
|
24
|
+
for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) >>> 0;
|
|
25
|
+
return DOT_COLOURS[h % DOT_COLOURS.length];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function rowKey(row: InboxRow): string {
|
|
29
|
+
return `${row.project_id ?? "global"}::${row.agent_slug}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Today → time of day; older → date. Same idea as a messaging app. */
|
|
33
|
+
function shortTime(iso: string): string {
|
|
34
|
+
if (!iso) return "";
|
|
35
|
+
const d = new Date(iso);
|
|
36
|
+
if (Number.isNaN(d.getTime())) return "";
|
|
37
|
+
const sameDay = new Date().toDateString() === d.toDateString();
|
|
38
|
+
return sameDay
|
|
39
|
+
? d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })
|
|
40
|
+
: d.toLocaleDateString(undefined, { day: "numeric", month: "short" });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function InboxList({
|
|
44
|
+
rows,
|
|
45
|
+
selectedKey,
|
|
46
|
+
onSelect,
|
|
47
|
+
action,
|
|
48
|
+
}: {
|
|
49
|
+
rows: InboxRow[];
|
|
50
|
+
selectedKey: string | null;
|
|
51
|
+
onSelect: (row: InboxRow) => void;
|
|
52
|
+
action?: React.ReactNode;
|
|
53
|
+
}) {
|
|
54
|
+
const [q, setQ] = useState("");
|
|
55
|
+
|
|
56
|
+
const filtered = useMemo(() => {
|
|
57
|
+
const needle = q.trim().toLowerCase();
|
|
58
|
+
if (!needle) return rows;
|
|
59
|
+
return rows.filter((r) =>
|
|
60
|
+
[r.agent_name, r.agent_slug, r.project_name, r.preview]
|
|
61
|
+
.filter(Boolean)
|
|
62
|
+
.some((v) => String(v).toLowerCase().includes(needle)));
|
|
63
|
+
}, [rows, q]);
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<aside className="flex w-full shrink-0 flex-col border-r border-border sm:w-72" data-testid="inbox-list">
|
|
67
|
+
<div className="flex shrink-0 items-center gap-2 border-b border-border p-2">
|
|
68
|
+
<div className="relative flex-1">
|
|
69
|
+
<Search size={13} className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-fg" />
|
|
70
|
+
<input
|
|
71
|
+
value={q}
|
|
72
|
+
onChange={(e) => setQ(e.target.value)}
|
|
73
|
+
placeholder={t("inbox.search")}
|
|
74
|
+
aria-label={t("inbox.search")}
|
|
75
|
+
className="w-full rounded-lg border border-border bg-muted/30 py-1.5 pl-7 pr-2 text-sm outline-none placeholder:text-muted-fg focus:border-primary/60"
|
|
76
|
+
/>
|
|
77
|
+
</div>
|
|
78
|
+
{action}
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
<div className="min-h-0 flex-1 overflow-y-auto p-1.5">
|
|
82
|
+
{!filtered.length ? (
|
|
83
|
+
<p className="px-3 py-6 text-center text-xs text-muted-fg">
|
|
84
|
+
{q ? t("inbox.no_match") : t("inbox.empty")}
|
|
85
|
+
</p>
|
|
86
|
+
) : null}
|
|
87
|
+
|
|
88
|
+
{filtered.map((row) => {
|
|
89
|
+
const key = rowKey(row);
|
|
90
|
+
const isActive = key === selectedKey;
|
|
91
|
+
const label = row.agent_name || row.agent_slug;
|
|
92
|
+
return (
|
|
93
|
+
<button
|
|
94
|
+
key={key}
|
|
95
|
+
type="button"
|
|
96
|
+
data-testid={`inbox-row-${row.agent_slug}`}
|
|
97
|
+
onClick={() => onSelect(row)}
|
|
98
|
+
className={cn(
|
|
99
|
+
"flex w-full items-start gap-2.5 rounded-lg px-2.5 py-2 text-left transition-colors",
|
|
100
|
+
isActive ? "bg-accent text-accent-fg" : "hover:bg-muted/50",
|
|
101
|
+
)}
|
|
102
|
+
>
|
|
103
|
+
{/* A coloured disc, not an emoji on plain background: at this size
|
|
104
|
+
the colour is what the eye picks the row out by. */}
|
|
105
|
+
<span
|
|
106
|
+
className={cn(
|
|
107
|
+
"mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full text-sm",
|
|
108
|
+
colourFor(row.agent_slug),
|
|
109
|
+
)}
|
|
110
|
+
aria-hidden
|
|
111
|
+
>
|
|
112
|
+
{row.agent_emoji || label.slice(0, 1).toUpperCase()}
|
|
113
|
+
</span>
|
|
114
|
+
|
|
115
|
+
<span className="min-w-0 flex-1">
|
|
116
|
+
<span className="flex items-baseline gap-2">
|
|
117
|
+
<span className="truncate text-sm font-medium">{label}</span>
|
|
118
|
+
{row.pinned ? (
|
|
119
|
+
<span className="shrink-0 rounded bg-primary/15 px-1 text-[9px] font-semibold uppercase tracking-wide text-primary">
|
|
120
|
+
{t("inbox.pinned")}
|
|
121
|
+
</span>
|
|
122
|
+
) : null}
|
|
123
|
+
<span className="ml-auto shrink-0 text-[10px] text-muted-fg">
|
|
124
|
+
{shortTime(row.last_activity_at)}
|
|
125
|
+
</span>
|
|
126
|
+
</span>
|
|
127
|
+
|
|
128
|
+
<span className="mt-0.5 flex items-center gap-1.5 text-[10px] text-muted-fg">
|
|
129
|
+
{row.project_name ? <span className="truncate">{row.project_name}</span> : null}
|
|
130
|
+
{row.channel ? <span className="opacity-70">· {row.channel}</span> : null}
|
|
131
|
+
</span>
|
|
132
|
+
|
|
133
|
+
{/* The agent's own last line. Echoing the user's prompt back
|
|
134
|
+
tells them nothing they do not already know. */}
|
|
135
|
+
<span className="mt-0.5 block truncate text-xs text-muted-fg">
|
|
136
|
+
{row.preview || t("inbox.no_reply_yet")}
|
|
137
|
+
</span>
|
|
138
|
+
</span>
|
|
139
|
+
</button>
|
|
140
|
+
);
|
|
141
|
+
})}
|
|
142
|
+
</div>
|
|
143
|
+
</aside>
|
|
144
|
+
);
|
|
145
|
+
}
|
|
@@ -1,34 +1,43 @@
|
|
|
1
1
|
import { useMemo, useState } from "react";
|
|
2
2
|
import useSWR from "swr";
|
|
3
|
-
import { Brain, Bot, Crown, RefreshCw } from "lucide-react";
|
|
3
|
+
import { Brain, Bot, Crown, NotebookPen, RefreshCw } from "lucide-react";
|
|
4
4
|
import { Agents, Projects } from "../../lib/api";
|
|
5
|
+
import { Notebook } from "../../lib/api/notebook";
|
|
5
6
|
import type { AgentEntry, FileContent } from "../../types/daemon";
|
|
6
7
|
import { cn } from "../../lib/cn";
|
|
7
8
|
import { Spinner, Empty } from "../ui";
|
|
8
9
|
import { useToast } from "../Toast";
|
|
9
10
|
import { t } from "../../i18n";
|
|
11
|
+
import { usePersonaName } from "../../hooks/usePersonaName";
|
|
10
12
|
import { FileViewer } from "../files/FileViewer";
|
|
11
13
|
|
|
12
14
|
// Which memory is open in the right pane.
|
|
13
|
-
type Sel = { kind: "project" } | { kind: "agent"; slug: string };
|
|
15
|
+
type Sel = { kind: "notebook" } | { kind: "project" } | { kind: "agent"; slug: string };
|
|
14
16
|
|
|
15
17
|
function selId(s: Sel): string {
|
|
18
|
+
if (s.kind === "notebook") return "notebook";
|
|
16
19
|
return s.kind === "project" ? "project" : `agent:${s.slug}`;
|
|
17
20
|
}
|
|
18
21
|
|
|
19
22
|
// On-disk-ish path shown in the viewer header (mirrors the real memory.md
|
|
20
23
|
// locations so it reads familiarly, like the docs surface).
|
|
21
24
|
function selPath(s: Sel): string {
|
|
25
|
+
// The notebook is NOT under the project — it is global to the super-agent.
|
|
26
|
+
// Showing its real absolute-ish path is the whole point: the confusion this
|
|
27
|
+
// entry fixes was not knowing where it lived.
|
|
28
|
+
if (s.kind === "notebook") return "~/.apx/memory.md";
|
|
22
29
|
return s.kind === "project" ? ".apc/memory.md" : `agents/${s.slug}/memory.md`;
|
|
23
30
|
}
|
|
24
31
|
|
|
25
32
|
function loadBody(pid: string, s: Sel): Promise<string> {
|
|
33
|
+
if (s.kind === "notebook") return Notebook.get().then((r) => r.body);
|
|
26
34
|
return s.kind === "project"
|
|
27
35
|
? Projects.memory.get(pid).then((r) => r.body)
|
|
28
36
|
: Agents.memory.get(pid, s.slug).then((r) => r.body);
|
|
29
37
|
}
|
|
30
38
|
|
|
31
39
|
function saveBody(pid: string, s: Sel, body: string): Promise<void> {
|
|
40
|
+
if (s.kind === "notebook") return Notebook.put(body).then(() => {});
|
|
32
41
|
return s.kind === "project"
|
|
33
42
|
? Projects.memory.put(pid, body).then(() => {})
|
|
34
43
|
: Agents.memory.put(pid, s.slug, body).then(() => {});
|
|
@@ -65,7 +74,12 @@ function SidebarItem({
|
|
|
65
74
|
// right for markdown edit / split-preview / save — the same surface as /docs.
|
|
66
75
|
export function MemoryBrowser({ pid }: { pid: string }) {
|
|
67
76
|
const toast = useToast();
|
|
68
|
-
|
|
77
|
+
// Opens on the notebook. It is the memory that ships in every prompt on
|
|
78
|
+
// every channel, so it is the one most worth seeing first.
|
|
79
|
+
const [sel, setSel] = useState<Sel>({ kind: "notebook" });
|
|
80
|
+
const notebook = useSWR("/api/notebook", () => Notebook.get());
|
|
81
|
+
// Never hardcode the agent's name (AGENTS.md rule 13) — it is the user's to set.
|
|
82
|
+
const persona = usePersonaName();
|
|
69
83
|
|
|
70
84
|
const agents = useSWR(`/api/projects/${pid}/agents`, () => Agents.list(pid));
|
|
71
85
|
|
|
@@ -115,8 +129,24 @@ export function MemoryBrowser({ pid }: { pid: string }) {
|
|
|
115
129
|
</div>
|
|
116
130
|
|
|
117
131
|
<div className="min-h-0 flex-1 overflow-y-auto p-1.5">
|
|
118
|
-
{/*
|
|
132
|
+
{/* The super-agent's own notebook. First, and in its own group,
|
|
133
|
+
because it is global — not a property of this project — and its
|
|
134
|
+
absence from this list is what made "where is Roby's memory?" an
|
|
135
|
+
unanswerable question. */}
|
|
119
136
|
<p className="px-1.5 pb-1 pt-1 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground/60">
|
|
137
|
+
{t("project.memories.super_agent_group")}
|
|
138
|
+
</p>
|
|
139
|
+
<SidebarItem
|
|
140
|
+
active={sel.kind === "notebook"}
|
|
141
|
+
onClick={() => setSel({ kind: "notebook" })}
|
|
142
|
+
icon={NotebookPen}
|
|
143
|
+
iconClass="text-emerald-400"
|
|
144
|
+
label={t("project.memories.notebook_item", { persona })}
|
|
145
|
+
sub={notebook.data ? t("project.memories.tokens", { n: notebook.data.approx_tokens }) : undefined}
|
|
146
|
+
/>
|
|
147
|
+
|
|
148
|
+
{/* General / project memory */}
|
|
149
|
+
<p className="px-1.5 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wide text-muted-foreground/60">
|
|
120
150
|
{t("project.memories.general_group")}
|
|
121
151
|
</p>
|
|
122
152
|
<SidebarItem
|
|
@@ -4,6 +4,7 @@ import { Badge, Button, Switch, Tip } from "../ui";
|
|
|
4
4
|
import { cn } from "../../lib/cn";
|
|
5
5
|
import { t } from "../../i18n";
|
|
6
6
|
import { kindMeta, scheduleHuman } from "./shared";
|
|
7
|
+
import { relativeWhen } from "../../lib/when";
|
|
7
8
|
import { ReadOnlyBlock } from "./ReadOnlyBlock";
|
|
8
9
|
import { ExecutionsList } from "./ExecutionsList";
|
|
9
10
|
|
|
@@ -30,7 +31,7 @@ export function RoutineDetail({
|
|
|
30
31
|
// Read-only content blocks, in pipeline order (pre → action → post).
|
|
31
32
|
const blocks: { title: string; body: string; mono?: boolean }[] = [];
|
|
32
33
|
if (pre.length) blocks.push({ title: t("project.routines.block_pre"), body: pre.join("\n"), mono: true });
|
|
33
|
-
if (routine.kind === "exec_agent" || routine.kind === "super_agent") {
|
|
34
|
+
if (routine.kind === "exec_agent" || routine.kind === "super_agent" || routine.kind === "watch") {
|
|
34
35
|
blocks.push({ title: t("project.routines.block_prompt"), body: String(spec.prompt || "") });
|
|
35
36
|
} else if (routine.kind === "telegram") {
|
|
36
37
|
blocks.push({ title: t("project.routines.block_text"), body: String(spec.text || "") });
|
|
@@ -64,9 +65,20 @@ export function RoutineDetail({
|
|
|
64
65
|
|
|
65
66
|
{/* compact meta: schedule / next / last */}
|
|
66
67
|
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-fg">
|
|
67
|
-
<span>⏱ {scheduleHuman(routine.schedule)}</span>
|
|
68
|
-
{
|
|
69
|
-
|
|
68
|
+
<span title={routine.schedule}>⏱ {scheduleHuman(routine.schedule)}</span>
|
|
69
|
+
{/* "in 8 hours" answers the question; the absolute time is one hover
|
|
70
|
+
away for when the exact minute matters. Two full timestamps side
|
|
71
|
+
by side made the row unreadable and still needed mental arithmetic. */}
|
|
72
|
+
{routine.next_run_at && (
|
|
73
|
+
<span title={new Date(routine.next_run_at).toLocaleString()}>
|
|
74
|
+
{t("project.routines.next_run")} {relativeWhen(routine.next_run_at, t as never)}
|
|
75
|
+
</span>
|
|
76
|
+
)}
|
|
77
|
+
{routine.last_run_at && (
|
|
78
|
+
<span title={new Date(routine.last_run_at).toLocaleString()}>
|
|
79
|
+
{t("project.routines.last_run")} {relativeWhen(routine.last_run_at, t as never)}
|
|
80
|
+
</span>
|
|
81
|
+
)}
|
|
70
82
|
<span className={cn(routine.last_status === "ok" && "text-emerald-500", routine.last_status === "error" && "text-destructive")}>
|
|
71
83
|
{t("agents_ui.last_label")} {routine.last_status || "—"}
|
|
72
84
|
</span>
|
|
@@ -8,6 +8,8 @@ import { useToast } from "../Toast";
|
|
|
8
8
|
import { cn } from "../../lib/cn";
|
|
9
9
|
import { t } from "../../i18n";
|
|
10
10
|
import { type Kind, kindMeta, kindOptions, schedPresets, scheduleHuman, splitLines, varsFor } from "./shared";
|
|
11
|
+
import { CronPicker } from "../cron/CronPicker";
|
|
12
|
+
import { parseCron } from "../../lib/cron";
|
|
11
13
|
import { VarTextarea } from "./VarTextarea";
|
|
12
14
|
import { AvailableVarsCard } from "./AvailableVarsCard";
|
|
13
15
|
|
|
@@ -59,12 +61,13 @@ export function RoutineEditor({
|
|
|
59
61
|
|
|
60
62
|
// Pre/post shell wrap the LLM kinds AND telegram (pre can fetch data, the text
|
|
61
63
|
// can use {{pre_output}}, post can react to the result).
|
|
62
|
-
const usesPrePost = kind === "exec_agent" || kind === "super_agent" || kind === "telegram";
|
|
64
|
+
const usesPrePost = kind === "exec_agent" || kind === "super_agent" || kind === "telegram" || kind === "watch";
|
|
63
65
|
|
|
64
66
|
const buildSpec = (): Record<string, unknown> => {
|
|
65
67
|
switch (kind) {
|
|
66
68
|
case "exec_agent": return { agent, prompt };
|
|
67
69
|
case "super_agent": return { prompt };
|
|
70
|
+
case "watch": return { prompt };
|
|
68
71
|
case "telegram": return { channel: tgChannel, ...(tgChatId ? { chat_id: tgChatId } : {}), text: tgText };
|
|
69
72
|
case "shell": return { command };
|
|
70
73
|
case "heartbeat": return { channel: hbChannel, message: hbMessage };
|
|
@@ -109,6 +112,7 @@ export function RoutineEditor({
|
|
|
109
112
|
switch (kind) {
|
|
110
113
|
case "exec_agent": return agent ? t("agents_ui.action_agent_answers", { agent }) : t("agents_ui.action_agent_pick_answers");
|
|
111
114
|
case "super_agent": return t("agents_ui.action_super_answers");
|
|
115
|
+
case "watch": return t("agents_ui.action_watch");
|
|
112
116
|
case "telegram": return t("agents_ui.action_telegram_channel", { channel: tgChannel });
|
|
113
117
|
case "shell": return command ? t("agents_ui.summary_runs_cmd", { cmd: command.slice(0, 48) }) : t("agents_ui.action_runs_shell");
|
|
114
118
|
case "heartbeat": return t("agents_ui.summary_heartbeat");
|
|
@@ -176,7 +180,13 @@ export function RoutineEditor({
|
|
|
176
180
|
{t("agents_ui.preset_manual")}
|
|
177
181
|
</button>
|
|
178
182
|
</div>
|
|
179
|
-
|
|
183
|
+
{/* A cron schedule gets the picker; every:/once:/manual stay a
|
|
184
|
+
text field, because those forms are already readable. */}
|
|
185
|
+
{parseCron(schedule.replace(/^cron\s+/i, "")) ? (
|
|
186
|
+
<CronPicker value={schedule.replace(/^cron\s+/i, "")} onChange={setSchedule} />
|
|
187
|
+
) : (
|
|
188
|
+
<Input value={schedule} onChange={(e) => setSchedule(e.target.value)} placeholder="every:10m · 0 9 * * 1-5 · once:ISO · manual" />
|
|
189
|
+
)}
|
|
180
190
|
</div>
|
|
181
191
|
</Field>
|
|
182
192
|
<AvailableVarsCard />
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// Shared helpers for the Routines screen + its components.
|
|
2
2
|
// Kept framework-free (pure functions + i18n) so list/detail/editor reuse them.
|
|
3
|
-
import { Bot, Crown, Heart, Send, Terminal } from "lucide-react";
|
|
3
|
+
import { Bot, Crown, Eye, Heart, Send, Terminal } from "lucide-react";
|
|
4
4
|
import type { RoutineEntry } from "../../lib/api";
|
|
5
5
|
import { t } from "../../i18n";
|
|
6
|
+
import { describeCron } from "../../lib/cron";
|
|
6
7
|
|
|
7
8
|
export type Kind = RoutineEntry["kind"];
|
|
8
9
|
|
|
@@ -17,6 +18,7 @@ export function kindMeta(): Record<Kind, { label: string; desc: string; icon: ty
|
|
|
17
18
|
super_agent: { label: t("agents_ui.kind_super_agent"), desc: t("agents_ui.kind_super_agent_desc"), icon: Crown },
|
|
18
19
|
telegram: { label: t("agents_ui.kind_telegram"), desc: t("agents_ui.kind_telegram_desc"), icon: Send },
|
|
19
20
|
shell: { label: t("agents_ui.kind_shell"), desc: t("agents_ui.kind_shell_desc"), icon: Terminal },
|
|
21
|
+
watch: { label: t("agents_ui.kind_watch"), desc: t("agents_ui.kind_watch_desc"), icon: Eye },
|
|
20
22
|
heartbeat: { label: t("agents_ui.kind_heartbeat"), desc: t("agents_ui.kind_heartbeat_desc"), icon: Heart },
|
|
21
23
|
};
|
|
22
24
|
}
|
|
@@ -49,16 +51,23 @@ export function scheduleHuman(s?: string): string {
|
|
|
49
51
|
return t("agents_ui.every_v", { v });
|
|
50
52
|
}
|
|
51
53
|
if (s.startsWith("once:")) return `once · ${new Date(s.slice(5)).toLocaleString()}`;
|
|
52
|
-
if (s.
|
|
53
|
-
|
|
54
|
+
if (s.trim().toLowerCase() === "manual") return t("agents_ui.sched_manual");
|
|
55
|
+
// Cron, read as a sentence. `cron · 0 9 * * *` was not a translation of
|
|
56
|
+
// anything — it just moved the expression behind a label. The leading "cron"
|
|
57
|
+
// word is tolerated because this editor used to write it.
|
|
58
|
+
const expr = s.replace(/^cron\s+/i, "");
|
|
59
|
+
return describeCron(expr, t as never) || expr;
|
|
54
60
|
}
|
|
55
61
|
|
|
56
62
|
export function schedPresets() {
|
|
57
63
|
return [
|
|
58
64
|
{ label: t("agents_ui.preset_every_10m"), value: "every:10m" },
|
|
59
65
|
{ label: t("agents_ui.preset_hourly"), value: "every:1h" },
|
|
60
|
-
|
|
61
|
-
|
|
66
|
+
// BARE cron, not `cron <expr>`: core parses the expression itself, and the
|
|
67
|
+
// labelled form used to come back invalid — so these two presets built
|
|
68
|
+
// routines that never ran.
|
|
69
|
+
{ label: t("agents_ui.preset_daily_9am"), value: "0 9 * * *" },
|
|
70
|
+
{ label: t("agents_ui.preset_weekdays_9am"), value: "0 9 * * 1-5" },
|
|
62
71
|
];
|
|
63
72
|
}
|
|
64
73
|
|