@agentprojectcontext/apx 1.31.2 → 1.32.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/constants.js +5 -0
- package/src/core/agent/run-agent.js +29 -1
- package/src/host/daemon/api/artifacts.js +117 -0
- package/src/host/daemon/api/code.js +12 -0
- package/src/host/daemon/plugins/desktop.js +34 -0
- package/src/host/daemon/plugins/telegram-ask.js +309 -0
- package/src/host/daemon/plugins/telegram.js +330 -2
- package/src/host/daemon/super-agent-tools/tools/ask-questions.js +96 -13
- package/src/interfaces/cli/commands/artifact.js +99 -0
- package/src/interfaces/cli/index.js +4 -0
- package/src/interfaces/cli/terminal-chat/renderer.js +22 -2
- package/src/interfaces/web/dist/assets/index-63P_ji1a.js +571 -0
- package/src/interfaces/web/dist/assets/index-63P_ji1a.js.map +1 -0
- package/src/interfaces/web/dist/assets/index-DLWy6dYz.css +1 -0
- package/src/interfaces/web/dist/index.html +2 -2
- package/src/interfaces/web/package-lock.json +6 -6
- package/src/interfaces/web/src/components/chat/AskQuestionsCard.tsx +72 -0
- package/src/interfaces/web/src/components/chat/InlineAskPanel.tsx +399 -0
- package/src/interfaces/web/src/components/chat/MessageBubble.tsx +16 -3
- package/src/interfaces/web/src/components/chat/MessageList.tsx +2 -1
- package/src/interfaces/web/src/components/code/CodeArtifactsTab.tsx +230 -0
- package/src/interfaces/web/src/components/code/CodeSidePanel.tsx +12 -4
- package/src/interfaces/web/src/i18n/en.ts +20 -0
- package/src/interfaces/web/src/i18n/es.ts +20 -0
- package/src/interfaces/web/src/lib/api/artifacts.ts +47 -0
- package/src/interfaces/web/src/lib/api.ts +1 -0
- package/src/interfaces/web/src/screens/modules/CodeScreen.tsx +23 -2
- package/src/interfaces/web/src/screens/project/ChatTab.tsx +15 -0
- package/src/interfaces/web/dist/assets/index-BDUsA6L6.css +0 -1
- package/src/interfaces/web/dist/assets/index-BV615I9p.js +0 -548
- package/src/interfaces/web/dist/assets/index-BV615I9p.js.map +0 -1
|
@@ -43,6 +43,7 @@ import { registerSender, resolveAllowedTools } from "../../../core/telegram-iden
|
|
|
43
43
|
import { buildRelationshipBlock } from "../../../core/agent/index.js";
|
|
44
44
|
import { getConfirmationStore as getConfirmStore } from "../../../core/confirmation/pending-store.js";
|
|
45
45
|
import { createTelegramConfirmAdapter } from "../../../core/confirmation/adapters/telegram.js";
|
|
46
|
+
import * as askFlow from "./telegram-ask.js";
|
|
46
47
|
|
|
47
48
|
const API_BASE = "https://api.telegram.org";
|
|
48
49
|
const nowIso = () => new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
@@ -577,6 +578,30 @@ class ChannelPoller {
|
|
|
577
578
|
text = text ? `${audioBody}\n${text}` : audioBody;
|
|
578
579
|
}
|
|
579
580
|
|
|
581
|
+
// If there's a pending ask_questions flow for this chat AND the current
|
|
582
|
+
// question is free-text, treat this message as the answer rather than a
|
|
583
|
+
// brand-new turn. Returns true when the message was consumed.
|
|
584
|
+
if (chat_id && text && await this._maybeConsumeAskTextAnswer({ chat_id, text })) {
|
|
585
|
+
// Still log the inbound so the chat history records what the user said.
|
|
586
|
+
appendGlobalMessage({
|
|
587
|
+
channel: "telegram",
|
|
588
|
+
direction: "in",
|
|
589
|
+
type: "user",
|
|
590
|
+
actor_id: msg.from?.id ? String(msg.from.id) : author,
|
|
591
|
+
external_id: String(u.update_id),
|
|
592
|
+
author,
|
|
593
|
+
body: text,
|
|
594
|
+
meta: {
|
|
595
|
+
chat_id,
|
|
596
|
+
user_id: msg.from?.id || null,
|
|
597
|
+
message_id: msg.message_id,
|
|
598
|
+
tg_channel: this.channel.name,
|
|
599
|
+
ask_answer: true,
|
|
600
|
+
},
|
|
601
|
+
});
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
|
|
580
605
|
// /reset or /new wipes the rolling context for this chat. We just
|
|
581
606
|
// remember a marker timestamp; subsequent inbounds will only consider
|
|
582
607
|
// history newer than this. Implemented by writing a synthetic message
|
|
@@ -848,6 +873,35 @@ class ChannelPoller {
|
|
|
848
873
|
replyActorId = SUPERAGENT_ACTOR_ID;
|
|
849
874
|
replyKind = "superagent";
|
|
850
875
|
saUsage = sa.usage;
|
|
876
|
+
|
|
877
|
+
// ── ask_questions integration ────────────────────────────────────
|
|
878
|
+
// If the super-agent ended this turn by calling ask_questions, hand
|
|
879
|
+
// off to the inline-keyboard flow instead of sending the bare
|
|
880
|
+
// assistant text. The flow keeps state per chat_id and re-runs the
|
|
881
|
+
// super-agent once every answer is collected.
|
|
882
|
+
const askQuestions = askFlow.extractAskQuestionsFromTrace(sa.trace);
|
|
883
|
+
if (askQuestions && chat_id) {
|
|
884
|
+
if (chat_id) this.activeRequests.delete(chat_id);
|
|
885
|
+
stopTyping();
|
|
886
|
+
try {
|
|
887
|
+
await this._startAskFlow({
|
|
888
|
+
chat_id,
|
|
889
|
+
projectId: target?.id,
|
|
890
|
+
authorId: msg.from?.id,
|
|
891
|
+
questions: askQuestions,
|
|
892
|
+
author,
|
|
893
|
+
agentDisplay,
|
|
894
|
+
relationshipBlock,
|
|
895
|
+
allowedTools,
|
|
896
|
+
target,
|
|
897
|
+
sender,
|
|
898
|
+
update_id: u.update_id,
|
|
899
|
+
});
|
|
900
|
+
} catch (e) {
|
|
901
|
+
this.log(`telegram[${this.channel.name}] ask flow start failed: ${e.message}`);
|
|
902
|
+
}
|
|
903
|
+
return; // The reply for this turn IS the ask flow.
|
|
904
|
+
}
|
|
851
905
|
} catch (e) {
|
|
852
906
|
if (abortCtrl.signal.aborted) {
|
|
853
907
|
// A newer message superseded this one. Whatever streamed so far is
|
|
@@ -928,6 +982,14 @@ class ChannelPoller {
|
|
|
928
982
|
}
|
|
929
983
|
|
|
930
984
|
async _handleCallbackQuery(callbackQuery) {
|
|
985
|
+
// Route ask_questions button presses before the confirmation adapter —
|
|
986
|
+
// both use `apx:<verb>:...` namespacing but ask owns its own state.
|
|
987
|
+
const data = callbackQuery.data || "";
|
|
988
|
+
if (data.startsWith("apx:ask:")) {
|
|
989
|
+
await this._handleAskCallback(callbackQuery);
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
|
|
931
993
|
const adapter = createTelegramConfirmAdapter({
|
|
932
994
|
token: resolveBotToken(this.channel),
|
|
933
995
|
chatId: callbackQuery.message?.chat?.id,
|
|
@@ -939,6 +1001,230 @@ class ChannelPoller {
|
|
|
939
1001
|
}
|
|
940
1002
|
}
|
|
941
1003
|
|
|
1004
|
+
// ── ask_questions: state-machine helpers ───────────────────────────────
|
|
1005
|
+
// The flow lives in telegram-ask.js; this class owns the I/O (sending
|
|
1006
|
+
// messages, editing keyboards, re-entering the super-agent loop with the
|
|
1007
|
+
// compiled answer once the flow finishes).
|
|
1008
|
+
|
|
1009
|
+
async _renderQuestion(state) {
|
|
1010
|
+
const text = askFlow.formatQuestionText(state);
|
|
1011
|
+
const reply_markup = askFlow.buildKeyboard(state);
|
|
1012
|
+
// If we already have a message for the previous question, leave its
|
|
1013
|
+
// keyboard wiped — we draw a fresh message per question for clearer
|
|
1014
|
+
// history in the chat (the question text stays as a record).
|
|
1015
|
+
if (state.messageId) {
|
|
1016
|
+
try {
|
|
1017
|
+
await this._editKeyboard({
|
|
1018
|
+
chat_id: state.chatId,
|
|
1019
|
+
message_id: state.messageId,
|
|
1020
|
+
reply_markup: { inline_keyboard: [] },
|
|
1021
|
+
});
|
|
1022
|
+
} catch { /* best-effort */ }
|
|
1023
|
+
}
|
|
1024
|
+
const sent = await this._send({
|
|
1025
|
+
chat_id: state.chatId,
|
|
1026
|
+
text,
|
|
1027
|
+
reply_markup,
|
|
1028
|
+
parse_mode: "Markdown",
|
|
1029
|
+
});
|
|
1030
|
+
state.messageId = sent?.message_id || null;
|
|
1031
|
+
askFlow.saveState(state.chatId, state);
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
// Kick off a brand-new ask flow after the super-agent called ask_questions.
|
|
1035
|
+
// The flow's `resume` callback captures the per-turn context (sender,
|
|
1036
|
+
// relationship, project) so when the compiled answer arrives we can run
|
|
1037
|
+
// another super-agent turn without retyping all the inputs.
|
|
1038
|
+
async _startAskFlow(ctx) {
|
|
1039
|
+
const state = askFlow.startFlow({
|
|
1040
|
+
chatId: ctx.chat_id,
|
|
1041
|
+
projectId: ctx.projectId,
|
|
1042
|
+
authorId: ctx.authorId,
|
|
1043
|
+
questions: ctx.questions,
|
|
1044
|
+
resume: async (compiled) => {
|
|
1045
|
+
await this._runResumedTurn({ ...ctx, compiled });
|
|
1046
|
+
},
|
|
1047
|
+
});
|
|
1048
|
+
await this._renderQuestion(state);
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
// Apply an inline-keyboard press, then react: redraw, advance, or finish.
|
|
1052
|
+
async _handleAskCallback(callbackQuery) {
|
|
1053
|
+
const chatId = callbackQuery.message?.chat?.id;
|
|
1054
|
+
if (!chatId) return;
|
|
1055
|
+
const result = askFlow.applyCallback(chatId, callbackQuery.data || "");
|
|
1056
|
+
// Ack the press regardless — keeps the spinner from hanging client-side.
|
|
1057
|
+
await this._answerCallback({ callback_query_id: callbackQuery.id });
|
|
1058
|
+
if (!result) return; // stale or unknown — adapter already ack'd.
|
|
1059
|
+
|
|
1060
|
+
if (result.action === "redraw") {
|
|
1061
|
+
// Multi-select toggle: just refresh the keyboard on the SAME message.
|
|
1062
|
+
try {
|
|
1063
|
+
await this._editKeyboard({
|
|
1064
|
+
chat_id: chatId,
|
|
1065
|
+
message_id: callbackQuery.message?.message_id,
|
|
1066
|
+
reply_markup: askFlow.buildKeyboard(result.state),
|
|
1067
|
+
});
|
|
1068
|
+
} catch (e) {
|
|
1069
|
+
this.log(`telegram[${this.channel.name}] redraw failed: ${e.message}`);
|
|
1070
|
+
}
|
|
1071
|
+
return;
|
|
1072
|
+
}
|
|
1073
|
+
if (result.action === "advance") {
|
|
1074
|
+
await this._renderQuestion(result.state);
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1077
|
+
if (result.action === "cancel") {
|
|
1078
|
+
try {
|
|
1079
|
+
await this._editKeyboard({
|
|
1080
|
+
chat_id: chatId,
|
|
1081
|
+
message_id: callbackQuery.message?.message_id,
|
|
1082
|
+
reply_markup: { inline_keyboard: [] },
|
|
1083
|
+
});
|
|
1084
|
+
await this._send({ chat_id: chatId, text: "Pregunta cancelada." });
|
|
1085
|
+
} catch { /* best-effort */ }
|
|
1086
|
+
return;
|
|
1087
|
+
}
|
|
1088
|
+
if (result.action === "done") {
|
|
1089
|
+
try {
|
|
1090
|
+
await this._editKeyboard({
|
|
1091
|
+
chat_id: chatId,
|
|
1092
|
+
message_id: callbackQuery.message?.message_id,
|
|
1093
|
+
reply_markup: { inline_keyboard: [] },
|
|
1094
|
+
});
|
|
1095
|
+
} catch { /* best-effort */ }
|
|
1096
|
+
// Feed the compiled answer back as a synthetic user turn.
|
|
1097
|
+
if (typeof result.state.resume === "function") {
|
|
1098
|
+
await result.state.resume(result.compiled);
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
// Apply a free-text user reply when there's a pending free-text question.
|
|
1104
|
+
// Returns true iff the message was consumed by the ask flow (so the normal
|
|
1105
|
+
// super-agent path should be skipped for this update).
|
|
1106
|
+
async _maybeConsumeAskTextAnswer({ chat_id, text }) {
|
|
1107
|
+
if (!chat_id || !text) return false;
|
|
1108
|
+
if (!askFlow.hasPendingFreeText(chat_id)) return false;
|
|
1109
|
+
const state = askFlow.applyTextAnswer(chat_id, text);
|
|
1110
|
+
if (!state) return false;
|
|
1111
|
+
// Advance: emit a synthetic "next" to move past this question.
|
|
1112
|
+
const next = askFlow.applyCallback(
|
|
1113
|
+
chat_id,
|
|
1114
|
+
`apx:ask:${state.correlationId}:next`,
|
|
1115
|
+
);
|
|
1116
|
+
if (!next) return true;
|
|
1117
|
+
if (next.action === "advance") {
|
|
1118
|
+
await this._renderQuestion(next.state);
|
|
1119
|
+
return true;
|
|
1120
|
+
}
|
|
1121
|
+
if (next.action === "done") {
|
|
1122
|
+
if (typeof next.state.resume === "function") {
|
|
1123
|
+
await next.state.resume(next.compiled);
|
|
1124
|
+
}
|
|
1125
|
+
return true;
|
|
1126
|
+
}
|
|
1127
|
+
return true;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
// Run a follow-up super-agent turn with the compiled answers as the user
|
|
1131
|
+
// prompt. Mirrors the post-runSuperAgent reply path in _handleUpdate but
|
|
1132
|
+
// skipped of the photo/audio/reset preamble. Re-enters the ask flow if the
|
|
1133
|
+
// model decides to ask again.
|
|
1134
|
+
async _runResumedTurn(ctx) {
|
|
1135
|
+
const { chat_id, compiled, target, relationshipBlock, allowedTools, author, agentDisplay, update_id, sender, authorId } = ctx;
|
|
1136
|
+
if (!chat_id) return;
|
|
1137
|
+
// Log the synthetic user message so getRecentTelegramTurnsFromFs picks
|
|
1138
|
+
// it up on the NEXT inbound. Mirrors how a normal text reply would be
|
|
1139
|
+
// recorded.
|
|
1140
|
+
appendGlobalMessage({
|
|
1141
|
+
channel: "telegram",
|
|
1142
|
+
direction: "in",
|
|
1143
|
+
type: "user",
|
|
1144
|
+
actor_id: authorId ? String(authorId) : (author || "ask_flow"),
|
|
1145
|
+
external_id: `ask-${Date.now()}`,
|
|
1146
|
+
author: author || "user",
|
|
1147
|
+
body: compiled,
|
|
1148
|
+
meta: {
|
|
1149
|
+
chat_id,
|
|
1150
|
+
user_id: authorId || null,
|
|
1151
|
+
tg_channel: this.channel.name,
|
|
1152
|
+
ask_flow: true,
|
|
1153
|
+
},
|
|
1154
|
+
});
|
|
1155
|
+
|
|
1156
|
+
const previousMessages = getRecentTelegramTurnsFromFs({
|
|
1157
|
+
chat_id,
|
|
1158
|
+
keepRecent: 40,
|
|
1159
|
+
max_age_hours: 24,
|
|
1160
|
+
});
|
|
1161
|
+
|
|
1162
|
+
const stopTyping = this._startTyping(chat_id);
|
|
1163
|
+
try {
|
|
1164
|
+
const sa = await runSuperAgent({
|
|
1165
|
+
globalConfig: this.globalConfig,
|
|
1166
|
+
projects: this.projects,
|
|
1167
|
+
plugins: this.plugins,
|
|
1168
|
+
registries: this.registries,
|
|
1169
|
+
prompt: compiled,
|
|
1170
|
+
previousMessages,
|
|
1171
|
+
channel: "telegram",
|
|
1172
|
+
relationshipBlock,
|
|
1173
|
+
allowedTools,
|
|
1174
|
+
channelMeta: { channel: "telegram", chat_id, author, route_to_agent: this.channel.route_to_agent },
|
|
1175
|
+
});
|
|
1176
|
+
stopTyping();
|
|
1177
|
+
|
|
1178
|
+
// Did the model ask again? Restart the flow instead of replying.
|
|
1179
|
+
const followupAsk = askFlow.extractAskQuestionsFromTrace(sa.trace);
|
|
1180
|
+
if (followupAsk) {
|
|
1181
|
+
await this._startAskFlow({
|
|
1182
|
+
chat_id,
|
|
1183
|
+
projectId: target?.id,
|
|
1184
|
+
authorId,
|
|
1185
|
+
questions: followupAsk,
|
|
1186
|
+
author,
|
|
1187
|
+
agentDisplay,
|
|
1188
|
+
relationshipBlock,
|
|
1189
|
+
allowedTools,
|
|
1190
|
+
target,
|
|
1191
|
+
sender,
|
|
1192
|
+
update_id,
|
|
1193
|
+
});
|
|
1194
|
+
return;
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
const replyText = sa.text ? stripThinking(sa.text).trim() : "";
|
|
1198
|
+
if (replyText) {
|
|
1199
|
+
await this._send({ chat_id, text: replyText });
|
|
1200
|
+
appendGlobalMessage({
|
|
1201
|
+
channel: "telegram",
|
|
1202
|
+
direction: "out",
|
|
1203
|
+
type: "agent",
|
|
1204
|
+
actor_id: SUPERAGENT_ACTOR_ID,
|
|
1205
|
+
actor_kind: "superagent",
|
|
1206
|
+
agent_slug: SUPERAGENT_ACTOR_ID,
|
|
1207
|
+
author: sa.name || agentDisplay,
|
|
1208
|
+
body: replyText,
|
|
1209
|
+
meta: {
|
|
1210
|
+
chat_id,
|
|
1211
|
+
tg_channel: this.channel.name,
|
|
1212
|
+
in_reply_to: update_id,
|
|
1213
|
+
final: true,
|
|
1214
|
+
ask_resume: true,
|
|
1215
|
+
...(sa.usage ? { usage: sa.usage } : {}),
|
|
1216
|
+
},
|
|
1217
|
+
});
|
|
1218
|
+
}
|
|
1219
|
+
} catch (e) {
|
|
1220
|
+
stopTyping();
|
|
1221
|
+
this.log(`telegram[${this.channel.name}] ask resume failed: ${e.message}`);
|
|
1222
|
+
try {
|
|
1223
|
+
await this._send({ chat_id, text: `⚠️ Error procesando tus respuestas (${e.message}).` });
|
|
1224
|
+
} catch { /* best-effort */ }
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
|
|
942
1228
|
// Show "typing..." indicator in the chat. Telegram clears it automatically
|
|
943
1229
|
// after 5 seconds, so call this every ~4s while a long operation is going.
|
|
944
1230
|
async _typing(chat_id) {
|
|
@@ -971,22 +1257,64 @@ class ChannelPoller {
|
|
|
971
1257
|
return () => { stopped = true; };
|
|
972
1258
|
}
|
|
973
1259
|
|
|
974
|
-
async _send({ chat_id, text }) {
|
|
1260
|
+
async _send({ chat_id, text, reply_markup, parse_mode }) {
|
|
975
1261
|
const token = resolveBotToken(this.channel);
|
|
976
1262
|
if (!token) throw new Error(`channel ${this.channel.name}: no bot_token`);
|
|
977
1263
|
const target = chat_id || resolveChatId(this.channel);
|
|
978
1264
|
if (!target) throw new Error(`channel ${this.channel.name}: no chat_id`);
|
|
979
1265
|
const url = `${API_BASE}/bot${token}/sendMessage`;
|
|
1266
|
+
const body = { chat_id: target, text };
|
|
1267
|
+
if (reply_markup) body.reply_markup = reply_markup;
|
|
1268
|
+
if (parse_mode) body.parse_mode = parse_mode;
|
|
980
1269
|
const res = await fetch(url, {
|
|
981
1270
|
method: "POST",
|
|
982
1271
|
headers: { "content-type": "application/json" },
|
|
983
|
-
body: JSON.stringify(
|
|
1272
|
+
body: JSON.stringify(body),
|
|
984
1273
|
});
|
|
985
1274
|
const json = await res.json();
|
|
986
1275
|
if (!json.ok) throw new Error(json.description || `send failed (${res.status})`);
|
|
987
1276
|
return json.result;
|
|
988
1277
|
}
|
|
989
1278
|
|
|
1279
|
+
// Replace just the inline keyboard on a previously-sent message (used to
|
|
1280
|
+
// refresh after a multi-select toggle, or to wipe buttons once the flow
|
|
1281
|
+
// has moved on). Best-effort: failures are logged but don't break the flow.
|
|
1282
|
+
async _editKeyboard({ chat_id, message_id, reply_markup }) {
|
|
1283
|
+
const token = resolveBotToken(this.channel);
|
|
1284
|
+
if (!token) return;
|
|
1285
|
+
try {
|
|
1286
|
+
const url = `${API_BASE}/bot${token}/editMessageReplyMarkup`;
|
|
1287
|
+
const body = { chat_id, message_id };
|
|
1288
|
+
if (reply_markup) body.reply_markup = reply_markup;
|
|
1289
|
+
await fetch(url, {
|
|
1290
|
+
method: "POST",
|
|
1291
|
+
headers: { "content-type": "application/json" },
|
|
1292
|
+
body: JSON.stringify(body),
|
|
1293
|
+
});
|
|
1294
|
+
} catch (e) {
|
|
1295
|
+
this.log(`telegram[${this.channel.name}] editMessageReplyMarkup failed: ${e.message}`);
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
// Acknowledge a callback button press so the user's Telegram client clears
|
|
1300
|
+
// the spinner on the tapped button. Optional `text` shows a small toast.
|
|
1301
|
+
async _answerCallback({ callback_query_id, text }) {
|
|
1302
|
+
const token = resolveBotToken(this.channel);
|
|
1303
|
+
if (!token) return;
|
|
1304
|
+
try {
|
|
1305
|
+
const url = `${API_BASE}/bot${token}/answerCallbackQuery`;
|
|
1306
|
+
const body = { callback_query_id };
|
|
1307
|
+
if (text) body.text = text;
|
|
1308
|
+
await fetch(url, {
|
|
1309
|
+
method: "POST",
|
|
1310
|
+
headers: { "content-type": "application/json" },
|
|
1311
|
+
body: JSON.stringify(body),
|
|
1312
|
+
});
|
|
1313
|
+
} catch (e) {
|
|
1314
|
+
this.log(`telegram[${this.channel.name}] answerCallbackQuery failed: ${e.message}`);
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
|
|
990
1318
|
/** Send a photo via this channel */
|
|
991
1319
|
async _sendPhoto({ chat_id, photo, caption, parse_mode }) {
|
|
992
1320
|
const token = resolveBotToken(this.channel);
|
|
@@ -1,3 +1,38 @@
|
|
|
1
|
+
// Normalize a raw question entry into the canonical shape rendered by every
|
|
2
|
+
// surface (web InlineAskPanel, future desktop/telegram/CLI). The model can
|
|
3
|
+
// pass either a plain string (legacy) or a rich object with options.
|
|
4
|
+
function normalizeQuestion(q) {
|
|
5
|
+
if (typeof q === "string") {
|
|
6
|
+
return { question: q, options: [], multiSelect: false, allowText: true };
|
|
7
|
+
}
|
|
8
|
+
if (!q || typeof q !== "object") return null;
|
|
9
|
+
const text = typeof q.question === "string" ? q.question : "";
|
|
10
|
+
if (!text) return null;
|
|
11
|
+
const options = Array.isArray(q.options)
|
|
12
|
+
? q.options
|
|
13
|
+
.map((o) => {
|
|
14
|
+
if (typeof o === "string") return { label: o };
|
|
15
|
+
if (o && typeof o === "object" && typeof o.label === "string") {
|
|
16
|
+
return {
|
|
17
|
+
label: o.label,
|
|
18
|
+
description: typeof o.description === "string" ? o.description : undefined,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
})
|
|
23
|
+
.filter(Boolean)
|
|
24
|
+
: [];
|
|
25
|
+
return {
|
|
26
|
+
question: text,
|
|
27
|
+
header: typeof q.header === "string" ? q.header : undefined,
|
|
28
|
+
options,
|
|
29
|
+
multiSelect: q.multiSelect === true,
|
|
30
|
+
// Free-text fallback: on by default. Set false explicitly to force a
|
|
31
|
+
// pick from `options`. Has no effect when options is empty.
|
|
32
|
+
allowText: q.allowText === false ? false : true,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
1
36
|
export default {
|
|
2
37
|
name: "ask_questions",
|
|
3
38
|
schema: {
|
|
@@ -7,26 +42,74 @@ export default {
|
|
|
7
42
|
type: "function",
|
|
8
43
|
function: {
|
|
9
44
|
name: "ask_questions",
|
|
10
|
-
description:
|
|
45
|
+
description:
|
|
46
|
+
"Ask the user one or more questions when you genuinely need input to proceed. " +
|
|
47
|
+
"Each question can be free-text OR a selectable list of options (single- or multi-select). " +
|
|
48
|
+
"Call this ONCE per turn — the loop hands control back to the user immediately.",
|
|
11
49
|
parameters: {
|
|
12
50
|
type: "object",
|
|
13
51
|
properties: {
|
|
14
52
|
questions: {
|
|
15
53
|
type: "array",
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
54
|
+
description:
|
|
55
|
+
"Questions for the user. Each item is an object with the question text " +
|
|
56
|
+
"and optional `options` for selectable answers (single- or multi-select). " +
|
|
57
|
+
"Leave `options` empty for free-text questions.",
|
|
58
|
+
items: {
|
|
59
|
+
type: "object",
|
|
60
|
+
properties: {
|
|
61
|
+
question: { type: "string", description: "The question text." },
|
|
62
|
+
header: {
|
|
63
|
+
type: "string",
|
|
64
|
+
description: "Optional short chip (≤12 chars) shown next to the question.",
|
|
65
|
+
},
|
|
66
|
+
options: {
|
|
67
|
+
type: "array",
|
|
68
|
+
description:
|
|
69
|
+
"Selectable answers. Omit or leave empty for a free-text question. " +
|
|
70
|
+
"Prefer 2–4 distinct, mutually-exclusive choices.",
|
|
71
|
+
items: {
|
|
72
|
+
type: "object",
|
|
73
|
+
properties: {
|
|
74
|
+
label: { type: "string", description: "Visible label." },
|
|
75
|
+
description: {
|
|
76
|
+
type: "string",
|
|
77
|
+
description: "Optional explanation shown under the label.",
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
required: ["label"],
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
multiSelect: {
|
|
84
|
+
type: "boolean",
|
|
85
|
+
description:
|
|
86
|
+
"true → user can pick several options (checkboxes). Default false (single-select).",
|
|
87
|
+
},
|
|
88
|
+
allowText: {
|
|
89
|
+
type: "boolean",
|
|
90
|
+
description:
|
|
91
|
+
"When options is non-empty, also show an 'Otro' free-text field. Default true.",
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
required: ["question"],
|
|
95
|
+
},
|
|
96
|
+
},
|
|
19
97
|
},
|
|
20
|
-
required: ["questions"]
|
|
21
|
-
}
|
|
22
|
-
}
|
|
98
|
+
required: ["questions"],
|
|
99
|
+
},
|
|
100
|
+
},
|
|
23
101
|
},
|
|
24
102
|
makeHandler: () => async ({ questions }) => {
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
103
|
+
// Normalize so downstream code (UI panels, persistence) always sees the
|
|
104
|
+
// canonical shape. The agent loop treats this tool as turn-ending
|
|
105
|
+
// (see TURN_ENDING_TOOLS in src/core/agent/constants.js).
|
|
106
|
+
const normalized = Array.isArray(questions)
|
|
107
|
+
? questions.map(normalizeQuestion).filter(Boolean)
|
|
108
|
+
: [];
|
|
109
|
+
return {
|
|
110
|
+
status: "Questions presented to user. Waiting for input.",
|
|
111
|
+
count: normalized.length,
|
|
112
|
+
questions: normalized,
|
|
30
113
|
};
|
|
31
|
-
}
|
|
114
|
+
},
|
|
32
115
|
};
|
|
@@ -1,6 +1,40 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import path from "node:path";
|
|
1
4
|
import { http } from "../http.js";
|
|
2
5
|
import { resolveProjectId } from "./project.js";
|
|
3
6
|
|
|
7
|
+
// First two bytes of an executable script. Used as a hint when the file
|
|
8
|
+
// doesn't have the exec bit but clearly intends to run (shebang line).
|
|
9
|
+
const SHEBANG = "#!";
|
|
10
|
+
|
|
11
|
+
// Decide if an artifact is "runnable": exec bit on the file, OR the file
|
|
12
|
+
// starts with a shebang. If shebang-but-not-exec, we set the bit before
|
|
13
|
+
// spawning so `./script` works without the user having to chmod +x.
|
|
14
|
+
function detectRunnable(absPath) {
|
|
15
|
+
let stat;
|
|
16
|
+
try {
|
|
17
|
+
stat = fs.statSync(absPath);
|
|
18
|
+
} catch {
|
|
19
|
+
return { runnable: false, reason: "not_found" };
|
|
20
|
+
}
|
|
21
|
+
if (!stat.isFile()) return { runnable: false, reason: "not_a_file" };
|
|
22
|
+
const execBit = (stat.mode & 0o111) !== 0;
|
|
23
|
+
let hasShebang = false;
|
|
24
|
+
try {
|
|
25
|
+
const fd = fs.openSync(absPath, "r");
|
|
26
|
+
const buf = Buffer.alloc(2);
|
|
27
|
+
fs.readSync(fd, buf, 0, 2, 0);
|
|
28
|
+
fs.closeSync(fd);
|
|
29
|
+
hasShebang = buf.toString("utf8") === SHEBANG;
|
|
30
|
+
} catch {
|
|
31
|
+
// ignore: hasShebang stays false
|
|
32
|
+
}
|
|
33
|
+
if (execBit) return { runnable: true, reason: "exec_bit", autoChmod: false };
|
|
34
|
+
if (hasShebang) return { runnable: true, reason: "shebang", autoChmod: true };
|
|
35
|
+
return { runnable: false, reason: "no_exec_no_shebang" };
|
|
36
|
+
}
|
|
37
|
+
|
|
4
38
|
export async function cmdArtifactCreate(args) {
|
|
5
39
|
const name = args._[0];
|
|
6
40
|
if (!name) throw new Error("apx artifact create: missing <name>");
|
|
@@ -43,3 +77,68 @@ export async function cmdArtifactRemove(args) {
|
|
|
43
77
|
await http.delete(`/projects/${pid}/artifacts/${encodeURIComponent(name)}`);
|
|
44
78
|
console.log(`removed artifact "${name}"`);
|
|
45
79
|
}
|
|
80
|
+
|
|
81
|
+
// `apx artifact run <name> [-- args...]`
|
|
82
|
+
//
|
|
83
|
+
// Resolves the artifact's absolute path via the daemon (single source of
|
|
84
|
+
// truth for project storage), then spawns the file LOCALLY with stdio
|
|
85
|
+
// inherited so the caller sees output as if they typed `./artifact <args>`.
|
|
86
|
+
// Detection is lenient: exec bit OR shebang → runnable; shebang-only files
|
|
87
|
+
// get a one-shot chmod +x so the user doesn't have to do it themselves.
|
|
88
|
+
//
|
|
89
|
+
// The remaining argv after `run <name>` is passed straight to the script,
|
|
90
|
+
// so `apx artifact run hello.sh -- hola mundo` becomes `./hello.sh hola mundo`.
|
|
91
|
+
export async function cmdArtifactRun(args) {
|
|
92
|
+
const name = args._[0];
|
|
93
|
+
if (!name) throw new Error("apx artifact run: missing <name>");
|
|
94
|
+
const pid = await resolveProjectId(args?.flags?.project);
|
|
95
|
+
// Pull the artifact record from the daemon for the absolute path.
|
|
96
|
+
let entry;
|
|
97
|
+
try {
|
|
98
|
+
entry = await http.get(`/projects/${pid}/artifacts/${encodeURIComponent(name)}`);
|
|
99
|
+
} catch (e) {
|
|
100
|
+
throw new Error(`artifact "${name}" not found in project #${pid}: ${e.message}`);
|
|
101
|
+
}
|
|
102
|
+
const absPath = entry.path;
|
|
103
|
+
if (!absPath || !fs.existsSync(absPath)) {
|
|
104
|
+
throw new Error(`artifact "${name}" path missing on disk: ${absPath}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const detection = detectRunnable(absPath);
|
|
108
|
+
if (!detection.runnable) {
|
|
109
|
+
const hint = detection.reason === "no_exec_no_shebang"
|
|
110
|
+
? "no es ejecutable (sin shebang ni bit +x). Probá `apx artifact show` para ver el contenido."
|
|
111
|
+
: `no se puede ejecutar (${detection.reason}).`;
|
|
112
|
+
throw new Error(`artifact "${name}" ${hint}`);
|
|
113
|
+
}
|
|
114
|
+
if (detection.autoChmod) {
|
|
115
|
+
try {
|
|
116
|
+
const st = fs.statSync(absPath);
|
|
117
|
+
fs.chmodSync(absPath, st.mode | 0o111);
|
|
118
|
+
} catch (e) {
|
|
119
|
+
throw new Error(`could not chmod +x ${absPath}: ${e.message}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Everything after the artifact name is forwarded to the script. The CLI's
|
|
124
|
+
// argv parser already strips top-level flags; what's left in `_` is name
|
|
125
|
+
// + script args.
|
|
126
|
+
const scriptArgs = (args._ || []).slice(1);
|
|
127
|
+
const cwd = path.dirname(absPath);
|
|
128
|
+
const child = spawn(absPath, scriptArgs, { stdio: "inherit", cwd });
|
|
129
|
+
|
|
130
|
+
await new Promise((resolve) => {
|
|
131
|
+
child.on("exit", (code, signal) => {
|
|
132
|
+
if (signal) {
|
|
133
|
+
// Killed by signal — surface a non-zero exit for shell pipelines.
|
|
134
|
+
process.exit(128 + (signal === "SIGINT" ? 2 : 15));
|
|
135
|
+
}
|
|
136
|
+
process.exit(code ?? 0);
|
|
137
|
+
});
|
|
138
|
+
child.on("error", (err) => {
|
|
139
|
+
console.error(`apx artifact run: spawn failed — ${err.message}`);
|
|
140
|
+
resolve();
|
|
141
|
+
process.exit(1);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
}
|
|
@@ -125,6 +125,7 @@ import {
|
|
|
125
125
|
cmdArtifactList,
|
|
126
126
|
cmdArtifactShow,
|
|
127
127
|
cmdArtifactRemove,
|
|
128
|
+
cmdArtifactRun,
|
|
128
129
|
} from "./commands/artifact.js";
|
|
129
130
|
import {
|
|
130
131
|
cmdTaskAdd,
|
|
@@ -1273,12 +1274,14 @@ const HELP_TOPICS = new Map(Object.entries({
|
|
|
1273
1274
|
["create <name>", "Create a new empty artifact. Prints its absolute path."],
|
|
1274
1275
|
["list | ls", "List artifacts in the project."],
|
|
1275
1276
|
["show <name>", "Print artifact content."],
|
|
1277
|
+
["run <name> [args...]", "Execute a runnable artifact (shebang or +x). Stdio is inherited."],
|
|
1276
1278
|
["remove | rm <name>", "Delete an artifact."],
|
|
1277
1279
|
],
|
|
1278
1280
|
examples: [
|
|
1279
1281
|
"apx artifact create check_asana.sh --project 0",
|
|
1280
1282
|
"apx artifact list",
|
|
1281
1283
|
"apx artifact show check_asana.sh",
|
|
1284
|
+
"apx artifact run check_asana.sh",
|
|
1282
1285
|
],
|
|
1283
1286
|
}),
|
|
1284
1287
|
"artifact create": topic({
|
|
@@ -2490,6 +2493,7 @@ async function dispatch(cmd, rest) {
|
|
|
2490
2493
|
else if (sub === "create" || sub === "new") await cmdArtifactCreate(a);
|
|
2491
2494
|
else if (sub === "show" || sub === "get") await cmdArtifactShow(a);
|
|
2492
2495
|
else if (sub === "remove" || sub === "rm") await cmdArtifactRemove(a);
|
|
2496
|
+
else if (sub === "run") await cmdArtifactRun(a);
|
|
2493
2497
|
else die(`unknown artifact subcommand: ${sub}`);
|
|
2494
2498
|
break;
|
|
2495
2499
|
}
|
|
@@ -326,11 +326,31 @@ function transcriptLines(transcript, width) {
|
|
|
326
326
|
if (isQuestion && trace.args?.questions) {
|
|
327
327
|
addLine(lines, "", C.bg);
|
|
328
328
|
addLine(lines, margin + label + C.muted + " (…)" + C.bg, C.bg);
|
|
329
|
-
for (const
|
|
330
|
-
|
|
329
|
+
for (const rawQ of trace.args.questions) {
|
|
330
|
+
// Rich shape: {question, options[], multiSelect, allowText}. Legacy: string.
|
|
331
|
+
const q = typeof rawQ === "string" ? { question: rawQ } : (rawQ || {});
|
|
332
|
+
const qText = typeof q.question === "string" ? q.question : "";
|
|
333
|
+
if (!qText) continue;
|
|
334
|
+
const qWrapped = wrapText(`• ${qText}`, inner - 2);
|
|
331
335
|
for (const line of qWrapped) {
|
|
332
336
|
addLine(lines, margin + C.primary + "┃ " + C.text + padAnsi(line, inner - 2), C.bg);
|
|
333
337
|
}
|
|
338
|
+
const opts = Array.isArray(q.options) ? q.options : [];
|
|
339
|
+
if (opts.length > 0) {
|
|
340
|
+
const marker = q.multiSelect ? "[ ]" : "( )";
|
|
341
|
+
opts.forEach((opt, i) => {
|
|
342
|
+
const label = (opt && typeof opt === "object" && typeof opt.label === "string")
|
|
343
|
+
? opt.label
|
|
344
|
+
: (typeof opt === "string" ? opt : "");
|
|
345
|
+
if (!label) return;
|
|
346
|
+
const desc = (opt && typeof opt === "object" && typeof opt.description === "string")
|
|
347
|
+
? ` — ${opt.description}` : "";
|
|
348
|
+
const optLine = ` ${marker} ${i + 1}. ${label}${desc}`;
|
|
349
|
+
for (const line of wrapText(optLine, inner - 2)) {
|
|
350
|
+
addLine(lines, margin + C.primary + "┃ " + C.muted + padAnsi(line, inner - 2), C.bg);
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
}
|
|
334
354
|
}
|
|
335
355
|
} else {
|
|
336
356
|
addLine(lines, margin + label + " " + name + C.bg, C.bg);
|