appback-remoteagent 0.15.6 → 0.16.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 +2 -0
- package/README.md +2 -0
- package/dist/bot.js +234 -17
- package/dist/config.js +2 -0
- package/dist/index.js +26 -0
- package/dist/services/agent-memory-service.js +66 -0
- package/dist/services/bridge-service.js +3 -1
- package/dist/services/workspace-cleanup-service.js +150 -0
- package/dist/telegram-command-menu.js +2 -0
- package/docs/OPERATIONS.md +69 -0
- package/package.json +3 -2
- package/scripts/disk-maintenance.sh +216 -0
- package/scripts/release-deploy.sh +28 -0
- package/scripts/selftest-telegram-update.mjs +73 -2
package/.env.example
CHANGED
|
@@ -18,6 +18,8 @@ TELEGRAM_RECOVERY_CHECK_INTERVAL_MS=60000
|
|
|
18
18
|
ARTIFACT_CLEANUP_ENABLED=true
|
|
19
19
|
ARTIFACT_RETENTION_DAYS=30
|
|
20
20
|
ARTIFACT_CLEANUP_INTERVAL_MS=86400000
|
|
21
|
+
WORKSPACE_CLEANUP_ENABLED=true
|
|
22
|
+
WORKSPACE_CLEANUP_INTERVAL_MS=86400000
|
|
21
23
|
REMOTEAGENT_WATCHDOG_ENABLED=true
|
|
22
24
|
REMOTEAGENT_WATCHDOG_INTERVAL_MS=300000
|
|
23
25
|
REMOTEAGENT_WATCHDOG_CPU_PERCENT=90
|
package/README.md
CHANGED
|
@@ -182,6 +182,8 @@ Telegram attachments are written into the local runtime under `~/.remoteagent/up
|
|
|
182
182
|
|
|
183
183
|
Attachments can be inspected with `/artifacts list` and cleaned manually with `/artifacts cleanup <days>`. RemoteAgent also runs periodic artifact cleanup when `ARTIFACT_CLEANUP_ENABLED=true`; by default it keeps 30 days and also removes old unindexed files under `uploads/telegram`.
|
|
184
184
|
|
|
185
|
+
Managed workspaces are cleaned separately. RemoteAgent periodically removes only orphan workspace directories that are no longer referenced by `state.json` when `WORKSPACE_CLEANUP_ENABLED=true`. Use `/cleanup` to clear the current session workspace; RemoteAgent keeps the session todo/state/history under `~/.remoteagent/managed/sessions/<session>` and refuses non-RemoteAgent-managed workspaces.
|
|
186
|
+
|
|
185
187
|
Current supported attachment classes:
|
|
186
188
|
|
|
187
189
|
- photos
|
package/dist/bot.js
CHANGED
|
@@ -9,6 +9,7 @@ import { config } from "./config.js";
|
|
|
9
9
|
import { ProviderSetupService } from "./services/provider-setup-service.js";
|
|
10
10
|
import { RemoteShellService } from "./services/remote-shell-service.js";
|
|
11
11
|
import { AgentMemoryService } from "./services/agent-memory-service.js";
|
|
12
|
+
import { WorkspaceCleanupService } from "./services/workspace-cleanup-service.js";
|
|
12
13
|
import { deleteTelegramCommandMenu, setTelegramCommandMenu } from "./telegram-command-menu.js";
|
|
13
14
|
const execFileAsync = promisify(execFile);
|
|
14
15
|
const HELP_TEXT = [
|
|
@@ -29,8 +30,11 @@ const HELP_TEXT = [
|
|
|
29
30
|
"/option [retry <count>|timeout <seconds>|intent <count>|command-menu <on|off|refresh>]",
|
|
30
31
|
"/state [clear|note <text>]",
|
|
31
32
|
"/artifacts list|cleanup <days>",
|
|
33
|
+
"/cleanup",
|
|
32
34
|
"/secret set|list|remove",
|
|
33
35
|
"/docs pin|find|list|remove|reinforce",
|
|
36
|
+
"/macro set|list|remove|<alias|number>",
|
|
37
|
+
"/매크로 set|list|remove|<alias|number>",
|
|
34
38
|
"/보강 <count>",
|
|
35
39
|
"/bots",
|
|
36
40
|
"/bot add <token>",
|
|
@@ -82,8 +86,10 @@ const RECOGNIZED_COMMANDS = new Set([
|
|
|
82
86
|
"option",
|
|
83
87
|
"state",
|
|
84
88
|
"artifacts",
|
|
89
|
+
"cleanup",
|
|
85
90
|
"secret",
|
|
86
91
|
"docs",
|
|
92
|
+
"macro",
|
|
87
93
|
"bots",
|
|
88
94
|
"bot",
|
|
89
95
|
"install",
|
|
@@ -92,7 +98,8 @@ const RECOGNIZED_COMMANDS = new Set([
|
|
|
92
98
|
]);
|
|
93
99
|
const TELEGRAM_STALE_UPDATE_GRACE_SECONDS = 10;
|
|
94
100
|
const TELEGRAM_PROCESS_STARTED_AT_SECONDS = Math.floor(Date.now() / 1000);
|
|
95
|
-
const
|
|
101
|
+
const workLoopTails = new Map();
|
|
102
|
+
const workLoopGenerations = new Map();
|
|
96
103
|
const REPORT_CONTINUE_PROMPT = [
|
|
97
104
|
"Continue the same task now.",
|
|
98
105
|
"Do more concrete work before replying again.",
|
|
@@ -177,6 +184,7 @@ export function createBot(token, bridge, botManagement, botInfo) {
|
|
|
177
184
|
const autoContinue = new AutoContinueController(path.join(config.dataDir, "stop-gates.json"));
|
|
178
185
|
const shellService = new RemoteShellService(config.commandTimeoutMs);
|
|
179
186
|
const memoryService = new AgentMemoryService(config.dataDir);
|
|
187
|
+
const workspaceCleanupService = new WorkspaceCleanupService(config.dataDir, config.workspaceRoot);
|
|
180
188
|
const sourceBotToken = token;
|
|
181
189
|
const setupService = new ProviderSetupService(config.setupCommandTimeoutMs, (provider) => bridge.listAvailableProviders().includes(provider), {
|
|
182
190
|
codex: config.codexInstallCommand,
|
|
@@ -196,6 +204,7 @@ export function createBot(token, bridge, botManagement, botInfo) {
|
|
|
196
204
|
if (!previous) {
|
|
197
205
|
return;
|
|
198
206
|
}
|
|
207
|
+
cancelQueuedWorkLoops(botId, chatId, previous.session.sessionId);
|
|
199
208
|
autoContinue.requestSessionStop(previous.session.sessionId);
|
|
200
209
|
messageBatcher.cancelPending(botId, chatId);
|
|
201
210
|
messageBatcher.cancelManual(botId, chatId);
|
|
@@ -350,6 +359,7 @@ ${bridge.formatStatus(mapping)}`);
|
|
|
350
359
|
const mapping = await bridge.switchSession(botId, chatId, sessionId);
|
|
351
360
|
if (previous && previous.session.sessionId !== mapping.session.sessionId) {
|
|
352
361
|
autoContinue.requestSessionStop(previous.session.sessionId);
|
|
362
|
+
cancelQueuedWorkLoops(botId, chatId, previous.session.sessionId);
|
|
353
363
|
messageBatcher.cancelPending(botId, chatId);
|
|
354
364
|
messageBatcher.cancelManual(botId, chatId);
|
|
355
365
|
await bridge.stopSessionRun(previous.session.sessionId, botId, chatId, "Chat switched to another session; previous session execution was stopped.");
|
|
@@ -449,6 +459,7 @@ ${bridge.formatStatus(mapping)}`);
|
|
|
449
459
|
const mapping = await bridge.status(botId, chatId);
|
|
450
460
|
const sessionId = mapping?.session.sessionId;
|
|
451
461
|
autoContinue.requestStop(botId, chatId, sessionId);
|
|
462
|
+
cancelQueuedWorkLoops(botId, chatId, sessionId);
|
|
452
463
|
const pendingBatch = messageBatcher.cancelPending(botId, chatId);
|
|
453
464
|
const manualBatch = messageBatcher.cancelManual(botId, chatId);
|
|
454
465
|
if (!autoContinue.beginStop(botId, chatId, sessionId)) {
|
|
@@ -620,6 +631,19 @@ ${bridge.formatStatus(mapping)}`);
|
|
|
620
631
|
}
|
|
621
632
|
await reply(ctx, "Usage: `/artifacts list` or `/artifacts cleanup <days>`", { parse_mode: "Markdown" });
|
|
622
633
|
});
|
|
634
|
+
bot.command("cleanup", async (ctx) => {
|
|
635
|
+
await ensureOwnerControlAccess(ctx);
|
|
636
|
+
const botId = getBotId();
|
|
637
|
+
const chatId = String(ctx.chat.id);
|
|
638
|
+
const mapping = await bridge.status(botId, chatId);
|
|
639
|
+
if (!mapping) {
|
|
640
|
+
await reply(ctx, "No paired session for this chat yet.");
|
|
641
|
+
return;
|
|
642
|
+
}
|
|
643
|
+
const result = await workspaceCleanupService.cleanupSessionWorkspace(mapping.session);
|
|
644
|
+
await bridge.logSystem(botId, chatId, `Workspace cleanup requested for ${mapping.session.publicId}.`);
|
|
645
|
+
await reply(ctx, result);
|
|
646
|
+
});
|
|
623
647
|
bot.command("secret", async (ctx) => {
|
|
624
648
|
await ensureOwnerControlAccess(ctx);
|
|
625
649
|
const { args, rest } = parseCommand(ctx.message?.text, 2);
|
|
@@ -700,6 +724,40 @@ ${bridge.formatStatus(mapping)}`);
|
|
|
700
724
|
}
|
|
701
725
|
await reply(ctx, "Usage: `/docs list`, `/docs find <keyword>`, `/docs pin <keyword> <path>`, `/docs remove <keyword>`, or `/docs reinforce <1-10>`", { parse_mode: "Markdown" });
|
|
702
726
|
});
|
|
727
|
+
const handleMacroCommand = async (ctx, text) => {
|
|
728
|
+
if (!ctx.chat) {
|
|
729
|
+
throw new Error("Telegram chat context is missing.");
|
|
730
|
+
}
|
|
731
|
+
const botId = getBotId();
|
|
732
|
+
const chatId = String(ctx.chat.id);
|
|
733
|
+
const parsed = parseMacroCommandText(text, botId);
|
|
734
|
+
if (parsed.kind === "help") {
|
|
735
|
+
await reply(ctx, await formatMacroHelp(memoryService));
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
if (parsed.kind === "set") {
|
|
739
|
+
await ensureOwnerControlAccess(ctx);
|
|
740
|
+
const macro = await memoryService.setMacro(parsed.alias, parsed.prompt);
|
|
741
|
+
await reply(ctx, `Saved macro '${macro.alias}'.`);
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
if (parsed.kind === "remove") {
|
|
745
|
+
await ensureOwnerControlAccess(ctx);
|
|
746
|
+
const removed = await memoryService.removeMacro(parsed.alias);
|
|
747
|
+
await reply(ctx, removed ? `Removed macro '${parsed.alias}'.` : `Macro was not found: ${parsed.alias}`);
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
const macro = await memoryService.getMacro(parsed.target);
|
|
751
|
+
if (!macro) {
|
|
752
|
+
await reply(ctx, `Macro was not found: ${parsed.target}\n\n${await memoryService.listMacros()}`);
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
await bridge.logSystem(botId, chatId, `Macro executed: ${macro.alias}`);
|
|
756
|
+
await messageBatcher.enqueue({ botToken: token, telegramChatId: ctx.chat.id }, botId, chatId, macro.prompt);
|
|
757
|
+
};
|
|
758
|
+
bot.command("macro", async (ctx) => {
|
|
759
|
+
await handleMacroCommand(ctx, ctx.message?.text ?? "/macro");
|
|
760
|
+
});
|
|
703
761
|
bot.command("bots", async (ctx) => {
|
|
704
762
|
await ensureOwnerControlAccess(ctx);
|
|
705
763
|
const pendingNotice = await botManagement.getPendingOperationNotice();
|
|
@@ -825,6 +883,11 @@ ${bridge.formatStatus(mapping)}`);
|
|
|
825
883
|
await reply(ctx, "Usage: `/보강 <1-10>`", { parse_mode: "Markdown" });
|
|
826
884
|
return;
|
|
827
885
|
}
|
|
886
|
+
const koreanMacro = parseKoreanMacroCommand(text, botId);
|
|
887
|
+
if (koreanMacro) {
|
|
888
|
+
await handleMacroCommand(ctx, text ?? "/매크로");
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
828
891
|
if (text && isRecognizedSlashCommand(text, botId)) {
|
|
829
892
|
return;
|
|
830
893
|
}
|
|
@@ -1155,22 +1218,46 @@ async function routeTelegramWorkLoop(bridge, botId, chatId, message, label, botM
|
|
|
1155
1218
|
const currentSession = await bridge.status(botId, chatId);
|
|
1156
1219
|
const sessionId = currentSession?.session.sessionId;
|
|
1157
1220
|
const activeKey = workLoopKey(botId, chatId, sessionId);
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1221
|
+
const previousTail = workLoopTails.get(activeKey);
|
|
1222
|
+
const generation = workLoopGenerations.get(activeKey) ?? 0;
|
|
1223
|
+
let release;
|
|
1224
|
+
const gate = new Promise((resolve) => {
|
|
1225
|
+
release = resolve;
|
|
1226
|
+
});
|
|
1227
|
+
const currentTail = (previousTail ?? Promise.resolve())
|
|
1228
|
+
.catch(() => undefined)
|
|
1229
|
+
.then(() => gate);
|
|
1230
|
+
workLoopTails.set(activeKey, currentTail);
|
|
1231
|
+
if (previousTail) {
|
|
1232
|
+
await bridge.logSystem(botId, chatId, `Queued overlapping Telegram work loop for ${activeKey}.`);
|
|
1233
|
+
await helpers.reportProgress([`Queued instruction for ${currentSession?.session.publicId ?? "this session"}. It will run after the active work finishes.`]);
|
|
1234
|
+
await previousTail.catch(() => undefined);
|
|
1235
|
+
if ((workLoopGenerations.get(activeKey) ?? 0) !== generation || autoContinue.isStopRequested(botId, chatId, sessionId)) {
|
|
1236
|
+
await bridge.logSystem(botId, chatId, `Discarded queued Telegram work loop for ${activeKey}.`);
|
|
1237
|
+
release();
|
|
1238
|
+
if (workLoopTails.get(activeKey) === currentTail) {
|
|
1239
|
+
workLoopTails.delete(activeKey);
|
|
1240
|
+
}
|
|
1241
|
+
throw new SilentTelegramAbort("Queued work was discarded.");
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
let managedContext = "";
|
|
1245
|
+
try {
|
|
1246
|
+
if (currentSession) {
|
|
1247
|
+
await memoryService.recordInstruction(currentSession.session, message);
|
|
1248
|
+
}
|
|
1249
|
+
managedContext = currentSession
|
|
1250
|
+
? await memoryService.formatProviderContext(currentSession.session)
|
|
1251
|
+
: "";
|
|
1252
|
+
autoContinue.clear(botId, chatId, sessionId);
|
|
1253
|
+
}
|
|
1254
|
+
catch (error) {
|
|
1255
|
+
release();
|
|
1256
|
+
if (workLoopTails.get(activeKey) === currentTail) {
|
|
1257
|
+
workLoopTails.delete(activeKey);
|
|
1258
|
+
}
|
|
1259
|
+
throw error;
|
|
1169
1260
|
}
|
|
1170
|
-
const managedContext = currentSession
|
|
1171
|
-
? await memoryService.formatProviderContext(currentSession.session)
|
|
1172
|
-
: "";
|
|
1173
|
-
autoContinue.clear(botId, chatId, sessionId);
|
|
1174
1261
|
let prompt = appendManagedContext(appendReportProtocol(message), managedContext);
|
|
1175
1262
|
const maxTurns = options.maxTurns ?? config.telegramAutoProgressMaxTurns;
|
|
1176
1263
|
const emptyResponseRetries = config.telegramEmptyResponseRetries;
|
|
@@ -1351,7 +1438,10 @@ async function routeTelegramWorkLoop(bridge, botId, chatId, message, label, botM
|
|
|
1351
1438
|
}
|
|
1352
1439
|
}
|
|
1353
1440
|
finally {
|
|
1354
|
-
|
|
1441
|
+
release();
|
|
1442
|
+
if (workLoopTails.get(activeKey) === currentTail) {
|
|
1443
|
+
workLoopTails.delete(activeKey);
|
|
1444
|
+
}
|
|
1355
1445
|
if (currentSession) {
|
|
1356
1446
|
if (providerCompleted) {
|
|
1357
1447
|
await botManagement.markProviderCompleted(botId, sessionId);
|
|
@@ -1365,6 +1455,15 @@ async function routeTelegramWorkLoop(bridge, botId, chatId, message, label, botM
|
|
|
1365
1455
|
function workLoopKey(botId, chatId, sessionId) {
|
|
1366
1456
|
return sessionId ? `session:${sessionId}` : `chat:${botId}:${chatId}`;
|
|
1367
1457
|
}
|
|
1458
|
+
function cancelQueuedWorkLoops(botId, chatId, sessionId) {
|
|
1459
|
+
const keys = [workLoopKey(botId, chatId, sessionId)];
|
|
1460
|
+
if (sessionId) {
|
|
1461
|
+
keys.push(workLoopKey(botId, chatId));
|
|
1462
|
+
}
|
|
1463
|
+
for (const key of keys) {
|
|
1464
|
+
workLoopGenerations.set(key, (workLoopGenerations.get(key) ?? 0) + 1);
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1368
1467
|
class SilentTelegramAbort extends Error {
|
|
1369
1468
|
constructor(message) {
|
|
1370
1469
|
super(message);
|
|
@@ -1711,6 +1810,124 @@ function parseCommand(text, headCount) {
|
|
|
1711
1810
|
rest: remaining || undefined,
|
|
1712
1811
|
};
|
|
1713
1812
|
}
|
|
1813
|
+
function parseMacroCommandText(text, botId) {
|
|
1814
|
+
const body = stripSlashCommand(text, ["macro", "매크로"], botId).trim();
|
|
1815
|
+
if (!body) {
|
|
1816
|
+
return { kind: "help" };
|
|
1817
|
+
}
|
|
1818
|
+
const tokens = tokenizeCommandBody(body);
|
|
1819
|
+
const action = tokens[0]?.toLowerCase();
|
|
1820
|
+
if (action === "list") {
|
|
1821
|
+
return { kind: "help" };
|
|
1822
|
+
}
|
|
1823
|
+
if (action === "set") {
|
|
1824
|
+
const alias = tokens[1]?.trim();
|
|
1825
|
+
const prompt = tokens.length >= 3
|
|
1826
|
+
? tokens.slice(2).join(" ").trim()
|
|
1827
|
+
: "";
|
|
1828
|
+
if (!alias || !prompt) {
|
|
1829
|
+
throw new Error("Usage: /macro set <alias> <instruction>");
|
|
1830
|
+
}
|
|
1831
|
+
return { kind: "set", alias, prompt };
|
|
1832
|
+
}
|
|
1833
|
+
if (action === "remove" || action === "rm" || action === "delete" || action === "del" || action === "clear") {
|
|
1834
|
+
const alias = tokens[1]?.trim();
|
|
1835
|
+
if (!alias || tokens.length > 2) {
|
|
1836
|
+
throw new Error("Usage: /macro remove <alias>");
|
|
1837
|
+
}
|
|
1838
|
+
return { kind: "remove", alias };
|
|
1839
|
+
}
|
|
1840
|
+
return { kind: "run", target: tokens.length === 1 ? tokens[0] : body };
|
|
1841
|
+
}
|
|
1842
|
+
function parseKoreanMacroCommand(text, botId) {
|
|
1843
|
+
return Boolean(text && stripSlashCommand(text, ["매크로"], botId) !== text);
|
|
1844
|
+
}
|
|
1845
|
+
function stripSlashCommand(text, names, botId) {
|
|
1846
|
+
const trimmed = text?.trim() ?? "";
|
|
1847
|
+
for (const name of names) {
|
|
1848
|
+
const escaped = escapeRegExp(name);
|
|
1849
|
+
const username = botId ? `(?:@${escapeRegExp(botId.replace(/^@/, ""))})?` : "(?:@\\w+)?";
|
|
1850
|
+
const pattern = new RegExp(`^/${escaped}${username}(?:\\s+|$)`, "i");
|
|
1851
|
+
if (pattern.test(trimmed)) {
|
|
1852
|
+
return trimmed.replace(pattern, "");
|
|
1853
|
+
}
|
|
1854
|
+
}
|
|
1855
|
+
return trimmed;
|
|
1856
|
+
}
|
|
1857
|
+
function tokenizeCommandBody(input) {
|
|
1858
|
+
const tokens = [];
|
|
1859
|
+
let current = "";
|
|
1860
|
+
let quote;
|
|
1861
|
+
let escaped = false;
|
|
1862
|
+
for (const char of input) {
|
|
1863
|
+
if (escaped) {
|
|
1864
|
+
current += char;
|
|
1865
|
+
escaped = false;
|
|
1866
|
+
continue;
|
|
1867
|
+
}
|
|
1868
|
+
if (char === "\\") {
|
|
1869
|
+
escaped = true;
|
|
1870
|
+
continue;
|
|
1871
|
+
}
|
|
1872
|
+
if (quote) {
|
|
1873
|
+
if (char === quote) {
|
|
1874
|
+
quote = undefined;
|
|
1875
|
+
}
|
|
1876
|
+
else {
|
|
1877
|
+
current += char;
|
|
1878
|
+
}
|
|
1879
|
+
continue;
|
|
1880
|
+
}
|
|
1881
|
+
if (char === "'" || char === "\"") {
|
|
1882
|
+
quote = char;
|
|
1883
|
+
continue;
|
|
1884
|
+
}
|
|
1885
|
+
if (/\s/.test(char)) {
|
|
1886
|
+
if (current) {
|
|
1887
|
+
tokens.push(current);
|
|
1888
|
+
current = "";
|
|
1889
|
+
}
|
|
1890
|
+
continue;
|
|
1891
|
+
}
|
|
1892
|
+
current += char;
|
|
1893
|
+
}
|
|
1894
|
+
if (escaped) {
|
|
1895
|
+
current += "\\";
|
|
1896
|
+
}
|
|
1897
|
+
if (quote) {
|
|
1898
|
+
throw new Error("Unclosed quote in command.");
|
|
1899
|
+
}
|
|
1900
|
+
if (current) {
|
|
1901
|
+
tokens.push(current);
|
|
1902
|
+
}
|
|
1903
|
+
return tokens;
|
|
1904
|
+
}
|
|
1905
|
+
function escapeRegExp(value) {
|
|
1906
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1907
|
+
}
|
|
1908
|
+
async function formatMacroHelp(memoryService) {
|
|
1909
|
+
return [
|
|
1910
|
+
"Macro command guide",
|
|
1911
|
+
"",
|
|
1912
|
+
"Commands:",
|
|
1913
|
+
"```text",
|
|
1914
|
+
"/macro",
|
|
1915
|
+
"/macro set 초기설정 너의 역할은 ...",
|
|
1916
|
+
"/macro set '초기설정' '너의 역할은 ...'",
|
|
1917
|
+
"/macro 초기설정",
|
|
1918
|
+
"/macro 1",
|
|
1919
|
+
"/macro remove 초기설정",
|
|
1920
|
+
"/매크로",
|
|
1921
|
+
"/매크로 초기설정",
|
|
1922
|
+
"```",
|
|
1923
|
+
"",
|
|
1924
|
+
"Rules:",
|
|
1925
|
+
"- Macro aliases can use Korean, letters, numbers, dot, underscore, and dash.",
|
|
1926
|
+
"- Numeric-only aliases are reserved for list selection and cannot be registered.",
|
|
1927
|
+
"",
|
|
1928
|
+
await memoryService.listMacros(),
|
|
1929
|
+
].join("\n");
|
|
1930
|
+
}
|
|
1714
1931
|
function parsePlanReinforcementCount(raw) {
|
|
1715
1932
|
const value = raw?.trim();
|
|
1716
1933
|
if (!value) {
|
package/dist/config.js
CHANGED
|
@@ -152,6 +152,8 @@ export const config = {
|
|
|
152
152
|
artifactCleanupEnabled: readBoolean("ARTIFACT_CLEANUP_ENABLED", true),
|
|
153
153
|
artifactRetentionDays: readTimeout("ARTIFACT_RETENTION_DAYS", 30),
|
|
154
154
|
artifactCleanupIntervalMs: readTimeout("ARTIFACT_CLEANUP_INTERVAL_MS", 86_400_000),
|
|
155
|
+
workspaceCleanupEnabled: readBoolean("WORKSPACE_CLEANUP_ENABLED", true),
|
|
156
|
+
workspaceCleanupIntervalMs: readTimeout("WORKSPACE_CLEANUP_INTERVAL_MS", 86_400_000),
|
|
155
157
|
dataDir: defaultDataDir,
|
|
156
158
|
defaultMode: readMode("DEFAULT_MODE", "codex"),
|
|
157
159
|
defaultWorkspace: path.resolve(process.env.DEFAULT_WORKSPACE?.trim() || os.homedir()),
|
package/dist/index.js
CHANGED
|
@@ -14,6 +14,7 @@ import { BridgeService } from "./services/bridge-service.js";
|
|
|
14
14
|
import { BotManagementService } from "./services/bot-management-service.js";
|
|
15
15
|
import { LocalUiService } from "./services/local-ui-service.js";
|
|
16
16
|
import { AgentMemoryService } from "./services/agent-memory-service.js";
|
|
17
|
+
import { WorkspaceCleanupService } from "./services/workspace-cleanup-service.js";
|
|
17
18
|
import { BotPollingStateService } from "./services/bot-polling-state-service.js";
|
|
18
19
|
import { ProviderRecoveryService } from "./services/provider-recovery-service.js";
|
|
19
20
|
import { computePolicyPollIntervalMs, computeRecentMessageRanks } from "./services/polling-policy.js";
|
|
@@ -56,6 +57,7 @@ async function main() {
|
|
|
56
57
|
const bridge = new BridgeService(store, adapters, config.defaultWorkspace, config.workspaceRoot, isProviderInstalled, config.defaultMode, config.codexSandboxMode);
|
|
57
58
|
const botManagement = new BotManagementService(config.dataDir, config.botRestartServiceName, config.botRestartHelperPath, botPollingState);
|
|
58
59
|
startArtifactCleanupSchedule(new AgentMemoryService(config.dataDir));
|
|
60
|
+
startWorkspaceCleanupSchedule(new WorkspaceCleanupService(config.dataDir, config.workspaceRoot));
|
|
59
61
|
if (config.localUiEnabled) {
|
|
60
62
|
const localUi = new LocalUiService(bridge, config.localUiHost, config.localUiPort);
|
|
61
63
|
await localUi.start()
|
|
@@ -88,6 +90,30 @@ async function main() {
|
|
|
88
90
|
});
|
|
89
91
|
await startManualPollingScheduler(bots);
|
|
90
92
|
}
|
|
93
|
+
function startWorkspaceCleanupSchedule(workspaceCleanupService) {
|
|
94
|
+
if (!config.workspaceCleanupEnabled) {
|
|
95
|
+
console.log("Workspace cleanup schedule is disabled.");
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
const run = async () => {
|
|
99
|
+
try {
|
|
100
|
+
const result = await workspaceCleanupService.cleanupOrphanWorkspaces();
|
|
101
|
+
console.log(`[workspace-cleanup] ${result}`);
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
console.error("[workspace-cleanup] failed:", error);
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
console.log(`Workspace cleanup schedule enabled: interval=${config.workspaceCleanupIntervalMs}ms`);
|
|
108
|
+
const initial = setTimeout(() => {
|
|
109
|
+
void run();
|
|
110
|
+
}, 120_000);
|
|
111
|
+
initial.unref();
|
|
112
|
+
const interval = setInterval(() => {
|
|
113
|
+
void run();
|
|
114
|
+
}, config.workspaceCleanupIntervalMs);
|
|
115
|
+
interval.unref();
|
|
116
|
+
}
|
|
91
117
|
function startArtifactCleanupSchedule(memoryService) {
|
|
92
118
|
if (!config.artifactCleanupEnabled) {
|
|
93
119
|
console.log("Artifact cleanup schedule is disabled.");
|
|
@@ -9,6 +9,7 @@ export class AgentMemoryService {
|
|
|
9
9
|
artifactsPath;
|
|
10
10
|
secretsPath;
|
|
11
11
|
docsPath;
|
|
12
|
+
macrosPath;
|
|
12
13
|
telegramUploadsDir;
|
|
13
14
|
constructor(dataDir) {
|
|
14
15
|
this.dataDir = dataDir;
|
|
@@ -16,6 +17,7 @@ export class AgentMemoryService {
|
|
|
16
17
|
this.artifactsPath = path.join(this.rootDir, "artifacts.json");
|
|
17
18
|
this.secretsPath = path.join(this.rootDir, "secrets.json");
|
|
18
19
|
this.docsPath = path.join(this.rootDir, "docs-index.json");
|
|
20
|
+
this.macrosPath = path.join(this.rootDir, "macros.json");
|
|
19
21
|
this.telegramUploadsDir = path.join(dataDir, "uploads", "telegram");
|
|
20
22
|
}
|
|
21
23
|
async recordInstruction(session, instruction) {
|
|
@@ -238,6 +240,57 @@ export class AgentMemoryService {
|
|
|
238
240
|
await this.writeSecrets(secrets);
|
|
239
241
|
return existed;
|
|
240
242
|
}
|
|
243
|
+
async setMacro(alias, prompt) {
|
|
244
|
+
const normalizedAlias = this.normalizeMacroAlias(alias);
|
|
245
|
+
const normalizedPrompt = prompt.trim();
|
|
246
|
+
if (!normalizedPrompt) {
|
|
247
|
+
throw new Error("Macro prompt must not be empty.");
|
|
248
|
+
}
|
|
249
|
+
const macros = await this.readMacros();
|
|
250
|
+
const now = new Date().toISOString();
|
|
251
|
+
const record = {
|
|
252
|
+
alias: normalizedAlias,
|
|
253
|
+
prompt: normalizedPrompt,
|
|
254
|
+
createdAt: macros[normalizedAlias]?.createdAt ?? now,
|
|
255
|
+
updatedAt: now,
|
|
256
|
+
};
|
|
257
|
+
macros[normalizedAlias] = record;
|
|
258
|
+
await this.writeJson(this.macrosPath, macros);
|
|
259
|
+
return record;
|
|
260
|
+
}
|
|
261
|
+
async removeMacro(alias) {
|
|
262
|
+
const normalizedAlias = this.normalizeMacroAlias(alias);
|
|
263
|
+
const macros = await this.readMacros();
|
|
264
|
+
const existed = Boolean(macros[normalizedAlias]);
|
|
265
|
+
delete macros[normalizedAlias];
|
|
266
|
+
await this.writeJson(this.macrosPath, macros);
|
|
267
|
+
return existed;
|
|
268
|
+
}
|
|
269
|
+
async getMacro(aliasOrIndex) {
|
|
270
|
+
const macros = Object.values(await this.readMacros())
|
|
271
|
+
.sort((left, right) => left.alias.localeCompare(right.alias, "ko"));
|
|
272
|
+
const trimmed = aliasOrIndex.trim();
|
|
273
|
+
if (/^[0-9]+$/.test(trimmed)) {
|
|
274
|
+
const index = Number.parseInt(trimmed, 10);
|
|
275
|
+
return index >= 1 && index <= macros.length ? macros[index - 1] : undefined;
|
|
276
|
+
}
|
|
277
|
+
const normalizedAlias = this.normalizeMacroAlias(trimmed);
|
|
278
|
+
return macros.find((macro) => macro.alias === normalizedAlias);
|
|
279
|
+
}
|
|
280
|
+
async listMacros() {
|
|
281
|
+
const macros = Object.values(await this.readMacros())
|
|
282
|
+
.sort((left, right) => left.alias.localeCompare(right.alias, "ko"));
|
|
283
|
+
if (macros.length === 0) {
|
|
284
|
+
return "No macros are stored.";
|
|
285
|
+
}
|
|
286
|
+
return [
|
|
287
|
+
`Macros (${macros.length})`,
|
|
288
|
+
...macros.map((macro, index) => {
|
|
289
|
+
const preview = macro.prompt.replace(/\s+/g, " ").slice(0, 120);
|
|
290
|
+
return `${index + 1}. ${macro.alias}\n ${preview}${macro.prompt.length > preview.length ? "..." : ""}`;
|
|
291
|
+
}),
|
|
292
|
+
].join("\n");
|
|
293
|
+
}
|
|
241
294
|
async getSecret(key) {
|
|
242
295
|
this.assertSecretKey(key);
|
|
243
296
|
const secrets = await this.readSecrets();
|
|
@@ -646,6 +699,19 @@ export class AgentMemoryService {
|
|
|
646
699
|
async readDocs() {
|
|
647
700
|
return this.readJson(this.docsPath, {});
|
|
648
701
|
}
|
|
702
|
+
async readMacros() {
|
|
703
|
+
return this.readJson(this.macrosPath, {});
|
|
704
|
+
}
|
|
705
|
+
normalizeMacroAlias(alias) {
|
|
706
|
+
const normalized = alias.trim();
|
|
707
|
+
if (/^[0-9]+$/.test(normalized)) {
|
|
708
|
+
throw new Error("Macro alias cannot be numeric. Numeric values are reserved for list selection.");
|
|
709
|
+
}
|
|
710
|
+
if (!/^[a-z0-9가-힣._-]{1,80}$/i.test(normalized)) {
|
|
711
|
+
throw new Error("Macro alias must be 1-80 chars and may contain letters, numbers, Korean, dot, underscore, or dash.");
|
|
712
|
+
}
|
|
713
|
+
return normalized;
|
|
714
|
+
}
|
|
649
715
|
normalizeKeyword(keyword) {
|
|
650
716
|
const normalized = keyword.trim().toLowerCase();
|
|
651
717
|
if (!/^[a-z0-9가-힣._-]{1,80}$/i.test(normalized)) {
|
|
@@ -354,7 +354,8 @@ export class BridgeService {
|
|
|
354
354
|
formatResponses(responses) {
|
|
355
355
|
return responses.map((response) => {
|
|
356
356
|
const sessionLabel = response.publicSessionId ?? response.sessionId;
|
|
357
|
-
const
|
|
357
|
+
const modelLabel = response.model?.trim() || this.defaultModelFor(response.provider);
|
|
358
|
+
const header = `[${response.provider.toUpperCase()} | ${modelLabel} | ${sessionLabel}]`;
|
|
358
359
|
return `${header}\n${response.output}`;
|
|
359
360
|
});
|
|
360
361
|
}
|
|
@@ -520,6 +521,7 @@ export class BridgeService {
|
|
|
520
521
|
responses.push({
|
|
521
522
|
...response,
|
|
522
523
|
publicSessionId: session.publicId,
|
|
524
|
+
model: providerSession.model ?? this.defaultModelFor(provider),
|
|
523
525
|
});
|
|
524
526
|
}
|
|
525
527
|
return responses;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
const PRESERVED_WORKSPACE_TODO_FILES = new Set(["TODO.md", "todo.md", "todo.json"]);
|
|
4
|
+
export class WorkspaceCleanupService {
|
|
5
|
+
dataDir;
|
|
6
|
+
workspaceRoot;
|
|
7
|
+
constructor(dataDir, workspaceRoot) {
|
|
8
|
+
this.dataDir = dataDir;
|
|
9
|
+
this.workspaceRoot = workspaceRoot;
|
|
10
|
+
}
|
|
11
|
+
async cleanupOrphanWorkspaces() {
|
|
12
|
+
const referenced = await this.referencedWorkspaceNames();
|
|
13
|
+
const entries = await fs.readdir(this.workspaceRoot, { withFileTypes: true }).catch(() => []);
|
|
14
|
+
const result = { removedPaths: 0, removedBytes: 0, skipped: 0, messages: [] };
|
|
15
|
+
for (const entry of entries) {
|
|
16
|
+
if (!entry.isDirectory()) {
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
if (referenced.has(entry.name)) {
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
const target = path.join(this.workspaceRoot, entry.name);
|
|
23
|
+
const bytes = await this.directorySize(target);
|
|
24
|
+
await fs.rm(target, { recursive: true, force: true });
|
|
25
|
+
result.removedPaths += 1;
|
|
26
|
+
result.removedBytes += bytes;
|
|
27
|
+
result.messages.push(`${entry.name} (${formatBytes(bytes)})`);
|
|
28
|
+
}
|
|
29
|
+
return [
|
|
30
|
+
"Workspace orphan cleanup finished.",
|
|
31
|
+
`removed=${result.removedPaths}`,
|
|
32
|
+
`freed=${formatBytes(result.removedBytes)}`,
|
|
33
|
+
result.messages.length > 0 ? `items=${result.messages.join(", ")}` : "items=none",
|
|
34
|
+
].join(" ");
|
|
35
|
+
}
|
|
36
|
+
async cleanupSessionWorkspace(session) {
|
|
37
|
+
if (!this.isManagedWorkspace(session.workspace)) {
|
|
38
|
+
return [
|
|
39
|
+
`Workspace cleanup skipped for ${session.publicId}.`,
|
|
40
|
+
"The current workspace is not managed by RemoteAgent.",
|
|
41
|
+
`workspace=${session.workspace}`,
|
|
42
|
+
].join("\n");
|
|
43
|
+
}
|
|
44
|
+
const result = { removedPaths: 0, removedBytes: 0, skipped: 0, messages: [] };
|
|
45
|
+
await this.cleanupWorkspaceContents(session.workspace, result);
|
|
46
|
+
return [
|
|
47
|
+
`Workspace cleanup finished for ${session.publicId}.`,
|
|
48
|
+
`workspace=${session.workspace}`,
|
|
49
|
+
`preservedSessionMemory=${path.join(this.dataDir, "managed", "sessions", session.publicId)}`,
|
|
50
|
+
`removed=${result.removedPaths}`,
|
|
51
|
+
`freed=${formatBytes(result.removedBytes)}`,
|
|
52
|
+
`skipped=${result.skipped}`,
|
|
53
|
+
result.messages.length > 0
|
|
54
|
+
? ["removedItems:", ...result.messages.slice(0, 30).map((item) => `- ${item}`)].join("\n")
|
|
55
|
+
: "removedItems: none",
|
|
56
|
+
].join("\n");
|
|
57
|
+
}
|
|
58
|
+
async cleanupWorkspaceContents(root, result) {
|
|
59
|
+
const entries = await fs.readdir(root, { withFileTypes: true }).catch(() => []);
|
|
60
|
+
for (const entry of entries) {
|
|
61
|
+
const entryPath = path.join(root, entry.name);
|
|
62
|
+
if (entry.isFile() && PRESERVED_WORKSPACE_TODO_FILES.has(entry.name)) {
|
|
63
|
+
result.skipped += 1;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
await this.removePath(entryPath, result);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
async removePath(target, result) {
|
|
70
|
+
const bytes = await this.pathSize(target);
|
|
71
|
+
await fs.rm(target, { recursive: true, force: true });
|
|
72
|
+
result.removedPaths += 1;
|
|
73
|
+
result.removedBytes += bytes;
|
|
74
|
+
result.messages.push(`${path.relative(this.workspaceRoot, target)} (${formatBytes(bytes)})`);
|
|
75
|
+
}
|
|
76
|
+
async referencedWorkspaceNames() {
|
|
77
|
+
const state = await this.readState();
|
|
78
|
+
const referenced = new Set();
|
|
79
|
+
const root = path.resolve(this.workspaceRoot);
|
|
80
|
+
for (const session of Object.values(state.sessions ?? {})) {
|
|
81
|
+
const workspace = session.workspace ? path.resolve(session.workspace) : "";
|
|
82
|
+
if (!workspace || !this.isPathInside(root, workspace)) {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
referenced.add(path.basename(workspace));
|
|
86
|
+
}
|
|
87
|
+
return referenced;
|
|
88
|
+
}
|
|
89
|
+
async readState() {
|
|
90
|
+
const statePath = path.join(this.dataDir, "state.json");
|
|
91
|
+
const raw = await fs.readFile(statePath, "utf8").catch((error) => {
|
|
92
|
+
throw new Error(`state unavailable, refusing workspace cleanup: ${error.message}`);
|
|
93
|
+
});
|
|
94
|
+
const parsed = JSON.parse(raw);
|
|
95
|
+
if (!parsed || typeof parsed !== "object" || !parsed.sessions || typeof parsed.sessions !== "object") {
|
|
96
|
+
throw new Error(`state unavailable, refusing workspace cleanup: invalid state at ${statePath}`);
|
|
97
|
+
}
|
|
98
|
+
return parsed;
|
|
99
|
+
}
|
|
100
|
+
isManagedWorkspace(workspace) {
|
|
101
|
+
const root = path.resolve(this.workspaceRoot);
|
|
102
|
+
const resolved = path.resolve(workspace);
|
|
103
|
+
return this.isPathInside(root, resolved) && resolved !== root;
|
|
104
|
+
}
|
|
105
|
+
isPathInside(parent, child) {
|
|
106
|
+
const relative = path.relative(parent, child);
|
|
107
|
+
return Boolean(relative) && !relative.startsWith("..") && !path.isAbsolute(relative);
|
|
108
|
+
}
|
|
109
|
+
async pathSize(target) {
|
|
110
|
+
const stat = await fs.stat(target).catch(() => undefined);
|
|
111
|
+
if (!stat) {
|
|
112
|
+
return 0;
|
|
113
|
+
}
|
|
114
|
+
if (stat.isFile()) {
|
|
115
|
+
return stat.size;
|
|
116
|
+
}
|
|
117
|
+
if (stat.isDirectory()) {
|
|
118
|
+
return this.directorySize(target);
|
|
119
|
+
}
|
|
120
|
+
return 0;
|
|
121
|
+
}
|
|
122
|
+
async directorySize(directory) {
|
|
123
|
+
const entries = await fs.readdir(directory, { withFileTypes: true }).catch(() => []);
|
|
124
|
+
let total = 0;
|
|
125
|
+
for (const entry of entries) {
|
|
126
|
+
const entryPath = path.join(directory, entry.name);
|
|
127
|
+
const stat = await fs.stat(entryPath).catch(() => undefined);
|
|
128
|
+
if (!stat) {
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (stat.isFile()) {
|
|
132
|
+
total += stat.size;
|
|
133
|
+
}
|
|
134
|
+
else if (stat.isDirectory()) {
|
|
135
|
+
total += await this.directorySize(entryPath);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return total;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function formatBytes(bytes) {
|
|
142
|
+
const units = ["B", "KB", "MB", "GB"];
|
|
143
|
+
let value = bytes;
|
|
144
|
+
let unitIndex = 0;
|
|
145
|
+
while (value >= 1024 && unitIndex < units.length - 1) {
|
|
146
|
+
value /= 1024;
|
|
147
|
+
unitIndex += 1;
|
|
148
|
+
}
|
|
149
|
+
return `${unitIndex === 0 ? value.toFixed(0) : value.toFixed(1)}${units[unitIndex]}`;
|
|
150
|
+
}
|
|
@@ -13,11 +13,13 @@ export const TELEGRAM_COMMAND_MENU = [
|
|
|
13
13
|
{ command: "option", description: "Show or change runtime options" },
|
|
14
14
|
{ command: "secret", description: "Store or manage hidden secret values" },
|
|
15
15
|
{ command: "docs", description: "Pin or find session documents" },
|
|
16
|
+
{ command: "macro", description: "Save or run reusable instructions" },
|
|
16
17
|
{ command: "model", description: "Show or change provider model" },
|
|
17
18
|
{ command: "stop", description: "Stop active work and clear queued messages" },
|
|
18
19
|
{ command: "sandbox", description: "Set Codex sandbox mode" },
|
|
19
20
|
{ command: "batch", description: "Collect and send a multi-message batch" },
|
|
20
21
|
{ command: "artifacts", description: "List or clean uploaded artifacts" },
|
|
22
|
+
{ command: "cleanup", description: "Clean current session workspace" },
|
|
21
23
|
{ command: "bots", description: "List configured Telegram bots" },
|
|
22
24
|
{ command: "bot", description: "Manage Telegram bots" },
|
|
23
25
|
{ command: "install", description: "Install or update Codex or Claude" },
|
package/docs/OPERATIONS.md
CHANGED
|
@@ -122,6 +122,75 @@ Check the lock owner:
|
|
|
122
122
|
cat /home/au2223/.remoteagent/remoteagent.lock
|
|
123
123
|
```
|
|
124
124
|
|
|
125
|
+
## Disk maintenance
|
|
126
|
+
|
|
127
|
+
RemoteAgent disk growth usually comes from Docker build cache, Docker volumes, Codex session logs, managed workspaces, Telegram uploads, and temporary build artifacts.
|
|
128
|
+
|
|
129
|
+
Use one script for repeatable checks and conservative cleanup:
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
npm run maintenance:disk -- report
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Run the safe cleanup path:
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
npm run maintenance:disk -- prune-safe
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
`prune-safe` performs only these actions:
|
|
142
|
+
|
|
143
|
+
- `docker builder prune -f`
|
|
144
|
+
- remove old `/tmp/remoteagent-codex-*`, `/tmp/remoteagent-claude-*`, and `/tmp/appback-*` directories older than 2 days
|
|
145
|
+
- remove managed workspace directories under `WORKSPACE_ROOT` only when they are not referenced by RemoteAgent `state.json`
|
|
146
|
+
|
|
147
|
+
Clean only orphan managed workspaces:
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
npm run maintenance:disk -- prune-workspaces
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
RemoteAgent also runs conservative workspace cleanup on a schedule when enabled:
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
WORKSPACE_CLEANUP_ENABLED=true
|
|
157
|
+
WORKSPACE_CLEANUP_INTERVAL_MS=86400000
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Scheduled workspace cleanup only removes managed workspace directories under `WORKSPACE_ROOT` when they are not referenced by RemoteAgent `state.json`.
|
|
161
|
+
If `state.json` is missing or invalid, workspace cleanup refuses to run.
|
|
162
|
+
|
|
163
|
+
Clean the current chat session workspace manually from Telegram:
|
|
164
|
+
|
|
165
|
+
```text
|
|
166
|
+
/cleanup
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
`/cleanup` does not delete the session workspace directory itself. It removes the contents of the current managed workspace while preserving RemoteAgent's session todo/state/history under `~/.remoteagent/managed/sessions/<session>`. If a top-level `TODO.md`, `todo.md`, or `todo.json` exists inside the workspace, it is also preserved. The command refuses non-RemoteAgent-managed workspaces.
|
|
170
|
+
|
|
171
|
+
Archive old Codex session logs explicitly:
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
npm run maintenance:disk -- prune-codex-sessions 45
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
This creates an archive under `~/.codex/session-archive/` and removes the archived jsonl files from `~/.codex/sessions`.
|
|
178
|
+
Use this only when old Codex resume history is no longer needed.
|
|
179
|
+
|
|
180
|
+
For server 30, run the installed package script through npm:
|
|
181
|
+
|
|
182
|
+
```bash
|
|
183
|
+
ssh au2223@192.168.0.30 'export PATH="/home/au2223/.local/bin:/home/au2223/.nvm/versions/node/v22.22.0/bin:$PATH"; npm explore -g appback-remoteagent -- npm run maintenance:disk -- report'
|
|
184
|
+
ssh au2223@192.168.0.30 'export PATH="/home/au2223/.local/bin:/home/au2223/.nvm/versions/node/v22.22.0/bin:$PATH"; npm explore -g appback-remoteagent -- npm run maintenance:disk -- prune-safe'
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
For server 26:
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
ssh ospadmin@192.168.0.26 'export PATH="$HOME/.local/bin:$PATH"; npm explore -g appback-remoteagent -- npm run maintenance:disk -- report'
|
|
191
|
+
ssh ospadmin@192.168.0.26 'export PATH="$HOME/.local/bin:$PATH"; npm explore -g appback-remoteagent -- npm run maintenance:disk -- prune-safe'
|
|
192
|
+
```
|
|
193
|
+
|
|
125
194
|
## Git workflow
|
|
126
195
|
|
|
127
196
|
The intended workflow is:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "appback-remoteagent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "Personal installable session server for continuing local AI work across PC and Telegram",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -23,7 +23,8 @@
|
|
|
23
23
|
"prepublishOnly": "node scripts/prepublish-guard.mjs",
|
|
24
24
|
"release:version": "bash scripts/release-version.sh",
|
|
25
25
|
"release:publish": "bash scripts/release-publish.sh",
|
|
26
|
-
"release:deploy": "bash scripts/release-deploy.sh"
|
|
26
|
+
"release:deploy": "bash scripts/release-deploy.sh",
|
|
27
|
+
"maintenance:disk": "bash scripts/disk-maintenance.sh"
|
|
27
28
|
},
|
|
28
29
|
"dependencies": {
|
|
29
30
|
"dotenv": "^16.6.1",
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
usage() {
|
|
5
|
+
cat >&2 <<'USAGE'
|
|
6
|
+
Usage: scripts/disk-maintenance.sh <report|prune-safe|prune-workspaces|prune-codex-sessions> [days]
|
|
7
|
+
|
|
8
|
+
Examples:
|
|
9
|
+
scripts/disk-maintenance.sh report
|
|
10
|
+
scripts/disk-maintenance.sh prune-safe
|
|
11
|
+
scripts/disk-maintenance.sh prune-workspaces
|
|
12
|
+
scripts/disk-maintenance.sh prune-codex-sessions 45
|
|
13
|
+
|
|
14
|
+
Notes:
|
|
15
|
+
report Prints disk, Docker, workspace, cache, and large-file usage.
|
|
16
|
+
prune-safe Removes Docker build cache, old RemoteAgent temp dirs, and orphan managed workspaces only.
|
|
17
|
+
prune-workspaces Removes only managed workspace directories not referenced by RemoteAgent state.
|
|
18
|
+
prune-codex-sessions <days>
|
|
19
|
+
Archives Codex session jsonl files older than <days> into ~/.codex/session-archive.
|
|
20
|
+
This can break resume for archived old sessions, so it must be explicit.
|
|
21
|
+
USAGE
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
ACTION="${1:-}"
|
|
25
|
+
RETENTION_DAYS="${2:-}"
|
|
26
|
+
DATA_DIR="${DATA_DIR:-$HOME/.remoteagent}"
|
|
27
|
+
WORKSPACE_ROOT="${WORKSPACE_ROOT:-$HOME/workspaces/remoteagent}"
|
|
28
|
+
CODEX_SESSIONS_DIR="${CODEX_SESSIONS_DIR:-$HOME/.codex/sessions}"
|
|
29
|
+
CODEX_ARCHIVE_DIR="${CODEX_ARCHIVE_DIR:-$HOME/.codex/session-archive}"
|
|
30
|
+
|
|
31
|
+
if [[ -z "$ACTION" ]]; then
|
|
32
|
+
usage
|
|
33
|
+
exit 1
|
|
34
|
+
fi
|
|
35
|
+
|
|
36
|
+
require_integer_days() {
|
|
37
|
+
local value="$1"
|
|
38
|
+
if [[ ! "$value" =~ ^[0-9]+$ ]] || [[ "$value" -lt 1 ]]; then
|
|
39
|
+
echo "Retention days must be a positive integer." >&2
|
|
40
|
+
exit 1
|
|
41
|
+
fi
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
print_section() {
|
|
45
|
+
printf '\n== %s ==\n' "$1"
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
docker_available() {
|
|
49
|
+
command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
report() {
|
|
53
|
+
print_section "filesystem"
|
|
54
|
+
df -hT /
|
|
55
|
+
|
|
56
|
+
print_section "top level"
|
|
57
|
+
sudo -n du -xhd1 / 2>/dev/null | sort -h | tail -30 || du -xhd1 "$HOME" 2>/dev/null | sort -h | tail -30 || true
|
|
58
|
+
|
|
59
|
+
print_section "home"
|
|
60
|
+
du -xhd1 "$HOME" 2>/dev/null | sort -h | tail -50 || true
|
|
61
|
+
|
|
62
|
+
if [[ -d "$WORKSPACE_ROOT" ]]; then
|
|
63
|
+
print_section "remoteagent workspaces"
|
|
64
|
+
du -xhd1 "$WORKSPACE_ROOT" 2>/dev/null | sort -h | tail -80 || true
|
|
65
|
+
print_section "orphan managed workspaces"
|
|
66
|
+
orphan_workspaces dry-run
|
|
67
|
+
fi
|
|
68
|
+
|
|
69
|
+
if [[ -d "$CODEX_SESSIONS_DIR" ]]; then
|
|
70
|
+
print_section "codex sessions"
|
|
71
|
+
du -xhd1 "$HOME/.codex" "$CODEX_SESSIONS_DIR" 2>/dev/null | sort -h | tail -40 || true
|
|
72
|
+
fi
|
|
73
|
+
|
|
74
|
+
print_section "tmp"
|
|
75
|
+
du -xhd1 /tmp 2>/dev/null | sort -h | tail -50 || true
|
|
76
|
+
|
|
77
|
+
if docker_available; then
|
|
78
|
+
print_section "docker system df"
|
|
79
|
+
docker system df || true
|
|
80
|
+
fi
|
|
81
|
+
|
|
82
|
+
print_section "largest files over 200M"
|
|
83
|
+
sudo -n find "$HOME" /var /tmp -xdev -type f -size +200M -printf '%s\t%p\n' 2>/dev/null \
|
|
84
|
+
| sort -n \
|
|
85
|
+
| tail -80 \
|
|
86
|
+
| awk '{size=$1/1024/1024/1024; $1=""; sub(/^\t/, ""); printf "%.2fG\t%s\n", size, $0}' || true
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
orphan_workspaces() {
|
|
90
|
+
local mode="${1:-dry-run}"
|
|
91
|
+
python3 - "$DATA_DIR" "$WORKSPACE_ROOT" "$mode" <<'PY'
|
|
92
|
+
import json
|
|
93
|
+
import os
|
|
94
|
+
import shutil
|
|
95
|
+
import subprocess
|
|
96
|
+
import sys
|
|
97
|
+
|
|
98
|
+
data_dir, workspace_root, mode = sys.argv[1:4]
|
|
99
|
+
state_path = os.path.join(data_dir, "state.json")
|
|
100
|
+
|
|
101
|
+
def size_label(path):
|
|
102
|
+
try:
|
|
103
|
+
return subprocess.check_output(["du", "-sh", path], text=True).split()[0]
|
|
104
|
+
except Exception:
|
|
105
|
+
return "?"
|
|
106
|
+
|
|
107
|
+
if not os.path.isdir(workspace_root):
|
|
108
|
+
print(f"workspace root not found: {workspace_root}")
|
|
109
|
+
sys.exit(0)
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
with open(state_path, "r", encoding="utf-8") as handle:
|
|
113
|
+
state = json.load(handle)
|
|
114
|
+
except Exception as exc:
|
|
115
|
+
print(f"state unavailable, refusing workspace cleanup: {exc}")
|
|
116
|
+
sys.exit(0 if mode == "dry-run" else 1)
|
|
117
|
+
|
|
118
|
+
referenced = set()
|
|
119
|
+
for session in (state.get("sessions") or {}).values():
|
|
120
|
+
workspace = session.get("workspace") or session.get("workspacePath")
|
|
121
|
+
if isinstance(workspace, str):
|
|
122
|
+
normalized = os.path.abspath(workspace)
|
|
123
|
+
root = os.path.abspath(workspace_root)
|
|
124
|
+
if normalized == root or normalized.startswith(root + os.sep):
|
|
125
|
+
referenced.add(os.path.basename(normalized.rstrip(os.sep)))
|
|
126
|
+
|
|
127
|
+
all_dirs = {
|
|
128
|
+
name for name in os.listdir(workspace_root)
|
|
129
|
+
if os.path.isdir(os.path.join(workspace_root, name))
|
|
130
|
+
}
|
|
131
|
+
orphans = sorted(all_dirs - referenced)
|
|
132
|
+
|
|
133
|
+
print(f"referenced={len(referenced)} all={len(all_dirs)} orphan={len(orphans)}")
|
|
134
|
+
for name in orphans:
|
|
135
|
+
path = os.path.join(workspace_root, name)
|
|
136
|
+
print(f"{size_label(path)}\t{name}")
|
|
137
|
+
if mode == "delete":
|
|
138
|
+
shutil.rmtree(path, ignore_errors=True)
|
|
139
|
+
PY
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
prune_safe() {
|
|
143
|
+
print_section "before"
|
|
144
|
+
df -hT /
|
|
145
|
+
|
|
146
|
+
if docker_available; then
|
|
147
|
+
print_section "docker builder prune"
|
|
148
|
+
docker builder prune -f
|
|
149
|
+
else
|
|
150
|
+
print_section "docker builder prune skipped"
|
|
151
|
+
echo "docker is unavailable or current user cannot access it"
|
|
152
|
+
fi
|
|
153
|
+
|
|
154
|
+
print_section "old temp directories"
|
|
155
|
+
find /tmp -maxdepth 1 -mindepth 1 -type d \
|
|
156
|
+
\( -name 'remoteagent-codex-*' -o -name 'remoteagent-claude-*' -o -name 'appback-*' \) \
|
|
157
|
+
-mtime +2 -print -exec rm -rf {} +
|
|
158
|
+
|
|
159
|
+
print_section "orphan managed workspaces"
|
|
160
|
+
orphan_workspaces delete
|
|
161
|
+
|
|
162
|
+
print_section "after"
|
|
163
|
+
df -hT /
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
prune_codex_sessions() {
|
|
167
|
+
local days="$1"
|
|
168
|
+
require_integer_days "$days"
|
|
169
|
+
|
|
170
|
+
if [[ ! -d "$CODEX_SESSIONS_DIR" ]]; then
|
|
171
|
+
echo "Codex sessions dir not found: $CODEX_SESSIONS_DIR"
|
|
172
|
+
exit 0
|
|
173
|
+
fi
|
|
174
|
+
|
|
175
|
+
mkdir -p "$CODEX_ARCHIVE_DIR"
|
|
176
|
+
local stamp
|
|
177
|
+
stamp="$(date +%Y%m%d-%H%M%S)"
|
|
178
|
+
local list_file archive_file
|
|
179
|
+
list_file="$(mktemp)"
|
|
180
|
+
archive_file="$CODEX_ARCHIVE_DIR/codex-sessions-older-than-${days}d-$stamp.tar.gz"
|
|
181
|
+
find "$CODEX_SESSIONS_DIR" -type f -name '*.jsonl' -mtime +"$days" -print > "$list_file"
|
|
182
|
+
|
|
183
|
+
if [[ ! -s "$list_file" ]]; then
|
|
184
|
+
rm -f "$list_file"
|
|
185
|
+
echo "No Codex session files older than ${days}d."
|
|
186
|
+
exit 0
|
|
187
|
+
fi
|
|
188
|
+
|
|
189
|
+
tar -czf "$archive_file" --files-from "$list_file"
|
|
190
|
+
while IFS= read -r file; do
|
|
191
|
+
rm -f "$file"
|
|
192
|
+
done < "$list_file"
|
|
193
|
+
rm -f "$list_file"
|
|
194
|
+
|
|
195
|
+
find "$CODEX_SESSIONS_DIR" -type d -empty -delete
|
|
196
|
+
echo "Archived old Codex session files to $archive_file"
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
case "$ACTION" in
|
|
200
|
+
report)
|
|
201
|
+
report
|
|
202
|
+
;;
|
|
203
|
+
prune-safe)
|
|
204
|
+
prune_safe
|
|
205
|
+
;;
|
|
206
|
+
prune-workspaces)
|
|
207
|
+
orphan_workspaces delete
|
|
208
|
+
;;
|
|
209
|
+
prune-codex-sessions)
|
|
210
|
+
prune_codex_sessions "${RETENTION_DAYS:-}"
|
|
211
|
+
;;
|
|
212
|
+
*)
|
|
213
|
+
usage
|
|
214
|
+
exit 1
|
|
215
|
+
;;
|
|
216
|
+
esac
|
|
@@ -39,6 +39,19 @@ deploy_30() {
|
|
|
39
39
|
ssh au2223@192.168.0.30 "VERSION=$VERSION bash -s" <<'REMOTE'
|
|
40
40
|
set -euo pipefail
|
|
41
41
|
export PATH="/home/au2223/.local/bin:/home/au2223/.nvm/versions/node/v22.22.0/bin:$PATH"
|
|
42
|
+
node - <<'NODE'
|
|
43
|
+
const fs = require("fs");
|
|
44
|
+
const path = "/home/au2223/.remoteagent/bot-polling-state.json";
|
|
45
|
+
const state = JSON.parse(fs.readFileSync(path, "utf8"));
|
|
46
|
+
const running = Object.values(state.bots || {})
|
|
47
|
+
.filter((bot) => Array.isArray(bot.runningSessionIds) && bot.runningSessionIds.length > 0)
|
|
48
|
+
.map((bot) => `${bot.username || bot.botId}: ${bot.runningSessionIds.join(", ")}`);
|
|
49
|
+
if (running.length > 0) {
|
|
50
|
+
console.error("RemoteAgent has active provider work. Retry deploy after it finishes:");
|
|
51
|
+
for (const item of running) console.error(`- ${item}`);
|
|
52
|
+
process.exit(2);
|
|
53
|
+
}
|
|
54
|
+
NODE
|
|
42
55
|
npm install -g "appback-remoteagent@$VERSION"
|
|
43
56
|
remoteagent-install
|
|
44
57
|
sudo -n systemctl restart remoteagent
|
|
@@ -53,6 +66,21 @@ deploy_26() {
|
|
|
53
66
|
ssh ospadmin@192.168.0.26 "VERSION=$VERSION bash -s" <<'REMOTE'
|
|
54
67
|
set -euo pipefail
|
|
55
68
|
export PATH="$HOME/.local/bin:$PATH"
|
|
69
|
+
node - <<'NODE'
|
|
70
|
+
const fs = require("fs");
|
|
71
|
+
const path = `${process.env.HOME}/.remoteagent/bot-polling-state.json`;
|
|
72
|
+
if (fs.existsSync(path)) {
|
|
73
|
+
const state = JSON.parse(fs.readFileSync(path, "utf8"));
|
|
74
|
+
const running = Object.values(state.bots || {})
|
|
75
|
+
.filter((bot) => Array.isArray(bot.runningSessionIds) && bot.runningSessionIds.length > 0)
|
|
76
|
+
.map((bot) => `${bot.username || bot.botId}: ${bot.runningSessionIds.join(", ")}`);
|
|
77
|
+
if (running.length > 0) {
|
|
78
|
+
console.error("RemoteAgent has active provider work. Retry deploy after it finishes:");
|
|
79
|
+
for (const item of running) console.error(`- ${item}`);
|
|
80
|
+
process.exit(2);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
NODE
|
|
56
84
|
npm install -g "appback-remoteagent@$VERSION"
|
|
57
85
|
remoteagent-install
|
|
58
86
|
~/.remoteagent/stop-remoteagent.sh || true
|
|
@@ -62,12 +62,20 @@ process.env.TELEGRAM_EMPTY_RESPONSE_RETRIES = "0";
|
|
|
62
62
|
process.env.TELEGRAM_RETRYABLE_ERROR_RETRIES = "0";
|
|
63
63
|
process.env.LOCAL_UI_ENABLED = "false";
|
|
64
64
|
|
|
65
|
-
const [
|
|
65
|
+
const [
|
|
66
|
+
{ createBot },
|
|
67
|
+
{ BridgeService },
|
|
68
|
+
{ BotManagementService },
|
|
69
|
+
{ FileStore },
|
|
70
|
+
{ AgentMemoryService },
|
|
71
|
+
{ WorkspaceCleanupService },
|
|
72
|
+
] = await Promise.all([
|
|
66
73
|
import(path.join(root, "dist", "bot.js")),
|
|
67
74
|
import(path.join(root, "dist", "services", "bridge-service.js")),
|
|
68
75
|
import(path.join(root, "dist", "services", "bot-management-service.js")),
|
|
69
76
|
import(path.join(root, "dist", "store", "file-store.js")),
|
|
70
77
|
import(path.join(root, "dist", "services", "agent-memory-service.js")),
|
|
78
|
+
import(path.join(root, "dist", "services", "workspace-cleanup-service.js")),
|
|
71
79
|
]);
|
|
72
80
|
|
|
73
81
|
const providerCalls = [];
|
|
@@ -188,6 +196,59 @@ if (!/^TELEGRAM_UNTAGGED_INTENT_RETRIES=4$/m.test(envText)) {
|
|
|
188
196
|
throw new Error(`Option command did not persist untagged intent retry limit to .env: ${envText}`);
|
|
189
197
|
}
|
|
190
198
|
|
|
199
|
+
const sessionWorkspace = session.workspace;
|
|
200
|
+
await fs.mkdir(path.join(sessionWorkspace, "node_modules", "left-pad"), { recursive: true });
|
|
201
|
+
await fs.mkdir(path.join(sessionWorkspace, "src"), { recursive: true });
|
|
202
|
+
await fs.writeFile(path.join(sessionWorkspace, "node_modules", "left-pad", "index.js"), "module.exports = 1;\n", "utf8");
|
|
203
|
+
await fs.writeFile(path.join(sessionWorkspace, "src", "keep.ts"), "export const keep = true;\n", "utf8");
|
|
204
|
+
await fs.writeFile(path.join(sessionWorkspace, "debug.log"), "temporary log\n", "utf8");
|
|
205
|
+
await fs.writeFile(path.join(sessionWorkspace, "TODO.md"), "- keep cleanup notes\n", "utf8");
|
|
206
|
+
await send("/cleanup");
|
|
207
|
+
await fs.access(path.join(sessionWorkspace, "TODO.md"));
|
|
208
|
+
if (await pathExists(path.join(sessionWorkspace, "node_modules"))) {
|
|
209
|
+
throw new Error("/cleanup did not remove node_modules");
|
|
210
|
+
}
|
|
211
|
+
if (await pathExists(path.join(sessionWorkspace, "debug.log"))) {
|
|
212
|
+
throw new Error("/cleanup did not remove log file");
|
|
213
|
+
}
|
|
214
|
+
if (await pathExists(path.join(sessionWorkspace, "src"))) {
|
|
215
|
+
throw new Error("/cleanup did not remove regular workspace contents");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const orphanWorkspace = path.join(workspaceRoot, "orphan123");
|
|
219
|
+
const referencedWorkspace = sessionWorkspace;
|
|
220
|
+
await fs.mkdir(orphanWorkspace, { recursive: true });
|
|
221
|
+
await fs.writeFile(path.join(orphanWorkspace, "artifact.tmp"), "orphan\n", "utf8");
|
|
222
|
+
const workspaceCleanup = new WorkspaceCleanupService(dataDir, workspaceRoot);
|
|
223
|
+
const orphanResult = await workspaceCleanup.cleanupOrphanWorkspaces();
|
|
224
|
+
if (!/removed=1/.test(orphanResult)) {
|
|
225
|
+
throw new Error(`Expected one orphan workspace removal, got: ${orphanResult}`);
|
|
226
|
+
}
|
|
227
|
+
if (await pathExists(orphanWorkspace)) {
|
|
228
|
+
throw new Error("Orphan workspace was not removed");
|
|
229
|
+
}
|
|
230
|
+
if (!(await pathExists(referencedWorkspace))) {
|
|
231
|
+
throw new Error("Referenced workspace was removed unexpectedly");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const missingStateDataDir = path.join(tmp, "missing-state-data");
|
|
235
|
+
const missingStateWorkspaceRoot = path.join(tmp, "missing-state-workspaces");
|
|
236
|
+
const shouldRemain = path.join(missingStateWorkspaceRoot, "should-remain");
|
|
237
|
+
await fs.mkdir(shouldRemain, { recursive: true });
|
|
238
|
+
const missingStateCleanup = new WorkspaceCleanupService(missingStateDataDir, missingStateWorkspaceRoot);
|
|
239
|
+
let refusedMissingState = false;
|
|
240
|
+
try {
|
|
241
|
+
await missingStateCleanup.cleanupOrphanWorkspaces();
|
|
242
|
+
} catch {
|
|
243
|
+
refusedMissingState = true;
|
|
244
|
+
}
|
|
245
|
+
if (!refusedMissingState) {
|
|
246
|
+
throw new Error("Workspace cleanup should refuse to run when state.json is missing");
|
|
247
|
+
}
|
|
248
|
+
if (!(await pathExists(shouldRemain))) {
|
|
249
|
+
throw new Error("Workspace cleanup removed a workspace when state.json was missing");
|
|
250
|
+
}
|
|
251
|
+
|
|
191
252
|
const memory = new AgentMemoryService(dataDir);
|
|
192
253
|
const developmentSession = {
|
|
193
254
|
...session,
|
|
@@ -258,6 +319,9 @@ if (!calls.some((call) => call.method === "sendMessage" && /Set provider executi
|
|
|
258
319
|
if (!calls.some((call) => call.method === "sendMessage" && /Set untagged intent retry limit to 4/.test(call.text))) {
|
|
259
320
|
throw new Error(`Did not see option intent acknowledgement. Calls: ${JSON.stringify(calls, null, 2)}`);
|
|
260
321
|
}
|
|
322
|
+
if (!calls.some((call) => call.method === "sendMessage" && /Workspace cleanup finished for S001/.test(call.text))) {
|
|
323
|
+
throw new Error(`Did not see workspace cleanup acknowledgement. Calls: ${JSON.stringify(calls, null, 2)}`);
|
|
324
|
+
}
|
|
261
325
|
if (calls.some((call) => /미완료 TODO|\/task|새 작업으로 접수/.test(call.text))) {
|
|
262
326
|
throw new Error(`Task gate language leaked to Telegram replies. Calls: ${JSON.stringify(calls, null, 2)}`);
|
|
263
327
|
}
|
|
@@ -333,7 +397,10 @@ const evidenceCalls = (await fs.readFile(telegramCalls, "utf8"))
|
|
|
333
397
|
if (missingEvidenceCalls !== 2) {
|
|
334
398
|
throw new Error(`Expected missing evidence result to be retried once, got ${missingEvidenceCalls}`);
|
|
335
399
|
}
|
|
336
|
-
if (!evidenceCalls.some((call) =>
|
|
400
|
+
if (!evidenceCalls.some((call) =>
|
|
401
|
+
/변경 파일: (?:`|<code>)src\/example\.ts(?:`|<\/code>)/.test(call.text)
|
|
402
|
+
&& /(?:`|<code>)npm run check(?:`|<\/code>) 통과/.test(call.text)
|
|
403
|
+
)) {
|
|
337
404
|
throw new Error(`Did not see recovered result with concrete evidence. Calls: ${JSON.stringify(evidenceCalls, null, 2)}`);
|
|
338
405
|
}
|
|
339
406
|
if (evidenceCalls.some((call) => call.method === "sendMessage" && /^수정 완료했습니다\.$/.test(call.text.trim()))) {
|
|
@@ -357,3 +424,7 @@ console.log(JSON.stringify({
|
|
|
357
424
|
}, null, 2));
|
|
358
425
|
|
|
359
426
|
process.exit(0);
|
|
427
|
+
|
|
428
|
+
async function pathExists(target) {
|
|
429
|
+
return fs.access(target).then(() => true, () => false);
|
|
430
|
+
}
|