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