@agentprojectcontext/apx 1.78.0 → 1.80.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/package.json +1 -1
  2. package/src/core/agent/run-agent.js +46 -6
  3. package/src/core/agent/super-agent.js +3 -0
  4. package/src/core/agent/tool-summary.js +65 -0
  5. package/src/core/agent/tools/handlers/list-commitments.js +80 -0
  6. package/src/core/agent/tools/handlers/list-tasks.js +66 -27
  7. package/src/core/agent/tools/handlers/record-commitment.js +68 -0
  8. package/src/core/agent/tools/handlers/send-telegram.js +68 -2
  9. package/src/core/agent/tools/names.js +6 -0
  10. package/src/core/agent/tools/registry.js +9 -0
  11. package/src/core/agent/tools/tool-call-parser.js +123 -6
  12. package/src/core/channels/telegram/ask-callbacks.js +121 -6
  13. package/src/core/channels/telegram/dispatch.js +21 -4
  14. package/src/core/channels/telegram/inbound/file.js +108 -0
  15. package/src/core/channels/telegram/inbound/photo.js +42 -11
  16. package/src/core/channels/telegram/media.js +31 -3
  17. package/src/core/channels/telegram/reply.js +32 -5
  18. package/src/core/config/paths.js +3 -0
  19. package/src/core/config/redact.js +22 -0
  20. package/src/core/daemon/service.js +238 -0
  21. package/src/core/engines/gemini.js +343 -63
  22. package/src/core/engines/openai-compatible.js +21 -2
  23. package/src/core/memory/consolidate.js +225 -0
  24. package/src/core/nudge/index.js +192 -0
  25. package/src/core/nudge/policy.js +143 -0
  26. package/src/core/nudge/store.js +141 -0
  27. package/src/core/profiles/bundled/secretary/PROFILE.md +8 -9
  28. package/src/core/profiles/bundled/secretary/config.schema.json +33 -3
  29. package/src/core/profiles/bundled/secretary/routines/day-close.json +7 -3
  30. package/src/core/profiles/bundled/secretary/routines/day-open.json +7 -3
  31. package/src/core/profiles/bundled/secretary/routines/watch.json +13 -0
  32. package/src/core/routines/runner.js +102 -3
  33. package/src/core/routines/signals.js +270 -0
  34. package/src/core/stores/commitments.js +331 -0
  35. package/src/core/stores/messages.js +10 -1
  36. package/src/core/stores/routines.js +17 -3
  37. package/src/core/util/thinking.js +51 -0
  38. package/src/host/daemon/api/commitments.js +135 -0
  39. package/src/host/daemon/api/nudges.js +112 -0
  40. package/src/host/daemon/api/routines.js +24 -0
  41. package/src/host/daemon/api/self-memory.js +50 -0
  42. package/src/host/daemon/api/telegram.js +42 -4
  43. package/src/host/daemon/api/voice.js +3 -1
  44. package/src/host/daemon/api.js +6 -0
  45. package/src/host/daemon/callback-reconciler.js +16 -0
  46. package/src/host/daemon/plugins/desktop/index.js +7 -1
  47. package/src/host/daemon/plugins/telegram/index.js +7 -2
  48. package/src/host/daemon/wakeup.js +17 -3
  49. package/src/interfaces/cli/commands/commitment.js +154 -0
  50. package/src/interfaces/cli/commands/daemon.js +57 -0
  51. package/src/interfaces/cli/commands/memory.js +73 -0
  52. package/src/interfaces/cli/commands/nudge.js +130 -0
  53. package/src/interfaces/cli/help/index.js +2 -2
  54. package/src/interfaces/cli/routes/commitment.js +19 -0
  55. package/src/interfaces/cli/routes/daemon.js +7 -1
  56. package/src/interfaces/cli/routes/index.js +4 -0
  57. package/src/interfaces/cli/routes/memory.js +10 -2
  58. package/src/interfaces/cli/routes/nudge.js +17 -0
  59. package/src/interfaces/web/dist/assets/index-CvEoGtTf.js +849 -0
  60. package/src/interfaces/web/dist/assets/index-CvEoGtTf.js.map +1 -0
  61. package/src/interfaces/web/dist/assets/index-DzBBXFaO.css +1 -0
  62. package/src/interfaces/web/dist/index.html +2 -2
  63. package/src/interfaces/web/package-lock.json +11 -10
  64. package/src/interfaces/web/src/components/Section.tsx +18 -3
  65. package/src/interfaces/web/src/components/chat/MessageBubble.tsx +13 -0
  66. package/src/interfaces/web/src/components/cron/CronPicker.tsx +196 -0
  67. package/src/interfaces/web/src/components/inbox/InboxList.tsx +145 -0
  68. package/src/interfaces/web/src/components/memory/MemoryBrowser.tsx +34 -4
  69. package/src/interfaces/web/src/components/routines/RoutineDetail.tsx +16 -4
  70. package/src/interfaces/web/src/components/routines/RoutineEditor.tsx +12 -2
  71. package/src/interfaces/web/src/components/routines/shared.ts +14 -5
  72. package/src/interfaces/web/src/components/settings/NudgePanel.tsx +183 -0
  73. package/src/interfaces/web/src/components/settings/ProfilePanel.tsx +36 -12
  74. package/src/interfaces/web/src/components/ui/filter-chips.tsx +47 -0
  75. package/src/interfaces/web/src/components/ui.tsx +1 -0
  76. package/src/interfaces/web/src/constants/index.ts +1 -0
  77. package/src/interfaces/web/src/hooks/useChat.ts +5 -1
  78. package/src/interfaces/web/src/hooks/useNudges.ts +38 -0
  79. package/src/interfaces/web/src/i18n/en.ts +127 -0
  80. package/src/interfaces/web/src/i18n/es.ts +127 -0
  81. package/src/interfaces/web/src/lib/api/commitments.ts +57 -0
  82. package/src/interfaces/web/src/lib/api/notebook.ts +23 -0
  83. package/src/interfaces/web/src/lib/api/nudges.ts +53 -0
  84. package/src/interfaces/web/src/lib/cron.ts +196 -0
  85. package/src/interfaces/web/src/lib/when.ts +32 -0
  86. package/src/interfaces/web/src/screens/InboxScreen.tsx +107 -77
  87. package/src/interfaces/web/src/screens/ProjectScreen.tsx +5 -2
  88. package/src/interfaces/web/src/screens/SettingsScreen.tsx +17 -3
  89. package/src/interfaces/web/src/screens/base/CommitmentsTab.tsx +239 -0
  90. package/src/interfaces/web/src/screens/base/GlobalTasksTab.tsx +102 -19
  91. package/src/interfaces/web/src/screens/base/LogsTab.tsx +15 -0
  92. package/src/interfaces/web/src/screens/project/ChatTab.tsx +21 -3
  93. package/src/interfaces/web/src/screens/project/RoutinesTab.tsx +13 -11
  94. package/src/interfaces/web/src/types/daemon.ts +10 -1
  95. package/src/interfaces/web/dist/assets/index-CBR_-QyA.js +0 -824
  96. package/src/interfaces/web/dist/assets/index-CBR_-QyA.js.map +0 -1
  97. package/src/interfaces/web/dist/assets/index-D_EJEA1n.css +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentprojectcontext/apx",
3
- "version": "1.78.0",
3
+ "version": "1.80.0",
4
4
  "description": "APX — unified CLI + daemon for the Agent Project Context (APC) standard.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -1,6 +1,7 @@
1
1
  import { callEngine } from "../engines/index.js";
2
2
  import {
3
3
  extractPseudoToolCalls,
4
+ extractBareFunctionCalls,
4
5
  cleanTextOfPseudoToolCalls,
5
6
  } from "./tools/tool-call-parser.js";
6
7
  import { resolveActiveModel, fallbackModels } from "./model-router.js";
@@ -122,6 +123,9 @@ export async function runAgent({
122
123
  system,
123
124
  prompt,
124
125
  previousMessages = [],
126
+ // Files that arrived with this turn: [{ kind, mime, data (base64), path }].
127
+ // Channel handlers (e.g. an inbound Telegram photo) populate it.
128
+ attachments = [],
125
129
  overrideModel = null,
126
130
  // Content-routed model for this turn (selectModelByRules). Unlike
127
131
  // overrideModel it is health-checked and falls back down the chain.
@@ -262,7 +266,19 @@ export async function runAgent({
262
266
  }
263
267
  };
264
268
 
265
- const conversation = [...previousMessages, { role: "user", content: prompt }];
269
+ // Attachments ride on THIS turn's user message. Only images are forwarded to
270
+ // the model (a multimodal engine renders them; the rest ignore the field);
271
+ // every other kind is already described in the prompt text by the channel
272
+ // handler, with its local path, so the agent can reach it with file tools.
273
+ const turnImages = attachments.filter((a) => a?.data && /^image\//.test(a.mime || ""));
274
+ const conversation = [
275
+ ...previousMessages,
276
+ {
277
+ role: "user",
278
+ content: prompt,
279
+ ...(turnImages.length > 0 ? { images: turnImages } : {}),
280
+ },
281
+ ];
266
282
  const trace = [];
267
283
  const totalUsage = { input_tokens: 0, output_tokens: 0 };
268
284
  let lastText = "";
@@ -412,16 +428,34 @@ export async function runAgent({
412
428
 
413
429
  let toolCalls = result.tool_calls || (result.message && result.message.tool_calls) || null;
414
430
 
431
+ // Names callable on THIS turn. Passed to the bare-call parser as an
432
+ // allow-list: without it, a model merely explaining `create_task({...})`
433
+ // in prose would have it executed for real.
434
+ const callableNames = effectiveSchemas
435
+ .map((s2) => s2?.function?.name || s2?.name)
436
+ .filter(Boolean);
437
+
415
438
  if ((!toolCalls || toolCalls.length === 0) && lastText) {
416
439
  const pseudo = extractPseudoToolCalls(lastText);
417
- if (pseudo.length > 0) {
418
- toolCalls = pseudo;
419
- lastText = cleanTextOfPseudoToolCalls(lastText);
440
+ // A model that writes `create_task({...})` as prose MEANT to call it. It
441
+ // then tells the user it did — so either we run it or the user is lied
442
+ // to. See the header of extractBareFunctionCalls.
443
+ const bare = pseudo.length ? [] : extractBareFunctionCalls(lastText, callableNames);
444
+ const recovered = pseudo.length ? pseudo : bare;
445
+ if (recovered.length > 0) {
446
+ toolCalls = recovered;
447
+ lastText = cleanTextOfPseudoToolCalls(lastText, callableNames);
448
+ await emitProgress(onEvent, {
449
+ type: "tool_calls_recovered",
450
+ from: pseudo.length ? "pseudo" : "bare_text",
451
+ model: activeModel,
452
+ tools: recovered.map((c) => c.function?.name).filter(Boolean),
453
+ });
420
454
  }
421
455
  }
422
456
 
423
457
  if (!toolCalls || toolCalls.length === 0) {
424
- lastText = cleanTextOfPseudoToolCalls(lastText) || lastText;
458
+ lastText = cleanTextOfPseudoToolCalls(lastText, callableNames) || lastText;
425
459
  // Dud turn (no tools, no text): re-prompt instead of ending empty, and
426
460
  // don't let it cost an iteration of the tool budget. `iter -= 1` cancels
427
461
  // the loop's `iter++`; the emptyRetries cap stops an all-empty model from
@@ -436,7 +470,7 @@ export async function runAgent({
436
470
  break;
437
471
  }
438
472
 
439
- const visibleText = dedupeGreeting(cleanTextOfPseudoToolCalls(lastText).trim());
473
+ const visibleText = dedupeGreeting(cleanTextOfPseudoToolCalls(lastText, callableNames).trim());
440
474
  if (visibleText) {
441
475
  await emitProgress(onEvent, { type: "assistant_text", text: visibleText, iteration: iter + 1 });
442
476
  }
@@ -445,6 +479,12 @@ export async function runAgent({
445
479
  role: "assistant",
446
480
  content: result.text || "",
447
481
  tool_calls: toolCalls,
482
+ // Gemini thinking-model fidelity: carry the raw parts array from the
483
+ // engine response so toGeminiContents() can replay the model turn
484
+ // verbatim (thought parts + thoughtSignatures + functionCalls) on the
485
+ // next request, avoiding the 400 "missing thought_signature" error.
486
+ // Other engines return undefined here, so this field is a no-op for them.
487
+ ...(result._geminiRawParts ? { _geminiRawParts: result._geminiRawParts } : {}),
448
488
  });
449
489
 
450
490
  let finishSummary = null;
@@ -33,6 +33,8 @@ export async function runSuperAgent({
33
33
  // Pre-rendered "who you're talking to" block (see buildRelationshipBlock).
34
34
  relationshipBlock = "",
35
35
  previousMessages = [],
36
+ // Files that arrived with this turn; forwarded to runAgent verbatim.
37
+ attachments = [],
36
38
  overrideModel = null,
37
39
  onEvent = null,
38
40
  signal,
@@ -146,6 +148,7 @@ export async function runSuperAgent({
146
148
  system,
147
149
  prompt: turnPrompt,
148
150
  previousMessages: history,
151
+ attachments,
149
152
  overrideModel,
150
153
  preferredModel: contentRoute?.model || null,
151
154
  toolSchemas,
@@ -0,0 +1,65 @@
1
+ // A compact record of what a turn actually DID, small enough to store on every
2
+ // message.
3
+ //
4
+ // The full trace carries arguments and results and can run to kilobytes; on a
5
+ // channel like Telegram, writing it to the ledger on every reply would bloat
6
+ // the day-file for a detail nobody reads back. What people do want, looking at
7
+ // a conversation after the fact, is "it read three files and sent a message" —
8
+ // and whether any of it failed. That fits in a line.
9
+ //
10
+ // Shape: { total, failed, tools: [{ name, count, failed }] }
11
+
12
+ /** How many entries to keep. A turn with 40 distinct tools is a runaway, not a report. */
13
+ const MAX_KINDS = 12;
14
+
15
+ function isError(result) {
16
+ if (!result || typeof result !== "object") return false;
17
+ // Tools signal failure two ways: an `error` field, or the budget's
18
+ // `suppressed`. Both are "it did not happen", which is what the reader needs.
19
+ return Boolean(result.error) || result.suppressed === true;
20
+ }
21
+
22
+ /**
23
+ * @param {{tool: string, result?: unknown}[]} trace
24
+ * @returns {{total: number, failed: number, tools: {name: string, count: number, failed: number}[]}|null}
25
+ * null when there is nothing to report, so callers can spread it
26
+ * conditionally without writing an empty object into every message.
27
+ */
28
+ export function summarizeToolTrace(trace) {
29
+ if (!Array.isArray(trace) || trace.length === 0) return null;
30
+
31
+ const byName = new Map();
32
+ let failed = 0;
33
+
34
+ for (const item of trace) {
35
+ const name = String(item?.tool || "tool");
36
+ const row = byName.get(name) || { name, count: 0, failed: 0 };
37
+ row.count += 1;
38
+ if (isError(item?.result)) {
39
+ row.failed += 1;
40
+ failed += 1;
41
+ }
42
+ byName.set(name, row);
43
+ }
44
+
45
+ // Failures first, then the busiest. If the list is truncated, what went wrong
46
+ // must survive the truncation — that is the half worth reading.
47
+ const tools = [...byName.values()].sort(
48
+ (a, b) => (b.failed - a.failed) || (b.count - a.count) || a.name.localeCompare(b.name),
49
+ );
50
+
51
+ return {
52
+ total: trace.length,
53
+ failed,
54
+ tools: tools.slice(0, MAX_KINDS),
55
+ };
56
+ }
57
+
58
+ /** One line for a terminal or a log. "" when there is nothing to say. */
59
+ export function formatToolSummary(summary) {
60
+ if (!summary?.tools?.length) return "";
61
+ const parts = summary.tools.map((t) =>
62
+ `${t.name}${t.count > 1 ? `×${t.count}` : ""}${t.failed ? ` (${t.failed} failed)` : ""}`,
63
+ );
64
+ return parts.join(", ");
65
+ }
@@ -0,0 +1,80 @@
1
+ import { listCommitments, listCommitmentsAcrossProjects } from "#core/stores/commitments.js";
2
+
3
+ /**
4
+ * Read back what is owed, to whom, by when.
5
+ *
6
+ * Cross-project by default. "What do I owe people this week" almost never
7
+ * respects a repo boundary, and forcing the model to pick a project first is
8
+ * how the anchors end up reporting one project's promises as if they were all
9
+ * of them.
10
+ */
11
+ export default {
12
+ name: "list_commitments",
13
+ schema: {
14
+ type: "function",
15
+ function: {
16
+ name: "list_commitments",
17
+ description:
18
+ "List commitments — things promised to named people. Use for 'what do I owe X', " +
19
+ "'what's overdue', or when preparing a meeting with someone. Searches ALL projects " +
20
+ "unless a project is given. Distinct from list_tasks: these have a counterparty " +
21
+ "waiting, and an overdue one costs trust.",
22
+ parameters: {
23
+ type: "object",
24
+ properties: {
25
+ project: { type: "string", description: "Optional project id, name or path. Omit to search every project." },
26
+ counterparty: { type: "string", description: "Filter by who is waiting — matches part of the name, case-insensitive." },
27
+ state: { type: "string", enum: ["open", "kept", "missed", "all"], description: "Defaults to open." },
28
+ overdue: { type: "boolean", description: "Only ones past their date and still open." },
29
+ due_before: { type: "string", description: "ISO date — only those due on or before it." },
30
+ limit: { type: "integer", description: "Max rows. Defaults to 50." },
31
+ },
32
+ },
33
+ },
34
+ },
35
+ makeHandler: ({ projects }) => async (args = {}) => {
36
+ const { project: ref, counterparty, state, overdue, due_before, limit } = args;
37
+ const opts = {
38
+ counterparty: counterparty || undefined,
39
+ state: state || undefined,
40
+ overdue: overdue === true || undefined,
41
+ due_before: due_before || undefined,
42
+ limit: Number.isFinite(limit) ? limit : 50,
43
+ };
44
+
45
+ if (ref) {
46
+ const r = String(ref);
47
+ const found = projects.list().find((p) => String(p.id) === r || p.name === r || p.path === r);
48
+ if (!found) return { error: `project not found: ${ref}` };
49
+ const proj = projects.get(found.id);
50
+ if (!proj) return { error: `project storage not loaded: ${ref}` };
51
+ return { commitments: listCommitments(proj.storagePath, opts).map(compact) };
52
+ }
53
+
54
+ const entries = [];
55
+ for (const entry of projects.list()) {
56
+ const p = projects.get(entry.id);
57
+ if (!p?.storagePath) continue;
58
+ entries.push({ id: entry.id, name: entry.name || entry.path, path: entry.path, storagePath: p.storagePath });
59
+ }
60
+ const { commitments, skipped } = listCommitmentsAcrossProjects(entries, opts);
61
+ return {
62
+ commitments: commitments.map(compact),
63
+ // Say what could not be read rather than quietly reporting less.
64
+ ...(skipped.length ? { skipped } : {}),
65
+ };
66
+ },
67
+ };
68
+
69
+ /** Only the fields worth spending prompt tokens on. */
70
+ function compact(c) {
71
+ return {
72
+ id: c.id,
73
+ counterparty: c.counterparty,
74
+ body: c.body,
75
+ due: c.due,
76
+ state: c.state,
77
+ ...(c.project_name ? { project: c.project_name } : {}),
78
+ ...(c.renegotiated_count ? { moved: c.renegotiated_count } : {}),
79
+ };
80
+ }
@@ -1,5 +1,18 @@
1
- import { listTasks } from "#core/stores/tasks.js";
1
+ import { listTasks, listTasksAcrossProjects } from "#core/stores/tasks.js";
2
2
 
3
+ /**
4
+ * Tasks, across every project by default.
5
+ *
6
+ * `project` USED TO BE REQUIRED, and that was the bug. The cross-project fold
7
+ * has existed in core since C2 and is exposed over HTTP and in the CLI, but the
8
+ * agent could not reach it — so a chief-of-staff routine asked "what is due
9
+ * today" by calling this once per registered project. On this install that was
10
+ * eleven calls, eleven prompt round-trips, and a morning anchor that ran out of
11
+ * iterations before it managed to say anything.
12
+ *
13
+ * Omitting `project` now means "everywhere", matching list_commitments. Passing
14
+ * one keeps the old behaviour exactly.
15
+ */
3
16
  export default {
4
17
  name: "list_tasks",
5
18
  schema: {
@@ -7,46 +20,72 @@ export default {
7
20
  function: {
8
21
  name: "list_tasks",
9
22
  description:
10
- "List tasks for a project. Use when the user asks 'what's pending', 'qué tengo que hacer', or to recall TODOs. Defaults to open tasks. Project resolves by id, name or absolute path.",
23
+ "List tasks. Use when the user asks 'what's pending', 'qué tengo que hacer', or to recall TODOs. " +
24
+ "Searches ALL projects unless you pass `project` — for anything cross-project " +
25
+ "(what is due today, what is overdue, a morning summary) OMIT it and call this ONCE. " +
26
+ "Never loop over projects calling this per project. Defaults to open tasks.",
11
27
  parameters: {
12
28
  type: "object",
13
- required: ["project"],
14
29
  properties: {
15
- project: { type: "string", description: "Project id, name, or path." },
30
+ project: { type: "string", description: "Optional project id, name or path. Omit to search every project." },
16
31
  state: { type: "string", enum: ["open", "done", "dropped", "all"], description: "Filter by state. Default 'open'." },
32
+ status: { type: "string", enum: ["pending", "running", "in_review", "blocked"], description: "Workflow sub-status of an open task." },
17
33
  tag: { type: "string", description: "Filter by exact tag match." },
18
34
  agent: { type: "string", description: "Filter by agent slug." },
19
35
  due_before: { type: "string", description: "Return only tasks due on or before this ISO date." },
20
- limit: { type: "number", description: "Cap on rows returned. Default unlimited (clamped server-side)." },
36
+ limit: { type: "number", description: "Cap on rows returned. Default 100." },
21
37
  },
22
38
  },
23
39
  },
24
40
  },
25
- makeHandler: ({ projects }) => async ({ project: ref, state, tag, agent, due_before, limit }) => {
26
- if (!ref) return { error: "project required" };
27
- const all = projects.list();
28
- const r = String(ref);
29
- const found = all.find((p) =>
30
- String(p.id) === r || p.name === r || p.path === r
31
- );
32
- if (!found) return { error: `project not found: ${ref}` };
33
- const proj = projects.get(found.id);
34
- if (!proj) return { error: `project storage not loaded: ${ref}` };
35
- const rows = listTasks(proj.storagePath, {
41
+ makeHandler: ({ projects }) => async (args = {}) => {
42
+ const { project: ref, state, status, tag, agent, due_before, limit } = args;
43
+ const opts = {
36
44
  state: state || undefined,
45
+ status: status || undefined,
37
46
  tag: tag || undefined,
38
47
  agent: agent || undefined,
39
48
  due_before: due_before || undefined,
40
- limit: typeof limit === "number" ? limit : undefined,
41
- });
42
- return rows.map((t) => ({
43
- id: t.id,
44
- state: t.state,
45
- title: t.title,
46
- tags: t.tags,
47
- due: t.due,
48
- agent: t.agent,
49
- created_at: t.created_at,
50
- }));
49
+ limit: typeof limit === "number" ? limit : 100,
50
+ };
51
+
52
+ if (ref) {
53
+ const r = String(ref);
54
+ const found = projects.list().find((p) => String(p.id) === r || p.name === r || p.path === r);
55
+ if (!found) return { error: `project not found: ${ref}` };
56
+ const proj = projects.get(found.id);
57
+ if (!proj) return { error: `project storage not loaded: ${ref}` };
58
+ return listTasks(proj.storagePath, opts).map(compact);
59
+ }
60
+
61
+ const entries = [];
62
+ for (const entry of projects.list()) {
63
+ const p = projects.get(entry.id);
64
+ if (!p?.storagePath) continue;
65
+ entries.push({ id: entry.id, name: entry.name || entry.path, path: entry.path, storagePath: p.storagePath });
66
+ }
67
+ const { tasks, skipped } = listTasksAcrossProjects(entries, opts);
68
+ return {
69
+ tasks: tasks.map(compact),
70
+ // Say what could not be read rather than quietly reporting less — an
71
+ // anchor that says "nothing due" because a store failed to open is worse
72
+ // than one that says it could not check.
73
+ ...(skipped.length ? { skipped } : {}),
74
+ };
51
75
  },
52
76
  };
77
+
78
+ /** Only the fields worth spending prompt tokens on. */
79
+ function compact(t) {
80
+ return {
81
+ id: t.id,
82
+ state: t.state,
83
+ status: t.status,
84
+ title: t.title,
85
+ tags: t.tags,
86
+ due: t.due,
87
+ agent: t.agent,
88
+ created_at: t.created_at,
89
+ ...(t.project_name ? { project: t.project_name } : {}),
90
+ };
91
+ }
@@ -0,0 +1,68 @@
1
+ import { createCommitment } from "#core/stores/commitments.js";
2
+
3
+ /**
4
+ * Capture a promise made to a person.
5
+ *
6
+ * Deliberately separate from create_task. The model needs a reason to reach
7
+ * for one over the other, and "did you promise this to someone by name?" is a
8
+ * question it can actually answer from the conversation — which is why the
9
+ * description leads with the phrasing that gives it away ("I told Ana I'd send
10
+ * it Friday") rather than with an abstract definition.
11
+ */
12
+ export default {
13
+ name: "record_commitment",
14
+ schema: {
15
+ type: "function",
16
+ function: {
17
+ name: "record_commitment",
18
+ description:
19
+ "Record something the user PROMISED TO A PERSON — 'I told Ana I'd send it Friday', " +
20
+ "'I said we'd have the quote by the 10th'. Use this instead of create_task whenever " +
21
+ "there is a named counterparty waiting on it: breaking a promise costs trust, and " +
22
+ "these are tracked, chased and reported separately from ordinary work. " +
23
+ "A to-do with no one waiting on it is a task, not a commitment. " +
24
+ "Do not ask permission to record one you clearly heard — record it and say you did.",
25
+ parameters: {
26
+ type: "object",
27
+ required: ["project", "counterparty", "body"],
28
+ properties: {
29
+ project: { type: "string", description: "Project id, name, or path." },
30
+ counterparty: { type: "string", description: "Who is waiting on this. A name as the user says it — free text, not an id." },
31
+ body: { type: "string", description: "What was promised, in one line." },
32
+ due: { type: "string", description: "When it was promised for (ISO date or datetime). Include it whenever the user gave one, even loosely resolved ('Friday' → that date)." },
33
+ origin_channel: { type: "string", description: "Where the promise was made (telegram, meeting, email, …)." },
34
+ origin_message_ref: { type: "string", description: "Optional reference back to the message it came from." },
35
+ },
36
+ },
37
+ },
38
+ },
39
+ makeHandler: ({ projects, channel }) => async (args = {}) => {
40
+ const { project: ref, counterparty, body, due, origin_channel, origin_message_ref } = args;
41
+ if (!ref) return { error: "project required" };
42
+ if (!counterparty) return { error: "counterparty required — without one this is a task, use create_task" };
43
+ if (!body) return { error: "body required" };
44
+
45
+ const r = String(ref);
46
+ const found = projects.list().find((p) => String(p.id) === r || p.name === r || p.path === r);
47
+ if (!found) return { error: `project not found: ${ref}` };
48
+ const proj = projects.get(found.id);
49
+ if (!proj) return { error: `project storage not loaded: ${ref}` };
50
+
51
+ const c = createCommitment(proj.storagePath, {
52
+ counterparty,
53
+ body,
54
+ due: due || null,
55
+ origin_channel: origin_channel || channel || "super-agent",
56
+ origin_message_ref: origin_message_ref || null,
57
+ created_by: "super-agent",
58
+ });
59
+
60
+ return {
61
+ id: c.id,
62
+ project: { id: proj.id, name: proj.name },
63
+ counterparty: c.counterparty,
64
+ due: c.due,
65
+ state: c.state,
66
+ };
67
+ },
68
+ };
@@ -1,3 +1,28 @@
1
+ import { canNudge, recordNudge, nudgeFeedbackKeyboard } from "#core/nudge/index.js";
2
+ import { CHANNELS } from "#core/constants/channels.js";
3
+
4
+ /**
5
+ * Is the person we are about to message the same person who is talking to us
6
+ * right now?
7
+ *
8
+ * PUSH PATH 3 OF 4, and the only one whose solicited-ness is not fixed. When
9
+ * the turn came in over Telegram and the text is going back to that same chat,
10
+ * the user is on the other end waiting — that is a reply, not an interruption,
11
+ * and spending their daily budget on it would be wrong. Every other case (a
12
+ * routine, a web turn pushing to the phone, a cron) is APX choosing to speak.
13
+ */
14
+ function isReplyToTheLiveChat(ctx, chatId) {
15
+ if (ctx?.channel !== CHANNELS.TELEGRAM) return false;
16
+ // buildTelegramMeta (channels/telegram/helpers.js:59) spells it `chatId`.
17
+ // Reading `chat_id` here would have silently made every reply look
18
+ // unsolicited and started charging the user's budget for their own answers.
19
+ const origin = ctx?.channelMeta?.chatId ?? ctx?.channelMeta?.chat_id;
20
+ if (!origin) return false;
21
+ // No chat_id given means "the channel default", which for a Telegram turn is
22
+ // the chat it arrived on.
23
+ if (chatId == null || chatId === "") return true;
24
+ return String(chatId) === String(origin);
25
+ }
1
26
 
2
27
  function decodeBase64(b64) {
3
28
  const clean = String(b64).replace(/^data:[a-z/-]+;base64,/, "");
@@ -76,7 +101,8 @@ export default {
76
101
  },
77
102
  },
78
103
  },
79
- makeHandler: ({ plugins, requirePermission }) => async (args = {}) => {
104
+ makeHandler: (ctx) => async (args = {}) => {
105
+ const { plugins, requirePermission, globalConfig } = ctx;
80
106
  const {
81
107
  channel, chat_id, text,
82
108
  photo_base64, photo_path, photo_url,
@@ -100,11 +126,44 @@ export default {
100
126
  );
101
127
  }
102
128
 
129
+ const solicited = isReplyToTheLiveChat(ctx, chat_id);
130
+ const gate = canNudge(
131
+ {
132
+ kind: ctx?.channelMeta?.routineName ? `routine:${ctx.channelMeta.routineName}` : "agent_message",
133
+ project_id: ctx?.channelMeta?.projectId ?? null,
134
+ // Severity is set by a DETECTOR when one ran (watch routines put their
135
+ // peak signal severity here), never by the model. Letting the model
136
+ // grade its own message would hand it a switch marked "ignore the
137
+ // budget" — and it would find it.
138
+ severity: ctx?.channelMeta?.signalSeverity || "normal",
139
+ unsolicited: !solicited,
140
+ // Set only by an anchor routine (routines/runner.js), never by the
141
+ // model — it has no way to declare its own message scheduled.
142
+ scheduled: ctx?.channelMeta?.scheduledByUser === true,
143
+ channel: "telegram",
144
+ },
145
+ globalConfig || {},
146
+ );
147
+ if (!gate.allowed) {
148
+ // Returned as a tool result, not thrown: the model should learn that it
149
+ // is out of budget and stop trying, which a raw error does not convey.
150
+ return {
151
+ ok: false,
152
+ suppressed: true,
153
+ reason: gate.reason,
154
+ retry_after_ms: gate.retry_after_ms,
155
+ hint:
156
+ "The interruption budget refused this unrequested message. Do not retry now — " +
157
+ "say it in your reply instead, or wait until the user writes to you.",
158
+ };
159
+ }
160
+
103
161
  const photo = decodePhoto({ photo_base64, photo_path, photo_url });
104
162
  if (photo) {
105
163
  const result = await telegram.sendPhoto({
106
164
  channel, chat_id, photo, caption: text, author: "apx",
107
165
  });
166
+ recordNudge(gate, { chat_id, preview: text });
108
167
  return { ok: true, kind: "photo", message_id: result.message_id };
109
168
  }
110
169
 
@@ -113,10 +172,17 @@ export default {
113
172
  const result = await telegram.sendDocument({
114
173
  channel, chat_id, document, caption: text, filename, mime_type,
115
174
  });
175
+ recordNudge(gate, { chat_id, preview: text });
116
176
  return { ok: true, kind: "document", message_id: result.message_id, filename };
117
177
  }
118
178
 
119
- const result = await telegram.send({ channel, chat_id, text });
179
+ const result = await telegram.send({
180
+ channel, chat_id, text,
181
+ // Only unprompted messages carry the feedback keyboard. Asking "was that
182
+ // useful?" under an answer the user just asked for is noise itself.
183
+ reply_markup: gate.unsolicited ? nudgeFeedbackKeyboard(gate.nudge_id) : undefined,
184
+ });
185
+ recordNudge(gate, { chat_id, preview: text });
120
186
  return { ok: true, kind: "text", message_id: result.message_id };
121
187
  },
122
188
  };
@@ -41,6 +41,8 @@ export const TOOLS = Object.freeze({
41
41
  // Tasks
42
42
  LIST_TASKS: "list_tasks",
43
43
  CREATE_TASK: "create_task",
44
+ RECORD_COMMITMENT: "record_commitment",
45
+ LIST_COMMITMENTS: "list_commitments",
44
46
 
45
47
  // Interaction
46
48
  ASK_QUESTIONS: "ask_questions",
@@ -135,6 +137,8 @@ export const NATIVE_TOOL_NAMES = new Set([
135
137
  TOOLS.LOAD_SKILL,
136
138
  TOOLS.LIST_TASKS,
137
139
  TOOLS.CREATE_TASK,
140
+ TOOLS.RECORD_COMMITMENT,
141
+ TOOLS.LIST_COMMITMENTS,
138
142
  TOOLS.ASK_QUESTIONS,
139
143
  TOOLS.SEARCH_SESSIONS,
140
144
  TOOLS.TRANSCRIBE_AUDIO,
@@ -182,6 +186,7 @@ export const CODE_PLAN_TOOLS = Object.freeze([
182
186
  TOOLS.LIST_SKILLS,
183
187
  TOOLS.LOAD_SKILL,
184
188
  TOOLS.LIST_TASKS,
189
+ TOOLS.LIST_COMMITMENTS,
185
190
  TOOLS.ASK_QUESTIONS,
186
191
  TOOLS.FETCH,
187
192
  TOOLS.SEARCH,
@@ -222,6 +227,7 @@ export const CODE_BUILD_TOOLS = "*";
222
227
  export const SIDE_EFFECT_TOOLS = new Set([
223
228
  TOOLS.SEND_TELEGRAM,
224
229
  TOOLS.CREATE_TASK,
230
+ TOOLS.RECORD_COMMITMENT,
225
231
  TOOLS.WRITE_FILE,
226
232
  TOOLS.EDIT_FILE,
227
233
  TOOLS.RUN_SHELL,
@@ -28,6 +28,8 @@ import loadSkill from "./handlers/load-skill.js";
28
28
  import transcribeAudio from "./handlers/transcribe-audio.js";
29
29
  import askQuestions from "./handlers/ask-questions.js";
30
30
  import createTask from "./handlers/create-task.js";
31
+ import recordCommitment from "./handlers/record-commitment.js";
32
+ import listCommitments from "./handlers/list-commitments.js";
31
33
  import listTasks from "./handlers/list-tasks.js";
32
34
  import discoverTools from "./handlers/discover-tools.js";
33
35
  import gitStatus from "./handlers/git-status.js";
@@ -81,6 +83,8 @@ const NATIVE_TOOLS = [
81
83
  askQuestions,
82
84
  createTask,
83
85
  listTasks,
86
+ recordCommitment,
87
+ listCommitments,
84
88
  discoverTools,
85
89
  gitStatus,
86
90
  gitDiff,
@@ -151,6 +155,11 @@ export const BASE_TOOL_NAMES = new Set([
151
155
  // Tasks (very common ask via chat).
152
156
  TOOLS.CREATE_TASK,
153
157
  TOOLS.LIST_TASKS,
158
+ // Commitments. In the base set on purpose: a promise is caught in passing
159
+ // ("le dije a Ana que el viernes"), and a tool the model has to discover
160
+ // first is a tool it will not reach for mid-sentence.
161
+ TOOLS.RECORD_COMMITMENT,
162
+ TOOLS.LIST_COMMITMENTS,
154
163
  // Files + basic shell — frequent enough on chat to keep hot.
155
164
  TOOLS.READ_FILE,
156
165
  TOOLS.WRITE_FILE,