@agentprojectcontext/apx 1.78.0 → 1.80.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/core/agent/run-agent.js +46 -6
- package/src/core/agent/super-agent.js +3 -0
- 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 +123 -6
- package/src/core/channels/telegram/ask-callbacks.js +121 -6
- package/src/core/channels/telegram/dispatch.js +21 -4
- package/src/core/channels/telegram/inbound/file.js +108 -0
- package/src/core/channels/telegram/inbound/photo.js +42 -11
- package/src/core/channels/telegram/media.js +31 -3
- package/src/core/channels/telegram/reply.js +32 -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 +343 -63
- 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 +10 -1
- 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
|
@@ -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
|
|
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({
|
|
309
|
-
|
|
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
|
-
|
|
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;
|
package/src/host/daemon/api.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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) {
|