@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
@@ -3,6 +3,32 @@
3
3
  // will TypeScript-error on unknown keys.
4
4
 
5
5
  export const es = {
6
+ cron: {
7
+ daily: "todos los días a las {at}",
8
+ weekdays: "de lunes a viernes a las {at}",
9
+ weekends: "fines de semana a las {at}",
10
+ on_days: "{days} a las {at}",
11
+ monthly: "el día {day} de cada mes a las {at}",
12
+ every_hour: "cada hora, al minuto {minute}",
13
+ every_n_hours: "cada {n} horas, al minuto {minute}",
14
+ every_n_minutes: "cada {n} minutos",
15
+ every_minute: "cada minuto",
16
+ every_label: "cada N horas en vez de una hora fija",
17
+ use_cron: "editar como cron",
18
+ use_picker: "usar el selector",
19
+ preset_every_day: "todos los días",
20
+ preset_weekdays: "días de semana",
21
+ preset_weekends: "fin de semana",
22
+ day_short: { sun: "Dom", mon: "Lun", tue: "Mar", wed: "Mié", thu: "Jue", fri: "Vie", sat: "Sáb" },
23
+ },
24
+ when: {
25
+ now: "ahora",
26
+ in: "en {amount}",
27
+ ago: "hace {amount}",
28
+ minutes: "{n} min",
29
+ hours: "{n} h",
30
+ days: "{n} d",
31
+ },
6
32
  common: {
7
33
  loading: "Cargando…",
8
34
  saving: "Guardando…",
@@ -144,6 +170,11 @@ export const es = {
144
170
  no_folders: "Sin carpetas.",
145
171
  },
146
172
  inbox: {
173
+ search: "Buscar",
174
+ no_match: "No coincide nada",
175
+ hide_quiet: "Ocultar agentes callados",
176
+ open_in_project: "Abrir en el proyecto",
177
+ super_agent_scope: "Todos los proyectos",
147
178
  title: "Bandeja de agentes",
148
179
  subtitle:
149
180
  "Cada agente como una conversación, lo más reciente primero.",
@@ -196,6 +227,8 @@ export const es = {
196
227
  devices_revoke: "Revocar",
197
228
  account_section: "Cuenta",
198
229
  agents_section: "Agentes & modelos",
230
+ super_agent_section: "Super-agente",
231
+ knowledge_section: "Modelos y conocimiento",
199
232
  channels_section: "Canales & dispositivos",
200
233
  modules_section: "Módulos",
201
234
  advanced_section: "Avanzado",
@@ -207,6 +240,7 @@ export const es = {
207
240
  engines: "Engines & modelos",
208
241
  telegram: "Telegram",
209
242
  devices: "Dispositivos",
243
+ nudge: "Interrupciones",
210
244
  advanced: "Avanzado",
211
245
  },
212
246
 
@@ -223,6 +257,9 @@ export const es = {
223
257
  activate: "Activar",
224
258
  replace_active: "Reemplazar el activo",
225
259
  deactivate: "Desactivar",
260
+ on: "Activo",
261
+ off: "Inactivo",
262
+ replaces_active: "Activarlo reemplaza al perfil activo",
226
263
  deactivate_title: "¿Desactivar el perfil?",
227
264
  deactivate_confirm:
228
265
  "APX vuelve a vanilla. Las rutinas del perfil se deshabilitan pero no se borran, y tu configuración, tareas y memoria quedan intactas: volver a activarlo restaura todo.",
@@ -246,6 +283,34 @@ export const es = {
246
283
  doctor_vanilla: "Sin perfil activo. APX se comporta como siempre.",
247
284
  },
248
285
 
286
+ nudge: {
287
+ title: "Presupuesto de interrupciones",
288
+ subtitle:
289
+ "Cada cuánto puede escribirte APX sin que se lo pidas. Las respuestas a tus propios mensajes nunca se retienen.",
290
+ off_hint:
291
+ "El presupuesto está apagado: sale todo mensaje no solicitado. Igual queda registrado acá abajo.",
292
+ on_hint: "{{sent}} enviados hoy, quedan {{left}}.",
293
+ source: "Definido por",
294
+ enabled: "Aplicar el presupuesto",
295
+ daily_max: "Mensajes por día",
296
+ daily_max_hint: "0 significa sin techo.",
297
+ quiet_hours: "Horas de silencio",
298
+ quiet_hours_hint: "HH:MM-HH:MM. Cruza la medianoche sin problema. Vacío = ninguna.",
299
+ cooldown: "Espacio mínimo (minutos)",
300
+ project_cooldown: "Espacio mínimo por proyecto (minutos)",
301
+ kind_cooldown: "Espacio mínimo por tipo (minutos)",
302
+ critical_bypass: "Lo crítico puede saltarse el presupuesto",
303
+ critical_bypass_hint: "Los saltos quedan siempre registrados y marcados.",
304
+ log_title: "Lo que mandó",
305
+ log_subtitle: "Sólo mensajes no solicitados, del más nuevo al más viejo. Lo que marques alimenta lo que manda después.",
306
+ log_empty: "Todavía no mandó nada sin que se lo pidieras.",
307
+ useful: "Me sirvió",
308
+ noise: "No me servía",
309
+ bypass: "se salteó el presupuesto",
310
+ saved: "Presupuesto actualizado",
311
+ unrated: "sin calificar",
312
+ },
313
+
249
314
  identity: {
250
315
  title: "Identidad",
251
316
  subtitle: "Datos del usuario. Configuración del agente va en Super-agente.",
@@ -339,6 +404,7 @@ export const es = {
339
404
  agents: "Agents",
340
405
  routines: "Rutinas",
341
406
  tasks: "Tasks",
407
+ commitments: "Compromisos",
342
408
  mcps: "MCPs",
343
409
  artifacts: "Artifacts",
344
410
  vars: "Variables",
@@ -443,6 +509,16 @@ export const es = {
443
509
  },
444
510
 
445
511
  global_tasks: {
512
+
513
+ add: "Agregar",
514
+
515
+ add_title: "Nueva tarea",
516
+
517
+ field_title: "Tarea",
518
+
519
+ field_project: "Proyecto",
520
+
521
+ field_due: "Vence",
446
522
  any_status: "cualquier estado",
447
523
  title: "Tasks (todos los proyectos)",
448
524
  subtitle: "Tareas agregadas de todos los proyectos registrados.",
@@ -451,6 +527,44 @@ export const es = {
451
527
  go_project: "Ir al proyecto",
452
528
  },
453
529
 
530
+ commitments: {
531
+
532
+ add: "Agregar",
533
+
534
+ add_title: "Registrar una promesa",
535
+
536
+ add_hint: "Algo que le dijiste a una persona concreta que ibas a hacer. Las tareas están al lado.",
537
+
538
+ field_who: "Quién está esperando",
539
+
540
+ field_who_hint: "El nombre como lo dirías vos — texto libre, no un contacto.",
541
+
542
+ field_what: "Qué prometiste",
543
+
544
+ field_what_ph: "mandar el presupuesto corregido",
545
+
546
+ field_due: "Para cuándo",
547
+
548
+ field_due_hint: "Opcional, pero es la parte que lo hace útil.",
549
+
550
+ field_project: "Proyecto",
551
+ title: "Compromisos",
552
+ subtitle: "Qué prometiste, a quién y para cuándo. Aparte de las tareas: acá hay alguien esperando.",
553
+ empty: "Nada prometido.",
554
+ overdue: "vencía",
555
+ overdue_only: "Pasados de fecha",
556
+ no_date: "sin fecha acordada",
557
+ moved: "movido",
558
+ mark_kept: "Cumplido",
559
+ mark_missed: "Incumplido",
560
+ state: {
561
+ open: "abiertos",
562
+ kept: "cumplidos",
563
+ missed: "incumplidos",
564
+ all: "todos",
565
+ },
566
+ },
567
+
454
568
  routines: {
455
569
  title: "Heartbeats / Routines",
456
570
  subtitle: "Cron, every:Nm, once:ISO. Cada rutina dispara un agente o un shell.",
@@ -782,6 +896,12 @@ export const es = {
782
896
  },
783
897
 
784
898
  memories: {
899
+
900
+ super_agent_group: "Super-agente",
901
+
902
+ notebook_item: "Libreta de {persona}",
903
+
904
+ tokens: "~{n} tok",
785
905
  sidebar_title: "Memorias",
786
906
  general_group: "General",
787
907
  general_item: "Memoria del proyecto",
@@ -873,6 +993,8 @@ export const es = {
873
993
  no_activity_ch: "Sin actividad en el canal \"{ch}\".",
874
994
  error: "No pude leer los mensajes: {msg}",
875
995
  show_more: "ver más",
996
+ event_routine_created: "rutina creada",
997
+ event_routine_updated: "rutina actualizada",
876
998
  show_less: "ver menos",
877
999
  daemon_errors: "Errores del daemon (~/.apx/logs/errors.jsonl)",
878
1000
  no_errors: "Sin errores registrados. 🎉",
@@ -1564,6 +1686,9 @@ export const es = {
1564
1686
  kind_routine: "rutina",
1565
1687
  kind_hierarchy: "jerarquía",
1566
1688
  nodes_drag_hint: "{n} nodos · arrastrá para reordenar",
1689
+ kind_watch: "Vigía",
1690
+ kind_watch_desc: "Barre buscando cosas que valga la pena notar — vencidos, promesas por caer, proyectos en silencio. No cuesta nada cuando no hay nada que reportar.",
1691
+ action_watch: "Vigila señales, y sólo juzga cuando encuentra alguna",
1567
1692
  kind_exec_agent: "Agente del proyecto",
1568
1693
  kind_exec_agent_desc: "Corre un agente del proyecto con un prompt. Vos elegís cuál.",
1569
1694
  kind_super_agent: "Super-agente",
@@ -1583,6 +1708,7 @@ export const es = {
1583
1708
  preset_every_10m: "cada 10 min",
1584
1709
  preset_hourly: "cada hora",
1585
1710
  preset_daily_9am: "diario 9am",
1711
+ sched_manual: "solo cuando lo corrés vos",
1586
1712
  preset_weekdays_9am: "días hábiles 9am",
1587
1713
  preset_manual: "Manual",
1588
1714
  var_pre_output_prompt: "Salida de texto de los pre-commands. Se reemplaza dentro del prompt/texto antes de enviarlo. Útil para inyectar datos frescos (clima, una API) en la instrucción.",
@@ -1873,6 +1999,7 @@ export const es = {
1873
1999
  shared_ui: {
1874
2000
  skill_inspector_title: "Skill Inspector ({embedder}) eligió estas skills para este turno",
1875
2001
  tools_count: "{n} tools",
2002
+ tools_failed: "{n} fallaron",
1876
2003
  tool_read_file: "Leer archivo",
1877
2004
  tool_write_file: "Escribir archivo",
1878
2005
  tool_edit_file: "Editar archivo",
@@ -0,0 +1,57 @@
1
+ import { http, unwrapPage } from "../http";
2
+
3
+ export type CommitmentState = "open" | "kept" | "missed";
4
+
5
+ export type CommitmentEntry = {
6
+ id: string;
7
+ created_at: string;
8
+ updated_at: string;
9
+ state: CommitmentState;
10
+ counterparty: string;
11
+ body: string;
12
+ promised_at: string;
13
+ due: string | null;
14
+ origin_channel: string | null;
15
+ origin_message_ref: string | null;
16
+ note?: string | null;
17
+ renegotiated_count?: number;
18
+ history: { due: string | null; moved_at: string; note: string | null }[];
19
+ };
20
+
21
+ export type GlobalCommitmentEntry = CommitmentEntry & {
22
+ project_id: string | number;
23
+ project_name: string;
24
+ };
25
+
26
+ type ListOpts = {
27
+ state?: CommitmentState | "all";
28
+ counterparty?: string;
29
+ overdue?: boolean;
30
+ limit: number;
31
+ offset: number;
32
+ };
33
+
34
+ function qs(o: ListOpts): string {
35
+ const p = new URLSearchParams();
36
+ if (o.state) p.set("state", o.state);
37
+ if (o.counterparty) p.set("counterparty", o.counterparty);
38
+ if (o.overdue) p.set("overdue", "1");
39
+ p.set("limit", String(o.limit));
40
+ p.set("offset", String(o.offset));
41
+ return p.toString();
42
+ }
43
+
44
+ export const Commitments = {
45
+ globalPage: (o: ListOpts) =>
46
+ http.get<unknown>(`/api/commitments?${qs(o)}`).then((b) => unwrapPage<GlobalCommitmentEntry>(b)),
47
+ listPage: (pid: string, o: ListOpts) =>
48
+ http.get<unknown>(`/api/projects/${pid}/commitments?${qs(o)}`).then((b) => unwrapPage<CommitmentEntry>(b)),
49
+ add: (pid: string, body: { counterparty: string; body: string; due?: string | null }) =>
50
+ http.post<CommitmentEntry>(`/api/projects/${pid}/commitments`, body),
51
+ kept: (pid: string, id: string, note?: string) =>
52
+ http.post<CommitmentEntry>(`/api/projects/${pid}/commitments/${id}/kept`, { note }),
53
+ missed: (pid: string, id: string, note?: string) =>
54
+ http.post<CommitmentEntry>(`/api/projects/${pid}/commitments/${id}/missed`, { note }),
55
+ renegotiate: (pid: string, id: string, due: string, note?: string) =>
56
+ http.post<CommitmentEntry>(`/api/projects/${pid}/commitments/${id}/renegotiate`, { due, note }),
57
+ };
@@ -0,0 +1,23 @@
1
+ import { http } from "../http";
2
+
3
+ /**
4
+ * The super-agent's own notebook (~/.apx/memory.md).
5
+ *
6
+ * Distinct from project memory (.apc/memory.md) and from each agent's memory.
7
+ * This is the one that ships in EVERY prompt on every channel, which is why it
8
+ * reports its own token cost.
9
+ */
10
+ export interface NotebookInfo {
11
+ body: string;
12
+ path: string;
13
+ chars: number;
14
+ approx_tokens: number;
15
+ entries: number;
16
+ /** How many entries were written by memory consolidation rather than by hand. */
17
+ consolidated: number;
18
+ }
19
+
20
+ export const Notebook = {
21
+ get: () => http.get<NotebookInfo>("/api/notebook"),
22
+ put: (body: string) => http.put<{ ok: true } & Omit<NotebookInfo, "body">>("/api/notebook", { body }),
23
+ };
@@ -0,0 +1,53 @@
1
+ import { http } from "../http";
2
+
3
+ export type NudgePolicy = {
4
+ enabled: boolean;
5
+ daily_max: number;
6
+ quiet_hours: string;
7
+ cooldown_minutes: number;
8
+ project_cooldown_minutes: number;
9
+ kind_cooldown_minutes: number;
10
+ critical_bypasses_budget: boolean;
11
+ };
12
+
13
+ export type NudgeEntry = {
14
+ id: string;
15
+ at: string;
16
+ kind: string;
17
+ project_id: string | null;
18
+ severity: string;
19
+ channel: string;
20
+ chat_id: string | null;
21
+ preview: string;
22
+ bypassed_budget: boolean;
23
+ feedback: { useful: boolean; note: string; at: string } | null;
24
+ };
25
+
26
+ export type NudgeStats = {
27
+ total: number;
28
+ today: number;
29
+ rated: number;
30
+ by_kind: { kind: string; sent: number; useful: number; noise: number }[];
31
+ };
32
+
33
+ export const NudgesApi = {
34
+ list: (limit = 50) =>
35
+ http.get<{ data: NudgeEntry[]; meta: { total: number; stats: NudgeStats } }>(
36
+ `/api/nudges?limit=${limit}`,
37
+ ),
38
+ policy: () =>
39
+ http.get<{
40
+ policy: NudgePolicy;
41
+ /** Which layers contributed: defaults → profile → user. */
42
+ source: string[];
43
+ defaults: NudgePolicy;
44
+ user_overrides: Partial<NudgePolicy>;
45
+ }>("/api/nudges/policy"),
46
+ setPolicy: (values: Partial<NudgePolicy>) =>
47
+ http.put<{ ok: true; policy: NudgePolicy; source: string[] }>("/api/nudges/policy", values),
48
+ feedback: (id: string, useful: boolean, note = "") =>
49
+ http.post<{ ok: true; entry: NudgeEntry }>(
50
+ `/api/nudges/${encodeURIComponent(id)}/feedback`,
51
+ { useful, note },
52
+ ),
53
+ };
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Five-field cron ⇄ human, both directions.
3
+ *
4
+ * `30 8 * * 1-5` is not a thing anyone should have to read to answer "when
5
+ * does my morning message arrive". Two pure functions, one file, so the
6
+ * summary shown in a status line and the picker used in a form can never
7
+ * disagree about what an expression means.
8
+ *
9
+ * Deliberately NOT a general cron library. It covers what APX writes and what
10
+ * the picker produces — fixed time, day-of-week sets, day-of-month, and
11
+ * step values — and says plainly when it does not understand something rather
12
+ * than guessing. `describeCron` returning null is the signal for a surface to
13
+ * fall back to showing the raw expression.
14
+ */
15
+
16
+ export interface CronParts {
17
+ minute: string;
18
+ hour: string;
19
+ dom: string;
20
+ month: string;
21
+ dow: string;
22
+ }
23
+
24
+ /** What a picker edits. */
25
+ export interface CronSelection {
26
+ /** "HH:MM" in the machine's local time. */
27
+ time: string;
28
+ /** 0=Sunday … 6=Saturday. Empty = every day. */
29
+ days: number[];
30
+ /** Run every N hours instead of at a fixed time. 0 = off. */
31
+ everyHours: number;
32
+ }
33
+
34
+ export const DAY_KEYS = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"] as const;
35
+
36
+ export function parseCron(expr: string): CronParts | null {
37
+ const f = String(expr || "").trim().split(/\s+/);
38
+ if (f.length !== 5) return null;
39
+ return { minute: f[0], hour: f[1], dom: f[2], month: f[3], dow: f[4] };
40
+ }
41
+
42
+ // Expand `1-5`, `1,3,5`, `*` and step values like `*/2` into concrete numbers.
43
+ // (Kept as a line comment: a `*/` inside a block comment closes it.)
44
+ function expand(field: string, min: number, max: number): number[] | null {
45
+ const out = new Set<number>();
46
+ for (const part of String(field).split(",")) {
47
+ const step = part.match(/^(.+)\/(\d+)$/);
48
+ const body = step ? step[1] : part;
49
+ const by = step ? Number(step[2]) : 1;
50
+ if (!Number.isFinite(by) || by < 1) return null;
51
+
52
+ let lo: number;
53
+ let hi: number;
54
+ if (body === "*") { lo = min; hi = max; }
55
+ else {
56
+ const range = body.match(/^(\d+)-(\d+)$/);
57
+ if (range) { lo = Number(range[1]); hi = Number(range[2]); }
58
+ else if (/^\d+$/.test(body)) { lo = Number(body); hi = lo; }
59
+ else return null;
60
+ }
61
+ if (lo < min || hi > max || lo > hi) return null;
62
+ for (let v = lo; v <= hi; v += by) out.add(v);
63
+ }
64
+ return [...out].sort((a, b) => a - b);
65
+ }
66
+
67
+ /** Day numbers a cron dow field selects. Cron accepts 7 for Sunday. */
68
+ export function cronDays(dow: string): number[] | null {
69
+ if (dow === "*" || dow === "?") return [];
70
+ const nums = expand(dow.replace(/\b7\b/g, "0"), 0, 6);
71
+ return nums && nums.length ? nums : null;
72
+ }
73
+
74
+ /**
75
+ * A sentence. Returns null when the expression is beyond what this understands
76
+ * — the caller shows the raw cron rather than a confident wrong reading.
77
+ */
78
+ export function describeCron(
79
+ expr: string,
80
+ t: (key: string, vars?: Record<string, string | number>) => string,
81
+ ): string | null {
82
+ const p = parseCron(expr);
83
+ if (!p) return null;
84
+ if (p.month !== "*") return null; // "in March" is not a case APX writes
85
+ if (p.dom !== "*" && p.dow !== "*") return null; // both set is ambiguous in cron itself
86
+
87
+ // Every N hours: `0 */2 * * *`.
88
+ const stepHour = p.hour.match(/^\*\/(\d+)$/);
89
+ if (stepHour && /^\d+$/.test(p.minute)) {
90
+ const n = Number(stepHour[1]);
91
+ return n === 1
92
+ ? t("cron.every_hour", { minute: pad(Number(p.minute)) })
93
+ : t("cron.every_n_hours", { n, minute: pad(Number(p.minute)) });
94
+ }
95
+ // Every N minutes: `*/15 * * * *`.
96
+ const stepMin = p.minute.match(/^\*\/(\d+)$/);
97
+ if (stepMin && p.hour === "*") return t("cron.every_n_minutes", { n: Number(stepMin[1]) });
98
+ if (p.minute === "*" && p.hour === "*") return t("cron.every_minute");
99
+
100
+ const hours = expand(p.hour, 0, 23);
101
+ const minutes = expand(p.minute, 0, 59);
102
+ if (!hours || !minutes || minutes.length !== 1) return null;
103
+
104
+ const at = hours.length === 1
105
+ ? `${pad(hours[0])}:${pad(minutes[0])}`
106
+ : hours.map((h) => `${pad(h)}:${pad(minutes[0])}`).join(", ");
107
+
108
+ if (p.dom !== "*") {
109
+ const doms = expand(p.dom, 1, 31);
110
+ if (!doms || doms.length !== 1) return null;
111
+ return t("cron.monthly", { day: doms[0], at });
112
+ }
113
+
114
+ const days = cronDays(p.dow);
115
+ if (days === null) return null;
116
+ if (!days.length) return t("cron.daily", { at });
117
+
118
+ const weekdays = [1, 2, 3, 4, 5];
119
+ const isWeekdays = days.length === 5 && weekdays.every((d) => days.includes(d));
120
+ if (isWeekdays) return t("cron.weekdays", { at });
121
+ if (days.length === 2 && days.includes(0) && days.includes(6)) return t("cron.weekends", { at });
122
+
123
+ const names = days.map((d) => t(`cron.day_short.${DAY_KEYS[d]}`)).join(", ");
124
+ return t("cron.on_days", { days: names, at });
125
+ }
126
+
127
+ /** Cron → what a picker should show. */
128
+ export function cronToSelection(expr: string): CronSelection {
129
+ const p = parseCron(expr);
130
+ const fallback: CronSelection = { time: "08:00", days: [], everyHours: 0 };
131
+ if (!p) return fallback;
132
+
133
+ const stepHour = p.hour.match(/^\*\/(\d+)$/);
134
+ if (stepHour) {
135
+ return {
136
+ time: `00:${pad(Number(/^\d+$/.test(p.minute) ? p.minute : 0))}`,
137
+ days: [],
138
+ everyHours: Number(stepHour[1]),
139
+ };
140
+ }
141
+ const hours = expand(p.hour, 0, 23);
142
+ const minutes = expand(p.minute, 0, 59);
143
+ const days = cronDays(p.dow);
144
+ return {
145
+ time: hours?.length && minutes?.length ? `${pad(hours[0])}:${pad(minutes[0])}` : fallback.time,
146
+ days: days ?? [],
147
+ everyHours: 0,
148
+ };
149
+ }
150
+
151
+ /** What a picker produces. Always a valid five-field expression. */
152
+ export function selectionToCron(sel: CronSelection): string {
153
+ if (sel.everyHours > 0) {
154
+ const [, mm] = splitTime(sel.time);
155
+ return `${mm} */${clamp(sel.everyHours, 1, 23)} * * *`;
156
+ }
157
+ const [hh, mm] = splitTime(sel.time);
158
+ // Sorted, and 0 kept as 0 — cron accepts both 0 and 7 for Sunday, and
159
+ // writing one form consistently keeps round-tripping stable.
160
+ const dow = sel.days.length && sel.days.length < 7
161
+ ? [...sel.days].sort((a, b) => a - b).join(",")
162
+ : "*";
163
+ return `${mm} ${hh} * * ${dow}`;
164
+ }
165
+
166
+ /** Is this something the pickers can represent without losing information? */
167
+ export function isPickerFriendly(expr: string): boolean {
168
+ const p = parseCron(expr);
169
+ if (!p) return false;
170
+ if (p.month !== "*" || p.dom !== "*") return false;
171
+ return selectionToCron(cronToSelection(expr)) === normalizeCron(expr);
172
+ }
173
+
174
+ /** `30 08 * * 1-5` → `30 8 * * 1,2,3,4,5`, so comparisons are meaningful. */
175
+ export function normalizeCron(expr: string): string {
176
+ const p = parseCron(expr);
177
+ if (!p) return String(expr || "").trim();
178
+ const days = cronDays(p.dow);
179
+ const dow = days && days.length && days.length < 7 ? days.join(",") : "*";
180
+ const one = (f: string) => (/^\d+$/.test(f) ? String(Number(f)) : f);
181
+ return `${one(p.minute)} ${one(p.hour)} ${p.dom} ${p.month} ${dow}`;
182
+ }
183
+
184
+ function splitTime(time: string): [string, string] {
185
+ const m = /^(\d{1,2}):(\d{2})$/.exec(String(time || "").trim());
186
+ if (!m) return ["8", "0"];
187
+ return [String(clamp(Number(m[1]), 0, 23)), String(clamp(Number(m[2]), 0, 59))];
188
+ }
189
+
190
+ function pad(n: number): string {
191
+ return String(n).padStart(2, "0");
192
+ }
193
+
194
+ function clamp(n: number, lo: number, hi: number): number {
195
+ return Math.min(hi, Math.max(lo, Number.isFinite(n) ? n : lo));
196
+ }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * "in 8 hours" / "2 minutes ago".
3
+ *
4
+ * A status row showing two absolute timestamps makes the reader do the
5
+ * arithmetic that the row exists to save them. The absolute value stays
6
+ * available as a tooltip for when the exact minute is what matters.
7
+ */
8
+ type T = (key: string, vars?: Record<string, string | number>) => string;
9
+
10
+ const MINUTE = 60_000;
11
+ const HOUR = 60 * MINUTE;
12
+ const DAY = 24 * HOUR;
13
+
14
+ export function relativeWhen(iso: string, t: T, now: number = Date.now()): string {
15
+ const ms = Date.parse(iso);
16
+ if (!Number.isFinite(ms)) return "—";
17
+ const diff = ms - now;
18
+ const ahead = diff >= 0;
19
+ const abs = Math.abs(diff);
20
+
21
+ // Under a minute reads as "now" in both directions: "in 34 seconds" is
22
+ // precision nobody acts on.
23
+ if (abs < MINUTE) return t("when.now");
24
+
25
+ const [n, unit] =
26
+ abs < HOUR ? [Math.round(abs / MINUTE), "minutes"] :
27
+ abs < DAY ? [Math.round(abs / HOUR), "hours"] :
28
+ [Math.round(abs / DAY), "days"];
29
+
30
+ const amount = t(`when.${unit}`, { n });
31
+ return ahead ? t("when.in", { amount }) : t("when.ago", { amount });
32
+ }