arisa 5.1.68 → 5.2.7
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/README.md +7 -4
- package/package.json +1 -1
- package/src/core/agent/agent-manager.js +46 -6
- package/src/core/agent/agent-session-lifecycle.js +80 -3
- package/src/core/agent/core-tools.js +1 -1
- package/src/core/agent/pi-auth-login.js +1 -1
- package/src/core/agent/pi-runtime.js +1 -1
- package/src/core/agent/runtime-context.js +1 -1
- package/src/core/agent/worker-heap-circuit-breaker.js +122 -0
- package/src/core/artifacts/artifact-store.js +1 -1
- package/src/core/capabilities/capability-service.js +1 -1
- package/src/core/config/config-defaults.js +30 -1
- package/src/core/config/config-store.js +1 -1
- package/src/core/conversation/session-seed-store.js +1 -1
- package/src/core/tasks/task-store.js +1 -1
- package/src/core/tools/daemon-client.js +180 -0
- package/src/core/tools/daemon-processes.js +19 -3
- package/src/core/tools/daemon-protocol.js +72 -0
- package/src/core/tools/daemon-runtime.js +13 -490
- package/src/core/tools/daemon-worker.js +310 -0
- package/src/core/tools/ipc-client.js +2 -2
- package/src/core/tools/memory-pressure.js +56 -0
- package/src/core/tools/official-tool-installer.js +1 -1
- package/src/core/tools/tool-config.js +1 -1
- package/src/core/tools/tool-process-output.js +100 -0
- package/src/core/tools/tool-process-runner.js +175 -0
- package/src/core/tools/tool-registry.js +99 -187
- package/src/core/tools/tool-resource-note-store.js +1 -1
- package/src/core/tools/tool-usage-store.js +1 -1
- package/src/core/tools/weighted-resource-governor.js +188 -38
- package/src/index.js +14 -2
- package/src/official-tools.lock.json +424 -50
- package/src/platform/paths.js +152 -0
- package/src/runtime/bootstrap-cli.js +121 -0
- package/src/runtime/bootstrap-config.js +97 -0
- package/src/runtime/bootstrap-telegram.js +325 -0
- package/src/runtime/bootstrap.js +6 -543
- package/src/runtime/doctor.js +6 -3
- package/src/runtime/flush.js +1 -1
- package/src/runtime/ipc/ipc-server.js +1 -1
- package/src/runtime/log-viewer.js +1 -1
- package/src/runtime/oom-protection.js +20 -0
- package/src/runtime/paths.js +3 -151
- package/src/runtime/restart-receipt.js +1 -1
- package/src/runtime/service-manager.js +1 -1
- package/src/runtime/service-supervisor.js +14 -0
- package/src/runtime/slave-cli.js +1 -1
- package/src/runtime/tool-process-supervisor.js +1 -1
- package/src/runtime/tui.js +200 -0
- package/src/runtime/update-manager.js +1 -1
- package/src/runtime/worker-recovery-report.js +142 -0
- package/src/transport/telegram/bot.js +42 -320
- package/src/transport/telegram/prompt-builders.js +8 -3
- package/src/transport/telegram/telegram-prompt-controller.js +346 -0
- package/src/transport/telegram/workspace-topic-store.js +1 -1
- package/test/agent-session-lifecycle.test.js +92 -0
- package/test/architecture-boundaries.test.js +29 -0
- package/test/bootstrap.test.js +65 -0
- package/test/daemon-process-invocation.test.js +27 -0
- package/test/daemon-runtime.test.js +36 -1
- package/test/doctor.test.js +22 -0
- package/test/memory-pressure.test.js +36 -0
- package/test/model-selection.test.js +11 -1
- package/test/official-tool-dependencies.test.js +1 -1
- package/test/official-tool-installer.test.js +18 -1
- package/test/oom-protection.test.js +32 -0
- package/test/paths.test.js +7 -0
- package/test/pi-compaction.test.js +9 -0
- package/test/service-manager.test.js +6 -1
- package/test/telegram-prompt-controller.test.js +81 -0
- package/test/telegram-text-artifact.test.js +30 -0
- package/test/tool-registry-run.test.js +108 -4
- package/test/tui.test.js +41 -0
- package/test/weighted-resource-governor.test.js +97 -5
- package/test/worker-heap-circuit-breaker.test.js +79 -0
- package/test/worker-recovery-report.test.js +69 -0
- package/test-fixtures/fake-daemon.js +5 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { mkdir } from "node:fs/promises";
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
export const arisaPackageDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|
8
|
+
export const arisaHomeDir = process.env.ARISA_HOME
|
|
9
|
+
? path.resolve(process.env.ARISA_HOME)
|
|
10
|
+
: path.join(os.homedir(), ".arisa");
|
|
11
|
+
export const stateDir = path.join(arisaHomeDir, "state");
|
|
12
|
+
export const tuiSessionsDir = path.join(stateDir, "pi-tui-sessions");
|
|
13
|
+
export const configFile = path.join(stateDir, "config.json");
|
|
14
|
+
export const piAuthFile = path.join(stateDir, "pi-auth.json");
|
|
15
|
+
export const servicePidFile = path.join(stateDir, "arisa.pid");
|
|
16
|
+
export const serviceLogFile = path.join(stateDir, "arisa.log");
|
|
17
|
+
export const restartReceiptFile = path.join(stateDir, "restart-receipt.json");
|
|
18
|
+
export const sessionStartOperationalNotesFile = path.join(stateDir, "session-start-operational-notes.json");
|
|
19
|
+
export function createIpcSocketPath({ homeDir = arisaHomeDir, platform = process.platform } = {}) {
|
|
20
|
+
if (platform === "win32") {
|
|
21
|
+
const suffix = crypto.createHash("sha256").update(homeDir).digest("hex").slice(0, 16);
|
|
22
|
+
return `\\\\.\\pipe\\arisa-${suffix}`;
|
|
23
|
+
}
|
|
24
|
+
return path.join(homeDir, "state", "arisa.sock");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const arisaIpcSocketFile = createIpcSocketPath();
|
|
28
|
+
export const tasksFile = path.join(stateDir, "tasks.json");
|
|
29
|
+
export const toolsDir = path.join(arisaHomeDir, "tools");
|
|
30
|
+
export const chatsDir = path.join(arisaHomeDir, "chats");
|
|
31
|
+
export const toolStateDir = path.join(stateDir, "tools");
|
|
32
|
+
|
|
33
|
+
export function requireToolName(toolName) {
|
|
34
|
+
const name = String(toolName ?? "");
|
|
35
|
+
if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(name)) {
|
|
36
|
+
throw new Error(`Invalid tool name: ${name || "empty"}`);
|
|
37
|
+
}
|
|
38
|
+
return name;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function getChatDir(chatId) {
|
|
42
|
+
return path.join(chatsDir, String(chatId));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function getChatArtifactsDir(chatId) {
|
|
46
|
+
return path.join(getChatDir(chatId), "artifacts");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function getChatArtifactsIndexFile(chatId) {
|
|
50
|
+
return path.join(getChatDir(chatId), "state", "artifacts.json");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function getChatSessionSeedFile(chatId) {
|
|
54
|
+
return path.join(getChatDir(chatId), "state", "session-seed.jsonl");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function getChatLegacyConversationHistoryFile(chatId) {
|
|
58
|
+
return path.join(getChatDir(chatId), "state", "conversation.jsonl");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function getChatToolUsageFile(chatId) {
|
|
62
|
+
return path.join(getChatDir(chatId), "state", "tool-usage.json");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function getChatToolResourceNotesFile(chatId) {
|
|
66
|
+
return path.join(getChatDir(chatId), "state", "tool-resource-notes.json");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function getChatTelegramWorkspacesFile(chatId) {
|
|
70
|
+
return path.join(getChatDir(chatId), "state", "telegram-workspaces.json");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function getChatToolStateDir(chatId, toolName) {
|
|
74
|
+
return path.join(getChatDir(chatId), "state", "tools", requireToolName(toolName));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function normalizeDaemonScope(scope = { type: "global" }) {
|
|
78
|
+
if (!scope || scope === "global" || scope.type === "global") {
|
|
79
|
+
return { type: "global" };
|
|
80
|
+
}
|
|
81
|
+
if (scope === "chat") {
|
|
82
|
+
throw new Error("chat daemon scope requires chatId");
|
|
83
|
+
}
|
|
84
|
+
if (scope.type !== "chat") {
|
|
85
|
+
throw new Error(`Unsupported daemon scope: ${scope.type || scope}`);
|
|
86
|
+
}
|
|
87
|
+
const chatId = String(scope.chatId ?? "").trim();
|
|
88
|
+
if (!/^-?\d+$/.test(chatId)) {
|
|
89
|
+
throw new Error(`Invalid chat daemon scope: ${chatId || "missing chatId"}`);
|
|
90
|
+
}
|
|
91
|
+
return { type: "chat", chatId };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function getDaemonInstanceId(scope = { type: "global" }) {
|
|
95
|
+
const normalized = normalizeDaemonScope(scope);
|
|
96
|
+
return normalized.type === "global" ? "global" : `chat:${normalized.chatId}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function getDaemonInstanceDir(toolName, scope = { type: "global" }) {
|
|
100
|
+
const normalized = normalizeDaemonScope(scope);
|
|
101
|
+
return normalized.type === "global"
|
|
102
|
+
? getToolStateDir(toolName)
|
|
103
|
+
: path.join(getChatToolStateDir(normalized.chatId, toolName), "daemon");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function getChatPiSessionsDir(chatId, sessionRevision = 0) {
|
|
107
|
+
if (!Number.isSafeInteger(sessionRevision) || sessionRevision < 0) {
|
|
108
|
+
throw new Error(`Invalid Pi session revision: ${sessionRevision}`);
|
|
109
|
+
}
|
|
110
|
+
const sessionsDir = path.join(getChatDir(chatId), "state", "pi-sessions");
|
|
111
|
+
return sessionRevision === 0
|
|
112
|
+
? sessionsDir
|
|
113
|
+
: path.join(sessionsDir, String(sessionRevision));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function getToolDir(toolName) {
|
|
117
|
+
return path.join(toolsDir, requireToolName(toolName));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function getToolConfigPath(toolName) {
|
|
121
|
+
return path.join(getToolDir(toolName), "config.js");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function getChatConfigDir(chatId) {
|
|
125
|
+
return path.join(getChatDir(chatId), "config");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function getChatTmpDir(chatId) {
|
|
129
|
+
return path.join(getChatDir(chatId), "tmp");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function getChatToolConfigPath(chatId, toolName) {
|
|
133
|
+
return path.join(getChatConfigDir(chatId), "tools", requireToolName(toolName), "config.js");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function getToolStateDir(toolName) {
|
|
137
|
+
return path.join(toolStateDir, requireToolName(toolName));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function getToolTmpDir(toolName) {
|
|
141
|
+
return path.join(getToolStateDir(toolName), "tmp");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function getChatToolTmpDir(chatId, toolName) {
|
|
145
|
+
return path.join(getChatTmpDir(chatId), "tools", toolName);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function ensureArisaHome() {
|
|
149
|
+
await mkdir(stateDir, { recursive: true });
|
|
150
|
+
await mkdir(toolsDir, { recursive: true });
|
|
151
|
+
await mkdir(chatsDir, { recursive: true });
|
|
152
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createPiOAuthLogin } from "../core/agent/pi-auth-login.js";
|
|
3
|
+
import {
|
|
4
|
+
createPiRuntime,
|
|
5
|
+
formatPiModelOption,
|
|
6
|
+
hasProviderAuth,
|
|
7
|
+
listPiProviders,
|
|
8
|
+
listProviderModels,
|
|
9
|
+
supportsProviderOAuth
|
|
10
|
+
} from "../core/agent/pi-runtime.js";
|
|
11
|
+
import {
|
|
12
|
+
buildBootstrapConfig,
|
|
13
|
+
formatProviderOption,
|
|
14
|
+
selectByIndex,
|
|
15
|
+
selectPiLoginOption,
|
|
16
|
+
sortBootstrapModels,
|
|
17
|
+
sortBootstrapProviders
|
|
18
|
+
} from "./bootstrap-config.js";
|
|
19
|
+
|
|
20
|
+
export async function openExternal(url) {
|
|
21
|
+
if (!url) return;
|
|
22
|
+
await new Promise((resolve) => {
|
|
23
|
+
let child;
|
|
24
|
+
if (process.platform === "darwin") {
|
|
25
|
+
child = spawn("open", [url], { stdio: "ignore" });
|
|
26
|
+
} else if (process.platform === "win32") {
|
|
27
|
+
child = spawn("cmd", ["/c", "start", "", url], { stdio: "ignore" });
|
|
28
|
+
} else {
|
|
29
|
+
child = spawn("xdg-open", [url], { stdio: "ignore" });
|
|
30
|
+
}
|
|
31
|
+
child.on("exit", () => resolve());
|
|
32
|
+
child.on("error", () => resolve());
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function runInternalPiLogin(provider, { rl = null } = {}) {
|
|
37
|
+
const login = createPiOAuthLogin({
|
|
38
|
+
provider,
|
|
39
|
+
onSelect: async ({ message, options }) => {
|
|
40
|
+
const selected = selectPiLoginOption(options);
|
|
41
|
+
if (!selected) return undefined;
|
|
42
|
+
console.log(`${message}\nUsing: ${selected.label || selected.id}\n`);
|
|
43
|
+
return selected.id;
|
|
44
|
+
},
|
|
45
|
+
onAuth: async ({ url, instructions, controller }) => {
|
|
46
|
+
console.log(`${instructions || "Open this URL to continue authentication:"}\n${url}\n`);
|
|
47
|
+
await openExternal(url);
|
|
48
|
+
if (controller.oauthProvider.usesCallbackServer && rl) {
|
|
49
|
+
const pasted = (await rl.question("Paste the redirect URL here if the browser does not return automatically, or press Enter to keep waiting: ")).trim();
|
|
50
|
+
if (pasted) controller.submitManualCode(pasted);
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
onDeviceCode: async ({ userCode, verificationUri }) => {
|
|
54
|
+
console.log(`Open this URL: ${verificationUri}`);
|
|
55
|
+
console.log(`Then enter code: ${userCode}\n`);
|
|
56
|
+
await openExternal(verificationUri);
|
|
57
|
+
},
|
|
58
|
+
onPrompt: async ({ message }) => {
|
|
59
|
+
if (!rl) throw new Error(`Pi login for ${provider} requires interactive input: ${message}`);
|
|
60
|
+
return (await rl.question(`${message} `)).trim();
|
|
61
|
+
},
|
|
62
|
+
onProgress: (message) => console.log(message)
|
|
63
|
+
});
|
|
64
|
+
await login.promise;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function collectCliBootstrapChoices({ telegramApiKey, rl, ask }) {
|
|
68
|
+
const telegramMaxChatIds = Number(await ask("Maximum authorized chat IDs", "1"));
|
|
69
|
+
const runtime = createPiRuntime();
|
|
70
|
+
const providers = sortBootstrapProviders(listPiProviders(runtime));
|
|
71
|
+
console.log("\nAvailable Pi providers:");
|
|
72
|
+
providers.forEach((item, index) => console.log(`${index + 1}. ${formatProviderOption(item)}`));
|
|
73
|
+
|
|
74
|
+
const selectedProvider = selectByIndex(providers, await ask("Select Pi provider by number", "1"));
|
|
75
|
+
const models = sortBootstrapModels(selectedProvider.provider, listProviderModels(selectedProvider.provider, runtime));
|
|
76
|
+
console.log(`\nAvailable models for ${selectedProvider.provider}:`);
|
|
77
|
+
models.forEach((model, index) => console.log(`${index + 1}. ${formatPiModelOption(model)}`));
|
|
78
|
+
|
|
79
|
+
const selectedModel = selectByIndex(models, await ask("Select Pi model by number", "1"));
|
|
80
|
+
const selectedAuthReady = hasProviderAuth(selectedProvider.provider, runtime);
|
|
81
|
+
const providerSupportsOAuth = supportsProviderOAuth(selectedProvider.provider, runtime);
|
|
82
|
+
console.log(`Selected model: ${selectedModel.provider}/${selectedModel.id}`);
|
|
83
|
+
console.log(`Existing Pi auth for ${selectedProvider.provider}: ${selectedAuthReady ? "yes" : "no"}`);
|
|
84
|
+
if (providerSupportsOAuth) {
|
|
85
|
+
console.log("Pi auth tip: leaving the API key empty will start Pi's internal login flow for this provider.");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let piApiKey = "";
|
|
89
|
+
while (true) {
|
|
90
|
+
piApiKey = (await rl.question(`Pi API key for ${selectedProvider.provider} (optional): `)).trim();
|
|
91
|
+
if (piApiKey) break;
|
|
92
|
+
if (hasProviderAuth(selectedProvider.provider, createPiRuntime())) break;
|
|
93
|
+
if (!providerSupportsOAuth) {
|
|
94
|
+
console.log(`No existing Pi auth found for ${selectedProvider.provider}. This provider requires an API key.`);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
console.log(`No existing Pi auth found for ${selectedProvider.provider}. Starting internal Pi login...`);
|
|
98
|
+
try {
|
|
99
|
+
await runInternalPiLogin(selectedProvider.provider, { rl });
|
|
100
|
+
} catch (error) {
|
|
101
|
+
console.log(`Internal Pi login failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
102
|
+
}
|
|
103
|
+
if (hasProviderAuth(selectedProvider.provider, createPiRuntime())) {
|
|
104
|
+
console.log(`Detected Pi auth for ${selectedProvider.provider}. Continuing bootstrap.`);
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
console.log(`Pi auth for ${selectedProvider.provider} is still missing after login.`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
config: buildBootstrapConfig({
|
|
112
|
+
telegramApiKey,
|
|
113
|
+
telegramMaxChatIds,
|
|
114
|
+
provider: selectedProvider.provider,
|
|
115
|
+
model: selectedModel.id,
|
|
116
|
+
piApiKey
|
|
117
|
+
}),
|
|
118
|
+
startInBackground: false,
|
|
119
|
+
viaTelegram: false
|
|
120
|
+
};
|
|
121
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { applyConfigDefaults } from "../core/config/config-defaults.js";
|
|
2
|
+
|
|
3
|
+
export function buildBootstrapConfig({
|
|
4
|
+
telegramApiKey,
|
|
5
|
+
telegramMaxChatIds,
|
|
6
|
+
authorizedChatIds = [],
|
|
7
|
+
chatMeta = {},
|
|
8
|
+
provider,
|
|
9
|
+
model,
|
|
10
|
+
piApiKey
|
|
11
|
+
}) {
|
|
12
|
+
return applyConfigDefaults({
|
|
13
|
+
telegram: {
|
|
14
|
+
token: telegramApiKey,
|
|
15
|
+
maxChatIds: telegramMaxChatIds,
|
|
16
|
+
authorizedChatIds,
|
|
17
|
+
chatMeta
|
|
18
|
+
},
|
|
19
|
+
pi: {
|
|
20
|
+
provider,
|
|
21
|
+
model,
|
|
22
|
+
apiKey: piApiKey
|
|
23
|
+
},
|
|
24
|
+
createdAt: new Date().toISOString()
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function sortBootstrapProviders(providers) {
|
|
29
|
+
const preferredOrder = ["openai-codex"];
|
|
30
|
+
const positions = new Map(providers.map((provider, index) => [provider.provider, index]));
|
|
31
|
+
|
|
32
|
+
return [...providers].sort((a, b) => {
|
|
33
|
+
const aPref = preferredOrder.indexOf(a.provider);
|
|
34
|
+
const bPref = preferredOrder.indexOf(b.provider);
|
|
35
|
+
const aRank = aPref === -1 ? Number.MAX_SAFE_INTEGER : aPref;
|
|
36
|
+
const bRank = bPref === -1 ? Number.MAX_SAFE_INTEGER : bPref;
|
|
37
|
+
if (aRank !== bRank) return aRank - bRank;
|
|
38
|
+
return (positions.get(a.provider) || 0) - (positions.get(b.provider) || 0);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function sortBootstrapModels(provider, models) {
|
|
43
|
+
const preferred = {
|
|
44
|
+
"openai-codex": ["gpt-5.5"]
|
|
45
|
+
};
|
|
46
|
+
const priority = preferred[provider] || [];
|
|
47
|
+
const positions = new Map(models.map((model, index) => [model.id, index]));
|
|
48
|
+
|
|
49
|
+
return [...models].sort((a, b) => {
|
|
50
|
+
const aIndex = priority.indexOf(a.id);
|
|
51
|
+
const bIndex = priority.indexOf(b.id);
|
|
52
|
+
const aRank = aIndex === -1 ? Number.MAX_SAFE_INTEGER : aIndex;
|
|
53
|
+
const bRank = bIndex === -1 ? Number.MAX_SAFE_INTEGER : bIndex;
|
|
54
|
+
if (aRank !== bRank) return aRank - bRank;
|
|
55
|
+
return (positions.get(b.id) || 0) - (positions.get(a.id) || 0);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function parsePositiveInteger(value, fallback = null) {
|
|
60
|
+
const number = Number(value);
|
|
61
|
+
if (!Number.isFinite(number) || number <= 0) return fallback;
|
|
62
|
+
return Math.floor(number);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function selectByIndex(items, value, fallbackIndex = 0) {
|
|
66
|
+
const index = parsePositiveInteger(value, fallbackIndex + 1) - 1;
|
|
67
|
+
return items[Math.max(0, Math.min(items.length - 1, index))];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function parseYesNo(value, fallback = true) {
|
|
71
|
+
const text = String(value ?? "").trim().toLowerCase();
|
|
72
|
+
if (!text) return fallback;
|
|
73
|
+
if (["y", "yes", "s", "si", "sí"].includes(text)) return true;
|
|
74
|
+
if (["n", "no"].includes(text)) return false;
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function getIncomingChatMeta(ctx) {
|
|
79
|
+
return {
|
|
80
|
+
languageCode: ctx.from?.language_code || "",
|
|
81
|
+
username: ctx.from?.username || "",
|
|
82
|
+
firstName: ctx.from?.first_name || "",
|
|
83
|
+
lastName: ctx.from?.last_name || ""
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function formatProviderOption(item) {
|
|
88
|
+
const authLabel = item.authConfigured ? "auth configured" : item.supportsOAuth ? "login or API key" : "API key";
|
|
89
|
+
return `${item.provider} (${item.modelCount} models, ${authLabel})`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function selectPiLoginOption(options = []) {
|
|
93
|
+
return options.find((option) => /device/i.test(`${option.id} ${option.label}`))
|
|
94
|
+
|| options.find((option) => /browser|oauth|web/i.test(`${option.id} ${option.label}`))
|
|
95
|
+
|| options[0]
|
|
96
|
+
|| null;
|
|
97
|
+
}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import { Bot } from "grammy";
|
|
2
|
+
import { createPiOAuthLogin } from "../core/agent/pi-auth-login.js";
|
|
3
|
+
import {
|
|
4
|
+
createPiRuntime,
|
|
5
|
+
formatPiModelOption,
|
|
6
|
+
hasProviderAuth,
|
|
7
|
+
listPiProviders,
|
|
8
|
+
listProviderModels,
|
|
9
|
+
supportsProviderOAuth
|
|
10
|
+
} from "../core/agent/pi-runtime.js";
|
|
11
|
+
import { telegramConfigDefaults } from "../core/config/config-defaults.js";
|
|
12
|
+
import { buildDeviceCodeTelegramMessage } from "../transport/telegram/device-code-message.js";
|
|
13
|
+
import { buildPagedInlineKeyboard } from "../transport/telegram/paged-inline-keyboard.js";
|
|
14
|
+
import {
|
|
15
|
+
buildBootstrapConfig,
|
|
16
|
+
formatProviderOption,
|
|
17
|
+
getIncomingChatMeta,
|
|
18
|
+
parseYesNo,
|
|
19
|
+
selectPiLoginOption,
|
|
20
|
+
sortBootstrapModels,
|
|
21
|
+
sortBootstrapProviders
|
|
22
|
+
} from "./bootstrap-config.js";
|
|
23
|
+
|
|
24
|
+
export async function runTelegramBootstrap({ telegramApiKey, setupToken, botInfo }) {
|
|
25
|
+
const bot = new Bot(telegramApiKey);
|
|
26
|
+
const runtime = createPiRuntime();
|
|
27
|
+
const providers = sortBootstrapProviders(listPiProviders(runtime));
|
|
28
|
+
let setupChatId = null;
|
|
29
|
+
let chatMeta = {};
|
|
30
|
+
let state = "await-start";
|
|
31
|
+
let telegramMaxChatIds = 1;
|
|
32
|
+
let selectedProvider = null;
|
|
33
|
+
let selectedModel = null;
|
|
34
|
+
let piApiKey = "";
|
|
35
|
+
let activeLogin = null;
|
|
36
|
+
let completed = false;
|
|
37
|
+
let resolveResult;
|
|
38
|
+
let rejectResult;
|
|
39
|
+
|
|
40
|
+
const resultPromise = new Promise((resolve, reject) => {
|
|
41
|
+
resolveResult = resolve;
|
|
42
|
+
rejectResult = reject;
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const sendSetupMessage = async (text, extra = {}) => {
|
|
46
|
+
if (!setupChatId) return;
|
|
47
|
+
await bot.api.sendMessage(setupChatId, text, extra);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const showSetupPrompt = async (ctx, text, extra = {}) => {
|
|
51
|
+
const messageId = ctx?.callbackQuery?.message?.message_id;
|
|
52
|
+
if (messageId && ctx.chat?.id) {
|
|
53
|
+
try {
|
|
54
|
+
await ctx.api.editMessageText(ctx.chat.id, messageId, text, extra);
|
|
55
|
+
return;
|
|
56
|
+
} catch {}
|
|
57
|
+
}
|
|
58
|
+
await sendSetupMessage(text, extra);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const isSetupChat = (ctx) => setupChatId && ctx.chat?.id === setupChatId;
|
|
62
|
+
|
|
63
|
+
const complete = (startInBackground) => {
|
|
64
|
+
if (completed) return;
|
|
65
|
+
completed = true;
|
|
66
|
+
resolveResult({
|
|
67
|
+
config: buildBootstrapConfig({
|
|
68
|
+
telegramApiKey,
|
|
69
|
+
telegramMaxChatIds,
|
|
70
|
+
authorizedChatIds: [setupChatId],
|
|
71
|
+
chatMeta: { [setupChatId]: chatMeta },
|
|
72
|
+
provider: selectedProvider.provider,
|
|
73
|
+
model: selectedModel.id,
|
|
74
|
+
piApiKey
|
|
75
|
+
}),
|
|
76
|
+
startInBackground,
|
|
77
|
+
viaTelegram: true
|
|
78
|
+
});
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const askProvider = async (ctx = null, page = 0) => {
|
|
82
|
+
state = "provider";
|
|
83
|
+
await showSetupPrompt(ctx, "Select the Pi provider Arisa should use:", {
|
|
84
|
+
reply_markup: buildPagedInlineKeyboard("provider", providers.map((provider) => ({ text: formatProviderOption(provider) })), {
|
|
85
|
+
page,
|
|
86
|
+
pageSize: telegramConfigDefaults.modelPickerPageSize
|
|
87
|
+
})
|
|
88
|
+
});
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const askModel = async (ctx = null, page = 0) => {
|
|
92
|
+
state = "model";
|
|
93
|
+
const models = sortBootstrapModels(selectedProvider.provider, listProviderModels(selectedProvider.provider, createPiRuntime()));
|
|
94
|
+
const keyboard = buildPagedInlineKeyboard("model", models.map((model) => ({ text: formatPiModelOption(model) })), {
|
|
95
|
+
page,
|
|
96
|
+
pageSize: telegramConfigDefaults.modelPickerPageSize
|
|
97
|
+
});
|
|
98
|
+
keyboard.inline_keyboard.push([{ text: "Back to providers", callback_data: "back:provider" }]);
|
|
99
|
+
await showSetupPrompt(ctx, `Select the model for ${selectedProvider.provider}:`, { reply_markup: keyboard });
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const askBackground = async (ctx = null) => {
|
|
103
|
+
state = "background";
|
|
104
|
+
await showSetupPrompt(ctx, "Bootstrap complete. Keep Arisa running in background now?", {
|
|
105
|
+
reply_markup: {
|
|
106
|
+
inline_keyboard: [
|
|
107
|
+
[{ text: "Yes, start in background", callback_data: "background:yes" }],
|
|
108
|
+
[{ text: "No, continue in foreground", callback_data: "background:no" }]
|
|
109
|
+
]
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const askApiKey = async (ctx = null) => {
|
|
115
|
+
state = "pi-api-key";
|
|
116
|
+
await showSetupPrompt(ctx, `Send the Pi API key for ${selectedProvider.provider}.`, {
|
|
117
|
+
reply_markup: { inline_keyboard: [] }
|
|
118
|
+
});
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const askAuthMethod = async (ctx = null) => {
|
|
122
|
+
const providerRuntime = createPiRuntime();
|
|
123
|
+
const selectedAuthReady = hasProviderAuth(selectedProvider.provider, providerRuntime);
|
|
124
|
+
const providerSupportsOAuth = supportsProviderOAuth(selectedProvider.provider, providerRuntime);
|
|
125
|
+
const buttons = [];
|
|
126
|
+
if (selectedAuthReady) buttons.push([{ text: "Use existing Pi auth", callback_data: "auth:existing" }]);
|
|
127
|
+
if (providerSupportsOAuth) {
|
|
128
|
+
buttons.push([{ text: selectedAuthReady ? "Run Pi login again" : "Start Pi login", callback_data: "auth:login" }]);
|
|
129
|
+
}
|
|
130
|
+
buttons.push([{ text: "Enter API key", callback_data: "auth:key" }]);
|
|
131
|
+
buttons.push([{ text: "Back to models", callback_data: "back:model" }]);
|
|
132
|
+
|
|
133
|
+
state = "auth-method";
|
|
134
|
+
await showSetupPrompt(ctx, [
|
|
135
|
+
`Selected model: ${selectedProvider.provider}/${selectedModel.id}`,
|
|
136
|
+
`Existing Pi auth for ${selectedProvider.provider}: ${selectedAuthReady ? "yes" : "no"}`,
|
|
137
|
+
"Choose how Arisa should authenticate Pi."
|
|
138
|
+
].join("\n"), { reply_markup: { inline_keyboard: buttons } });
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const finishPiLogin = async (login) => {
|
|
142
|
+
try {
|
|
143
|
+
await login.promise;
|
|
144
|
+
activeLogin = null;
|
|
145
|
+
if (hasProviderAuth(selectedProvider.provider, createPiRuntime())) {
|
|
146
|
+
await sendSetupMessage(`Detected Pi auth for ${selectedProvider.provider}.`);
|
|
147
|
+
await askBackground();
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
await sendSetupMessage(`Pi auth for ${selectedProvider.provider} is still missing after login.`);
|
|
151
|
+
await askAuthMethod();
|
|
152
|
+
} catch (error) {
|
|
153
|
+
activeLogin = null;
|
|
154
|
+
await sendSetupMessage(`Pi login failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
155
|
+
await askAuthMethod();
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const startPiLogin = async () => {
|
|
160
|
+
if (hasProviderAuth(selectedProvider.provider, createPiRuntime())) {
|
|
161
|
+
await sendSetupMessage(`Existing Pi auth for ${selectedProvider.provider} detected.`);
|
|
162
|
+
await askBackground();
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
state = "pi-login";
|
|
167
|
+
const login = createPiOAuthLogin({
|
|
168
|
+
provider: selectedProvider.provider,
|
|
169
|
+
onSelect: async ({ message, options }) => {
|
|
170
|
+
const selected = selectPiLoginOption(options);
|
|
171
|
+
if (!selected) return undefined;
|
|
172
|
+
await sendSetupMessage(`${message}\nUsing: ${selected.label || selected.id}`);
|
|
173
|
+
return selected.id;
|
|
174
|
+
},
|
|
175
|
+
onAuth: async ({ url, instructions }) => {
|
|
176
|
+
await sendSetupMessage([
|
|
177
|
+
instructions || "Open this URL to continue Pi authentication:",
|
|
178
|
+
url,
|
|
179
|
+
"After login, paste the full redirect URL back here."
|
|
180
|
+
].join("\n"));
|
|
181
|
+
},
|
|
182
|
+
onDeviceCode: async ({ userCode, verificationUri, expiresInSeconds }) => {
|
|
183
|
+
const { text, ...options } = buildDeviceCodeTelegramMessage({ userCode, verificationUri, expiresInSeconds });
|
|
184
|
+
await sendSetupMessage(text, options);
|
|
185
|
+
},
|
|
186
|
+
onPrompt: async ({ message, controller }) => {
|
|
187
|
+
await sendSetupMessage(`${message}\nReply here with the value.`);
|
|
188
|
+
return controller.waitForManualCode();
|
|
189
|
+
},
|
|
190
|
+
onProgress: (message) => {
|
|
191
|
+
if (message) console.log(`[bootstrap] Pi auth progress: ${message}`);
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
activeLogin = login;
|
|
196
|
+
finishPiLogin(login);
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
bot.catch((error) => console.error("Telegram setup bot error:", error));
|
|
200
|
+
|
|
201
|
+
bot.command("start", async (ctx) => {
|
|
202
|
+
if (String(ctx.match || "").trim() !== setupToken) {
|
|
203
|
+
await ctx.reply("Invalid setup link. Use the link shown in the Arisa bootstrap terminal.");
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
if (setupChatId && ctx.chat.id !== setupChatId) {
|
|
207
|
+
await ctx.reply("This setup session is already connected to another chat.");
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
setupChatId = ctx.chat.id;
|
|
211
|
+
chatMeta = getIncomingChatMeta(ctx);
|
|
212
|
+
await ctx.reply([
|
|
213
|
+
`Connected to @${botInfo.username}.`,
|
|
214
|
+
"This chat will be the only Telegram chat authorized during setup."
|
|
215
|
+
].join("\n"));
|
|
216
|
+
await askProvider();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
bot.on("callback_query:data", async (ctx) => {
|
|
220
|
+
await ctx.answerCallbackQuery().catch(() => {});
|
|
221
|
+
if (!isSetupChat(ctx)) return;
|
|
222
|
+
const data = String(ctx.callbackQuery.data || "");
|
|
223
|
+
const [action, rawValue] = data.split(":");
|
|
224
|
+
if (action === "noop") return;
|
|
225
|
+
if (action === "back" && rawValue === "provider" && state === "model") {
|
|
226
|
+
selectedProvider = null;
|
|
227
|
+
selectedModel = null;
|
|
228
|
+
await askProvider(ctx);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (action === "back" && rawValue === "model" && state === "auth-method") {
|
|
232
|
+
selectedModel = null;
|
|
233
|
+
await askModel(ctx);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
if (action === "provider-page" && state === "provider") {
|
|
237
|
+
await askProvider(ctx, Number(rawValue));
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (action === "model-page" && state === "model") {
|
|
241
|
+
await askModel(ctx, Number(rawValue));
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
if (action === "provider" && state === "provider") {
|
|
245
|
+
selectedProvider = providers[Number(rawValue)];
|
|
246
|
+
if (!selectedProvider) return;
|
|
247
|
+
await askModel(ctx);
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
if (action === "model" && state === "model") {
|
|
251
|
+
const models = sortBootstrapModels(selectedProvider.provider, listProviderModels(selectedProvider.provider, createPiRuntime()));
|
|
252
|
+
selectedModel = models[Number(rawValue)];
|
|
253
|
+
if (!selectedModel) return;
|
|
254
|
+
await askAuthMethod(ctx);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
if (action === "auth" && state === "auth-method") {
|
|
258
|
+
if (rawValue === "existing") {
|
|
259
|
+
if (hasProviderAuth(selectedProvider.provider, createPiRuntime())) {
|
|
260
|
+
await askBackground(ctx);
|
|
261
|
+
} else {
|
|
262
|
+
await sendSetupMessage(`No existing Pi auth found for ${selectedProvider.provider}.`);
|
|
263
|
+
await askAuthMethod(ctx);
|
|
264
|
+
}
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if (rawValue === "login") {
|
|
268
|
+
await startPiLogin();
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (rawValue === "key") {
|
|
272
|
+
await askApiKey(ctx);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if (action === "background" && state === "background") {
|
|
277
|
+
await sendSetupMessage(rawValue === "yes"
|
|
278
|
+
? "Saving config. Arisa will start in background now."
|
|
279
|
+
: "Saving config. Arisa will continue in foreground.");
|
|
280
|
+
complete(rawValue === "yes");
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
bot.on("message:text", async (ctx) => {
|
|
285
|
+
if (!isSetupChat(ctx)) return;
|
|
286
|
+
const text = String(ctx.message.text || "").trim();
|
|
287
|
+
if (!text || text.startsWith("/")) return;
|
|
288
|
+
if (state === "pi-api-key") {
|
|
289
|
+
piApiKey = text;
|
|
290
|
+
await askBackground();
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
if (state === "pi-login" && activeLogin?.manualInputRequested) {
|
|
294
|
+
if (activeLogin.submitManualCode(text)) await ctx.reply("Got it. Finishing Pi login now...");
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
if (state === "background") {
|
|
298
|
+
const answer = parseYesNo(text, true);
|
|
299
|
+
if (answer === null) {
|
|
300
|
+
await ctx.reply("Please answer yes or no.");
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
await ctx.reply(answer
|
|
304
|
+
? "Saving config. Arisa will start in background now."
|
|
305
|
+
: "Saving config. Arisa will continue in foreground.");
|
|
306
|
+
complete(answer);
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
await bot.api.deleteWebhook({ drop_pending_updates: true });
|
|
311
|
+
console.log("Waiting for Telegram setup to complete...");
|
|
312
|
+
const polling = bot.start().then(() => {
|
|
313
|
+
if (!completed) throw new Error("Telegram setup bot stopped before bootstrap completed.");
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
try {
|
|
317
|
+
return await Promise.race([resultPromise, polling]);
|
|
318
|
+
} catch (error) {
|
|
319
|
+
rejectResult(error);
|
|
320
|
+
throw error;
|
|
321
|
+
} finally {
|
|
322
|
+
bot.stop();
|
|
323
|
+
await polling.catch(() => {});
|
|
324
|
+
}
|
|
325
|
+
}
|