@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
@@ -0,0 +1,225 @@
1
+ // Post-session memory consolidation.
2
+ //
3
+ // THE GAP THIS CLOSES: self-memory.js has always claimed the notebook is
4
+ // "refreshed by skimming its own recent sessions". That function did not
5
+ // exist. Today a durable fact only reaches ~/.apx/memory.md if the model
6
+ // happens to call `remember` in the moment. The RAG index and the compactor
7
+ // are automatic, but those are RETRIEVAL, not learning.
8
+ //
9
+ // THE DANGER, which is bigger than the gap: memory.md is injected into every
10
+ // prompt on every channel. A notebook that grows without judgement is a
11
+ // permanent tax paid on every turn for facts nobody needed. So the whole
12
+ // design here leans one way — when in doubt, do not save.
13
+ //
14
+ // Three deliberate constraints:
15
+ //
16
+ // 1. CANDIDATES, NOT WRITES, by default. `apx memory consolidate` proposes;
17
+ // writing is a separate, explicit step. A background job that silently
18
+ // edits the file the agent believes about itself is not something to
19
+ // switch on by default.
20
+ // 2. DEDUP AGAINST WHAT IS ALREADY THERE. The same fact learned three times
21
+ // is one fact.
22
+ // 3. EVERY WRITE IS TAGGED AND REVERSIBLE. Consolidated bullets carry a
23
+ // marker so `revert` can remove exactly what a run added and nothing the
24
+ // user or the model wrote by hand.
25
+ import { readSelfMemory, appendSelfMemory, parseSelfMemoryEntries, SELF_MEMORY_PATH } from "#core/agent/self-memory.js";
26
+ import fs from "node:fs";
27
+
28
+ /** Marks a bullet as machine-distilled, so a revert can find its own writes. */
29
+ export const CONSOLIDATED_CHANNEL = "consolidated";
30
+
31
+ /** Conservative by design — see the header. */
32
+ export const DEFAULT_LIMITS = Object.freeze({
33
+ /** Never propose more than this from one run. A day is not a biography. */
34
+ max_candidates: 5,
35
+ /** Below this, a line is chatter, not a fact. */
36
+ min_chars: 20,
37
+ /** Above this it is a paragraph; the notebook holds one-liners. */
38
+ max_chars: 240,
39
+ /**
40
+ * Jaccard overlap at which two facts are "the same fact".
41
+ *
42
+ * Set low on purpose. The two errors here are not symmetric: calling a new
43
+ * fact a duplicate costs one thing not saved, which the model can say again
44
+ * tomorrow. Calling a duplicate new costs a permanent second copy in a file
45
+ * that ships on every turn of every channel. So it errs toward dedup — real
46
+ * paraphrases ("instead of npm" / "rather than npm") land around 0.55.
47
+ */
48
+ dedup_threshold: 0.5,
49
+ });
50
+
51
+ /**
52
+ * Openers that mark a durable fact about how someone works or what they
53
+ * decided, as opposed to a transient exchange. Matching on shape rather than
54
+ * meaning keeps this deterministic and testable — the model does the
55
+ * distilling upstream; this decides what is worth keeping.
56
+ */
57
+ const DURABLE_MARKERS = [
58
+ /\b(prefer|prefers|prefiere|prefiero)\b/i,
59
+ /\b(decided|decidimos|decidí|decision)\b/i,
60
+ /\b(always|never|siempre|nunca)\b/i,
61
+ /\b(uses?|usa|usamos)\b.*\b(instead of|en vez de)\b/i,
62
+ /\b(works? (?:on|at)|trabaja en)\b/i,
63
+ /\b(deadline|vence|due)\b/i,
64
+ /\b(rule|regla|convention|convención)\b/i,
65
+ ];
66
+
67
+ /** Things that look like facts but age out within a day. */
68
+ const TRANSIENT_MARKERS = [
69
+ /\b(today|hoy|right now|ahora mismo|esta mañana|this morning)\b/i,
70
+ /\b(will (?:check|look)|voy a (?:ver|revisar))\b/i,
71
+ /^\s*(ok|okay|dale|listo|thanks|gracias|perfecto)\b/i,
72
+ ];
73
+
74
+ /** Words that carry meaning, lowercased, for overlap comparison. */
75
+ function tokenise(text) {
76
+ return new Set(
77
+ String(text || "")
78
+ .toLowerCase()
79
+ .replace(/[^\p{L}\p{N}\s]/gu, " ")
80
+ .split(/\s+/)
81
+ .filter((w) => w.length > 3),
82
+ );
83
+ }
84
+
85
+ /** Jaccard overlap. 1 = identical vocabulary, 0 = nothing in common. */
86
+ export function similarity(a, b) {
87
+ const A = tokenise(a);
88
+ const B = tokenise(b);
89
+ if (!A.size || !B.size) return 0;
90
+ let shared = 0;
91
+ for (const w of A) if (B.has(w)) shared += 1;
92
+ return shared / (A.size + B.size - shared);
93
+ }
94
+
95
+ /** Is this line worth keeping past today? */
96
+ export function looksDurable(text, limits = DEFAULT_LIMITS) {
97
+ const s = String(text || "").trim();
98
+ if (s.length < limits.min_chars || s.length > limits.max_chars) return false;
99
+ if (TRANSIENT_MARKERS.some((re) => re.test(s))) return false;
100
+ return DURABLE_MARKERS.some((re) => re.test(s));
101
+ }
102
+
103
+ /**
104
+ * Propose durable facts from a batch of candidate lines.
105
+ *
106
+ * The caller supplies the lines — from a model's distillation of a session, or
107
+ * from anywhere else. This module owns the JUDGEMENT about what survives, so
108
+ * the same rules apply no matter who is proposing.
109
+ *
110
+ * @param {string[]} candidates
111
+ * @param {object} opts { existing?: string, limits?, now? }
112
+ * @returns {{ kept: string[], rejected: {text: string, reason: string}[] }}
113
+ */
114
+ export function proposeConsolidation(candidates, opts = {}) {
115
+ const limits = { ...DEFAULT_LIMITS, ...(opts.limits || {}) };
116
+ const existingText = opts.existing !== undefined ? opts.existing : readSelfMemory();
117
+ const known = parseSelfMemoryEntries(existingText).map((e) => e.text);
118
+
119
+ const kept = [];
120
+ const rejected = [];
121
+
122
+ for (const raw of candidates || []) {
123
+ const text = String(raw || "").replace(/\s+/g, " ").trim();
124
+ if (!text) continue;
125
+
126
+ if (kept.length >= limits.max_candidates) {
127
+ rejected.push({ text, reason: "over the per-run limit" });
128
+ continue;
129
+ }
130
+ if (!looksDurable(text, limits)) {
131
+ rejected.push({ text, reason: "not durable" });
132
+ continue;
133
+ }
134
+
135
+ // Against what is already saved AND against what this run already kept —
136
+ // a session that says the same thing twice must not save it twice.
137
+ const clash = [...known, ...kept].find((k) => similarity(k, text) >= limits.dedup_threshold);
138
+ if (clash) {
139
+ rejected.push({ text, reason: `already known: "${clash.slice(0, 60)}"` });
140
+ continue;
141
+ }
142
+
143
+ kept.push(text);
144
+ }
145
+
146
+ return { kept, rejected };
147
+ }
148
+
149
+ /**
150
+ * Write accepted facts to the notebook, tagged so they can be reverted.
151
+ * Separate from proposing on purpose: the decision to write is the user's.
152
+ */
153
+ export function applyConsolidation(facts, opts = {}) {
154
+ const written = [];
155
+ for (const text of facts || []) {
156
+ appendSelfMemory(text, { channel: CONSOLIDATED_CHANNEL, ...(opts.time ? { time: opts.time } : {}) });
157
+ written.push(text);
158
+ }
159
+ return { written, path: SELF_MEMORY_PATH };
160
+ }
161
+
162
+ /**
163
+ * Undo consolidation: drop bullets this module wrote, leaving everything the
164
+ * user or the model wrote by hand exactly where it is.
165
+ *
166
+ * @param {object} opts { since?: "YYYY-MM-DD" } — only that day and after.
167
+ * @returns {{ removed: number, path: string }}
168
+ */
169
+ export function revertConsolidation(opts = {}) {
170
+ const text = readSelfMemory();
171
+ if (!text.trim()) return { removed: 0, path: SELF_MEMORY_PATH };
172
+
173
+ const since = opts.since || "";
174
+ const lines = text.split("\n");
175
+ const out = [];
176
+ let day = "";
177
+ let removed = 0;
178
+
179
+ for (const line of lines) {
180
+ const h = line.trim().match(/^##\s+(\d{4}-\d{2}-\d{2})/);
181
+ if (h) { day = h[1]; out.push(line); continue; }
182
+
183
+ // Only ever removes a bullet carrying OUR channel tag. A hand-written note
184
+ // that happens to sit next to one is untouched.
185
+ const isOurs = /^[-*]\s+(?:\[[^\]]+\]\s*)?\[consolidated\]\s/i.test(line.trim());
186
+ if (isOurs && (!since || day >= since)) {
187
+ removed += 1;
188
+ continue;
189
+ }
190
+ out.push(line);
191
+ }
192
+
193
+ if (removed) {
194
+ fs.writeFileSync(SELF_MEMORY_PATH, stripEmptyDays(out).join("\n"));
195
+ }
196
+ return { removed, path: SELF_MEMORY_PATH };
197
+ }
198
+
199
+ /** Drop day headings left with no bullets under them. */
200
+ function stripEmptyDays(lines) {
201
+ const out = [];
202
+ for (let i = 0; i < lines.length; i++) {
203
+ const isHeading = /^##\s+\d{4}-\d{2}-\d{2}/.test(lines[i].trim());
204
+ if (!isHeading) { out.push(lines[i]); continue; }
205
+ let hasBullet = false;
206
+ for (let j = i + 1; j < lines.length; j++) {
207
+ if (/^##\s+/.test(lines[j].trim())) break;
208
+ if (/^[-*]\s+\S/.test(lines[j].trim())) { hasBullet = true; break; }
209
+ }
210
+ if (hasBullet) out.push(lines[i]);
211
+ }
212
+ return out;
213
+ }
214
+
215
+ /** How much of the prompt budget the notebook is currently costing. */
216
+ export function notebookSize() {
217
+ const text = readSelfMemory();
218
+ const entries = parseSelfMemoryEntries(text);
219
+ return {
220
+ chars: text.length,
221
+ approx_tokens: Math.ceil(text.length / 4),
222
+ entries: entries.length,
223
+ consolidated: entries.filter((e) => e.channel === CONSOLIDATED_CHANNEL).length,
224
+ };
225
+ }
@@ -0,0 +1,192 @@
1
+ // The interruption budget — one gate every unrequested message passes through.
2
+ //
3
+ // THE RULE THAT MATTERS: the gate lives at the CALL SITES that decide to speak
4
+ // unprompted, never inside the channel's `_send`. `_send` also carries the
5
+ // user's own replies, and a budget that can swallow an answer someone is
6
+ // waiting for reads as a hung bot, not as restraint. Each caller states
7
+ // `unsolicited` explicitly, so the intent is visible in the diff rather than
8
+ // inferred from the call stack.
9
+ //
10
+ // The budget is a feature, not a limitation: it is what makes the message get
11
+ // opened when the agent does speak.
12
+ //
13
+ // Shape of a call site:
14
+ //
15
+ // const gate = canNudge({ kind: "day_open", project_id, severity }, config);
16
+ // if (!gate.allowed) return; // log gate.reason
17
+ // await send({ ..., reply_markup: nudgeFeedbackKeyboard(gate.nudge_id) });
18
+ // recordNudge(gate, { chat_id, preview: text });
19
+ import { shortId } from "#core/util/ids.js";
20
+ import { resolveNudgePolicy, isQuietAt, quietEndsAt } from "./policy.js";
21
+ import {
22
+ readNudgeLedger, appendNudge, setNudgeFeedback, nudgesOnDay, lastNudge,
23
+ } from "./store.js";
24
+
25
+ export { resolveNudgePolicy, isQuietAt, DEFAULT_POLICY } from "./policy.js";
26
+ export { listNudges, nudgeStats, readNudgeLedger } from "./store.js";
27
+
28
+ /** Severity that may cross a closed gate, when the policy allows it. */
29
+ const CRITICAL = "critical";
30
+
31
+ /**
32
+ * May APX speak right now?
33
+ *
34
+ * @param {object} req
35
+ * @param {string} req.kind what sort of interruption ("day_open", "signal", "session_result", …)
36
+ * @param {string?} req.project_id which project it concerns, when it concerns one
37
+ * @param {string} req.severity "low" | "normal" | "high" | "critical"
38
+ * @param {boolean} req.unsolicited false for a delivery the user asked for. Be honest here.
39
+ * @param {boolean} req.scheduled true when the user themselves put this on the clock (an
40
+ * anchor routine). Exempt from the ceiling, still recorded.
41
+ * @param {string} req.channel
42
+ * @param {object} config parsed ~/.apx/config.json
43
+ * @param {Date} now injectable for tests
44
+ * @returns {{allowed: boolean, reason: string, retry_after_ms: number|null, nudge_id: string,
45
+ * kind: string, project_id: string|null, severity: string, channel: string,
46
+ * unsolicited: boolean, bypassed_budget: boolean, policy: object}}
47
+ */
48
+ export function canNudge(req = {}, config = {}, now = new Date()) {
49
+ const kind = req.kind || "unknown";
50
+ const projectId = req.project_id ?? null;
51
+ const severity = req.severity || "normal";
52
+ const channel = req.channel || "telegram";
53
+ const unsolicited = req.unsolicited !== false;
54
+ const scheduled = req.scheduled === true;
55
+ const policy = resolveNudgePolicy(config);
56
+
57
+ const decision = (allowed, reason, retry_after_ms = null, bypassed_budget = false) => ({
58
+ allowed, reason, retry_after_ms,
59
+ nudge_id: shortId("ndg"),
60
+ kind, project_id: projectId, severity, channel, unsolicited, scheduled, bypassed_budget, policy,
61
+ });
62
+
63
+ // A reply, or a result the user launched and is waiting for. It passes
64
+ // through so the audit holds and so it lands in the ledger, but it never
65
+ // spends a budget it did not ask to spend.
66
+ if (!unsolicited) return decision(true, "solicited");
67
+
68
+ if (!policy.enabled) return decision(true, "budget-disabled");
69
+
70
+ // An ANCHOR — the morning and evening messages the user themselves put on
71
+ // the clock. The profile schema calls the daily number "the ceiling OUTSIDE
72
+ // the anchors", and it means it: a budget of three that two anchors spend
73
+ // leaves one, which is not what anybody chose. Charging someone's own
74
+ // schedule against their interruption allowance is the same category of
75
+ // wrong as gating a reply.
76
+ //
77
+ // Quiet hours are skipped too, for the same reason: an anchor scheduled
78
+ // inside them is a contradiction the USER wrote, and their explicit cron
79
+ // beats our default window.
80
+ if (scheduled) return decision(true, "scheduled-by-user");
81
+
82
+ const isCritical = severity === CRITICAL;
83
+ if (isCritical && policy.critical_bypasses_budget) {
84
+ // Audited, per the spec: it goes in the ledger flagged, so an integration
85
+ // that discovers "critical" as a way to shout cannot do it quietly.
86
+ return decision(true, "critical-bypass", null, true);
87
+ }
88
+
89
+ if (isQuietAt(policy.quiet_hours, now)) {
90
+ const ends = quietEndsAt(policy.quiet_hours, now);
91
+ return decision(false, `quiet-hours (${policy.quiet_hours})`, ends ? ends - now : null);
92
+ }
93
+
94
+ const ledger = readNudgeLedger();
95
+
96
+ if (policy.daily_max > 0) {
97
+ // Anchors and audited bypasses are recorded but do not consume the
98
+ // allowance — otherwise the number the user configured is not the number
99
+ // they get.
100
+ const today = nudgesOnDay(now, ledger).filter((e) => !e.bypassed_budget && !e.scheduled);
101
+ if (today.length >= policy.daily_max) {
102
+ return decision(
103
+ false,
104
+ `daily budget spent (${today.length}/${policy.daily_max})`,
105
+ msUntilTomorrow(now),
106
+ );
107
+ }
108
+ }
109
+
110
+ const cooldowns = [
111
+ [policy.cooldown_minutes, () => true, "global cooldown"],
112
+ [policy.project_cooldown_minutes, (e) => projectId != null && String(e.project_id) === String(projectId), `cooldown for this project`],
113
+ [policy.kind_cooldown_minutes, (e) => e.kind === kind, `cooldown for "${kind}"`],
114
+ ];
115
+
116
+ for (const [minutes, match, label] of cooldowns) {
117
+ if (!minutes) continue;
118
+ const last = lastNudge(match, ledger);
119
+ if (!last) continue;
120
+ const elapsed = now - new Date(last.at);
121
+ const window = minutes * 60_000;
122
+ if (Number.isFinite(elapsed) && elapsed < window) {
123
+ return decision(false, `${label} (${minutes}m)`, window - elapsed);
124
+ }
125
+ }
126
+
127
+ return decision(true, "within budget");
128
+ }
129
+
130
+ /**
131
+ * Record a nudge that was actually delivered. Takes the decision object
132
+ * `canNudge` returned, so the id in the feedback button and the id in the
133
+ * ledger are the same one.
134
+ */
135
+ export function recordNudge(decision, { chat_id = null, preview = "" } = {}) {
136
+ if (!decision?.allowed) return null;
137
+ // Solicited traffic is not an interruption and must not fill the ledger the
138
+ // user reads to answer "how often does this thing bother me".
139
+ if (decision.unsolicited === false) return null;
140
+ return appendNudge({
141
+ id: decision.nudge_id,
142
+ kind: decision.kind,
143
+ project_id: decision.project_id,
144
+ severity: decision.severity,
145
+ channel: decision.channel,
146
+ chat_id,
147
+ preview,
148
+ bypassed_budget: decision.bypassed_budget,
149
+ scheduled: decision.scheduled,
150
+ });
151
+ }
152
+
153
+ /**
154
+ * "That wasn't useful." The loop is not optional: initiative that never learns
155
+ * gets switched off, and the switch is the user muting the bot.
156
+ */
157
+ export function recordFeedback(nudgeId, useful, note = "") {
158
+ return setNudgeFeedback(nudgeId, { useful, note });
159
+ }
160
+
161
+ /** Telegram inline keyboard for a proactive push. Two taps, no typing. */
162
+ export function nudgeFeedbackKeyboard(nudgeId) {
163
+ if (!nudgeId) return undefined;
164
+ return {
165
+ inline_keyboard: [[
166
+ { text: "👍 Útil", callback_data: `apx:nudge:${nudgeId}:useful` },
167
+ { text: "👎 No me servía", callback_data: `apx:nudge:${nudgeId}:noise` },
168
+ ]],
169
+ };
170
+ }
171
+
172
+ /**
173
+ * Handle a press on that keyboard. Returns the acknowledgement text, or null
174
+ * when the callback belongs to someone else.
175
+ */
176
+ export function applyNudgeCallback(data) {
177
+ if (typeof data !== "string" || !data.startsWith("apx:nudge:")) return null;
178
+ const [nudgeId, verb] = data.slice("apx:nudge:".length).split(":");
179
+ if (!nudgeId || (verb !== "useful" && verb !== "noise")) return null;
180
+ const entry = recordFeedback(nudgeId, verb === "useful");
181
+ if (!entry) return { ack: "Ese mensaje ya no está en el registro.", entry: null };
182
+ return {
183
+ ack: verb === "useful" ? "Anotado: te sirvió." : "Anotado: no te servía.",
184
+ entry,
185
+ };
186
+ }
187
+
188
+ function msUntilTomorrow(now) {
189
+ const t = new Date(now);
190
+ t.setHours(24, 0, 0, 0);
191
+ return t - now;
192
+ }
@@ -0,0 +1,143 @@
1
+ // How many unrequested messages APX may send, and when.
2
+ //
3
+ // Three layers, lowest precedence first:
4
+ //
5
+ // 1. CORE DEFAULTS — permissive, and `enabled: false`. Vanilla APX delivers
6
+ // exactly what it delivered before this module existed. Turning a budget
7
+ // on by default would have silently muted push paths people already rely
8
+ // on, which is a regression dressed as a feature.
9
+ // 2. THE ACTIVE PROFILE — a profile that declares `nudge_budget_per_day` or
10
+ // `quiet_hours` is stating a criterion, and switches enforcement ON. This
11
+ // is the split the whole profile subsystem rests on: core owns the
12
+ // capability (a gate, a ledger, a feedback loop), the profile owns the
13
+ // judgement (three a day, quiet after ten).
14
+ // 3. THE USER — `config.nudge` in ~/.apx/config.json, written by the panel or
15
+ // by hand. Always wins. Someone who sets a number should get that number
16
+ // whatever profile they install later.
17
+ import { readActiveProfile, effectiveProfileConfig } from "#core/profiles/store.js";
18
+
19
+ /** Permissive on purpose — see the header. */
20
+ export const DEFAULT_POLICY = Object.freeze({
21
+ enabled: false,
22
+ daily_max: 0, // 0 = no ceiling
23
+ quiet_hours: "", // "" = never quiet
24
+ cooldown_minutes: 0, // between any two nudges
25
+ project_cooldown_minutes: 0, // between two nudges about the SAME project
26
+ kind_cooldown_minutes: 0, // between two nudges of the SAME kind
27
+ critical_bypasses_budget: true,
28
+ });
29
+
30
+ /**
31
+ * Profile config keys this module understands. Deliberately generic names: a
32
+ * chief-of-staff profile and a study-tutor profile both mean the same thing by
33
+ * "how often may you interrupt me". A profile that declares neither key leaves
34
+ * the gate off.
35
+ */
36
+ const PROFILE_KEYS = Object.freeze({
37
+ nudge_budget_per_day: "daily_max",
38
+ quiet_hours: "quiet_hours",
39
+ nudge_cooldown_minutes: "cooldown_minutes",
40
+ nudge_project_cooldown_minutes: "project_cooldown_minutes",
41
+ nudge_kind_cooldown_minutes: "kind_cooldown_minutes",
42
+ });
43
+
44
+ /**
45
+ * Resolve the effective policy for the current config.
46
+ *
47
+ * @param {object} config the parsed ~/.apx/config.json
48
+ * @returns {typeof DEFAULT_POLICY & { source: string[] }}
49
+ */
50
+ export function resolveNudgePolicy(config = {}) {
51
+ const policy = { ...DEFAULT_POLICY };
52
+ const source = ["defaults"];
53
+
54
+ // ── layer 2: the active profile ──────────────────────────────────────────
55
+ let profileConfig = null;
56
+ try {
57
+ const active = readActiveProfile(config);
58
+ if (active) profileConfig = effectiveProfileConfig(active, config);
59
+ } catch {
60
+ // A broken or half-installed profile must not take the gate down with it.
61
+ profileConfig = null;
62
+ }
63
+
64
+ if (profileConfig) {
65
+ let touched = false;
66
+ for (const [profileKey, policyKey] of Object.entries(PROFILE_KEYS)) {
67
+ const v = profileConfig[profileKey];
68
+ if (v === undefined || v === null || v === "") continue;
69
+ policy[policyKey] = v;
70
+ touched = true;
71
+ }
72
+ // Declaring a budget IS opting in. A profile that says "three a day" and
73
+ // then gets ignored because a separate flag was off is worse than useless.
74
+ if (touched) {
75
+ policy.enabled = true;
76
+ source.push("profile");
77
+ }
78
+ }
79
+
80
+ // ── layer 3: the user ────────────────────────────────────────────────────
81
+ const user = config?.nudge;
82
+ if (user && typeof user === "object") {
83
+ let touched = false;
84
+ for (const key of Object.keys(DEFAULT_POLICY)) {
85
+ if (user[key] === undefined || user[key] === null) continue;
86
+ policy[key] = user[key];
87
+ touched = true;
88
+ }
89
+ if (touched) source.push("user");
90
+ }
91
+
92
+ policy.daily_max = toNonNegativeInt(policy.daily_max);
93
+ policy.cooldown_minutes = toNonNegativeInt(policy.cooldown_minutes);
94
+ policy.project_cooldown_minutes = toNonNegativeInt(policy.project_cooldown_minutes);
95
+ policy.kind_cooldown_minutes = toNonNegativeInt(policy.kind_cooldown_minutes);
96
+ policy.enabled = policy.enabled === true;
97
+ policy.critical_bypasses_budget = policy.critical_bypasses_budget !== false;
98
+ policy.quiet_hours = typeof policy.quiet_hours === "string" ? policy.quiet_hours.trim() : "";
99
+
100
+ return { ...policy, source };
101
+ }
102
+
103
+ function toNonNegativeInt(v) {
104
+ const n = Number.parseInt(v, 10);
105
+ return Number.isFinite(n) && n > 0 ? n : 0;
106
+ }
107
+
108
+ /**
109
+ * Parse a "HH:MM-HH:MM" window into minutes-from-midnight.
110
+ * Returns null when the string is absent or unparseable — an unreadable window
111
+ * must not accidentally mean "always quiet".
112
+ */
113
+ export function parseQuietHours(spec) {
114
+ const m = /^\s*(\d{1,2}):(\d{2})\s*-\s*(\d{1,2}):(\d{2})\s*$/.exec(String(spec || ""));
115
+ if (!m) return null;
116
+ const [h1, m1, h2, m2] = [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])];
117
+ if (h1 > 23 || h2 > 23 || m1 > 59 || m2 > 59) return null;
118
+ return { start: h1 * 60 + m1, end: h2 * 60 + m2 };
119
+ }
120
+
121
+ /**
122
+ * Is `date` inside the quiet window? Handles windows that cross midnight,
123
+ * which is the normal case for sleep ("22:00-07:30").
124
+ */
125
+ export function isQuietAt(spec, date = new Date()) {
126
+ const w = parseQuietHours(spec);
127
+ if (!w) return false;
128
+ const mins = date.getHours() * 60 + date.getMinutes();
129
+ if (w.start === w.end) return false; // zero-width window
130
+ if (w.start < w.end) return mins >= w.start && mins < w.end;
131
+ return mins >= w.start || mins < w.end; // crosses midnight
132
+ }
133
+
134
+ /** When does the current quiet window end? Null when not quiet. */
135
+ export function quietEndsAt(spec, date = new Date()) {
136
+ if (!isQuietAt(spec, date)) return null;
137
+ const w = parseQuietHours(spec);
138
+ const end = new Date(date);
139
+ end.setSeconds(0, 0);
140
+ end.setHours(Math.floor(w.end / 60), w.end % 60);
141
+ if (end <= date) end.setDate(end.getDate() + 1);
142
+ return end;
143
+ }