@agentprojectcontext/apx 1.78.0 → 1.79.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/core/agent/run-agent.js +30 -5
- package/src/core/agent/tool-summary.js +65 -0
- package/src/core/agent/tools/handlers/list-commitments.js +80 -0
- package/src/core/agent/tools/handlers/list-tasks.js +66 -27
- package/src/core/agent/tools/handlers/record-commitment.js +68 -0
- package/src/core/agent/tools/handlers/send-telegram.js +68 -2
- package/src/core/agent/tools/names.js +6 -0
- package/src/core/agent/tools/registry.js +9 -0
- package/src/core/agent/tools/tool-call-parser.js +70 -1
- package/src/core/channels/telegram/ask-callbacks.js +35 -0
- package/src/core/channels/telegram/dispatch.js +3 -0
- package/src/core/channels/telegram/reply.js +30 -5
- package/src/core/config/paths.js +3 -0
- package/src/core/config/redact.js +22 -0
- package/src/core/daemon/service.js +238 -0
- package/src/core/engines/gemini.js +322 -60
- package/src/core/engines/openai-compatible.js +21 -2
- package/src/core/memory/consolidate.js +225 -0
- package/src/core/nudge/index.js +192 -0
- package/src/core/nudge/policy.js +143 -0
- package/src/core/nudge/store.js +141 -0
- package/src/core/profiles/bundled/secretary/PROFILE.md +8 -9
- package/src/core/profiles/bundled/secretary/config.schema.json +33 -3
- package/src/core/profiles/bundled/secretary/routines/day-close.json +7 -3
- package/src/core/profiles/bundled/secretary/routines/day-open.json +7 -3
- package/src/core/profiles/bundled/secretary/routines/watch.json +13 -0
- package/src/core/routines/runner.js +102 -3
- package/src/core/routines/signals.js +270 -0
- package/src/core/stores/commitments.js +331 -0
- package/src/core/stores/messages.js +4 -0
- package/src/core/stores/routines.js +17 -3
- package/src/core/util/thinking.js +51 -0
- package/src/host/daemon/api/commitments.js +135 -0
- package/src/host/daemon/api/nudges.js +112 -0
- package/src/host/daemon/api/routines.js +24 -0
- package/src/host/daemon/api/self-memory.js +50 -0
- package/src/host/daemon/api/telegram.js +42 -4
- package/src/host/daemon/api/voice.js +3 -1
- package/src/host/daemon/api.js +6 -0
- package/src/host/daemon/callback-reconciler.js +16 -0
- package/src/host/daemon/plugins/desktop/index.js +7 -1
- package/src/host/daemon/plugins/telegram/index.js +7 -2
- package/src/host/daemon/wakeup.js +17 -3
- package/src/interfaces/cli/commands/commitment.js +154 -0
- package/src/interfaces/cli/commands/daemon.js +57 -0
- package/src/interfaces/cli/commands/memory.js +73 -0
- package/src/interfaces/cli/commands/nudge.js +130 -0
- package/src/interfaces/cli/help/index.js +2 -2
- package/src/interfaces/cli/routes/commitment.js +19 -0
- package/src/interfaces/cli/routes/daemon.js +7 -1
- package/src/interfaces/cli/routes/index.js +4 -0
- package/src/interfaces/cli/routes/memory.js +10 -2
- package/src/interfaces/cli/routes/nudge.js +17 -0
- package/src/interfaces/web/dist/assets/index-CvEoGtTf.js +849 -0
- package/src/interfaces/web/dist/assets/index-CvEoGtTf.js.map +1 -0
- package/src/interfaces/web/dist/assets/index-DzBBXFaO.css +1 -0
- package/src/interfaces/web/dist/index.html +2 -2
- package/src/interfaces/web/package-lock.json +11 -10
- package/src/interfaces/web/src/components/Section.tsx +18 -3
- package/src/interfaces/web/src/components/chat/MessageBubble.tsx +13 -0
- package/src/interfaces/web/src/components/cron/CronPicker.tsx +196 -0
- package/src/interfaces/web/src/components/inbox/InboxList.tsx +145 -0
- package/src/interfaces/web/src/components/memory/MemoryBrowser.tsx +34 -4
- package/src/interfaces/web/src/components/routines/RoutineDetail.tsx +16 -4
- package/src/interfaces/web/src/components/routines/RoutineEditor.tsx +12 -2
- package/src/interfaces/web/src/components/routines/shared.ts +14 -5
- package/src/interfaces/web/src/components/settings/NudgePanel.tsx +183 -0
- package/src/interfaces/web/src/components/settings/ProfilePanel.tsx +36 -12
- package/src/interfaces/web/src/components/ui/filter-chips.tsx +47 -0
- package/src/interfaces/web/src/components/ui.tsx +1 -0
- package/src/interfaces/web/src/constants/index.ts +1 -0
- package/src/interfaces/web/src/hooks/useChat.ts +5 -1
- package/src/interfaces/web/src/hooks/useNudges.ts +38 -0
- package/src/interfaces/web/src/i18n/en.ts +127 -0
- package/src/interfaces/web/src/i18n/es.ts +127 -0
- package/src/interfaces/web/src/lib/api/commitments.ts +57 -0
- package/src/interfaces/web/src/lib/api/notebook.ts +23 -0
- package/src/interfaces/web/src/lib/api/nudges.ts +53 -0
- package/src/interfaces/web/src/lib/cron.ts +196 -0
- package/src/interfaces/web/src/lib/when.ts +32 -0
- package/src/interfaces/web/src/screens/InboxScreen.tsx +107 -77
- package/src/interfaces/web/src/screens/ProjectScreen.tsx +5 -2
- package/src/interfaces/web/src/screens/SettingsScreen.tsx +17 -3
- package/src/interfaces/web/src/screens/base/CommitmentsTab.tsx +239 -0
- package/src/interfaces/web/src/screens/base/GlobalTasksTab.tsx +102 -19
- package/src/interfaces/web/src/screens/base/LogsTab.tsx +15 -0
- package/src/interfaces/web/src/screens/project/ChatTab.tsx +21 -3
- package/src/interfaces/web/src/screens/project/RoutinesTab.tsx +13 -11
- package/src/interfaces/web/src/types/daemon.ts +10 -1
- package/src/interfaces/web/dist/assets/index-CBR_-QyA.js +0 -824
- package/src/interfaces/web/dist/assets/index-CBR_-QyA.js.map +0 -1
- package/src/interfaces/web/dist/assets/index-D_EJEA1n.css +0 -1
package/package.json
CHANGED
|
@@ -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
|
-
|
|
418
|
-
|
|
419
|
-
|
|
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
|
|
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: "
|
|
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
|
|
36
|
+
limit: { type: "number", description: "Cap on rows returned. Default 100." },
|
|
21
37
|
},
|
|
22
38
|
},
|
|
23
39
|
},
|
|
24
40
|
},
|
|
25
|
-
makeHandler: ({ projects }) => async (
|
|
26
|
-
|
|
27
|
-
const
|
|
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 :
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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: (
|
|
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({
|
|
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
|
-
|
|
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, "");
|