@grinev/opencode-telegram-bot 0.16.0 → 0.17.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/.env.example +8 -0
- package/README.md +72 -42
- package/dist/app/start-bot-app.js +76 -8
- package/dist/bot/commands/definitions.js +2 -0
- package/dist/bot/commands/opencode-start.js +27 -24
- package/dist/bot/commands/opencode-stop.js +51 -23
- package/dist/bot/commands/projects.js +30 -6
- package/dist/bot/commands/skills.js +376 -0
- package/dist/bot/commands/status.js +21 -14
- package/dist/bot/commands/worktree.js +196 -0
- package/dist/bot/handlers/inline-menu.js +1 -0
- package/dist/bot/handlers/voice.js +8 -1
- package/dist/bot/index.js +39 -4
- package/dist/cli/args.js +36 -7
- package/dist/cli.js +131 -17
- package/dist/config.js +3 -0
- package/dist/git/worktree.js +158 -0
- package/dist/i18n/de.js +39 -11
- package/dist/i18n/en.js +39 -11
- package/dist/i18n/es.js +39 -11
- package/dist/i18n/fr.js +39 -11
- package/dist/i18n/ru.js +39 -11
- package/dist/i18n/zh.js +39 -11
- package/dist/opencode/process.js +182 -0
- package/dist/pinned/manager.js +28 -61
- package/dist/project/manager.js +47 -32
- package/dist/runtime/bootstrap.js +119 -7
- package/dist/scheduled-task/executor.js +137 -7
- package/dist/scheduled-task/runtime.js +8 -0
- package/dist/service/manager.js +244 -0
- package/dist/service/runtime.js +19 -0
- package/dist/settings/manager.js +9 -12
- package/package.json +1 -1
- package/dist/process/manager.js +0 -273
- /package/dist/{process → service}/types.js +0 -0
package/dist/pinned/manager.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import { readFile, stat } from "node:fs/promises";
|
|
2
|
-
import path from "node:path";
|
|
3
1
|
import { logger } from "../utils/logger.js";
|
|
4
2
|
import { opencodeClient } from "../opencode/client.js";
|
|
3
|
+
import { getGitWorktreeContext } from "../git/worktree.js";
|
|
5
4
|
import { getCurrentSession } from "../session/manager.js";
|
|
6
5
|
import { getCurrentProject, getPinnedMessageId, setPinnedMessageId, clearPinnedMessageId, } from "../settings/manager.js";
|
|
7
6
|
import { getStoredModel } from "../model/manager.js";
|
|
@@ -16,8 +15,9 @@ class PinnedMessageManager {
|
|
|
16
15
|
chatId: null,
|
|
17
16
|
sessionId: null,
|
|
18
17
|
sessionTitle: t("pinned.default_session_title"),
|
|
19
|
-
|
|
18
|
+
projectPath: "",
|
|
20
19
|
projectBranch: null,
|
|
20
|
+
projectWorktreePath: null,
|
|
21
21
|
tokensUsed: 0,
|
|
22
22
|
tokensLimit: 0,
|
|
23
23
|
lastUpdated: 0,
|
|
@@ -419,66 +419,27 @@ class PinnedMessageManager {
|
|
|
419
419
|
*/
|
|
420
420
|
async refreshProjectMetadata() {
|
|
421
421
|
const project = getCurrentProject();
|
|
422
|
-
this.state.
|
|
423
|
-
|
|
424
|
-
this.state.
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
* Resolve current git branch for a project worktree.
|
|
428
|
-
*/
|
|
429
|
-
async getGitBranchName(worktree) {
|
|
430
|
-
if (!worktree) {
|
|
431
|
-
return null;
|
|
422
|
+
this.state.projectPath = project?.worktree || t("pinned.unknown");
|
|
423
|
+
this.state.projectBranch = null;
|
|
424
|
+
this.state.projectWorktreePath = null;
|
|
425
|
+
if (!project?.worktree) {
|
|
426
|
+
return;
|
|
432
427
|
}
|
|
433
428
|
try {
|
|
434
|
-
const
|
|
435
|
-
if (!
|
|
436
|
-
return
|
|
429
|
+
const worktreeContext = await getGitWorktreeContext(project.worktree);
|
|
430
|
+
if (!worktreeContext) {
|
|
431
|
+
return;
|
|
437
432
|
}
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
433
|
+
this.state.projectPath = worktreeContext.mainProjectPath;
|
|
434
|
+
this.state.projectBranch = worktreeContext.branch;
|
|
435
|
+
this.state.projectWorktreePath = worktreeContext.isLinkedWorktree
|
|
436
|
+
? worktreeContext.activeWorktreePath
|
|
437
|
+
: null;
|
|
442
438
|
}
|
|
443
439
|
catch (err) {
|
|
444
|
-
logger.debug("[PinnedManager] Could not resolve git
|
|
445
|
-
return null;
|
|
440
|
+
logger.debug("[PinnedManager] Could not resolve git worktree metadata:", err);
|
|
446
441
|
}
|
|
447
442
|
}
|
|
448
|
-
/**
|
|
449
|
-
* Resolve git directory for a normal repository or linked worktree.
|
|
450
|
-
*/
|
|
451
|
-
async resolveGitDir(worktree) {
|
|
452
|
-
const gitPath = path.join(worktree, ".git");
|
|
453
|
-
try {
|
|
454
|
-
const gitStat = await stat(gitPath);
|
|
455
|
-
if (gitStat.isDirectory()) {
|
|
456
|
-
return gitPath;
|
|
457
|
-
}
|
|
458
|
-
if (!gitStat.isFile()) {
|
|
459
|
-
return null;
|
|
460
|
-
}
|
|
461
|
-
const gitPointer = (await readFile(gitPath, "utf-8")).trim();
|
|
462
|
-
const match = gitPointer.match(/^gitdir:\s*(.+)$/i);
|
|
463
|
-
if (!match) {
|
|
464
|
-
return null;
|
|
465
|
-
}
|
|
466
|
-
return path.resolve(worktree, match[1].trim());
|
|
467
|
-
}
|
|
468
|
-
catch {
|
|
469
|
-
return null;
|
|
470
|
-
}
|
|
471
|
-
}
|
|
472
|
-
/**
|
|
473
|
-
* Extract project name from worktree path
|
|
474
|
-
*/
|
|
475
|
-
extractProjectName(worktree) {
|
|
476
|
-
if (!worktree)
|
|
477
|
-
return "";
|
|
478
|
-
// Get last part of path
|
|
479
|
-
const parts = worktree.replace(/\\/g, "/").split("/");
|
|
480
|
-
return parts[parts.length - 1] || "";
|
|
481
|
-
}
|
|
482
443
|
/**
|
|
483
444
|
* Make file path relative to project worktree
|
|
484
445
|
*/
|
|
@@ -540,14 +501,17 @@ class PinnedMessageManager {
|
|
|
540
501
|
const currentModel = getStoredModel();
|
|
541
502
|
const modelName = formatModelDisplayName(currentModel.providerID, currentModel.modelID);
|
|
542
503
|
const projectDisplayName = this.state.projectBranch
|
|
543
|
-
? `${this.state.
|
|
544
|
-
: this.state.
|
|
504
|
+
? `${this.state.projectPath}: ${this.state.projectBranch}`
|
|
505
|
+
: this.state.projectPath;
|
|
545
506
|
const lines = [
|
|
546
507
|
`${this.state.sessionTitle}`,
|
|
547
508
|
t("pinned.line.project", { project: projectDisplayName }),
|
|
548
|
-
t("pinned.line.model", { model: modelName }),
|
|
549
|
-
formatContextLine(this.state.tokensUsed, this.state.tokensLimit),
|
|
550
509
|
];
|
|
510
|
+
if (this.state.projectWorktreePath) {
|
|
511
|
+
lines.push(t("pinned.line.worktree", { worktree: this.state.projectWorktreePath }));
|
|
512
|
+
}
|
|
513
|
+
lines.push(t("pinned.line.model", { model: modelName }));
|
|
514
|
+
lines.push(formatContextLine(this.state.tokensUsed, this.state.tokensLimit));
|
|
551
515
|
if (this.state.cost !== undefined && this.state.cost !== null) {
|
|
552
516
|
lines.push(formatCostLine(this.state.cost));
|
|
553
517
|
}
|
|
@@ -710,7 +674,9 @@ class PinnedMessageManager {
|
|
|
710
674
|
this.state.sessionId = null;
|
|
711
675
|
this.state.tokensUsed = 0;
|
|
712
676
|
this.state.tokensLimit = 0;
|
|
677
|
+
this.state.projectPath = "";
|
|
713
678
|
this.state.projectBranch = null;
|
|
679
|
+
this.state.projectWorktreePath = null;
|
|
714
680
|
this.state.changedFiles = [];
|
|
715
681
|
this.lastRenderedMessageText = null;
|
|
716
682
|
this.pendingUpdate = false;
|
|
@@ -725,8 +691,9 @@ class PinnedMessageManager {
|
|
|
725
691
|
this.state.messageId = null;
|
|
726
692
|
this.state.sessionId = null;
|
|
727
693
|
this.state.sessionTitle = t("pinned.default_session_title");
|
|
728
|
-
this.state.
|
|
694
|
+
this.state.projectPath = "";
|
|
729
695
|
this.state.projectBranch = null;
|
|
696
|
+
this.state.projectWorktreePath = null;
|
|
730
697
|
this.state.tokensUsed = 0;
|
|
731
698
|
this.state.tokensLimit = 0;
|
|
732
699
|
this.state.changedFiles = [];
|
package/dist/project/manager.js
CHANGED
|
@@ -3,35 +3,8 @@ import path from "node:path";
|
|
|
3
3
|
import { opencodeClient } from "../opencode/client.js";
|
|
4
4
|
import { getCachedSessionProjects } from "../session/cache-manager.js";
|
|
5
5
|
import { logger } from "../utils/logger.js";
|
|
6
|
-
async function
|
|
7
|
-
|
|
8
|
-
return false;
|
|
9
|
-
}
|
|
10
|
-
const gitPath = path.join(worktree, ".git");
|
|
11
|
-
try {
|
|
12
|
-
const gitStat = await stat(gitPath);
|
|
13
|
-
if (!gitStat.isFile()) {
|
|
14
|
-
return false;
|
|
15
|
-
}
|
|
16
|
-
const gitPointer = (await readFile(gitPath, "utf-8")).trim();
|
|
17
|
-
const match = gitPointer.match(/^gitdir:\s*(.+)$/i);
|
|
18
|
-
if (!match) {
|
|
19
|
-
return false;
|
|
20
|
-
}
|
|
21
|
-
const gitDir = path.resolve(worktree, match[1].trim()).replace(/\\/g, "/").toLowerCase();
|
|
22
|
-
return gitDir.includes("/.git/worktrees/");
|
|
23
|
-
}
|
|
24
|
-
catch {
|
|
25
|
-
return false;
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
function worktreeKey(worktree) {
|
|
29
|
-
if (process.platform === "win32") {
|
|
30
|
-
return worktree.toLowerCase();
|
|
31
|
-
}
|
|
32
|
-
return worktree;
|
|
33
|
-
}
|
|
34
|
-
export async function getProjects() {
|
|
6
|
+
async function getResolvedProjects(options) {
|
|
7
|
+
const includeLinkedWorktrees = options?.includeLinkedWorktrees === true;
|
|
35
8
|
const { data: projects, error } = await opencodeClient.project.list();
|
|
36
9
|
if (error || !projects) {
|
|
37
10
|
throw error || new Error("No data received from server");
|
|
@@ -64,11 +37,53 @@ export async function getProjects() {
|
|
|
64
37
|
});
|
|
65
38
|
}
|
|
66
39
|
const projectList = Array.from(mergedByWorktree.values()).sort((left, right) => right.lastUpdated - left.lastUpdated);
|
|
40
|
+
if (includeLinkedWorktrees) {
|
|
41
|
+
return projectList;
|
|
42
|
+
}
|
|
67
43
|
const linkedWorktreeFlags = await Promise.all(projectList.map((project) => isLinkedGitWorktree(project.worktree)));
|
|
68
44
|
const visibleProjects = projectList.filter((_, index) => !linkedWorktreeFlags[index]);
|
|
69
45
|
const hiddenLinkedWorktrees = projectList.length - visibleProjects.length;
|
|
70
46
|
logger.debug(`[ProjectManager] Projects resolved: api=${projects.length}, cached=${cachedProjects.length}, hiddenLinkedWorktrees=${hiddenLinkedWorktrees}, total=${visibleProjects.length}`);
|
|
71
|
-
return visibleProjects
|
|
47
|
+
return visibleProjects;
|
|
48
|
+
}
|
|
49
|
+
async function isLinkedGitWorktree(worktree) {
|
|
50
|
+
if (worktree === "/") {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
const gitPath = path.join(worktree, ".git");
|
|
54
|
+
try {
|
|
55
|
+
const gitStat = await stat(gitPath);
|
|
56
|
+
if (!gitStat.isFile()) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
const gitPointer = (await readFile(gitPath, "utf-8")).trim();
|
|
60
|
+
const match = gitPointer.match(/^gitdir:\s*(.+)$/i);
|
|
61
|
+
if (!match) {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
const gitDir = path.resolve(worktree, match[1].trim()).replace(/\\/g, "/").toLowerCase();
|
|
65
|
+
return gitDir.includes("/.git/worktrees/");
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function worktreeKey(worktree) {
|
|
72
|
+
const pathModule = isWindowsWorktreePath(worktree) ? path.win32 : path.posix;
|
|
73
|
+
const normalizedWorktree = pathModule.normalize(worktree);
|
|
74
|
+
const root = pathModule.parse(normalizedWorktree).root;
|
|
75
|
+
const trimmedWorktree = normalizedWorktree === root ? normalizedWorktree : normalizedWorktree.replace(/[\\/]+$/, "");
|
|
76
|
+
if (pathModule === path.win32) {
|
|
77
|
+
return trimmedWorktree.toLowerCase();
|
|
78
|
+
}
|
|
79
|
+
return trimmedWorktree;
|
|
80
|
+
}
|
|
81
|
+
function isWindowsWorktreePath(worktree) {
|
|
82
|
+
return process.platform === "win32" || /^[a-zA-Z]:[\\/]/.test(worktree) || /^\\\\/.test(worktree);
|
|
83
|
+
}
|
|
84
|
+
export async function getProjects() {
|
|
85
|
+
const projects = await getResolvedProjects();
|
|
86
|
+
return projects.map(({ id, worktree, name }) => ({ id, worktree, name }));
|
|
72
87
|
}
|
|
73
88
|
export async function getProjectById(id) {
|
|
74
89
|
const projects = await getProjects();
|
|
@@ -79,11 +94,11 @@ export async function getProjectById(id) {
|
|
|
79
94
|
return project;
|
|
80
95
|
}
|
|
81
96
|
export async function getProjectByWorktree(worktree) {
|
|
82
|
-
const projects = await
|
|
97
|
+
const projects = await getResolvedProjects({ includeLinkedWorktrees: true });
|
|
83
98
|
const key = worktreeKey(worktree);
|
|
84
99
|
const project = projects.find((p) => worktreeKey(p.worktree) === key);
|
|
85
100
|
if (!project) {
|
|
86
101
|
throw new Error(`Project with worktree ${worktree} not found`);
|
|
87
102
|
}
|
|
88
|
-
return project;
|
|
103
|
+
return { id: project.id, worktree: project.worktree, name: project.name };
|
|
89
104
|
}
|
|
@@ -10,6 +10,16 @@ const DEFAULT_API_URL = "http://localhost:4096";
|
|
|
10
10
|
const DEFAULT_SERVER_USERNAME = "opencode";
|
|
11
11
|
const FALLBACK_MODEL_PROVIDER = "opencode";
|
|
12
12
|
const FALLBACK_MODEL_ID = "big-pickle";
|
|
13
|
+
const WIZARD_ENV_KEYS = [
|
|
14
|
+
"BOT_LOCALE",
|
|
15
|
+
"TELEGRAM_BOT_TOKEN",
|
|
16
|
+
"TELEGRAM_ALLOWED_USER_ID",
|
|
17
|
+
"OPENCODE_API_URL",
|
|
18
|
+
"OPENCODE_SERVER_USERNAME",
|
|
19
|
+
"OPENCODE_SERVER_PASSWORD",
|
|
20
|
+
"OPENCODE_MODEL_PROVIDER",
|
|
21
|
+
"OPENCODE_MODEL_ID",
|
|
22
|
+
];
|
|
13
23
|
function isPositiveInteger(value) {
|
|
14
24
|
return /^[1-9]\d*$/.test(value);
|
|
15
25
|
}
|
|
@@ -55,10 +65,63 @@ function removeEnvKey(lines, key) {
|
|
|
55
65
|
const regex = new RegExp(`^\\s*(?:export\\s+)?${escapeRegex(key)}\\s*=`);
|
|
56
66
|
return lines.filter((line) => !regex.test(line));
|
|
57
67
|
}
|
|
68
|
+
function parseEnvAssignmentLine(line) {
|
|
69
|
+
const match = /^(\s*#\s*)?(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/.exec(line);
|
|
70
|
+
if (!match) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
key: match[2],
|
|
75
|
+
rawValue: match[3],
|
|
76
|
+
line,
|
|
77
|
+
isCommented: typeof match[1] === "string",
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
function buildTemplateKeySet(templateContent) {
|
|
81
|
+
const keys = new Set();
|
|
82
|
+
for (const line of normalizeEnvLineEndings(templateContent)) {
|
|
83
|
+
const parsedLine = parseEnvAssignmentLine(line);
|
|
84
|
+
if (parsedLine !== null) {
|
|
85
|
+
keys.add(parsedLine.key);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return keys;
|
|
89
|
+
}
|
|
90
|
+
function collectActiveEnvAssignments(content) {
|
|
91
|
+
const assignments = new Map();
|
|
92
|
+
for (const line of normalizeEnvLineEndings(content)) {
|
|
93
|
+
const parsedLine = parseEnvAssignmentLine(line);
|
|
94
|
+
if (parsedLine === null || parsedLine.isCommented) {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
assignments.set(parsedLine.key, parsedLine);
|
|
98
|
+
}
|
|
99
|
+
return assignments;
|
|
100
|
+
}
|
|
101
|
+
function collectCustomEnvAssignments(existingContent, templateKeys) {
|
|
102
|
+
const customAssignments = [];
|
|
103
|
+
const seenKeys = new Set();
|
|
104
|
+
const lines = normalizeEnvLineEndings(existingContent);
|
|
105
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
106
|
+
const parsedLine = parseEnvAssignmentLine(lines[index]);
|
|
107
|
+
if (parsedLine === null || parsedLine.isCommented || templateKeys.has(parsedLine.key)) {
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (seenKeys.has(parsedLine.key)) {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
seenKeys.add(parsedLine.key);
|
|
114
|
+
customAssignments.push(parsedLine);
|
|
115
|
+
}
|
|
116
|
+
return customAssignments.reverse().map((assignment) => assignment.line);
|
|
117
|
+
}
|
|
118
|
+
function renderEnvAssignment(key, rawValue) {
|
|
119
|
+
return `${key}=${rawValue}`;
|
|
120
|
+
}
|
|
58
121
|
function finalizeEnvContent(lines) {
|
|
59
122
|
return `${lines.join("\n")}\n`;
|
|
60
123
|
}
|
|
61
|
-
|
|
124
|
+
function buildFlatEnvFileContent(existingContent, values) {
|
|
62
125
|
let lines = normalizeEnvLineEndings(existingContent);
|
|
63
126
|
const orderedUpdates = [
|
|
64
127
|
["BOT_LOCALE", values.BOT_LOCALE],
|
|
@@ -78,6 +141,44 @@ export function buildEnvFileContent(existingContent, values) {
|
|
|
78
141
|
}
|
|
79
142
|
return finalizeEnvContent(lines);
|
|
80
143
|
}
|
|
144
|
+
export function buildEnvFileContent(existingContent, values, envExampleContent) {
|
|
145
|
+
if (!envExampleContent) {
|
|
146
|
+
return buildFlatEnvFileContent(existingContent, values);
|
|
147
|
+
}
|
|
148
|
+
const templateLines = normalizeEnvLineEndings(envExampleContent);
|
|
149
|
+
if (templateLines.length === 0) {
|
|
150
|
+
return buildFlatEnvFileContent(existingContent, values);
|
|
151
|
+
}
|
|
152
|
+
const templateKeys = buildTemplateKeySet(envExampleContent);
|
|
153
|
+
const existingAssignments = collectActiveEnvAssignments(existingContent);
|
|
154
|
+
const wizardOverrides = new Map(WIZARD_ENV_KEYS.map((key) => [key, values[key]]));
|
|
155
|
+
const renderedLines = templateLines.map((line) => {
|
|
156
|
+
const parsedLine = parseEnvAssignmentLine(line);
|
|
157
|
+
if (parsedLine === null) {
|
|
158
|
+
return line;
|
|
159
|
+
}
|
|
160
|
+
if (wizardOverrides.has(parsedLine.key)) {
|
|
161
|
+
const overrideValue = wizardOverrides.get(parsedLine.key);
|
|
162
|
+
if (overrideValue && overrideValue.trim().length > 0) {
|
|
163
|
+
return renderEnvAssignment(parsedLine.key, overrideValue);
|
|
164
|
+
}
|
|
165
|
+
return line;
|
|
166
|
+
}
|
|
167
|
+
const existingAssignment = existingAssignments.get(parsedLine.key);
|
|
168
|
+
if (existingAssignment !== undefined) {
|
|
169
|
+
return renderEnvAssignment(parsedLine.key, existingAssignment.rawValue);
|
|
170
|
+
}
|
|
171
|
+
return line;
|
|
172
|
+
});
|
|
173
|
+
const customAssignments = collectCustomEnvAssignments(existingContent, templateKeys);
|
|
174
|
+
if (customAssignments.length > 0) {
|
|
175
|
+
if (renderedLines.length > 0 && renderedLines[renderedLines.length - 1] !== "") {
|
|
176
|
+
renderedLines.push("");
|
|
177
|
+
}
|
|
178
|
+
renderedLines.push(...customAssignments);
|
|
179
|
+
}
|
|
180
|
+
return finalizeEnvContent(renderedLines);
|
|
181
|
+
}
|
|
81
182
|
async function readEnvFileIfExists(filePath) {
|
|
82
183
|
try {
|
|
83
184
|
return await fs.readFile(filePath, "utf-8");
|
|
@@ -112,14 +213,24 @@ function getEnvExamplePath() {
|
|
|
112
213
|
const currentFilePath = fileURLToPath(import.meta.url);
|
|
113
214
|
return path.resolve(path.dirname(currentFilePath), "..", "..", ".env.example");
|
|
114
215
|
}
|
|
115
|
-
async function
|
|
216
|
+
async function loadEnvExampleContent() {
|
|
217
|
+
try {
|
|
218
|
+
return await fs.readFile(getEnvExamplePath(), "utf-8");
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
function loadModelDefaultsFromEnvExample(envExampleContent) {
|
|
116
225
|
const fallbackDefaults = {
|
|
117
226
|
provider: FALLBACK_MODEL_PROVIDER,
|
|
118
227
|
modelId: FALLBACK_MODEL_ID,
|
|
119
228
|
};
|
|
120
229
|
try {
|
|
121
|
-
|
|
122
|
-
|
|
230
|
+
if (!envExampleContent) {
|
|
231
|
+
return fallbackDefaults;
|
|
232
|
+
}
|
|
233
|
+
const parsed = dotenv.parse(envExampleContent);
|
|
123
234
|
const provider = parsed.OPENCODE_MODEL_PROVIDER?.trim();
|
|
124
235
|
const modelId = parsed.OPENCODE_MODEL_ID?.trim();
|
|
125
236
|
if (!provider || !modelId) {
|
|
@@ -305,11 +416,12 @@ async function validateExistingEnv(envFilePath) {
|
|
|
305
416
|
}
|
|
306
417
|
async function runWizardAndPersist(runtimePaths) {
|
|
307
418
|
ensureInteractiveTty();
|
|
308
|
-
const [existingContent,
|
|
419
|
+
const [existingContent, envExampleContent, wizardValues] = await Promise.all([
|
|
309
420
|
readEnvFileIfExists(runtimePaths.envFilePath),
|
|
310
|
-
|
|
421
|
+
loadEnvExampleContent(),
|
|
311
422
|
collectWizardValues(),
|
|
312
423
|
]);
|
|
424
|
+
const modelDefaults = loadModelDefaultsFromEnvExample(envExampleContent);
|
|
313
425
|
const existingParsed = existingContent ? dotenv.parse(existingContent) : {};
|
|
314
426
|
const provider = existingParsed.OPENCODE_MODEL_PROVIDER || modelDefaults.provider;
|
|
315
427
|
const modelId = existingParsed.OPENCODE_MODEL_ID || modelDefaults.modelId;
|
|
@@ -323,7 +435,7 @@ async function runWizardAndPersist(runtimePaths) {
|
|
|
323
435
|
OPENCODE_MODEL_PROVIDER: provider,
|
|
324
436
|
OPENCODE_MODEL_ID: modelId,
|
|
325
437
|
};
|
|
326
|
-
const envContent = buildEnvFileContent(existingContent ?? "", envValues);
|
|
438
|
+
const envContent = buildEnvFileContent(existingContent ?? "", envValues, envExampleContent);
|
|
327
439
|
await writeFileAtomically(runtimePaths.envFilePath, envContent);
|
|
328
440
|
await ensureSettingsFile(runtimePaths.settingsFilePath);
|
|
329
441
|
process.stdout.write(t("runtime.wizard.saved", {
|
|
@@ -1,7 +1,12 @@
|
|
|
1
|
+
import { config } from "../config.js";
|
|
1
2
|
import { opencodeClient } from "../opencode/client.js";
|
|
2
3
|
import { logger } from "../utils/logger.js";
|
|
3
4
|
const SCHEDULED_TASK_AGENT = "build";
|
|
4
5
|
const SCHEDULED_TASK_SESSION_TITLE = "Scheduled task run";
|
|
6
|
+
const EXECUTION_POLL_INTERVAL_MS = 2000;
|
|
7
|
+
const MAX_IDLE_POLLS_WITHOUT_RESULT = 3;
|
|
8
|
+
const MODELS_DOCS_URL = "https://opencode.ai/docs/config/#models";
|
|
9
|
+
const EXECUTION_TIMEOUT_ERROR_PREFIX = "Scheduled task exceeded bot execution timeout";
|
|
5
10
|
function collectResponseText(parts) {
|
|
6
11
|
return parts
|
|
7
12
|
.filter((part) => part.type === "text" && typeof part.text === "string" && !part.ignored)
|
|
@@ -9,15 +14,143 @@ function collectResponseText(parts) {
|
|
|
9
14
|
.join("")
|
|
10
15
|
.trim();
|
|
11
16
|
}
|
|
12
|
-
function
|
|
17
|
+
function extractErrorMessage(error) {
|
|
13
18
|
if (error instanceof Error && error.message.trim()) {
|
|
14
19
|
return error.message.trim();
|
|
15
20
|
}
|
|
16
21
|
if (typeof error === "string" && error.trim()) {
|
|
17
22
|
return error.trim();
|
|
18
23
|
}
|
|
24
|
+
if (!error || typeof error !== "object") {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
const typedError = error;
|
|
28
|
+
if (typeof typedError.data?.message === "string" && typedError.data.message.trim()) {
|
|
29
|
+
return typedError.data.message.trim();
|
|
30
|
+
}
|
|
31
|
+
if (typeof typedError.message === "string" && typedError.message.trim()) {
|
|
32
|
+
return typedError.message.trim();
|
|
33
|
+
}
|
|
34
|
+
if (typeof typedError.name === "string" && typedError.name.trim()) {
|
|
35
|
+
return typedError.name.trim();
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
function isTimeoutErrorMessage(message) {
|
|
40
|
+
return /(timed out|timeout|time out|deadline exceeded|request aborted)/i.test(message);
|
|
41
|
+
}
|
|
42
|
+
function isBotExecutionTimeoutMessage(message) {
|
|
43
|
+
return message.startsWith(EXECUTION_TIMEOUT_ERROR_PREFIX);
|
|
44
|
+
}
|
|
45
|
+
function createExecutionTimeoutMessage() {
|
|
46
|
+
return `${EXECUTION_TIMEOUT_ERROR_PREFIX} after ${config.bot.scheduledTaskExecutionTimeoutMinutes} minutes.`;
|
|
47
|
+
}
|
|
48
|
+
function getExecutionTimeoutMs() {
|
|
49
|
+
return config.bot.scheduledTaskExecutionTimeoutMinutes * 60 * 1000;
|
|
50
|
+
}
|
|
51
|
+
function normalizeScheduledTaskErrorMessage(message) {
|
|
52
|
+
if (isBotExecutionTimeoutMessage(message) ||
|
|
53
|
+
!isTimeoutErrorMessage(message) ||
|
|
54
|
+
message.includes(MODELS_DOCS_URL)) {
|
|
55
|
+
return message;
|
|
56
|
+
}
|
|
57
|
+
return `${message} Check OpenCode model timeout settings: ${MODELS_DOCS_URL}`;
|
|
58
|
+
}
|
|
59
|
+
function toErrorMessage(error) {
|
|
60
|
+
const message = extractErrorMessage(error);
|
|
61
|
+
if (message) {
|
|
62
|
+
return normalizeScheduledTaskErrorMessage(message);
|
|
63
|
+
}
|
|
19
64
|
return "Unknown scheduled task execution error";
|
|
20
65
|
}
|
|
66
|
+
function sleep(ms) {
|
|
67
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
68
|
+
}
|
|
69
|
+
function findLatestAssistantMessage(messages) {
|
|
70
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
71
|
+
const message = messages[index];
|
|
72
|
+
if (message?.info.role === "assistant") {
|
|
73
|
+
return message;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
function extractAssistantResult(message) {
|
|
79
|
+
if (!message) {
|
|
80
|
+
return { resultText: null, errorMessage: null, completed: false };
|
|
81
|
+
}
|
|
82
|
+
const errorMessage = extractErrorMessage(message.info.error);
|
|
83
|
+
if (errorMessage) {
|
|
84
|
+
return {
|
|
85
|
+
resultText: null,
|
|
86
|
+
errorMessage: normalizeScheduledTaskErrorMessage(errorMessage),
|
|
87
|
+
completed: true,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
const resultText = collectResponseText(message.parts);
|
|
91
|
+
return {
|
|
92
|
+
resultText,
|
|
93
|
+
errorMessage: null,
|
|
94
|
+
completed: Boolean(message.info.time?.completed),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
async function loadAssistantResult(sessionId, directory) {
|
|
98
|
+
const { data: messages, error: messagesError } = await opencodeClient.session.messages({
|
|
99
|
+
sessionID: sessionId,
|
|
100
|
+
directory,
|
|
101
|
+
});
|
|
102
|
+
if (messagesError || !messages) {
|
|
103
|
+
throw messagesError || new Error("Failed to load scheduled task messages");
|
|
104
|
+
}
|
|
105
|
+
return extractAssistantResult(findLatestAssistantMessage(messages));
|
|
106
|
+
}
|
|
107
|
+
async function waitForScheduledTaskResult(sessionId, directory) {
|
|
108
|
+
const startedAtMs = Date.now();
|
|
109
|
+
const executionTimeoutMs = getExecutionTimeoutMs();
|
|
110
|
+
let idlePollsWithoutResult = 0;
|
|
111
|
+
while (true) {
|
|
112
|
+
if (Date.now() - startedAtMs >= executionTimeoutMs) {
|
|
113
|
+
throw new Error(createExecutionTimeoutMessage());
|
|
114
|
+
}
|
|
115
|
+
const assistantResult = await loadAssistantResult(sessionId, directory);
|
|
116
|
+
if (assistantResult.errorMessage) {
|
|
117
|
+
throw new Error(assistantResult.errorMessage);
|
|
118
|
+
}
|
|
119
|
+
if (assistantResult.completed) {
|
|
120
|
+
if (assistantResult.resultText) {
|
|
121
|
+
return assistantResult.resultText;
|
|
122
|
+
}
|
|
123
|
+
throw new Error("Scheduled task returned an empty assistant response");
|
|
124
|
+
}
|
|
125
|
+
const { data: statuses, error: statusError } = await opencodeClient.session.status({
|
|
126
|
+
directory,
|
|
127
|
+
});
|
|
128
|
+
if (statusError || !statuses) {
|
|
129
|
+
throw statusError || new Error("Failed to load scheduled task status");
|
|
130
|
+
}
|
|
131
|
+
const sessionStatus = statuses[sessionId];
|
|
132
|
+
if (!sessionStatus || sessionStatus.type === "idle") {
|
|
133
|
+
const confirmedAssistantResult = await loadAssistantResult(sessionId, directory);
|
|
134
|
+
if (confirmedAssistantResult.errorMessage) {
|
|
135
|
+
throw new Error(confirmedAssistantResult.errorMessage);
|
|
136
|
+
}
|
|
137
|
+
if (confirmedAssistantResult.completed) {
|
|
138
|
+
if (confirmedAssistantResult.resultText) {
|
|
139
|
+
return confirmedAssistantResult.resultText;
|
|
140
|
+
}
|
|
141
|
+
throw new Error("Scheduled task returned an empty assistant response");
|
|
142
|
+
}
|
|
143
|
+
idlePollsWithoutResult += 1;
|
|
144
|
+
if (idlePollsWithoutResult >= MAX_IDLE_POLLS_WITHOUT_RESULT) {
|
|
145
|
+
throw new Error("Scheduled task finished without a completed assistant response");
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
idlePollsWithoutResult = 0;
|
|
150
|
+
}
|
|
151
|
+
await sleep(EXECUTION_POLL_INTERVAL_MS);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
21
154
|
export async function executeScheduledTask(task) {
|
|
22
155
|
const startedAt = new Date().toISOString();
|
|
23
156
|
let sessionId = null;
|
|
@@ -45,14 +178,11 @@ export async function executeScheduledTask(task) {
|
|
|
45
178
|
if (task.model.variant) {
|
|
46
179
|
promptOptions.variant = task.model.variant;
|
|
47
180
|
}
|
|
48
|
-
const {
|
|
49
|
-
if (promptError
|
|
181
|
+
const { error: promptError } = await opencodeClient.session.promptAsync(promptOptions);
|
|
182
|
+
if (promptError) {
|
|
50
183
|
throw promptError || new Error("Scheduled task prompt execution failed");
|
|
51
184
|
}
|
|
52
|
-
const resultText =
|
|
53
|
-
if (!resultText) {
|
|
54
|
-
throw new Error("Scheduled task returned an empty assistant response");
|
|
55
|
-
}
|
|
185
|
+
const resultText = await waitForScheduledTaskResult(session.id, session.directory);
|
|
56
186
|
return {
|
|
57
187
|
taskId: task.id,
|
|
58
188
|
status: "success",
|
|
@@ -122,6 +122,14 @@ export class ScheduledTaskRuntime {
|
|
|
122
122
|
this.flushInProgress = false;
|
|
123
123
|
}
|
|
124
124
|
}
|
|
125
|
+
shutdown() {
|
|
126
|
+
for (const timer of this.timersByTaskId.values()) {
|
|
127
|
+
clearTimeout(timer);
|
|
128
|
+
}
|
|
129
|
+
this.timersByTaskId.clear();
|
|
130
|
+
this.runningTaskIds.clear();
|
|
131
|
+
this.initialized = false;
|
|
132
|
+
}
|
|
125
133
|
__resetForTests() {
|
|
126
134
|
for (const timer of this.timersByTaskId.values()) {
|
|
127
135
|
clearTimeout(timer);
|