@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.
- package/package.json +1 -1
- package/src/core/agent/run-agent.js +30 -5
- package/src/core/agent/tool-summary.js +65 -0
- package/src/core/agent/tools/handlers/list-commitments.js +80 -0
- package/src/core/agent/tools/handlers/list-tasks.js +66 -27
- package/src/core/agent/tools/handlers/record-commitment.js +68 -0
- package/src/core/agent/tools/handlers/send-telegram.js +68 -2
- package/src/core/agent/tools/names.js +6 -0
- package/src/core/agent/tools/registry.js +9 -0
- package/src/core/agent/tools/tool-call-parser.js +70 -1
- package/src/core/channels/telegram/ask-callbacks.js +35 -0
- package/src/core/channels/telegram/dispatch.js +3 -0
- package/src/core/channels/telegram/reply.js +30 -5
- package/src/core/config/paths.js +3 -0
- package/src/core/config/redact.js +22 -0
- package/src/core/daemon/service.js +238 -0
- package/src/core/engines/gemini.js +322 -60
- package/src/core/engines/openai-compatible.js +21 -2
- package/src/core/memory/consolidate.js +225 -0
- package/src/core/nudge/index.js +192 -0
- package/src/core/nudge/policy.js +143 -0
- package/src/core/nudge/store.js +141 -0
- package/src/core/profiles/bundled/secretary/PROFILE.md +8 -9
- package/src/core/profiles/bundled/secretary/config.schema.json +33 -3
- package/src/core/profiles/bundled/secretary/routines/day-close.json +7 -3
- package/src/core/profiles/bundled/secretary/routines/day-open.json +7 -3
- package/src/core/profiles/bundled/secretary/routines/watch.json +13 -0
- package/src/core/routines/runner.js +102 -3
- package/src/core/routines/signals.js +270 -0
- package/src/core/stores/commitments.js +331 -0
- package/src/core/stores/messages.js +4 -0
- package/src/core/stores/routines.js +17 -3
- package/src/core/util/thinking.js +51 -0
- package/src/host/daemon/api/commitments.js +135 -0
- package/src/host/daemon/api/nudges.js +112 -0
- package/src/host/daemon/api/routines.js +24 -0
- package/src/host/daemon/api/self-memory.js +50 -0
- package/src/host/daemon/api/telegram.js +42 -4
- package/src/host/daemon/api/voice.js +3 -1
- package/src/host/daemon/api.js +6 -0
- package/src/host/daemon/callback-reconciler.js +16 -0
- package/src/host/daemon/plugins/desktop/index.js +7 -1
- package/src/host/daemon/plugins/telegram/index.js +7 -2
- package/src/host/daemon/wakeup.js +17 -3
- package/src/interfaces/cli/commands/commitment.js +154 -0
- package/src/interfaces/cli/commands/daemon.js +57 -0
- package/src/interfaces/cli/commands/memory.js +73 -0
- package/src/interfaces/cli/commands/nudge.js +130 -0
- package/src/interfaces/cli/help/index.js +2 -2
- package/src/interfaces/cli/routes/commitment.js +19 -0
- package/src/interfaces/cli/routes/daemon.js +7 -1
- package/src/interfaces/cli/routes/index.js +4 -0
- package/src/interfaces/cli/routes/memory.js +10 -2
- package/src/interfaces/cli/routes/nudge.js +17 -0
- package/src/interfaces/web/dist/assets/index-CvEoGtTf.js +849 -0
- package/src/interfaces/web/dist/assets/index-CvEoGtTf.js.map +1 -0
- package/src/interfaces/web/dist/assets/index-DzBBXFaO.css +1 -0
- package/src/interfaces/web/dist/index.html +2 -2
- package/src/interfaces/web/package-lock.json +11 -10
- package/src/interfaces/web/src/components/Section.tsx +18 -3
- package/src/interfaces/web/src/components/chat/MessageBubble.tsx +13 -0
- package/src/interfaces/web/src/components/cron/CronPicker.tsx +196 -0
- package/src/interfaces/web/src/components/inbox/InboxList.tsx +145 -0
- package/src/interfaces/web/src/components/memory/MemoryBrowser.tsx +34 -4
- package/src/interfaces/web/src/components/routines/RoutineDetail.tsx +16 -4
- package/src/interfaces/web/src/components/routines/RoutineEditor.tsx +12 -2
- package/src/interfaces/web/src/components/routines/shared.ts +14 -5
- package/src/interfaces/web/src/components/settings/NudgePanel.tsx +183 -0
- package/src/interfaces/web/src/components/settings/ProfilePanel.tsx +36 -12
- package/src/interfaces/web/src/components/ui/filter-chips.tsx +47 -0
- package/src/interfaces/web/src/components/ui.tsx +1 -0
- package/src/interfaces/web/src/constants/index.ts +1 -0
- package/src/interfaces/web/src/hooks/useChat.ts +5 -1
- package/src/interfaces/web/src/hooks/useNudges.ts +38 -0
- package/src/interfaces/web/src/i18n/en.ts +127 -0
- package/src/interfaces/web/src/i18n/es.ts +127 -0
- package/src/interfaces/web/src/lib/api/commitments.ts +57 -0
- package/src/interfaces/web/src/lib/api/notebook.ts +23 -0
- package/src/interfaces/web/src/lib/api/nudges.ts +53 -0
- package/src/interfaces/web/src/lib/cron.ts +196 -0
- package/src/interfaces/web/src/lib/when.ts +32 -0
- package/src/interfaces/web/src/screens/InboxScreen.tsx +107 -77
- package/src/interfaces/web/src/screens/ProjectScreen.tsx +5 -2
- package/src/interfaces/web/src/screens/SettingsScreen.tsx +17 -3
- package/src/interfaces/web/src/screens/base/CommitmentsTab.tsx +239 -0
- package/src/interfaces/web/src/screens/base/GlobalTasksTab.tsx +102 -19
- package/src/interfaces/web/src/screens/base/LogsTab.tsx +15 -0
- package/src/interfaces/web/src/screens/project/ChatTab.tsx +21 -3
- package/src/interfaces/web/src/screens/project/RoutinesTab.tsx +13 -11
- package/src/interfaces/web/src/types/daemon.ts +10 -1
- package/src/interfaces/web/dist/assets/index-CBR_-QyA.js +0 -824
- package/src/interfaces/web/dist/assets/index-CBR_-QyA.js.map +0 -1
- package/src/interfaces/web/dist/assets/index-D_EJEA1n.css +0 -1
|
@@ -0,0 +1,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
|
+
}
|