@agentprojectcontext/apx 1.78.0 → 1.79.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 (93) hide show
  1. package/package.json +1 -1
  2. package/src/core/agent/run-agent.js +30 -5
  3. package/src/core/agent/tool-summary.js +65 -0
  4. package/src/core/agent/tools/handlers/list-commitments.js +80 -0
  5. package/src/core/agent/tools/handlers/list-tasks.js +66 -27
  6. package/src/core/agent/tools/handlers/record-commitment.js +68 -0
  7. package/src/core/agent/tools/handlers/send-telegram.js +68 -2
  8. package/src/core/agent/tools/names.js +6 -0
  9. package/src/core/agent/tools/registry.js +9 -0
  10. package/src/core/agent/tools/tool-call-parser.js +70 -1
  11. package/src/core/channels/telegram/ask-callbacks.js +35 -0
  12. package/src/core/channels/telegram/dispatch.js +3 -0
  13. package/src/core/channels/telegram/reply.js +30 -5
  14. package/src/core/config/paths.js +3 -0
  15. package/src/core/config/redact.js +22 -0
  16. package/src/core/daemon/service.js +238 -0
  17. package/src/core/engines/gemini.js +322 -60
  18. package/src/core/engines/openai-compatible.js +21 -2
  19. package/src/core/memory/consolidate.js +225 -0
  20. package/src/core/nudge/index.js +192 -0
  21. package/src/core/nudge/policy.js +143 -0
  22. package/src/core/nudge/store.js +141 -0
  23. package/src/core/profiles/bundled/secretary/PROFILE.md +8 -9
  24. package/src/core/profiles/bundled/secretary/config.schema.json +33 -3
  25. package/src/core/profiles/bundled/secretary/routines/day-close.json +7 -3
  26. package/src/core/profiles/bundled/secretary/routines/day-open.json +7 -3
  27. package/src/core/profiles/bundled/secretary/routines/watch.json +13 -0
  28. package/src/core/routines/runner.js +102 -3
  29. package/src/core/routines/signals.js +270 -0
  30. package/src/core/stores/commitments.js +331 -0
  31. package/src/core/stores/messages.js +4 -0
  32. package/src/core/stores/routines.js +17 -3
  33. package/src/core/util/thinking.js +51 -0
  34. package/src/host/daemon/api/commitments.js +135 -0
  35. package/src/host/daemon/api/nudges.js +112 -0
  36. package/src/host/daemon/api/routines.js +24 -0
  37. package/src/host/daemon/api/self-memory.js +50 -0
  38. package/src/host/daemon/api/telegram.js +42 -4
  39. package/src/host/daemon/api/voice.js +3 -1
  40. package/src/host/daemon/api.js +6 -0
  41. package/src/host/daemon/callback-reconciler.js +16 -0
  42. package/src/host/daemon/plugins/desktop/index.js +7 -1
  43. package/src/host/daemon/plugins/telegram/index.js +7 -2
  44. package/src/host/daemon/wakeup.js +17 -3
  45. package/src/interfaces/cli/commands/commitment.js +154 -0
  46. package/src/interfaces/cli/commands/daemon.js +57 -0
  47. package/src/interfaces/cli/commands/memory.js +73 -0
  48. package/src/interfaces/cli/commands/nudge.js +130 -0
  49. package/src/interfaces/cli/help/index.js +2 -2
  50. package/src/interfaces/cli/routes/commitment.js +19 -0
  51. package/src/interfaces/cli/routes/daemon.js +7 -1
  52. package/src/interfaces/cli/routes/index.js +4 -0
  53. package/src/interfaces/cli/routes/memory.js +10 -2
  54. package/src/interfaces/cli/routes/nudge.js +17 -0
  55. package/src/interfaces/web/dist/assets/index-CvEoGtTf.js +849 -0
  56. package/src/interfaces/web/dist/assets/index-CvEoGtTf.js.map +1 -0
  57. package/src/interfaces/web/dist/assets/index-DzBBXFaO.css +1 -0
  58. package/src/interfaces/web/dist/index.html +2 -2
  59. package/src/interfaces/web/package-lock.json +11 -10
  60. package/src/interfaces/web/src/components/Section.tsx +18 -3
  61. package/src/interfaces/web/src/components/chat/MessageBubble.tsx +13 -0
  62. package/src/interfaces/web/src/components/cron/CronPicker.tsx +196 -0
  63. package/src/interfaces/web/src/components/inbox/InboxList.tsx +145 -0
  64. package/src/interfaces/web/src/components/memory/MemoryBrowser.tsx +34 -4
  65. package/src/interfaces/web/src/components/routines/RoutineDetail.tsx +16 -4
  66. package/src/interfaces/web/src/components/routines/RoutineEditor.tsx +12 -2
  67. package/src/interfaces/web/src/components/routines/shared.ts +14 -5
  68. package/src/interfaces/web/src/components/settings/NudgePanel.tsx +183 -0
  69. package/src/interfaces/web/src/components/settings/ProfilePanel.tsx +36 -12
  70. package/src/interfaces/web/src/components/ui/filter-chips.tsx +47 -0
  71. package/src/interfaces/web/src/components/ui.tsx +1 -0
  72. package/src/interfaces/web/src/constants/index.ts +1 -0
  73. package/src/interfaces/web/src/hooks/useChat.ts +5 -1
  74. package/src/interfaces/web/src/hooks/useNudges.ts +38 -0
  75. package/src/interfaces/web/src/i18n/en.ts +127 -0
  76. package/src/interfaces/web/src/i18n/es.ts +127 -0
  77. package/src/interfaces/web/src/lib/api/commitments.ts +57 -0
  78. package/src/interfaces/web/src/lib/api/notebook.ts +23 -0
  79. package/src/interfaces/web/src/lib/api/nudges.ts +53 -0
  80. package/src/interfaces/web/src/lib/cron.ts +196 -0
  81. package/src/interfaces/web/src/lib/when.ts +32 -0
  82. package/src/interfaces/web/src/screens/InboxScreen.tsx +107 -77
  83. package/src/interfaces/web/src/screens/ProjectScreen.tsx +5 -2
  84. package/src/interfaces/web/src/screens/SettingsScreen.tsx +17 -3
  85. package/src/interfaces/web/src/screens/base/CommitmentsTab.tsx +239 -0
  86. package/src/interfaces/web/src/screens/base/GlobalTasksTab.tsx +102 -19
  87. package/src/interfaces/web/src/screens/base/LogsTab.tsx +15 -0
  88. package/src/interfaces/web/src/screens/project/ChatTab.tsx +21 -3
  89. package/src/interfaces/web/src/screens/project/RoutinesTab.tsx +13 -11
  90. package/src/interfaces/web/src/types/daemon.ts +10 -1
  91. package/src/interfaces/web/dist/assets/index-CBR_-QyA.js +0 -824
  92. package/src/interfaces/web/dist/assets/index-CBR_-QyA.js.map +0 -1
  93. package/src/interfaces/web/dist/assets/index-D_EJEA1n.css +0 -1
@@ -17,6 +17,35 @@
17
17
 
18
18
  const THINK_RE = /<(?:think|thinking)>([\s\S]*?)<\/(?:think|thinking)>/gi;
19
19
 
20
+ // Untagged chain-of-thought.
21
+ //
22
+ // Some models — routed free tiers especially — emit their planning as the
23
+ // answer itself, with no tags at all: "We need to produce a response to user
24
+ // request: ...". That reached a real user's Telegram as a 668-token English
25
+ // wall in place of two sentences of Spanish.
26
+ //
27
+ // Detection is deliberately narrow. It only fires when the text OPENS with
28
+ // first-person-plural planning about producing a response, which is a register
29
+ // no genuine reply uses. Anything looser would eat real answers that happen to
30
+ // begin with "We need to".
31
+ const UNTAGGED_COT_OPENERS = [
32
+ /^we need to (produce|craft|write|generate|compose) (a |an )?(response|reply|answer|message)/i,
33
+ /^(the )?user (is )?(asking|says|wants|requests)\b[\s\S]{0,200}?\bwe (should|need to|must)\b/i,
34
+ /^okay,? (so )?(the )?user\b[\s\S]{0,120}?\bwe (should|need to)\b/i,
35
+ /^interpretation:\s/i,
36
+ /^let'?s (think|analyze|break this down)\b/i,
37
+ ];
38
+
39
+ /**
40
+ * True when `text` reads as raw planning rather than a reply.
41
+ * Only ever consulted for the ANSWER half, after tagged blocks are removed.
42
+ */
43
+ export function looksLikeUntaggedReasoning(text) {
44
+ const t = String(text || "").trimStart();
45
+ if (t.length < 120) return false; // too short to be a planning dump
46
+ return UNTAGGED_COT_OPENERS.some((re) => re.test(t));
47
+ }
48
+
20
49
  export function splitThinking(text) {
21
50
  if (!text || typeof text !== "string") return { thinking: "", answer: text || "" };
22
51
  const blocks = [];
@@ -34,6 +63,28 @@ export function stripThinking(text) {
34
63
  return splitThinking(text).answer;
35
64
  }
36
65
 
66
+ /**
67
+ * The answer, with untagged planning treated as thinking too.
68
+ *
69
+ * `stripThinking` only removes TAGGED blocks. A model that dumps its planning
70
+ * with no tags slips straight through, which is how a 668-token English
71
+ * chain-of-thought reached a user's phone instead of two sentences of Spanish.
72
+ *
73
+ * When the remaining answer still reads as raw planning, this returns "" — the
74
+ * caller decides what to do with an empty reply, which is a far better failure
75
+ * than shipping the model's notes to the user. Never guesses at salvaging a
76
+ * partial answer out of it.
77
+ *
78
+ * @returns {{ answer: string, leaked: boolean, thinking: string }}
79
+ */
80
+ export function stripReasoning(text) {
81
+ const { thinking, answer } = splitThinking(text);
82
+ if (looksLikeUntaggedReasoning(answer)) {
83
+ return { answer: "", leaked: true, thinking: [thinking, answer].filter(Boolean).join("\n\n") };
84
+ }
85
+ return { answer, leaked: false, thinking };
86
+ }
87
+
37
88
  export function formatForChannel(text, channel) {
38
89
  const { thinking, answer } = splitThinking(text);
39
90
  // Channels where reasoning would be noise to a human operator
@@ -0,0 +1,135 @@
1
+ // Commitments — what you promised, to whom, by when. Backed by
2
+ // core/stores/commitments.js (JSONL event log, sibling of tasks).
3
+ //
4
+ // GET /commitments cross-project
5
+ // ?state=open|kept|missed|all
6
+ // &counterparty=X&overdue=1
7
+ // &due_before=ISO&due_after=ISO
8
+ // &sort=due|newest&limit=N&offset=N
9
+ // GET /projects/:pid/commitments same filters, one project
10
+ // POST /projects/:pid/commitments { counterparty, body, due?, … }
11
+ // GET /projects/:pid/commitments/:id (id or prefix)
12
+ // PATCH /projects/:pid/commitments/:id { patch: {...} }
13
+ // POST /projects/:pid/commitments/:id/kept { note? }
14
+ // POST /projects/:pid/commitments/:id/missed { note? }
15
+ // POST /projects/:pid/commitments/:id/renegotiate { due, note? }
16
+ // GET /projects/:pid/commitments-summary
17
+ import {
18
+ createCommitment,
19
+ listCommitments,
20
+ listCommitmentsAcrossProjects,
21
+ getCommitment,
22
+ patchCommitment,
23
+ keepCommitment,
24
+ missCommitment,
25
+ renegotiateCommitment,
26
+ countCommitments,
27
+ } from "#core/stores/commitments.js";
28
+ import { pageEnvelope } from "./shared.js";
29
+
30
+ /** Shared filter parsing so the cross-project and per-project views cannot drift. */
31
+ function filtersFrom(query) {
32
+ const { state, counterparty, due_before, due_after, overdue, updated_since, sort } = query;
33
+ return {
34
+ state: state === "all" ? "all" : (state || undefined),
35
+ counterparty: counterparty || undefined,
36
+ due_before: due_before || undefined,
37
+ due_after: due_after || undefined,
38
+ overdue: overdue === "1" || overdue === "true" || undefined,
39
+ updated_since: updated_since || undefined,
40
+ sort: sort === "newest" ? "newest" : "due",
41
+ };
42
+ }
43
+
44
+ export function register(api, { project, projects }) {
45
+ api.get("/commitments", (req, res) => {
46
+ const entries = [];
47
+ for (const entry of projects.list()) {
48
+ const p = projects.get(entry.id);
49
+ if (!p?.storagePath) continue;
50
+ entries.push({
51
+ id: entry.id,
52
+ name: entry.name || entry.path,
53
+ path: entry.path,
54
+ storagePath: p.storagePath,
55
+ });
56
+ }
57
+
58
+ const { commitments, skipped } = listCommitmentsAcrossProjects(entries, filtersFrom(req.query));
59
+ const envelope = pageEnvelope(commitments, req.query);
60
+ if (skipped.length) envelope.meta = { ...(envelope.meta || {}), skipped };
61
+ res.json(envelope);
62
+ });
63
+
64
+ api.get("/projects/:pid/commitments", (req, res) => {
65
+ const p = project(req, res);
66
+ if (!p) return;
67
+ res.json(pageEnvelope(listCommitments(p.storagePath, filtersFrom(req.query)), req.query));
68
+ });
69
+
70
+ api.post("/projects/:pid/commitments", (req, res) => {
71
+ const p = project(req, res);
72
+ if (!p) return;
73
+ try {
74
+ res.status(201).json(createCommitment(p.storagePath, req.body || {}));
75
+ } catch (e) {
76
+ res.status(400).json({ error: e.message });
77
+ }
78
+ });
79
+
80
+ api.get("/projects/:pid/commitments/:id", (req, res) => {
81
+ const p = project(req, res);
82
+ if (!p) return;
83
+ const row = getCommitment(p.storagePath, req.params.id);
84
+ if (!row) return res.status(404).json({ error: "commitment not found" });
85
+ res.json(row);
86
+ });
87
+
88
+ api.patch("/projects/:pid/commitments/:id", (req, res) => {
89
+ const p = project(req, res);
90
+ if (!p) return;
91
+ const { patch } = req.body || {};
92
+ if (!patch || typeof patch !== "object") {
93
+ return res.status(400).json({ error: "patch object required" });
94
+ }
95
+ const updated = patchCommitment(p.storagePath, req.params.id, patch);
96
+ if (!updated) return res.status(404).json({ error: "commitment not found" });
97
+ res.json(updated);
98
+ });
99
+
100
+ api.post("/projects/:pid/commitments/:id/kept", (req, res) => {
101
+ const p = project(req, res);
102
+ if (!p) return;
103
+ const updated = keepCommitment(p.storagePath, req.params.id, req.body?.note || null);
104
+ if (!updated) return res.status(404).json({ error: "commitment not found" });
105
+ res.json(updated);
106
+ });
107
+
108
+ api.post("/projects/:pid/commitments/:id/missed", (req, res) => {
109
+ const p = project(req, res);
110
+ if (!p) return;
111
+ const updated = missCommitment(p.storagePath, req.params.id, req.body?.note || null);
112
+ if (!updated) return res.status(404).json({ error: "commitment not found" });
113
+ res.json(updated);
114
+ });
115
+
116
+ api.post("/projects/:pid/commitments/:id/renegotiate", (req, res) => {
117
+ const p = project(req, res);
118
+ if (!p) return;
119
+ const { due, note } = req.body || {};
120
+ if (!due) return res.status(400).json({ error: "a new due date is required" });
121
+ try {
122
+ const updated = renegotiateCommitment(p.storagePath, req.params.id, due, note || null);
123
+ if (!updated) return res.status(404).json({ error: "commitment not found" });
124
+ res.json(updated);
125
+ } catch (e) {
126
+ res.status(400).json({ error: e.message });
127
+ }
128
+ });
129
+
130
+ api.get("/projects/:pid/commitments-summary", (req, res) => {
131
+ const p = project(req, res);
132
+ if (!p) return;
133
+ res.json(countCommitments(p.storagePath));
134
+ });
135
+ }
@@ -0,0 +1,112 @@
1
+ // The interruption budget, as seen from outside.
2
+ //
3
+ // GET /nudges — the ledger, newest first
4
+ // GET /nudges/policy — the effective policy and where it came from
5
+ // PUT /nudges/policy — the user's overrides (config.nudge)
6
+ // POST /nudges/:id/feedback — { useful, note? }
7
+ // POST /nudges/check — dry-run the gate without sending
8
+ //
9
+ // The ledger is the honest answer to "how often does this thing bother me",
10
+ // which is the question that decides whether someone keeps the bot on.
11
+ import { readConfig, writeConfig } from "#core/config/index.js";
12
+ import {
13
+ listNudges, nudgeStats, recordFeedback, canNudge,
14
+ resolveNudgePolicy, DEFAULT_POLICY,
15
+ } from "#core/nudge/index.js";
16
+ import { pageEnvelope } from "./shared.js";
17
+
18
+ export function register(api) {
19
+ api.get("/nudges", (req, res) => {
20
+ try {
21
+ const rows = listNudges({
22
+ limit: req.query.limit || 50,
23
+ kind: req.query.kind || "",
24
+ project_id: req.query.project_id || "",
25
+ with_feedback:
26
+ req.query.with_feedback === "1" ? true
27
+ : req.query.with_feedback === "0" ? false
28
+ : null,
29
+ });
30
+ const envelope = pageEnvelope(rows, req.query);
31
+ envelope.meta = { ...(envelope.meta || {}), stats: nudgeStats() };
32
+ res.json(envelope);
33
+ } catch (e) {
34
+ res.status(500).json({ error: e.message });
35
+ }
36
+ });
37
+
38
+ api.get("/nudges/policy", (_req, res) => {
39
+ try {
40
+ const cfg = readConfig();
41
+ const { source, ...policy } = resolveNudgePolicy(cfg);
42
+ res.json({
43
+ policy,
44
+ // Which layers contributed, so the panel can say "this came from your
45
+ // profile" instead of showing a number with no provenance.
46
+ source,
47
+ defaults: DEFAULT_POLICY,
48
+ user_overrides: cfg.nudge || {},
49
+ });
50
+ } catch (e) {
51
+ res.status(500).json({ error: e.message });
52
+ }
53
+ });
54
+
55
+ api.put("/nudges/policy", (req, res) => {
56
+ const body = req.body || {};
57
+ try {
58
+ const cfg = readConfig();
59
+ const next = { ...(cfg.nudge || {}) };
60
+ for (const key of Object.keys(DEFAULT_POLICY)) {
61
+ if (!(key in body)) continue;
62
+ // null clears the override and hands the key back to the profile.
63
+ if (body[key] === null) delete next[key];
64
+ else next[key] = body[key];
65
+ }
66
+ cfg.nudge = next;
67
+ writeConfig(cfg);
68
+ const { source, ...policy } = resolveNudgePolicy(cfg);
69
+ res.json({ ok: true, policy, source, user_overrides: next });
70
+ } catch (e) {
71
+ res.status(400).json({ error: e.message });
72
+ }
73
+ });
74
+
75
+ api.post("/nudges/:id/feedback", (req, res) => {
76
+ const { useful, note } = req.body || {};
77
+ if (typeof useful !== "boolean") {
78
+ return res.status(400).json({ error: "useful (boolean) required" });
79
+ }
80
+ try {
81
+ const entry = recordFeedback(req.params.id, useful, note || "");
82
+ if (!entry) return res.status(404).json({ error: `no nudge: ${req.params.id}` });
83
+ res.json({ ok: true, entry });
84
+ } catch (e) {
85
+ res.status(500).json({ error: e.message });
86
+ }
87
+ });
88
+
89
+ // Dry run. Lets a caller (or a curious user) ask "would this get through?"
90
+ // without spending anything — nothing is recorded here.
91
+ api.post("/nudges/check", (req, res) => {
92
+ const { kind, project_id, severity, unsolicited } = req.body || {};
93
+ try {
94
+ const gate = canNudge(
95
+ {
96
+ kind: kind || "unknown",
97
+ project_id: project_id ?? null,
98
+ severity: severity || "normal",
99
+ unsolicited: unsolicited !== false,
100
+ },
101
+ readConfig(),
102
+ );
103
+ res.json({
104
+ allowed: gate.allowed,
105
+ reason: gate.reason,
106
+ retry_after_ms: gate.retry_after_ms,
107
+ });
108
+ } catch (e) {
109
+ res.status(500).json({ error: e.message });
110
+ }
111
+ });
112
+ }
@@ -16,6 +16,7 @@ import {
16
16
  setEnabled as setRoutineEnabled,
17
17
  runRoutineNow,
18
18
  } from "#core/routines/index.js";
19
+ import { CHANNELS } from "#core/constants/channels.js";
19
20
 
20
21
  export function register(api, { projects, registries, plugins, project, config }) {
21
22
  api.get("/projects/:pid/routines", (req, res) => {
@@ -38,7 +39,30 @@ export function register(api, { projects, registries, plugins, project, config }
38
39
  try {
39
40
  // Accepts every field including the pipeline extensions
40
41
  // (pre_commands, post_commands, skip_prompt_on).
42
+ const existed = !!getRoutine(p.storagePath, (req.body || {}).name);
41
43
  const r = upsertRoutine(p.storagePath, req.body || {});
44
+ // Say it happened, where it happened. A routine appearing in a list on a
45
+ // screen nobody has open is not the same as being told one now exists —
46
+ // and "what did that just create?" is the question people actually ask
47
+ // after asking for something to be set up.
48
+ p.logMessage?.({
49
+ channel: CHANNELS.ROUTINE,
50
+ direction: "out",
51
+ type: "system",
52
+ actor_id: "apx:routine",
53
+ author: "apx",
54
+ body: existed
55
+ ? `routine ${r.name} updated (${r.kind}, ${r.schedule})`
56
+ : `routine ${r.name} created (${r.kind}, ${r.schedule})`,
57
+ meta: {
58
+ event: existed ? "routine_updated" : "routine_created",
59
+ routine: r.name,
60
+ routine_id: r.id,
61
+ kind: r.kind,
62
+ schedule: r.schedule,
63
+ enabled: r.enabled !== false,
64
+ },
65
+ });
42
66
  res.status(201).json(r);
43
67
  } catch (e) {
44
68
  res.status(400).json({ error: e.message });
@@ -0,0 +1,50 @@
1
+ // The super-agent's own notebook — ~/.apx/memory.md.
2
+ //
3
+ // GET /notebook → { body, path, size, approx_tokens, entries, consolidated }
4
+ // PUT /notebook { body }
5
+ //
6
+ // WHY THIS EXISTS. This file is the one memory that ships in EVERY prompt on
7
+ // every channel, and until now it was the only one with no screen. The Memories
8
+ // tab listed project memory and each agent's memory; the super-agent's own
9
+ // notebook was reachable from the model (read_self_memory / remember) and from
10
+ // the CLI, and nowhere a person could look. So "where is Roby's memory?" had no
11
+ // answer in the product, which is a fair thing to be confused by.
12
+ //
13
+ // It reports its own size because that size is a tax paid on every turn — see
14
+ // core/memory/consolidate.js.
15
+ import { readSelfMemory, SELF_MEMORY_PATH } from "#core/agent/self-memory.js";
16
+ import { notebookSize } from "#core/memory/consolidate.js";
17
+ import fs from "node:fs";
18
+ import path from "node:path";
19
+
20
+ const MAX_BODY = 256 * 1024; // a notebook past this is a symptom, not a note
21
+
22
+ export function register(api) {
23
+ api.get("/notebook", (_req, res) => {
24
+ try {
25
+ const body = readSelfMemory();
26
+ res.json({ body, path: SELF_MEMORY_PATH, ...notebookSize() });
27
+ } catch (e) {
28
+ res.status(500).json({ error: e.message });
29
+ }
30
+ });
31
+
32
+ api.put("/notebook", (req, res) => {
33
+ const { body } = req.body || {};
34
+ if (typeof body !== "string") {
35
+ return res.status(400).json({ error: "body (string) required" });
36
+ }
37
+ if (body.length > MAX_BODY) {
38
+ return res.status(413).json({
39
+ error: `notebook too large (${body.length} > ${MAX_BODY} bytes) — it ships in every prompt`,
40
+ });
41
+ }
42
+ try {
43
+ fs.mkdirSync(path.dirname(SELF_MEMORY_PATH), { recursive: true });
44
+ fs.writeFileSync(SELF_MEMORY_PATH, body);
45
+ res.json({ ok: true, path: SELF_MEMORY_PATH, ...notebookSize() });
46
+ } catch (e) {
47
+ res.status(500).json({ error: e.message });
48
+ }
49
+ });
50
+ }
@@ -6,7 +6,11 @@
6
6
  // POST /telegram/send_photo { chat_id?, photo, caption?, parse_mode?, channel? }
7
7
  // POST /telegram/send_voice { chat_id?, audio, caption?, duration?, channel? }
8
8
  // POST /telegram/send_audio { chat_id?, audio, caption?, title?, performer?, channel? }
9
- // POST /telegram/notify (alias of /telegram/send; daemon-initiated pushes)
9
+ // POST /telegram/notify { chat_id?, text, channel?, kind?, project_id?,
10
+ // severity?, unsolicited? }
11
+ // Daemon-initiated pushes. Goes through the
12
+ // interruption budget (core/nudge) and answers
13
+ // 429 with a retry_after_ms when suppressed.
10
14
  //
11
15
  // GET /telegram/channels — list configured channels
12
16
  // POST /telegram/channels { name, ... } — create or replace one channel
@@ -39,6 +43,7 @@ import {
39
43
  } from "#core/config/index.js";
40
44
 
41
45
  import { redactChannel, isSecretMarker } from "#core/config/redact.js";
46
+ import { canNudge, recordNudge, nudgeFeedbackKeyboard } from "#core/nudge/index.js";
42
47
 
43
48
  export function register(api, { telegram }) {
44
49
  api.get("/telegram/status", (_req, res) => {
@@ -299,14 +304,47 @@ export function register(api, { telegram }) {
299
304
  });
300
305
 
301
306
  // Alias for proactive daemon-initiated pushes (routines, error handlers, …).
307
+ //
308
+ // PUSH PATH 2 OF 4 — this endpoint exists FOR unrequested messages, so the
309
+ // interruption budget applies by default. A caller delivering something the
310
+ // user is waiting for says so with `unsolicited: false`, and that choice is
311
+ // then visible in its own diff rather than hidden in this handler.
302
312
  api.post("/telegram/notify", async (req, res) => {
303
- const { chat_id, text, channel } = req.body || {};
313
+ const { chat_id, text, channel, kind, project_id, severity, unsolicited } = req.body || {};
304
314
  if (!text) return res.status(400).json({ error: "text required" });
305
315
  if (!telegram)
306
316
  return res.status(503).json({ error: "telegram plugin not loaded" });
317
+
318
+ const gate = canNudge(
319
+ {
320
+ kind: kind || "notify",
321
+ project_id: project_id ?? null,
322
+ severity: severity || "normal",
323
+ unsolicited: unsolicited !== false,
324
+ channel: "telegram",
325
+ },
326
+ readConfig(),
327
+ );
328
+ if (!gate.allowed) {
329
+ // 429, not 500: nothing failed. The caller is being asked to wait, and
330
+ // told for how long, so a routine can decide to try again rather than
331
+ // treating a working guardrail as an outage.
332
+ return res.status(429).json({
333
+ ok: false,
334
+ suppressed: true,
335
+ reason: gate.reason,
336
+ retry_after_ms: gate.retry_after_ms,
337
+ });
338
+ }
339
+
307
340
  try {
308
- const r = await telegram.send({ chat_id, text, channel });
309
- res.status(202).json({ ok: true, message_id: r.message_id, via: "notify" });
341
+ const r = await telegram.send({
342
+ chat_id, text, channel,
343
+ reply_markup: gate.unsolicited ? nudgeFeedbackKeyboard(gate.nudge_id) : undefined,
344
+ meta: { nudge_id: gate.unsolicited ? gate.nudge_id : undefined, nudge_kind: gate.kind },
345
+ });
346
+ recordNudge(gate, { chat_id, preview: text });
347
+ res.status(202).json({ ok: true, message_id: r.message_id, via: "notify", nudge_id: gate.nudge_id });
310
348
  } catch (e) {
311
349
  res.status(502).json({ error: e.message });
312
350
  }
@@ -18,6 +18,7 @@
18
18
  //
19
19
  // Domain logic (channel context, suggestion parsing, audio decoding) lives in
20
20
  // core/. This file is just glue: parse request → call core → format response.
21
+ import { stripReasoning } from "#core/util/thinking.js";
21
22
  import fs from "node:fs";
22
23
  import path from "node:path";
23
24
  import { TTS_TMP_DIR } from "#core/config/paths.js";
@@ -128,7 +129,8 @@ export function register(api, { projects, plugins, registries }) {
128
129
  systemSuffix: channelCtx.systemSuffix,
129
130
  previousMessages,
130
131
  });
131
- const raw = (result?.text || "").trim();
132
+ // Spoken back to the user — never narrate raw planning.
133
+ const raw = stripReasoning(result?.text || "").answer.trim();
132
134
  replyModel = result?.model || null;
133
135
  replyUsage = result?.usage || null;
134
136
  replyName = result?.name || null;
@@ -46,6 +46,7 @@ import { register as registerRoutines } from "./api/routines.js";
46
46
  import { register as registerArtifacts } from "./api/artifacts.js";
47
47
  import { register as registerArtifactPreview } from "./api/artifact-preview.js";
48
48
  import { register as registerTasks } from "./api/tasks.js";
49
+ import { register as registerCommitments } from "./api/commitments.js";
49
50
  import { register as registerOrganization } from "./api/organization.js";
50
51
  import { register as registerProjectFiles } from "./api/files-project.js";
51
52
  import { register as registerConfig } from "./api/config.js";
@@ -64,6 +65,8 @@ import { register as registerAdminConfig } from "./api/admin-config.js";
64
65
  import { register as registerIdentity } from "./api/identity.js";
65
66
  import { register as registerProfiles } from "./api/profiles.js";
66
67
  import { register as registerInbox } from "./api/inbox.js";
68
+ import { register as registerSelfMemory } from "./api/self-memory.js";
69
+ import { register as registerNudges } from "./api/nudges.js";
67
70
  import { register as registerWeb, registerWebToken } from "./api/web.js";
68
71
  import { register as registerConfirm } from "./api/confirm.js";
69
72
 
@@ -143,6 +146,7 @@ export function buildApi({
143
146
  registerArtifacts(api, ctx);
144
147
  registerArtifactPreview(api, ctx);
145
148
  registerTasks(api, ctx);
149
+ registerCommitments(api, ctx);
146
150
  registerOrganization(api, ctx);
147
151
  registerProjectFiles(api, ctx);
148
152
  registerConfig(api, ctx);
@@ -169,6 +173,8 @@ export function buildApi({
169
173
  registerIdentity(api, ctx);
170
174
  registerProfiles(api, ctx);
171
175
  registerInbox(api, ctx);
176
+ registerSelfMemory(api, ctx);
177
+ registerNudges(api, ctx);
172
178
  registerWebToken(api, ctx);
173
179
 
174
180
  // ---- API 404 (MUST be last on the router) ------------------------
@@ -15,6 +15,8 @@
15
15
  // session close captured remains). The rich A2A relay (Roby re-voicing the
16
16
  // result) stays the job of the live in-process path.
17
17
  import { listPendingCallbacks, deletePendingCallback, readSessionState } from "#core/stores/runtime-callbacks.js";
18
+ import { readConfig } from "#core/config/index.js";
19
+ import { canNudge, recordNudge } from "#core/nudge/index.js";
18
20
 
19
21
  const GRACE_MS = 30_000; // let the live in-process path win a fresh completion
20
22
  const STALE_MS = 24 * 60 * 60 * 1000; // drop IOUs for runs that never finished in a day
@@ -62,11 +64,25 @@ export async function reconcilePendingCallbacks({ plugins, log }) {
62
64
  if (Number.isFinite(compAge) && compAge < GRACE_MS) continue;
63
65
 
64
66
  if (!telegram) continue; // telegram plugin not up this boot — retry next tick
67
+
68
+ // PUSH PATH 4 OF 4. Declared SOLICITED, and that is a judgement worth
69
+ // stating: the user launched this runtime and is waiting for its result.
70
+ // Arriving late does not make it an interruption, and holding it back
71
+ // for quiet hours would mean losing an answer they asked for. It still
72
+ // passes through the gate so the audit is real and so a future policy
73
+ // can reach it without hunting for a fifth path nobody remembered.
74
+ const gate = canNudge(
75
+ { kind: "session_result", severity: "normal", unsolicited: false, channel: "telegram" },
76
+ readConfig(),
77
+ );
78
+ if (!gate.allowed) continue; // never today; retry next tick if it ever is
79
+
65
80
  await telegram.send({
66
81
  channel: entry.tg_channel || undefined,
67
82
  chat_id: entry.chat_id,
68
83
  text: deliverText(entry, state),
69
84
  });
85
+ recordNudge(gate, { chat_id: entry.chat_id });
70
86
  deletePendingCallback(entry.session_id);
71
87
  log?.(`callback-reconciler: delivered late callback for ${entry.session_id} → chat ${entry.chat_id}`);
72
88
  } catch (e) {
@@ -25,6 +25,7 @@ import {
25
25
  import { runSuperAgent, isSuperAgentEnabled } from "#core/agent/super-agent.js";
26
26
  import { appendGlobalMessage } from "#core/stores/messages.js";
27
27
  import { stripEmotionTags } from "#core/voice/emotions.js";
28
+ import { stripReasoning } from "#core/util/thinking.js";
28
29
  import { CHANNELS } from "#core/constants/channels.js";
29
30
  import { tryResolveSkillCommand } from "#core/agent/skills/trigger.js";
30
31
 
@@ -123,7 +124,12 @@ async function _handleMessage({ ws, text, previousMessages }, { projects, config
123
124
  emittedSegments.push(seg);
124
125
  // `text` is what the bubble shows (no [tags]); `speak` keeps the inline
125
126
  // emotion tags so the renderer's per-segment TTS can use them.
126
- _send(ws, { type: "segment", seq: ++segSeq, text: stripEmotionTags(seg), speak: seg });
127
+ // Desktop is voice-first: an unstripped reasoning dump would be spoken
128
+ // aloud, at length, in the wrong language. Drop the segment entirely
129
+ // rather than narrate the model's notes.
130
+ const spoken = stripReasoning(seg).answer;
131
+ if (!spoken.trim()) return;
132
+ _send(ws, { type: "segment", seq: ++segSeq, text: stripEmotionTags(spoken), speak: spoken });
127
133
  };
128
134
 
129
135
  try {
@@ -311,9 +311,13 @@ export default {
311
311
  // that bot; otherwise first available bot-tokened channel. Always logs
312
312
  // the outbound on `messages` of the channel's target project so audit
313
313
  // trails are complete.
314
- async send({ channel: channelName, chat_id, text, author = resolveAgentName(config) }) {
314
+ // `reply_markup` carries the interruption-budget feedback keyboard on
315
+ // proactive pushes (core/nudge). It is NOT a gate: the gate belongs at
316
+ // the call sites that decide to speak unprompted, because this method
317
+ // also carries traffic the user asked for.
318
+ async send({ channel: channelName, chat_id, text, reply_markup, author = resolveAgentName(config), meta: extraMeta }) {
315
319
  const p = pickPoller(pollers, channelName);
316
- const result = await p._send({ chat_id, text });
320
+ const result = await p._send({ chat_id, text, reply_markup });
317
321
  appendGlobalMessage({
318
322
  channel: CHANNELS.TELEGRAM,
319
323
  direction: "out",
@@ -327,6 +331,7 @@ export default {
327
331
  chat_id: chat_id || resolveChatId(p.channel),
328
332
  tg_channel: p.channel.name,
329
333
  via: channelName ? "explicit" : "auto",
334
+ ...(extraMeta || {}),
330
335
  },
331
336
  });
332
337
  return result;
@@ -2,6 +2,7 @@
2
2
  import fetch from "node-fetch";
3
3
  import { readIdentity, writeIdentity } from "#core/identity/index.js";
4
4
  import { resolveProvider, getAdapter } from "#core/engines/index.js";
5
+ import { canNudge, recordNudge, nudgeFeedbackKeyboard } from "#core/nudge/index.js";
5
6
 
6
7
  const WAKEUP_COOLDOWN_MS = 30 * 60 * 1000; // 30 min
7
8
 
@@ -50,12 +51,12 @@ async function generateMessage(identity, engineConfig) {
50
51
  }
51
52
  }
52
53
 
53
- async function sendTelegram(token, chatId, text) {
54
+ async function sendTelegram(token, chatId, text, reply_markup) {
54
55
  const url = `https://api.telegram.org/bot${token}/sendMessage`;
55
56
  const res = await fetch(url, {
56
57
  method: "POST",
57
58
  headers: { "Content-Type": "application/json" },
58
- body: JSON.stringify({ chat_id: chatId, text }),
59
+ body: JSON.stringify({ chat_id: chatId, text, ...(reply_markup ? { reply_markup } : {}) }),
59
60
  });
60
61
  const json = await res.json();
61
62
  if (!json.ok) throw new Error(json.description || "telegram send failed");
@@ -75,10 +76,23 @@ export async function triggerWakeup(config, log) {
75
76
  if (elapsed < WAKEUP_COOLDOWN_MS) return;
76
77
  }
77
78
 
79
+ // PUSH PATH 1 OF 4 — nobody asked for this one. "I restarted" is the least
80
+ // interesting thing APX can say, so it is also the first thing a budget
81
+ // should be allowed to swallow.
82
+ const gate = canNudge(
83
+ { kind: "wakeup", severity: "low", unsolicited: true, channel: "telegram" },
84
+ config,
85
+ );
86
+ if (!gate.allowed) {
87
+ log?.(`wakeup: suppressed by the interruption budget — ${gate.reason}`);
88
+ return;
89
+ }
90
+
78
91
  try {
79
92
  const message = await generateMessage(identity, config);
80
93
  const text = message || `${identity.agent_name} online. Ready.`;
81
- await sendTelegram(tg.bot_token, tg.chat_id, text);
94
+ await sendTelegram(tg.bot_token, tg.chat_id, text, nudgeFeedbackKeyboard(gate.nudge_id));
95
+ recordNudge(gate, { chat_id: tg.chat_id, preview: text });
82
96
  writeIdentity({ last_wakeup: new Date().toISOString() });
83
97
  log?.(`wakeup: sent to Telegram chat ${tg.chat_id}`);
84
98
  } catch (e) {