@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.
Files changed (97) hide show
  1. package/package.json +1 -1
  2. package/src/core/agent/run-agent.js +46 -6
  3. package/src/core/agent/super-agent.js +3 -0
  4. package/src/core/agent/tool-summary.js +65 -0
  5. package/src/core/agent/tools/handlers/list-commitments.js +80 -0
  6. package/src/core/agent/tools/handlers/list-tasks.js +66 -27
  7. package/src/core/agent/tools/handlers/record-commitment.js +68 -0
  8. package/src/core/agent/tools/handlers/send-telegram.js +68 -2
  9. package/src/core/agent/tools/names.js +6 -0
  10. package/src/core/agent/tools/registry.js +9 -0
  11. package/src/core/agent/tools/tool-call-parser.js +123 -6
  12. package/src/core/channels/telegram/ask-callbacks.js +121 -6
  13. package/src/core/channels/telegram/dispatch.js +21 -4
  14. package/src/core/channels/telegram/inbound/file.js +108 -0
  15. package/src/core/channels/telegram/inbound/photo.js +42 -11
  16. package/src/core/channels/telegram/media.js +31 -3
  17. package/src/core/channels/telegram/reply.js +32 -5
  18. package/src/core/config/paths.js +3 -0
  19. package/src/core/config/redact.js +22 -0
  20. package/src/core/daemon/service.js +238 -0
  21. package/src/core/engines/gemini.js +343 -63
  22. package/src/core/engines/openai-compatible.js +21 -2
  23. package/src/core/memory/consolidate.js +225 -0
  24. package/src/core/nudge/index.js +192 -0
  25. package/src/core/nudge/policy.js +143 -0
  26. package/src/core/nudge/store.js +141 -0
  27. package/src/core/profiles/bundled/secretary/PROFILE.md +8 -9
  28. package/src/core/profiles/bundled/secretary/config.schema.json +33 -3
  29. package/src/core/profiles/bundled/secretary/routines/day-close.json +7 -3
  30. package/src/core/profiles/bundled/secretary/routines/day-open.json +7 -3
  31. package/src/core/profiles/bundled/secretary/routines/watch.json +13 -0
  32. package/src/core/routines/runner.js +102 -3
  33. package/src/core/routines/signals.js +270 -0
  34. package/src/core/stores/commitments.js +331 -0
  35. package/src/core/stores/messages.js +10 -1
  36. package/src/core/stores/routines.js +17 -3
  37. package/src/core/util/thinking.js +51 -0
  38. package/src/host/daemon/api/commitments.js +135 -0
  39. package/src/host/daemon/api/nudges.js +112 -0
  40. package/src/host/daemon/api/routines.js +24 -0
  41. package/src/host/daemon/api/self-memory.js +50 -0
  42. package/src/host/daemon/api/telegram.js +42 -4
  43. package/src/host/daemon/api/voice.js +3 -1
  44. package/src/host/daemon/api.js +6 -0
  45. package/src/host/daemon/callback-reconciler.js +16 -0
  46. package/src/host/daemon/plugins/desktop/index.js +7 -1
  47. package/src/host/daemon/plugins/telegram/index.js +7 -2
  48. package/src/host/daemon/wakeup.js +17 -3
  49. package/src/interfaces/cli/commands/commitment.js +154 -0
  50. package/src/interfaces/cli/commands/daemon.js +57 -0
  51. package/src/interfaces/cli/commands/memory.js +73 -0
  52. package/src/interfaces/cli/commands/nudge.js +130 -0
  53. package/src/interfaces/cli/help/index.js +2 -2
  54. package/src/interfaces/cli/routes/commitment.js +19 -0
  55. package/src/interfaces/cli/routes/daemon.js +7 -1
  56. package/src/interfaces/cli/routes/index.js +4 -0
  57. package/src/interfaces/cli/routes/memory.js +10 -2
  58. package/src/interfaces/cli/routes/nudge.js +17 -0
  59. package/src/interfaces/web/dist/assets/index-CvEoGtTf.js +849 -0
  60. package/src/interfaces/web/dist/assets/index-CvEoGtTf.js.map +1 -0
  61. package/src/interfaces/web/dist/assets/index-DzBBXFaO.css +1 -0
  62. package/src/interfaces/web/dist/index.html +2 -2
  63. package/src/interfaces/web/package-lock.json +11 -10
  64. package/src/interfaces/web/src/components/Section.tsx +18 -3
  65. package/src/interfaces/web/src/components/chat/MessageBubble.tsx +13 -0
  66. package/src/interfaces/web/src/components/cron/CronPicker.tsx +196 -0
  67. package/src/interfaces/web/src/components/inbox/InboxList.tsx +145 -0
  68. package/src/interfaces/web/src/components/memory/MemoryBrowser.tsx +34 -4
  69. package/src/interfaces/web/src/components/routines/RoutineDetail.tsx +16 -4
  70. package/src/interfaces/web/src/components/routines/RoutineEditor.tsx +12 -2
  71. package/src/interfaces/web/src/components/routines/shared.ts +14 -5
  72. package/src/interfaces/web/src/components/settings/NudgePanel.tsx +183 -0
  73. package/src/interfaces/web/src/components/settings/ProfilePanel.tsx +36 -12
  74. package/src/interfaces/web/src/components/ui/filter-chips.tsx +47 -0
  75. package/src/interfaces/web/src/components/ui.tsx +1 -0
  76. package/src/interfaces/web/src/constants/index.ts +1 -0
  77. package/src/interfaces/web/src/hooks/useChat.ts +5 -1
  78. package/src/interfaces/web/src/hooks/useNudges.ts +38 -0
  79. package/src/interfaces/web/src/i18n/en.ts +127 -0
  80. package/src/interfaces/web/src/i18n/es.ts +127 -0
  81. package/src/interfaces/web/src/lib/api/commitments.ts +57 -0
  82. package/src/interfaces/web/src/lib/api/notebook.ts +23 -0
  83. package/src/interfaces/web/src/lib/api/nudges.ts +53 -0
  84. package/src/interfaces/web/src/lib/cron.ts +196 -0
  85. package/src/interfaces/web/src/lib/when.ts +32 -0
  86. package/src/interfaces/web/src/screens/InboxScreen.tsx +107 -77
  87. package/src/interfaces/web/src/screens/ProjectScreen.tsx +5 -2
  88. package/src/interfaces/web/src/screens/SettingsScreen.tsx +17 -3
  89. package/src/interfaces/web/src/screens/base/CommitmentsTab.tsx +239 -0
  90. package/src/interfaces/web/src/screens/base/GlobalTasksTab.tsx +102 -19
  91. package/src/interfaces/web/src/screens/base/LogsTab.tsx +15 -0
  92. package/src/interfaces/web/src/screens/project/ChatTab.tsx +21 -3
  93. package/src/interfaces/web/src/screens/project/RoutinesTab.tsx +13 -11
  94. package/src/interfaces/web/src/types/daemon.ts +10 -1
  95. package/src/interfaces/web/dist/assets/index-CBR_-QyA.js +0 -824
  96. package/src/interfaces/web/dist/assets/index-CBR_-QyA.js.map +0 -1
  97. 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
- const [sel, setSel] = useState<Sel>({ kind: "project" });
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
- {/* General / project memory */}
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
- {routine.next_run_at && <span>{t("project.routines.next_run")} {new Date(routine.next_run_at).toLocaleString()}</span>}
69
- {routine.last_run_at && <span>{t("project.routines.last_run")} {new Date(routine.last_run_at).toLocaleString()}</span>}
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
- <Input value={schedule} onChange={(e) => setSchedule(e.target.value)} placeholder="every:10m · cron 0 9 * * 1-5 · once:ISO · manual" />
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.startsWith("cron ")) return `cron · ${s.slice(5)}`;
53
- return s;
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
- { label: t("agents_ui.preset_daily_9am"), value: "cron 0 9 * * *" },
61
- { label: t("agents_ui.preset_weekdays_9am"), value: "cron 0 9 * * 1-5" },
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