@agentprojectcontext/apx 1.31.1 → 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.
Files changed (57) hide show
  1. package/README.md +0 -1
  2. package/package.json +1 -1
  3. package/skills/apc-context/SKILL.md +0 -1
  4. package/src/core/agent/constants.js +5 -0
  5. package/src/core/agent/run-agent.js +29 -1
  6. package/src/core/confirmation/adapters/code.js +41 -0
  7. package/src/core/confirmation/adapters/telegram.js +134 -0
  8. package/src/core/confirmation/adapters/terminal.js +35 -0
  9. package/src/core/confirmation/adapters/web.js +53 -0
  10. package/src/core/confirmation/index.js +44 -0
  11. package/src/core/confirmation/pending-store.js +68 -0
  12. package/src/host/daemon/api/artifacts.js +117 -0
  13. package/src/host/daemon/api/code.js +14 -0
  14. package/src/host/daemon/api/confirm.js +30 -0
  15. package/src/host/daemon/api/super-agent.js +12 -4
  16. package/src/host/daemon/api.js +2 -0
  17. package/src/host/daemon/plugins/desktop.js +34 -0
  18. package/src/host/daemon/plugins/telegram-ask.js +309 -0
  19. package/src/host/daemon/plugins/telegram.js +358 -2
  20. package/src/host/daemon/super-agent-tools/helpers.js +27 -6
  21. package/src/host/daemon/super-agent-tools/index.js +1 -0
  22. package/src/host/daemon/super-agent-tools/tools/add-project.js +2 -2
  23. package/src/host/daemon/super-agent-tools/tools/ask-questions.js +96 -13
  24. package/src/host/daemon/super-agent-tools/tools/call-mcp.js +1 -1
  25. package/src/host/daemon/super-agent-tools/tools/call-runtime.js +1 -1
  26. package/src/host/daemon/super-agent-tools/tools/edit-file.js +2 -2
  27. package/src/host/daemon/super-agent-tools/tools/import-agent.js +2 -2
  28. package/src/host/daemon/super-agent-tools/tools/run-shell.js +1 -1
  29. package/src/host/daemon/super-agent-tools/tools/search-files.js +1 -4
  30. package/src/host/daemon/super-agent-tools/tools/send-telegram.js +1 -1
  31. package/src/host/daemon/super-agent-tools/tools/set-identity.js +2 -2
  32. package/src/host/daemon/super-agent-tools/tools/set-permission-mode.js +2 -2
  33. package/src/host/daemon/super-agent-tools/tools/write-file.js +2 -2
  34. package/src/host/daemon/super-agent.js +5 -1
  35. package/src/interfaces/cli/commands/artifact.js +99 -0
  36. package/src/interfaces/cli/index.js +4 -0
  37. package/src/interfaces/cli/terminal-chat/renderer.js +22 -2
  38. package/src/interfaces/web/dist/assets/index-63P_ji1a.js +571 -0
  39. package/src/interfaces/web/dist/assets/index-63P_ji1a.js.map +1 -0
  40. package/src/interfaces/web/dist/assets/index-DLWy6dYz.css +1 -0
  41. package/src/interfaces/web/dist/index.html +2 -2
  42. package/src/interfaces/web/package-lock.json +6 -6
  43. package/src/interfaces/web/src/components/chat/AskQuestionsCard.tsx +72 -0
  44. package/src/interfaces/web/src/components/chat/InlineAskPanel.tsx +399 -0
  45. package/src/interfaces/web/src/components/chat/MessageBubble.tsx +16 -3
  46. package/src/interfaces/web/src/components/chat/MessageList.tsx +2 -1
  47. package/src/interfaces/web/src/components/code/CodeArtifactsTab.tsx +230 -0
  48. package/src/interfaces/web/src/components/code/CodeSidePanel.tsx +12 -4
  49. package/src/interfaces/web/src/i18n/en.ts +20 -0
  50. package/src/interfaces/web/src/i18n/es.ts +20 -0
  51. package/src/interfaces/web/src/lib/api/artifacts.ts +47 -0
  52. package/src/interfaces/web/src/lib/api.ts +1 -0
  53. package/src/interfaces/web/src/screens/modules/CodeScreen.tsx +23 -2
  54. package/src/interfaces/web/src/screens/project/ChatTab.tsx +15 -0
  55. package/src/interfaces/web/dist/assets/index-BDUsA6L6.css +0 -1
  56. package/src/interfaces/web/dist/assets/index-BV615I9p.js +0 -548
  57. package/src/interfaces/web/dist/assets/index-BV615I9p.js.map +0 -1
@@ -12,6 +12,7 @@ import {
12
12
  } from "./shared.js";
13
13
  import { loggerFor } from "../../../core/logging.js";
14
14
  import { appendGlobalMessage } from "../../../core/messages-store.js";
15
+ import { createWebConfirmAdapter } from "../../../core/confirmation/adapters/web.js";
15
16
 
16
17
  const log = loggerFor("super-agent");
17
18
 
@@ -79,6 +80,15 @@ export function register(app, { projects, registries, plugins, project, config }
79
80
  res.write(JSON.stringify(event) + "\n");
80
81
  };
81
82
 
83
+ const onEvent = wrapOnEventForLog(send, {
84
+ trace_id: req.apxTraceId,
85
+ channel: ctx.channel,
86
+ });
87
+
88
+ // Web/TUI channels receive a "confirmation_required" SSE event and respond
89
+ // via POST /super-agent/confirm/:correlationId (see api/confirm.js).
90
+ const requestConfirmation = createWebConfirmAdapter({ onEvent });
91
+
82
92
  try {
83
93
  const saResult = await runSuperAgent({
84
94
  globalConfig: config,
@@ -94,10 +104,8 @@ export function register(app, { projects, registries, plugins, project, config }
94
104
  ...(Number.isFinite(Number(maxIters)) ? { maxIters: Number(maxIters) } : {}),
95
105
  ...(Number.isFinite(Number(maxTokens)) ? { maxTokens: Number(maxTokens) } : {}),
96
106
  ...(completionContract ? { completionContract: true } : {}),
97
- onEvent: wrapOnEventForLog(send, {
98
- trace_id: req.apxTraceId,
99
- channel: ctx.channel,
100
- }),
107
+ onEvent,
108
+ requestConfirmation,
101
109
  });
102
110
  projects.rebuild(p.id);
103
111
  logWebTurn(ctx.channel, { prompt, replyText: saResult.text });
@@ -46,6 +46,7 @@ import { register as registerAdmin } from "./api/admin.js";
46
46
  import { register as registerAdminConfig } from "./api/admin-config.js";
47
47
  import { register as registerIdentity } from "./api/identity.js";
48
48
  import { register as registerWeb } from "./api/web.js";
49
+ import { register as registerConfirm } from "./api/confirm.js";
49
50
 
50
51
  export function buildApi({
51
52
  projects,
@@ -108,6 +109,7 @@ export function buildApi({
108
109
  registerEngines(app, ctx);
109
110
  registerExec(app, ctx);
110
111
  registerSuperAgent(app, ctx);
112
+ registerConfirm(app, ctx);
111
113
  registerCode(app, ctx);
112
114
  registerConversations(app, ctx);
113
115
  registerConnections(app, ctx);
@@ -146,6 +146,15 @@ async function _handleMessage({ ws, text, previousMessages }, { projects, config
146
146
  _send(ws, { type: "tool_start", name: t.tool, args: t.args });
147
147
  } else if (event.type === "tool_result") {
148
148
  _send(ws, { type: "tool_done", name: event.trace.tool });
149
+ // ask_questions on desktop is voice-first: there's no inline-keyboard
150
+ // UI to render, so we turn the structured questions into a spoken
151
+ // segment. The user voice-replies on the next turn and the super-agent
152
+ // sees that reply in its history. Each option is announced inline so
153
+ // TTS reads them aloud naturally.
154
+ if (event.trace?.tool === "ask_questions") {
155
+ const segments = formatAskQuestionsForVoice(event.trace.args?.questions);
156
+ if (segments) emitSegment(segments);
157
+ }
149
158
  } else if (event.type === "assistant_text" && event.text) {
150
159
  // A complete assistant text segment (e.g. the "I'll check…" intro
151
160
  // emitted right before a tool runs). Ship it as its own message.
@@ -193,6 +202,31 @@ async function _handleMessage({ ws, text, previousMessages }, { projects, config
193
202
  }
194
203
  }
195
204
 
205
+ // Build a voice-friendly transcript of an ask_questions tool call so the
206
+ // desktop's TTS reads the prompt aloud and the bubble shows what was asked.
207
+ // Single question + options reads as "<question> Opciones: A; B; C."
208
+ // Multiple questions are numbered. Free-text questions just speak the prompt.
209
+ function formatAskQuestionsForVoice(raw) {
210
+ if (!Array.isArray(raw) || raw.length === 0) return null;
211
+ const lines = [];
212
+ raw.forEach((rawQ, idx) => {
213
+ const q = typeof rawQ === "string" ? { question: rawQ } : (rawQ || {});
214
+ const text = typeof q.question === "string" ? q.question.trim() : "";
215
+ if (!text) return;
216
+ const prefix = raw.length > 1 ? `${idx + 1}. ` : "";
217
+ const opts = Array.isArray(q.options) ? q.options : [];
218
+ const optLabels = opts
219
+ .map((o) => (typeof o === "string" ? o : (o && typeof o.label === "string" ? o.label : "")))
220
+ .filter(Boolean);
221
+ let line = `${prefix}${text}`;
222
+ if (optLabels.length > 0) {
223
+ line += ` Opciones: ${optLabels.join("; ")}.`;
224
+ }
225
+ lines.push(line);
226
+ });
227
+ return lines.length > 0 ? lines.join("\n") : null;
228
+ }
229
+
196
230
  function _send(ws, msg) {
197
231
  if (ws) {
198
232
  sendToClient(ws, msg);
@@ -0,0 +1,309 @@
1
+ // Telegram ask_questions integration.
2
+ //
3
+ // When the super-agent ends a turn with an `ask_questions` tool call, the
4
+ // telegram plugin calls into this module instead of sending the bare reply
5
+ // text. We render each question as a Telegram message with an inline keyboard
6
+ // (one button per option, plus skip/cancel), keep the in-flight state in
7
+ // memory keyed by chat_id, and resume by feeding the compiled answers back
8
+ // to the super-agent as a synthetic user prompt.
9
+ //
10
+ // State is intentionally process-local: an ask flow that started before a
11
+ // daemon restart simply dies; the user can re-issue the original prompt.
12
+
13
+ import { performance } from "node:perf_hooks";
14
+
15
+ const ASK_TTL_MS = 30 * 60_000; // 30 min — abandoned flows GC'd after this
16
+
17
+ const STORE = new Map(); // chat_id (string) → AskState
18
+
19
+ // AskState shape:
20
+ // {
21
+ // chatId, projectId, authorId,
22
+ // correlationId, // short id used in callback_data to dedupe restarts
23
+ // questions: AskQuestion[],
24
+ // answers: { picked: Set<number>, text: string, skipped: boolean }[],
25
+ // index: number,
26
+ // messageId: number|null, // last sent question message (for edit/disable)
27
+ // createdAt, lastTouchedAt,
28
+ // resume: (compiled: string) => Promise<void>, // called when flow completes
29
+ // }
30
+
31
+ function emptyAnswer() {
32
+ return { picked: new Set(), text: "", skipped: false };
33
+ }
34
+
35
+ function genCorrelationId() {
36
+ // Time-derived monotonically-ish id, kept short for Telegram's 64-byte
37
+ // callback_data limit. No Date.now() in workflows but we're in normal Node.
38
+ return Math.floor(performance.now() * 1000).toString(36) + Math.floor(Math.random() * 36 ** 4).toString(36);
39
+ }
40
+
41
+ // Normalize whatever shape the model passed (strings or {question,...} objs)
42
+ // into the canonical question record. Identical contract to the web side
43
+ // (InlineAskPanel.tsx normalizeQuestionClient).
44
+ export function normalizeQuestion(q) {
45
+ if (typeof q === "string") {
46
+ return { question: q, options: [], multiSelect: false, allowText: true };
47
+ }
48
+ if (!q || typeof q !== "object") return null;
49
+ const text = typeof q.question === "string" ? q.question : "";
50
+ if (!text) return null;
51
+ const rawOptions = Array.isArray(q.options) ? q.options : [];
52
+ const options = rawOptions
53
+ .map((o) => {
54
+ if (typeof o === "string") return { label: o };
55
+ if (o && typeof o === "object" && typeof o.label === "string") {
56
+ return {
57
+ label: o.label,
58
+ description: typeof o.description === "string" ? o.description : undefined,
59
+ };
60
+ }
61
+ return null;
62
+ })
63
+ .filter(Boolean);
64
+ return {
65
+ question: text,
66
+ header: typeof q.header === "string" ? q.header : undefined,
67
+ options,
68
+ multiSelect: q.multiSelect === true,
69
+ allowText: q.allowText === false ? false : true,
70
+ };
71
+ }
72
+
73
+ // Pull the most recent ask_questions tool call out of a super-agent trace.
74
+ // Returns the normalized question list, or null when the turn didn't ask.
75
+ export function extractAskQuestionsFromTrace(trace) {
76
+ if (!Array.isArray(trace)) return null;
77
+ for (let i = trace.length - 1; i >= 0; i--) {
78
+ const t = trace[i];
79
+ if (t && t.tool === "ask_questions") {
80
+ const raw = (t.args && Array.isArray(t.args.questions)) ? t.args.questions : [];
81
+ const normalized = raw.map(normalizeQuestion).filter(Boolean);
82
+ return normalized.length > 0 ? normalized : null;
83
+ }
84
+ }
85
+ return null;
86
+ }
87
+
88
+ // Compile collected answers into a single user-message string. Mirrors the
89
+ // shape produced by the web InlineAskPanel.compileAnswers so the super-agent
90
+ // sees consistent input across surfaces.
91
+ export function compileAnswers(state) {
92
+ const lines = [];
93
+ state.questions.forEach((q, i) => {
94
+ const a = state.answers[i] || emptyAnswer();
95
+ if (a.skipped) {
96
+ lines.push(`- ${q.question}\n → (omitido)`);
97
+ return;
98
+ }
99
+ const parts = [];
100
+ if (q.options && q.options.length > 0) {
101
+ const labels = [...a.picked]
102
+ .sort((x, y) => x - y)
103
+ .map((idx) => q.options[idx]?.label)
104
+ .filter(Boolean);
105
+ if (labels.length > 0) parts.push(labels.join(", "));
106
+ }
107
+ const text = (a.text || "").trim();
108
+ if (text) {
109
+ parts.push(q.options && q.options.length > 0 ? `(Otro: ${text})` : text);
110
+ }
111
+ const answerText = parts.length > 0 ? parts.join(" ") : "(sin respuesta)";
112
+ lines.push(`- ${q.question}\n → ${answerText}`);
113
+ });
114
+ return lines.join("\n");
115
+ }
116
+
117
+ // Build the Telegram InlineKeyboardMarkup for one question. Single-select:
118
+ // pressing an option commits immediately; the keyboard disappears via
119
+ // editMessageReplyMarkup. Multi-select: each press toggles a check on the
120
+ // label; a "✓ Confirmar" row commits. No options: keyboard has only a Saltar
121
+ // row, and the user is expected to reply with text.
122
+ export function buildKeyboard(state) {
123
+ const cid = state.correlationId;
124
+ const q = state.questions[state.index];
125
+ const a = state.answers[state.index] || emptyAnswer();
126
+ const rows = [];
127
+
128
+ if (Array.isArray(q.options) && q.options.length > 0) {
129
+ q.options.forEach((opt, i) => {
130
+ const picked = a.picked.has(i);
131
+ const label = q.multiSelect
132
+ ? `${picked ? "☑" : "☐"} ${opt.label}`
133
+ : opt.label;
134
+ rows.push([
135
+ { text: label, callback_data: `apx:ask:${cid}:opt:${i}` },
136
+ ]);
137
+ });
138
+ if (q.multiSelect) {
139
+ rows.push([{ text: "✓ Confirmar", callback_data: `apx:ask:${cid}:next` }]);
140
+ }
141
+ }
142
+
143
+ // Control row: skip + cancel. Plus a back arrow when we're past Q1.
144
+ const controls = [];
145
+ if (state.index > 0) controls.push({ text: "◀︎ Atrás", callback_data: `apx:ask:${cid}:back` });
146
+ controls.push({ text: "Omitir", callback_data: `apx:ask:${cid}:skip` });
147
+ controls.push({ text: "Cerrar", callback_data: `apx:ask:${cid}:cancel` });
148
+ rows.push(controls);
149
+
150
+ return { inline_keyboard: rows };
151
+ }
152
+
153
+ // Plain-text body of the question message: header (N/M) + question + a hint
154
+ // for free-text questions.
155
+ export function formatQuestionText(state) {
156
+ const q = state.questions[state.index];
157
+ const total = state.questions.length;
158
+ const head = total > 1 ? `[${state.index + 1}/${total}] ` : "";
159
+ const hasOptions = Array.isArray(q.options) && q.options.length > 0;
160
+ const hint = hasOptions
161
+ ? (q.multiSelect
162
+ ? "\n\n_Multi-selección: tocá las opciones que quieras y después Confirmar._"
163
+ : "\n\n_Tocá una opción para responder._")
164
+ : "\n\n_Respondé con un mensaje de texto._";
165
+ return `❓ ${head}${q.question}${hint}`;
166
+ }
167
+
168
+ // ---- Store API ------------------------------------------------------------
169
+
170
+ export function saveState(chatId, state) {
171
+ STORE.set(String(chatId), { ...state, lastTouchedAt: Date.now() });
172
+ }
173
+
174
+ export function getState(chatId) {
175
+ const s = STORE.get(String(chatId));
176
+ if (!s) return null;
177
+ if (Date.now() - s.lastTouchedAt > ASK_TTL_MS) {
178
+ STORE.delete(String(chatId));
179
+ return null;
180
+ }
181
+ return s;
182
+ }
183
+
184
+ export function clearState(chatId) {
185
+ STORE.delete(String(chatId));
186
+ }
187
+
188
+ export function hasPendingFreeText(chatId) {
189
+ const s = getState(chatId);
190
+ if (!s) return false;
191
+ const q = s.questions[s.index];
192
+ if (!q) return false;
193
+ return !(Array.isArray(q.options) && q.options.length > 0);
194
+ }
195
+
196
+ // Apply a user text reply to the currently-pending free-text question.
197
+ // Returns the updated state (caller decides whether to advance) or null if
198
+ // there was no pending free-text question.
199
+ export function applyTextAnswer(chatId, text) {
200
+ const s = getState(chatId);
201
+ if (!s) return null;
202
+ const q = s.questions[s.index];
203
+ const hasOptions = Array.isArray(q.options) && q.options.length > 0;
204
+ if (hasOptions) return null; // multi/single-select questions are answered via callback only
205
+ const ans = s.answers[s.index] || emptyAnswer();
206
+ ans.text = (text || "").trim();
207
+ ans.skipped = false;
208
+ s.answers[s.index] = ans;
209
+ saveState(chatId, s);
210
+ return s;
211
+ }
212
+
213
+ // Apply a callback_query button press. Returns one of:
214
+ // { action: "advance", state } — render the next question
215
+ // { action: "redraw", state } — same question, refresh the keyboard (toggle)
216
+ // { action: "done", state, compiled } — last question answered
217
+ // { action: "cancel", state } — user closed the panel
218
+ // null — callback wasn't ours
219
+ //
220
+ // callback_data scheme: apx:ask:<correlationId>:<verb>[:<arg>]
221
+ // verbs: opt:<i>, next, back, skip, cancel
222
+ export function applyCallback(chatId, data) {
223
+ const s = getState(chatId);
224
+ if (!s) return null;
225
+ if (typeof data !== "string" || !data.startsWith("apx:ask:")) return null;
226
+ const rest = data.slice("apx:ask:".length); // <corr>:<verb>[:<arg>]
227
+ const [corr, verb, arg] = rest.split(":");
228
+ if (corr !== s.correlationId) {
229
+ // Stale button from a previous flow.
230
+ return null;
231
+ }
232
+ const q = s.questions[s.index];
233
+ const ans = s.answers[s.index] || emptyAnswer();
234
+
235
+ if (verb === "opt") {
236
+ const optIdx = Number.parseInt(arg, 10);
237
+ if (!Number.isFinite(optIdx) || optIdx < 0 || optIdx >= (q.options?.length || 0)) {
238
+ return null;
239
+ }
240
+ if (q.multiSelect) {
241
+ // Toggle and stay on the same question.
242
+ if (ans.picked.has(optIdx)) ans.picked.delete(optIdx);
243
+ else ans.picked.add(optIdx);
244
+ ans.skipped = false;
245
+ s.answers[s.index] = ans;
246
+ saveState(chatId, s);
247
+ return { action: "redraw", state: s };
248
+ }
249
+ // Single-select: commit + advance.
250
+ ans.picked = new Set([optIdx]);
251
+ ans.skipped = false;
252
+ s.answers[s.index] = ans;
253
+ return advance(s);
254
+ }
255
+
256
+ if (verb === "next") return advance(s);
257
+ if (verb === "back") {
258
+ if (s.index > 0) {
259
+ s.index -= 1;
260
+ saveState(chatId, s);
261
+ }
262
+ return { action: "advance", state: s };
263
+ }
264
+ if (verb === "skip") {
265
+ s.answers[s.index] = { picked: new Set(), text: "", skipped: true };
266
+ return advance(s);
267
+ }
268
+ if (verb === "cancel") {
269
+ clearState(chatId);
270
+ return { action: "cancel", state: s };
271
+ }
272
+ return null;
273
+ }
274
+
275
+ function advance(s) {
276
+ if (s.index >= s.questions.length - 1) {
277
+ const compiled = compileAnswers(s);
278
+ clearState(s.chatId);
279
+ return { action: "done", state: s, compiled };
280
+ }
281
+ s.index += 1;
282
+ saveState(s.chatId, s);
283
+ return { action: "advance", state: s };
284
+ }
285
+
286
+ // Build the initial state and persist it. Caller must follow up with the
287
+ // first sendMessage (use formatQuestionText + buildKeyboard).
288
+ export function startFlow({ chatId, projectId, authorId, questions, resume }) {
289
+ const state = {
290
+ chatId: String(chatId),
291
+ projectId: projectId != null ? String(projectId) : null,
292
+ authorId: authorId != null ? String(authorId) : null,
293
+ correlationId: genCorrelationId(),
294
+ questions,
295
+ answers: questions.map(() => emptyAnswer()),
296
+ index: 0,
297
+ messageId: null,
298
+ createdAt: Date.now(),
299
+ lastTouchedAt: Date.now(),
300
+ resume,
301
+ };
302
+ saveState(chatId, state);
303
+ return state;
304
+ }
305
+
306
+ // Test-only: clear the global store between unit tests.
307
+ export function _reset() {
308
+ STORE.clear();
309
+ }