@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
package/package.json
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
1
|
export const MAX_TOOL_ITERS = 6;
|
|
2
2
|
export const ACK_ONLY_TOOLS = new Set(["send_telegram"]);
|
|
3
3
|
export const MAX_CONSECUTIVE_ACKS = 2;
|
|
4
|
+
// Tools whose semantics REQUIRE handing control back to the user. After the
|
|
5
|
+
// tool runs we break the loop — even under completionContract — because the
|
|
6
|
+
// task literally cannot advance without a human reply. Without this, models
|
|
7
|
+
// under forced toolChoice spam the same question across iterations.
|
|
8
|
+
export const TURN_ENDING_TOOLS = new Set(["ask_questions"]);
|
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
cleanTextOfPseudoToolCalls,
|
|
5
5
|
} from "./tool-call-parser.js";
|
|
6
6
|
import { resolveActiveModel, fallbackModels } from "./model-router.js";
|
|
7
|
-
import { MAX_TOOL_ITERS, ACK_ONLY_TOOLS, MAX_CONSECUTIVE_ACKS } from "./constants.js";
|
|
7
|
+
import { MAX_TOOL_ITERS, ACK_ONLY_TOOLS, MAX_CONSECUTIVE_ACKS, TURN_ENDING_TOOLS } from "./constants.js";
|
|
8
8
|
import {
|
|
9
9
|
isShortConfirmation,
|
|
10
10
|
lastAssistantAskedForConfirmation,
|
|
@@ -347,6 +347,7 @@ export async function runAgent({
|
|
|
347
347
|
});
|
|
348
348
|
|
|
349
349
|
let finishSummary = null;
|
|
350
|
+
let turnEndingQuestions = null;
|
|
350
351
|
for (const tc of toolCalls) {
|
|
351
352
|
const fn = tc.function || tc;
|
|
352
353
|
const name = fn.name;
|
|
@@ -409,6 +410,20 @@ export async function runAgent({
|
|
|
409
410
|
tool_name: name,
|
|
410
411
|
content: JSON.stringify(toolResult),
|
|
411
412
|
});
|
|
413
|
+
|
|
414
|
+
// Capture turn-ending intents (e.g. ask_questions). The loop cannot
|
|
415
|
+
// legitimately advance without a user reply; under completionContract
|
|
416
|
+
// forcing another tool call just produces ask_questions spam.
|
|
417
|
+
if (TURN_ENDING_TOOLS.has(name) && !turnEndingQuestions) {
|
|
418
|
+
// Questions may be plain strings (legacy) or {question, options, ...}.
|
|
419
|
+
// For the assistant_text fallback we only need the prompt strings.
|
|
420
|
+
const qs = Array.isArray(args.questions)
|
|
421
|
+
? args.questions
|
|
422
|
+
.map((q) => (typeof q === "string" ? q : q && typeof q.question === "string" ? q.question : null))
|
|
423
|
+
.filter(Boolean)
|
|
424
|
+
: [];
|
|
425
|
+
turnEndingQuestions = qs;
|
|
426
|
+
}
|
|
412
427
|
}
|
|
413
428
|
|
|
414
429
|
// Task declared complete via the contract — emit the summary as the final
|
|
@@ -421,6 +436,19 @@ export async function runAgent({
|
|
|
421
436
|
break;
|
|
422
437
|
}
|
|
423
438
|
|
|
439
|
+
// ask_questions (or future turn-ending tools): the task is genuinely
|
|
440
|
+
// blocked on user input. Exit the loop — completionContract or not,
|
|
441
|
+
// asking again gets us nowhere. We deliberately do NOT emit a synthetic
|
|
442
|
+
// assistant_text and we leave lastText empty so persistence and one-shot
|
|
443
|
+
// API callers don't end up with a duplicate bullet list next to the
|
|
444
|
+
// rendering surfaces' own UI (web AskQuestionsCard, terminal renderer,
|
|
445
|
+
// telegram inline keyboard). The structured questions live on the tool
|
|
446
|
+
// trace — that's the canonical source.
|
|
447
|
+
if (turnEndingQuestions) {
|
|
448
|
+
if (!lastText) lastText = "";
|
|
449
|
+
break;
|
|
450
|
+
}
|
|
451
|
+
|
|
424
452
|
const allAckOnly = toolCalls.every((tc) => {
|
|
425
453
|
const n = (tc.function?.name) || tc.name;
|
|
426
454
|
return ACK_ONLY_TOOLS.has(n);
|
|
@@ -2,14 +2,93 @@
|
|
|
2
2
|
// GET /projects/:pid/artifacts
|
|
3
3
|
// POST /projects/:pid/artifacts
|
|
4
4
|
// GET /projects/:pid/artifacts/:name
|
|
5
|
+
// POST /projects/:pid/artifacts/:name/run body: { args?: string[] }
|
|
5
6
|
// DELETE /projects/:pid/artifacts/:name
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import { spawn } from "node:child_process";
|
|
9
|
+
import path from "node:path";
|
|
6
10
|
import {
|
|
11
|
+
artifactPath,
|
|
7
12
|
createArtifact,
|
|
8
13
|
listArtifacts,
|
|
9
14
|
readArtifact,
|
|
10
15
|
removeArtifact,
|
|
11
16
|
} from "../../../core/artifacts-store.js";
|
|
12
17
|
|
|
18
|
+
// Same heuristic as `apx artifact run` (cli/commands/artifact.js): exec bit
|
|
19
|
+
// OR shebang counts as runnable. We auto-chmod when shebang-only so the
|
|
20
|
+
// web Run button "just works" the way it would from the terminal.
|
|
21
|
+
function detectRunnable(absPath) {
|
|
22
|
+
let stat;
|
|
23
|
+
try {
|
|
24
|
+
stat = fs.statSync(absPath);
|
|
25
|
+
} catch {
|
|
26
|
+
return { runnable: false, reason: "not_found" };
|
|
27
|
+
}
|
|
28
|
+
if (!stat.isFile()) return { runnable: false, reason: "not_a_file" };
|
|
29
|
+
const execBit = (stat.mode & 0o111) !== 0;
|
|
30
|
+
let hasShebang = false;
|
|
31
|
+
try {
|
|
32
|
+
const fd = fs.openSync(absPath, "r");
|
|
33
|
+
const buf = Buffer.alloc(2);
|
|
34
|
+
fs.readSync(fd, buf, 0, 2, 0);
|
|
35
|
+
fs.closeSync(fd);
|
|
36
|
+
hasShebang = buf.toString("utf8") === "#!";
|
|
37
|
+
} catch { /* leave hasShebang = false */ }
|
|
38
|
+
if (execBit) return { runnable: true, autoChmod: false };
|
|
39
|
+
if (hasShebang) return { runnable: true, autoChmod: true };
|
|
40
|
+
return { runnable: false, reason: "no_exec_no_shebang" };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Cap stdout/stderr captured per run so a runaway script can't blow up the
|
|
44
|
+
// daemon. 256 KiB each — enough for typical script output, small enough to
|
|
45
|
+
// fit in one HTTP response without streaming.
|
|
46
|
+
const MAX_CAPTURE_BYTES = 256 * 1024;
|
|
47
|
+
// Hard timeout for synchronous web execution. Long-running scripts should
|
|
48
|
+
// be invoked from the terminal where the user has direct stdio.
|
|
49
|
+
const RUN_TIMEOUT_MS = 30_000;
|
|
50
|
+
|
|
51
|
+
function runArtifact({ absPath, cwd, args, timeoutMs = RUN_TIMEOUT_MS }) {
|
|
52
|
+
return new Promise((resolve) => {
|
|
53
|
+
const started = Date.now();
|
|
54
|
+
const child = spawn(absPath, Array.isArray(args) ? args : [], { cwd });
|
|
55
|
+
let stdout = "";
|
|
56
|
+
let stderr = "";
|
|
57
|
+
let truncated = false;
|
|
58
|
+
let timedOut = false;
|
|
59
|
+
const cap = (s, chunk) => {
|
|
60
|
+
if (s.length >= MAX_CAPTURE_BYTES) { truncated = true; return s; }
|
|
61
|
+
const next = s + chunk.toString("utf8");
|
|
62
|
+
if (next.length > MAX_CAPTURE_BYTES) { truncated = true; return next.slice(0, MAX_CAPTURE_BYTES); }
|
|
63
|
+
return next;
|
|
64
|
+
};
|
|
65
|
+
child.stdout.on("data", (c) => { stdout = cap(stdout, c); });
|
|
66
|
+
child.stderr.on("data", (c) => { stderr = cap(stderr, c); });
|
|
67
|
+
const killer = setTimeout(() => {
|
|
68
|
+
timedOut = true;
|
|
69
|
+
try { child.kill("SIGTERM"); } catch { /* ignore */ }
|
|
70
|
+
setTimeout(() => { try { child.kill("SIGKILL"); } catch { /* ignore */ } }, 1500);
|
|
71
|
+
}, timeoutMs);
|
|
72
|
+
child.on("error", (err) => {
|
|
73
|
+
clearTimeout(killer);
|
|
74
|
+
resolve({ ok: false, error: err.message, durationMs: Date.now() - started });
|
|
75
|
+
});
|
|
76
|
+
child.on("exit", (code, signal) => {
|
|
77
|
+
clearTimeout(killer);
|
|
78
|
+
resolve({
|
|
79
|
+
ok: !timedOut && code === 0,
|
|
80
|
+
exitCode: code,
|
|
81
|
+
signal,
|
|
82
|
+
timedOut,
|
|
83
|
+
truncated,
|
|
84
|
+
stdout,
|
|
85
|
+
stderr,
|
|
86
|
+
durationMs: Date.now() - started,
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
13
92
|
export function register(app, { project }) {
|
|
14
93
|
app.get("/projects/:pid/artifacts", (req, res) => {
|
|
15
94
|
const p = project(req, res);
|
|
@@ -49,4 +128,42 @@ export function register(app, { project }) {
|
|
|
49
128
|
);
|
|
50
129
|
res.status(ok ? 204 : 404).end();
|
|
51
130
|
});
|
|
131
|
+
|
|
132
|
+
// Synchronous execute. Web's "Run" button hits this; the terminal CLI uses
|
|
133
|
+
// its own local spawn (stdio inherited) so it can run interactively. Output
|
|
134
|
+
// is captured up to MAX_CAPTURE_BYTES and the call is bounded by
|
|
135
|
+
// RUN_TIMEOUT_MS — anything longer should go through the terminal.
|
|
136
|
+
app.post("/projects/:pid/artifacts/:name/run", async (req, res) => {
|
|
137
|
+
const p = project(req, res);
|
|
138
|
+
if (!p) return;
|
|
139
|
+
const name = decodeURIComponent(req.params.name);
|
|
140
|
+
const absPath = artifactPath(p.storagePath, name);
|
|
141
|
+
if (!fs.existsSync(absPath)) {
|
|
142
|
+
return res.status(404).json({ error: `artifact "${name}" not found` });
|
|
143
|
+
}
|
|
144
|
+
const detection = detectRunnable(absPath);
|
|
145
|
+
if (!detection.runnable) {
|
|
146
|
+
return res.status(400).json({
|
|
147
|
+
error: `artifact "${name}" is not runnable`,
|
|
148
|
+
reason: detection.reason,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
if (detection.autoChmod) {
|
|
152
|
+
try {
|
|
153
|
+
const st = fs.statSync(absPath);
|
|
154
|
+
fs.chmodSync(absPath, st.mode | 0o111);
|
|
155
|
+
} catch (e) {
|
|
156
|
+
return res.status(500).json({ error: `chmod failed: ${e.message}` });
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const args = Array.isArray(req.body?.args)
|
|
160
|
+
? req.body.args.filter((a) => typeof a === "string")
|
|
161
|
+
: [];
|
|
162
|
+
const result = await runArtifact({
|
|
163
|
+
absPath,
|
|
164
|
+
cwd: path.dirname(absPath),
|
|
165
|
+
args,
|
|
166
|
+
});
|
|
167
|
+
res.json(result);
|
|
168
|
+
});
|
|
52
169
|
}
|
|
@@ -68,6 +68,18 @@ function modeGuidanceFor(mode) {
|
|
|
68
68
|
"confirmation and do not stop after one step — keep calling tools until the",
|
|
69
69
|
"entire task is done, then briefly summarize what you changed and why.",
|
|
70
70
|
"Prefer surgical edits over rewrites.",
|
|
71
|
+
"When the user asks for a reusable script, snippet, or 'artifact' (something",
|
|
72
|
+
"they want to keep and run later), put it under `artifacts/<name>` inside",
|
|
73
|
+
"the project — it then shows up in the Artifacts tab. Don't drop reusable",
|
|
74
|
+
"scripts at the project root.",
|
|
75
|
+
"If a parameter you need is missing (API key, app id, target URL, …), call",
|
|
76
|
+
"`ask_questions` ONCE with all your questions and stop — control returns",
|
|
77
|
+
"to the user. Do not call ask_questions again in the same turn; you'll just",
|
|
78
|
+
"get the same blank state back. Each question can be a string (free-text",
|
|
79
|
+
"answer) OR an object {question, options:[{label, description}], multiSelect}",
|
|
80
|
+
"for choices. Prefer 2–4 mutually-exclusive options when a question has a",
|
|
81
|
+
"natural shortlist (yes/no, which-of-these, …); leave options empty for",
|
|
82
|
+
"open-ended answers (API keys, names, free-form ideas).",
|
|
71
83
|
].join(" ");
|
|
72
84
|
}
|
|
73
85
|
|
|
@@ -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
|
+
}
|