@agentprojectcontext/apx 1.78.0 → 1.79.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/package.json +1 -1
  2. package/src/core/agent/run-agent.js +30 -5
  3. package/src/core/agent/tool-summary.js +65 -0
  4. package/src/core/agent/tools/handlers/list-commitments.js +80 -0
  5. package/src/core/agent/tools/handlers/list-tasks.js +66 -27
  6. package/src/core/agent/tools/handlers/record-commitment.js +68 -0
  7. package/src/core/agent/tools/handlers/send-telegram.js +68 -2
  8. package/src/core/agent/tools/names.js +6 -0
  9. package/src/core/agent/tools/registry.js +9 -0
  10. package/src/core/agent/tools/tool-call-parser.js +70 -1
  11. package/src/core/channels/telegram/ask-callbacks.js +35 -0
  12. package/src/core/channels/telegram/dispatch.js +3 -0
  13. package/src/core/channels/telegram/reply.js +30 -5
  14. package/src/core/config/paths.js +3 -0
  15. package/src/core/config/redact.js +22 -0
  16. package/src/core/daemon/service.js +238 -0
  17. package/src/core/engines/gemini.js +322 -60
  18. package/src/core/engines/openai-compatible.js +21 -2
  19. package/src/core/memory/consolidate.js +225 -0
  20. package/src/core/nudge/index.js +192 -0
  21. package/src/core/nudge/policy.js +143 -0
  22. package/src/core/nudge/store.js +141 -0
  23. package/src/core/profiles/bundled/secretary/PROFILE.md +8 -9
  24. package/src/core/profiles/bundled/secretary/config.schema.json +33 -3
  25. package/src/core/profiles/bundled/secretary/routines/day-close.json +7 -3
  26. package/src/core/profiles/bundled/secretary/routines/day-open.json +7 -3
  27. package/src/core/profiles/bundled/secretary/routines/watch.json +13 -0
  28. package/src/core/routines/runner.js +102 -3
  29. package/src/core/routines/signals.js +270 -0
  30. package/src/core/stores/commitments.js +331 -0
  31. package/src/core/stores/messages.js +4 -0
  32. package/src/core/stores/routines.js +17 -3
  33. package/src/core/util/thinking.js +51 -0
  34. package/src/host/daemon/api/commitments.js +135 -0
  35. package/src/host/daemon/api/nudges.js +112 -0
  36. package/src/host/daemon/api/routines.js +24 -0
  37. package/src/host/daemon/api/self-memory.js +50 -0
  38. package/src/host/daemon/api/telegram.js +42 -4
  39. package/src/host/daemon/api/voice.js +3 -1
  40. package/src/host/daemon/api.js +6 -0
  41. package/src/host/daemon/callback-reconciler.js +16 -0
  42. package/src/host/daemon/plugins/desktop/index.js +7 -1
  43. package/src/host/daemon/plugins/telegram/index.js +7 -2
  44. package/src/host/daemon/wakeup.js +17 -3
  45. package/src/interfaces/cli/commands/commitment.js +154 -0
  46. package/src/interfaces/cli/commands/daemon.js +57 -0
  47. package/src/interfaces/cli/commands/memory.js +73 -0
  48. package/src/interfaces/cli/commands/nudge.js +130 -0
  49. package/src/interfaces/cli/help/index.js +2 -2
  50. package/src/interfaces/cli/routes/commitment.js +19 -0
  51. package/src/interfaces/cli/routes/daemon.js +7 -1
  52. package/src/interfaces/cli/routes/index.js +4 -0
  53. package/src/interfaces/cli/routes/memory.js +10 -2
  54. package/src/interfaces/cli/routes/nudge.js +17 -0
  55. package/src/interfaces/web/dist/assets/index-CvEoGtTf.js +849 -0
  56. package/src/interfaces/web/dist/assets/index-CvEoGtTf.js.map +1 -0
  57. package/src/interfaces/web/dist/assets/index-DzBBXFaO.css +1 -0
  58. package/src/interfaces/web/dist/index.html +2 -2
  59. package/src/interfaces/web/package-lock.json +11 -10
  60. package/src/interfaces/web/src/components/Section.tsx +18 -3
  61. package/src/interfaces/web/src/components/chat/MessageBubble.tsx +13 -0
  62. package/src/interfaces/web/src/components/cron/CronPicker.tsx +196 -0
  63. package/src/interfaces/web/src/components/inbox/InboxList.tsx +145 -0
  64. package/src/interfaces/web/src/components/memory/MemoryBrowser.tsx +34 -4
  65. package/src/interfaces/web/src/components/routines/RoutineDetail.tsx +16 -4
  66. package/src/interfaces/web/src/components/routines/RoutineEditor.tsx +12 -2
  67. package/src/interfaces/web/src/components/routines/shared.ts +14 -5
  68. package/src/interfaces/web/src/components/settings/NudgePanel.tsx +183 -0
  69. package/src/interfaces/web/src/components/settings/ProfilePanel.tsx +36 -12
  70. package/src/interfaces/web/src/components/ui/filter-chips.tsx +47 -0
  71. package/src/interfaces/web/src/components/ui.tsx +1 -0
  72. package/src/interfaces/web/src/constants/index.ts +1 -0
  73. package/src/interfaces/web/src/hooks/useChat.ts +5 -1
  74. package/src/interfaces/web/src/hooks/useNudges.ts +38 -0
  75. package/src/interfaces/web/src/i18n/en.ts +127 -0
  76. package/src/interfaces/web/src/i18n/es.ts +127 -0
  77. package/src/interfaces/web/src/lib/api/commitments.ts +57 -0
  78. package/src/interfaces/web/src/lib/api/notebook.ts +23 -0
  79. package/src/interfaces/web/src/lib/api/nudges.ts +53 -0
  80. package/src/interfaces/web/src/lib/cron.ts +196 -0
  81. package/src/interfaces/web/src/lib/when.ts +32 -0
  82. package/src/interfaces/web/src/screens/InboxScreen.tsx +107 -77
  83. package/src/interfaces/web/src/screens/ProjectScreen.tsx +5 -2
  84. package/src/interfaces/web/src/screens/SettingsScreen.tsx +17 -3
  85. package/src/interfaces/web/src/screens/base/CommitmentsTab.tsx +239 -0
  86. package/src/interfaces/web/src/screens/base/GlobalTasksTab.tsx +102 -19
  87. package/src/interfaces/web/src/screens/base/LogsTab.tsx +15 -0
  88. package/src/interfaces/web/src/screens/project/ChatTab.tsx +21 -3
  89. package/src/interfaces/web/src/screens/project/RoutinesTab.tsx +13 -11
  90. package/src/interfaces/web/src/types/daemon.ts +10 -1
  91. package/src/interfaces/web/dist/assets/index-CBR_-QyA.js +0 -824
  92. package/src/interfaces/web/dist/assets/index-CBR_-QyA.js.map +0 -1
  93. package/src/interfaces/web/dist/assets/index-D_EJEA1n.css +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentprojectcontext/apx",
3
- "version": "1.78.0",
3
+ "version": "1.79.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";
@@ -412,16 +413,34 @@ export async function runAgent({
412
413
 
413
414
  let toolCalls = result.tool_calls || (result.message && result.message.tool_calls) || null;
414
415
 
416
+ // Names callable on THIS turn. Passed to the bare-call parser as an
417
+ // allow-list: without it, a model merely explaining `create_task({...})`
418
+ // in prose would have it executed for real.
419
+ const callableNames = effectiveSchemas
420
+ .map((s2) => s2?.function?.name || s2?.name)
421
+ .filter(Boolean);
422
+
415
423
  if ((!toolCalls || toolCalls.length === 0) && lastText) {
416
424
  const pseudo = extractPseudoToolCalls(lastText);
417
- if (pseudo.length > 0) {
418
- toolCalls = pseudo;
419
- lastText = cleanTextOfPseudoToolCalls(lastText);
425
+ // A model that writes `create_task({...})` as prose MEANT to call it. It
426
+ // then tells the user it did — so either we run it or the user is lied
427
+ // to. See the header of extractBareFunctionCalls.
428
+ const bare = pseudo.length ? [] : extractBareFunctionCalls(lastText, callableNames);
429
+ const recovered = pseudo.length ? pseudo : bare;
430
+ if (recovered.length > 0) {
431
+ toolCalls = recovered;
432
+ lastText = cleanTextOfPseudoToolCalls(lastText, callableNames);
433
+ await emitProgress(onEvent, {
434
+ type: "tool_calls_recovered",
435
+ from: pseudo.length ? "pseudo" : "bare_text",
436
+ model: activeModel,
437
+ tools: recovered.map((c) => c.function?.name).filter(Boolean),
438
+ });
420
439
  }
421
440
  }
422
441
 
423
442
  if (!toolCalls || toolCalls.length === 0) {
424
- lastText = cleanTextOfPseudoToolCalls(lastText) || lastText;
443
+ lastText = cleanTextOfPseudoToolCalls(lastText, callableNames) || lastText;
425
444
  // Dud turn (no tools, no text): re-prompt instead of ending empty, and
426
445
  // don't let it cost an iteration of the tool budget. `iter -= 1` cancels
427
446
  // the loop's `iter++`; the emptyRetries cap stops an all-empty model from
@@ -436,7 +455,7 @@ export async function runAgent({
436
455
  break;
437
456
  }
438
457
 
439
- const visibleText = dedupeGreeting(cleanTextOfPseudoToolCalls(lastText).trim());
458
+ const visibleText = dedupeGreeting(cleanTextOfPseudoToolCalls(lastText, callableNames).trim());
440
459
  if (visibleText) {
441
460
  await emitProgress(onEvent, { type: "assistant_text", text: visibleText, iteration: iter + 1 });
442
461
  }
@@ -445,6 +464,12 @@ export async function runAgent({
445
464
  role: "assistant",
446
465
  content: result.text || "",
447
466
  tool_calls: toolCalls,
467
+ // Gemini thinking-model fidelity: carry the raw parts array from the
468
+ // engine response so toGeminiContents() can replay the model turn
469
+ // verbatim (thought parts + thoughtSignatures + functionCalls) on the
470
+ // next request, avoiding the 400 "missing thought_signature" error.
471
+ // Other engines return undefined here, so this field is a no-op for them.
472
+ ...(result._geminiRawParts ? { _geminiRawParts: result._geminiRawParts } : {}),
448
473
  });
449
474
 
450
475
  let finishSummary = null;
@@ -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,
@@ -162,7 +162,70 @@ function extractLlamaDottedFunctionCalls(text) {
162
162
  // trivial wrappers (<tool_call>, ```tool_use, _icall(), etc.) that often sit
163
163
  // around them. Used to clean up final answers that the model emitted with
164
164
  // leftover textual tool-call gunk.
165
- export function cleanTextOfPseudoToolCalls(text) {
165
+ /**
166
+ * The bare `tool_name({...json...})` form.
167
+ *
168
+ * WHERE THIS COMES FROM, and why it is not hypothetical: APX renders past tool
169
+ * results into model context as `[tool result: <name>] <body>`
170
+ * (stores/messages.js). A weaker model reads that pattern in its own history
171
+ * and imitates it in PROSE — gemini-3.5-flash produced
172
+ *
173
+ * [tool result: create_task] create_task({"project":"apx","title":"…"})
174
+ *
175
+ * and then told the user the task was filed. It was not. That is the worst
176
+ * failure mode available: a confident false confirmation, with nothing on disk.
177
+ *
178
+ * GATED ON KNOWN TOOL NAMES, deliberately. The other two passes key off
179
+ * unambiguous markers (`<function.` or a `{name, arguments}` pair); a bare
180
+ * `foo({...})` is ordinary prose about code. Without the allow-list, a model
181
+ * EXPLAINING `create_task({...})` in an answer would silently create a task.
182
+ * So: no known names, no matches.
183
+ *
184
+ * @param {string} text
185
+ * @param {Iterable<string>} knownNames tool names callable on this turn
186
+ */
187
+ export function extractBareFunctionCalls(text, knownNames) {
188
+ const allowed = new Set(knownNames || []);
189
+ if (!text || typeof text !== "string" || allowed.size === 0) return [];
190
+
191
+ const out = [];
192
+ // `name(` where name is a plausible identifier. The `[tool result: x] `
193
+ // prefix the model copies is left for the cleaner to strip.
194
+ const re = /(^|[^A-Za-z0-9_.])([a-z][a-z0-9_]{2,63})\s*\(\s*(?=\{)/g;
195
+ let m;
196
+ while ((m = re.exec(text)) !== null) {
197
+ const name = m[2];
198
+ if (!allowed.has(name)) continue;
199
+ // The '{' the regex already looked ahead to.
200
+ const i = text.indexOf("{", m.index + m[0].length - 1);
201
+ if (i < 0) continue;
202
+ const balanced = readBalancedJson(text, i);
203
+ if (!balanced.ok) continue;
204
+ let args;
205
+ try {
206
+ args = JSON.parse(text.slice(i, balanced.end));
207
+ } catch {
208
+ continue;
209
+ }
210
+ if (!args || typeof args !== "object" || Array.isArray(args)) continue;
211
+
212
+ // Include the closing paren in the raw span when it is there, so the
213
+ // cleaner removes the whole call rather than leaving a dangling `)`.
214
+ let end = balanced.end;
215
+ const after = text.slice(end).match(/^\s*\)/);
216
+ if (after) end += after[0].length;
217
+
218
+ const rawStart = m.index + (m[1] ? m[1].length : 0);
219
+ out.push({
220
+ id: nextId(),
221
+ type: "function",
222
+ function: { name, arguments: JSON.stringify(args) },
223
+ _raw: text.slice(rawStart, end),
224
+ });
225
+ }
226
+ return out;
227
+ }
228
+ export function cleanTextOfPseudoToolCalls(text, knownNames) {
166
229
  if (!text || typeof text !== "string") return text;
167
230
 
168
231
  // Strip explicit XML-like fences first
@@ -179,6 +242,12 @@ export function cleanTextOfPseudoToolCalls(text) {
179
242
  for (const call of extractPseudoToolCalls(out)) {
180
243
  if (call._raw) out = out.replace(call._raw, "");
181
244
  }
245
+ // The bare `name({...})` form, and the `[tool result: name]` prefix the
246
+ // model copies out of its own transcript.
247
+ for (const call of extractBareFunctionCalls(out, knownNames)) {
248
+ if (call._raw) out = out.replace(call._raw, "");
249
+ }
250
+ out = out.replace(/\[tool result:\s*[^\]]+\]\s*/gi, "");
182
251
  // Some models emit a stray `</function>` after the args without the
183
252
  // opening tag — sweep those too.
184
253
  out = out.replace(/<\/?function(?:\.[a-zA-Z_][a-zA-Z0-9_]*)?>/gi, "");