appback-remoteagent 0.22.1 → 0.22.3

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 CHANGED
@@ -29,6 +29,8 @@ RemoteAgent is currently organized around six core capabilities.
29
29
  | Telegram attachments | Telegram can send images, text, Markdown, PDF, Word documents, spreadsheet files, archives, and audio/voice inputs into the runtime | Supported |
30
30
  | Telegram Mini App UI | A richer Telegram-native UI can sit on top of the same runtime and session model | Planned next |
31
31
 
32
+ Consecutive Telegram text updates received within the message batch window are treated as one user input. When their combined text exceeds 3,900 characters, RemoteAgent stores the complete UTF-8 text under `DATA_DIR/uploads/telegram`, indexes it as an artifact, and sends the provider one instruction containing the file path. This prevents Telegram-split long inputs from starting separate provider executions. Provider responses continue to use Telegram-safe message chunking.
33
+
32
34
  ## Product direction
33
35
 
34
36
  RemoteAgent is a self-hosted personal runtime, not a hosted SaaS.
package/dist/bot.js CHANGED
@@ -102,6 +102,7 @@ const RECOGNIZED_COMMANDS = new Set([
102
102
  ]);
103
103
  const TELEGRAM_STALE_UPDATE_GRACE_SECONDS = 10;
104
104
  const TELEGRAM_PROCESS_STARTED_AT_SECONDS = Math.floor(Date.now() / 1000);
105
+ const TELEGRAM_LONG_TEXT_FILE_THRESHOLD = 3900;
105
106
  const workLoopTails = new Map();
106
107
  const workLoopGenerations = new Map();
107
108
  const queuedWorkLoops = new Map();
@@ -233,10 +234,26 @@ export function createBot(token, bridge, botManagement, botInfo) {
233
234
  claude: config.claudeInstallCommand,
234
235
  }, config.claudeLoginStartCommand, config.claudeLoginFinishCommand);
235
236
  const messageBatcher = new TelegramMessageBatcher(config.telegramMessageBatchMs, async (target, botId, chatId, text) => {
237
+ let request = text;
238
+ if (text.length > TELEGRAM_LONG_TEXT_FILE_THRESHOLD) {
239
+ const saved = await saveLongTelegramText(botId, chatId, text);
240
+ const mapping = await bridge.status(botId, chatId).catch(() => undefined);
241
+ await memoryService.recordArtifact({
242
+ session: mapping?.session,
243
+ botId,
244
+ chatId,
245
+ kind: "text",
246
+ filePath: saved.path,
247
+ fileName: saved.fileName,
248
+ mimeType: "text/plain",
249
+ });
250
+ request = formatLongTelegramTextPrompt(saved.path, text.length);
251
+ await bridge.logSystem(botId, chatId, `Telegram long text saved as UTF-8 attachment (${text.length} chars): ${saved.path}`);
252
+ }
236
253
  await bridge.logSystem(botId, chatId, `Telegram text dispatch (${text.length} chars).`);
237
254
  await runWithPendingAnimation(target.botToken, target.telegramChatId, async (helpers) => {
238
255
  return {
239
- chunks: await routeTelegramWorkLoop(bridge, botId, chatId, text, "Telegram text request", botManagement, helpers, autoContinue, memoryService),
256
+ chunks: await routeTelegramWorkLoop(bridge, botId, chatId, request, "Telegram text request", botManagement, helpers, autoContinue, memoryService),
240
257
  };
241
258
  });
242
259
  });
@@ -2980,6 +2997,23 @@ function safePathSegment(value) {
2980
2997
  const safe = value.replace(/[^a-zA-Z0-9._-]+/g, "_").replace(/^_+|_+$/g, "");
2981
2998
  return safe || "file";
2982
2999
  }
3000
+ async function saveLongTelegramText(botId, chatId, text) {
3001
+ const directory = path.join(config.dataDir, "uploads", "telegram", safePathSegment(botId), safePathSegment(chatId));
3002
+ await fs.mkdir(directory, { recursive: true });
3003
+ const fileName = `${Date.now()}-telegram-long-message-${randomUUID()}.txt`;
3004
+ const outputPath = path.join(directory, fileName);
3005
+ await fs.writeFile(outputPath, text, { encoding: "utf8", mode: 0o600 });
3006
+ return { path: outputPath, fileName };
3007
+ }
3008
+ function formatLongTelegramTextPrompt(filePath, characterCount) {
3009
+ return [
3010
+ "The user sent a long Telegram text that RemoteAgent stored as a UTF-8 text file.",
3011
+ `File: ${filePath}`,
3012
+ `Character count: ${characterCount}`,
3013
+ "Read the entire file directly and treat its complete contents as the user's active instruction.",
3014
+ "Do not process only a preview and do not ask the user to resend the split messages.",
3015
+ ].join("\n");
3016
+ }
2983
3017
  async function normalizeTelegramDelivery(chunks) {
2984
3018
  const documents = new Map();
2985
3019
  const normalizedChunks = await Promise.all(chunks.map(async (chunk) => {
@@ -0,0 +1,411 @@
1
+ # Database Backup and Read-Only Standby Plan
2
+
3
+ Last verified: 2026-08-27 (Asia/Seoul)
4
+
5
+ ## Goal
6
+
7
+ - `.110` and `.111` remain the only writable database servers.
8
+ - `.40` keeps off-host backups and read-only PostgreSQL standby instances.
9
+ - During a primary outage, applications may read from `.40`, but writes fail closed.
10
+ - `.40` is never promoted. After the primary returns, replication resumes in the
11
+ original direction, so reverse synchronization is not required.
12
+
13
+ This is a read-only disaster-recovery design. It is not automatic multi-primary
14
+ HA.
15
+
16
+ ## Verified Primary Layout
17
+
18
+ | Primary | Database | Container | PostgreSQL | Backup stanza |
19
+ |---|---|---|---|---|
20
+ | `192.168.33.110` | Damoa | `damoa-db` | 15.19 | `damoa` |
21
+ | `192.168.33.111` | Hub | `hub-db` | 15.19 | `hub111` |
22
+ | `192.168.33.111` | Title Clash | `tc-db` | 15.19 | `tc111` |
23
+ | `192.168.33.111` | Predict Clash | `pc-db` | 15.19 | `pc111` |
24
+ | `192.168.33.111` | Claw Clash | `cc-db` | 15.19 | `cc111` |
25
+
26
+ All five primaries were verified with:
27
+
28
+ ```text
29
+ wal_level=replica
30
+ max_wal_senders=10
31
+ max_replication_slots=10
32
+ hot_standby=on
33
+ archive_mode=on
34
+ archive_command=pgbackrest ... archive-push
35
+ ```
36
+
37
+ ## Current Backup Path
38
+
39
+ All stanzas use pgBackRest over SFTP and write to:
40
+
41
+ ```text
42
+ appback@192.168.33.40:/home/appback/backup/pgbackrest/repo
43
+ ```
44
+
45
+ Schedules:
46
+
47
+ | Primary | Full | Differential | Continuous WAL |
48
+ |---|---|---|---|
49
+ | `.110` Damoa | Sunday 18:00 UTC | Mon-Sat 18:00 UTC | yes |
50
+ | `.111` four DBs | Sunday 17:00 UTC | Mon-Sat 17:00 UTC | yes |
51
+
52
+ On 2026-08-27, all five stanzas had a successful 2026-08-23 full backup,
53
+ successful differential backups through 2026-08-26, and WAL files arriving on
54
+ 2026-08-27. The repository contained only these active stanzas:
55
+
56
+ ```text
57
+ damoa
58
+ hub111
59
+ tc111
60
+ pc111
61
+ cc111
62
+ ```
63
+
64
+ Old `.110` stanzas named `hub`, `tc`, `pc`, and `cc` were removed after the
65
+ services moved to `.111`.
66
+
67
+ ## Server `.40` DR Conversion: 2026-08-27
68
+
69
+ The development workload previously sharing `.40` was moved before adding
70
+ standby databases. This keeps backup and recovery capacity isolated from test
71
+ builds and duplicate agent runtimes.
72
+
73
+ ### Workloads moved to `.50`
74
+
75
+ - Damoa test deployment:
76
+ `/home/appback/deploy/damoa-test` on `.40` to
77
+ `/opt/appback/dev/damoa-test` on `.50`
78
+ - Damoa test ingress:
79
+ `dev.appback.app` is terminated by cloudflared on `.30` and now forwards to
80
+ `http://192.168.33.50:3213`
81
+ - Ten duplicate `appback-ai-agent` PM2 processes remain active on `.50`; their
82
+ `.40` copies were stopped
83
+
84
+ The `.50` firewall permits the Damoa test port only from `.30`. The public
85
+ endpoint and the internal `.30 -> .50` endpoint both returned HTTP 200 after
86
+ cutover.
87
+
88
+ ### Cutover verification
89
+
90
+ The final source snapshot is retained at:
91
+
92
+ ```text
93
+ /home/appback/backup/migrations/damoa-test-cutover-20260827T075116Z
94
+ ```
95
+
96
+ The copied snapshot is retained on `.50` at:
97
+
98
+ ```text
99
+ /opt/appback/backups/migrations/damoa-test-cutover-20260827T075116Z
100
+ ```
101
+
102
+ The source and destination SHA-256 values matched for the database dump,
103
+ object-store archive, and source environment snapshot. After restore, all 83
104
+ public PostgreSQL table row counts produced an identical aggregate hash. All
105
+ non-MinIO-internal user objects also produced an identical file hash list.
106
+
107
+ The Damoa test edge uses Docker DNS re-resolution for both `damoa-api` and
108
+ `tc-minio`. Nginx configuration validation, API readiness, edge health, and the
109
+ public `dev.appback.app` response were verified after the final restore.
110
+ `route_snapshot_unavailable` refresh warnings appeared in both the old `.40`
111
+ API logs and the `.50` API logs, so they were not introduced by the migration.
112
+
113
+ ### `.40` retained rollback state
114
+
115
+ The old Damoa test containers, volumes, deployment directory, and immutable
116
+ images remain on `.40`, but every `damoa-test-*` container is stopped. They are
117
+ rollback material and must not be started while `dev.appback.app` points to
118
+ `.50`.
119
+
120
+ Before stopping the duplicate PM2 agents, `.40` retained its previous PM2 dump
121
+ and crontab under:
122
+
123
+ ```text
124
+ /home/appback/backup/dr-conversion-20260827T075410Z
125
+ ```
126
+
127
+ The `.40` PM2 reboot entry was removed and its daemon was stopped. Backup
128
+ retention cron jobs and the RemoteAgent service remain active. No deployment
129
+ directory, Docker volume, image, external-disk data, pgBackRest data, or
130
+ RemoteAgent workspace was deleted during this conversion.
131
+
132
+ Rollback order:
133
+
134
+ 1. Stop the Damoa test stack on `.50`.
135
+ 2. Restore the saved `.40` crontab only if the duplicate PM2 agents must also
136
+ return.
137
+ 3. Start the `.40` Damoa test stack and verify its internal readiness.
138
+ 4. Change the `.30` cloudflared route back to `192.168.33.40:3213`, validate the
139
+ configuration, restart cloudflared, and verify the public endpoint.
140
+
141
+ Do not run `.40` and `.50` as simultaneous writable copies of the Damoa test
142
+ database or object store.
143
+
144
+ ## Accumulation Audit: 2026-08-27
145
+
146
+ The repository did not contain unknown or orphan pgBackRest stanzas. The active
147
+ repository directories were limited to `damoa`, `hub111`, `tc111`, `pc111`, and
148
+ `cc111`. The non-database backup sets were also small:
149
+
150
+ ```text
151
+ appback-minio current + history: about 1.4 GB
152
+ damoa-media current + history: about 3.9 GB
153
+ ```
154
+
155
+ The following unresolved accumulation risks were found.
156
+
157
+ ### Damoa WAL growth
158
+
159
+ The `damoa` archive occupied about 187 GB and was growing by approximately
160
+ 37-60 GB per day. PostgreSQL reported about 909 GB of WAL generated since the
161
+ statistics reset on 2026-08-24. This was real WAL, not duplicate archive files.
162
+
163
+ The write workload repeatedly updates or replaces large portions of several
164
+ catalog tables. PostgreSQL was also configured with `max_wal_size=1GB`,
165
+ `checkpoint_timeout=5min`, `wal_compression=off`, and had 1,746 requested
166
+ checkpoints during the sampled period. Full-page images therefore account for
167
+ a significant part of the WAL volume.
168
+
169
+ The `.110` data directory also retained about 32.6 GB in `pg_wal` because
170
+ `wal_keep_size=32GB`, even though no replication slot or live standby existed.
171
+
172
+ ### Stale bind-mounted pgBackRest configuration
173
+
174
+ The host path `/opt/appback/pgbackrest/config/pgbackrest.conf` had already been
175
+ replaced with the Damoa-only retention policy, but `damoa-db` still had the old
176
+ unlinked inode bind-mounted. The running container therefore continued to use:
177
+
178
+ ```text
179
+ repo1-retention-full=4
180
+ repo1-retention-diff=14
181
+ archive-async=y
182
+ ```
183
+
184
+ instead of the host file's intended `full=1`, `diff=6`, explicit archive
185
+ retention, and Damoa-only stanza. Replacing a bind-mounted file atomically does
186
+ not update the inode already mounted into a running container. The database
187
+ container must be recreated or the mounted inode must otherwise be updated and
188
+ verified before relying on the new policy.
189
+
190
+ At the observed WAL rate, the `.40` internal disk's approximately 119 GB free
191
+ space may be exhausted before the next weekly full backup. A successful new
192
+ full backup will not expire the old chain while the running container still
193
+ uses retention count 4.
194
+
195
+ ### Backups without bounded retention
196
+
197
+ - `.40` `appback-minio/history` had seven daily change sets but no explicit
198
+ age/count cleanup in the backup script or user cron.
199
+ - `.111` retained about 18 GB of Damoa pre-migration dumps even though Damoa now
200
+ runs on `.110`.
201
+ - `.111` retained about 3.6 GB of deployment rollback dumps and about 995 MB of
202
+ legacy Title Clash originals without a general retention job.
203
+ - `.40` retained about 3.2 GB under `usb-enclosure-safety-copy`. Keep it until
204
+ the old 4 TB MinIO disk is mounted read-only and verified, then reassess it.
205
+ - `.40` RemoteAgent workspaces consumed about 21 GB. The two large workspaces
206
+ were still referenced by sessions, so they were not orphans and must not be
207
+ removed automatically.
208
+
209
+ ### Required correction order
210
+
211
+ 1. Make the running `damoa-db` consume the current pgBackRest configuration and
212
+ verify the effective settings from inside the container.
213
+ 2. Run and verify a new Damoa full backup, then confirm expiration reclaimed the
214
+ previous backup chain and its WAL.
215
+ 3. Add disk thresholds and projected-days-to-full monitoring for the `.40`
216
+ repository.
217
+ 4. Reduce Damoa WAL at the source by reviewing the catalog synchronization
218
+ write pattern and PostgreSQL checkpoint/WAL settings.
219
+ 5. Reduce `wal_keep_size` while no streaming standby exists; select a new value
220
+ as part of standby deployment rather than retaining an unused 32 GB.
221
+ 6. Add explicit retention to MinIO history and deployment rollback dumps.
222
+ 7. Remove `.111` Damoa migration dumps only after the `.110` restore path is
223
+ independently verified.
224
+
225
+ ## Corrections Applied: 2026-08-27
226
+
227
+ The accumulation incident was corrected in the following order.
228
+
229
+ 1. Recreated only `damoa-db` so its bind-mounted pgBackRest configuration uses
230
+ the current host file. The effective container configuration is now strict
231
+ SFTP host-key verification with SHA-256, `full=1`, `diff=6`, `archive=1`,
232
+ and synchronous archive submission.
233
+ 2. Added all verified `.40` SSH host keys to the pinned `known_hosts` file and
234
+ proved a strict pgBackRest repository connection before running a backup.
235
+ 3. Created and verified full backup `20260827-043735F`. Expiration removed the
236
+ superseded Damoa backup chain and its WAL. The `.40` pgBackRest repository
237
+ fell from about 229 GB to 18 GB, and root filesystem use fell from 74% to
238
+ 26%.
239
+ 4. Applied these reloadable Damoa PostgreSQL settings:
240
+
241
+ ```text
242
+ wal_keep_size=1GB
243
+ wal_compression=pglz
244
+ max_wal_size=8GB
245
+ checkpoint_timeout=15min
246
+ ```
247
+
248
+ A PostgreSQL checkpoint then reduced `.110` `pg_wal` from about 32.6 GB to
249
+ about 2 GB. No WAL file was deleted manually. The `.110` root filesystem is
250
+ now 13% used.
251
+ 5. Repaired the isolated Damoa restore verifier and completed an actual
252
+ restore, WAL replay, read-only query, and shutdown test for the new full
253
+ backup. The verified database system ID was `7666642961956692002`.
254
+ 6. Removed 18 GB of obsolete pre-migration Damoa dumps from `.111` after the
255
+ restore test passed. Also removed about 1.5 GB of deployment rollback
256
+ entries older than 35 days. The `.111` root filesystem fell from 64% to 57%
257
+ used.
258
+ 7. Installed bounded 35-day cleanup jobs for `.111` deployment rollback
259
+ entries and `.40` Damoa media and Appback MinIO history. The shared cleanup
260
+ command is dry-run by default, only considers immediate children of an
261
+ explicitly supplied root, and requires `--apply` before deletion.
262
+
263
+ Continuous Damoa WAL archiving was observed advancing after the backup and
264
+ expiration. The active pgBackRest stanza set remains exactly `damoa`, `hub111`,
265
+ `tc111`, `pc111`, and `cc111`.
266
+
267
+ The database tuning mitigates storage growth but does not remove its source.
268
+ Damoa catalog synchronization still performs unusually high update/replace
269
+ volume across campaign route, media, coordinate, and source tables. That
270
+ application write amplification requires a separate code and query review.
271
+
272
+ The following data was intentionally retained:
273
+
274
+ - `.111` legacy Title Clash originals, about 995 MB, until ownership and
275
+ duplication are independently verified.
276
+ - `.40` `usb-enclosure-safety-copy`, about 3.2 GB, until the preserved 4 TB
277
+ MinIO disk is mounted read-only and compared.
278
+ - `.40` RemoteAgent workspaces referenced by active session state. They are not
279
+ orphan workspaces and must not be deleted by a backup cleanup job.
280
+
281
+ ## Important Limitation
282
+
283
+ The pgBackRest repository is recovery material, not a queryable standby. A
284
+ PostgreSQL process cannot serve reads directly from the repository. Read-only
285
+ outage service requires five restored PostgreSQL instances on `.40` that keep
286
+ replaying WAL.
287
+
288
+ ## Target Layout on `.40`
289
+
290
+ Use one PostgreSQL 15 standby per source database. Assign separate ports and
291
+ data directories. Keep the application-facing endpoints separate:
292
+
293
+ ```text
294
+ write endpoint -> primary only (.110 or .111)
295
+ read endpoint -> primary normally, .40 standby during an outage
296
+ ```
297
+
298
+ Standby requirements:
299
+
300
+ - `hot_standby=on`
301
+ - recovery remains active
302
+ - no promotion trigger and no automatic failover manager
303
+ - application credentials on `.40` receive read-only privileges
304
+ - network rules allow application reads but block unintended administrative
305
+ writes
306
+ - monitoring checks replay delay, receive/replay LSN, last replay time, disk
307
+ space, and restore errors
308
+
309
+ Recommended replication method:
310
+
311
+ 1. Bootstrap each standby from its pgBackRest backup.
312
+ 2. Use asynchronous physical streaming replication from the primary.
313
+ 3. Keep pgBackRest WAL restore configured as a gap-recovery fallback.
314
+ 4. If streaming is interrupted, continue replaying archived WAL when available.
315
+
316
+ ## Dual-Bay Allocation
317
+
318
+ The dual-bay enclosure currently attached to `.40` contains:
319
+
320
+ | Device | Size | Label | Current state |
321
+ |---|---:|---|---|
322
+ | Toshiba | 4 TB | `MINIO4T` | unmounted, preserves the previous MinIO data |
323
+ | WDC | 2 TB | `STORAGE2T` | unmounted |
324
+
325
+ Recommended allocation after data validation:
326
+
327
+ - 2 TB: PostgreSQL standby data directories for all five databases.
328
+ - 4 TB: pgBackRest repository and MinIO read-only replica data.
329
+
330
+ The five PostgreSQL datasets currently total well below 200 GB, so the 2 TB
331
+ disk has ample capacity. The two disks share one USB bridge and power source;
332
+ they are not independent backup copies. The writable primaries on `.110` and
333
+ `.111` remain the independent source copies.
334
+
335
+ Do not reformat or repurpose the 4 TB disk until its old MinIO data has been
336
+ mounted read-only, inventoried, and compared with the active `.110` MinIO.
337
+
338
+ ## Implementation Order
339
+
340
+ 1. Mount the 4 TB disk read-only on `.40` and verify the preserved MinIO data.
341
+ 2. Mount and endurance-test the 2 TB disk, then create standby data paths.
342
+ 3. Move the pgBackRest repository to the 4 TB disk with a verified maintenance
343
+ window, preserving the existing repository path with a bind mount.
344
+ 4. Bootstrap one low-risk standby first, recommended `pc111`.
345
+ 5. Verify read-only SQL, WAL replay, reconnect, restart, and primary recovery.
346
+ 6. Repeat for `tc111`, `hub111`, `cc111`, then `damoa`.
347
+ 7. Add separate read endpoints and prove that writes to `.40` fail.
348
+ 8. Test a primary outage without promoting `.40`, then restore the primary and
349
+ verify replication resumes.
350
+ 9. Add monitoring and a periodic restore/read test. A backup is not considered
351
+ verified solely because archive files exist.
352
+
353
+ ## Service Continuity Boundary
354
+
355
+ A read-only database does not keep an application available if the application
356
+ server itself is down. In particular, a complete `.110` outage also removes the
357
+ Damoa API unless a read-only application instance exists on another host. The
358
+ same rule applies to services hosted on `.111`.
359
+
360
+ MinIO follows the same policy independently:
361
+
362
+ - writes go only to `.110`
363
+ - `.110` replicates one way to an independent `.40` MinIO
364
+ - `.40` uses read-only application credentials
365
+ - `.40` is not written to during a `.110` outage
366
+
367
+ ## Container DNS Continuity
368
+
369
+ Recreating MinIO may assign it a different Docker network address. A healthy
370
+ MinIO container and a healthy edge container do not prove that the edge is
371
+ using the current address: an Nginx worker can retain the address resolved when
372
+ it started and continue returning 502 for uncached objects.
373
+
374
+ The `.110` edge configuration therefore uses Docker DNS `127.0.0.11` with
375
+ bounded re-resolution for both application upstreams:
376
+
377
+ ```nginx
378
+ resolver 127.0.0.11 valid=10s ipv6=off;
379
+
380
+ upstream damoa_api_upstream {
381
+ zone damoa_api_upstream 64k;
382
+ server damoa-api:3100 resolve;
383
+ }
384
+
385
+ upstream damoa_media_upstream {
386
+ zone damoa_media_upstream 64k;
387
+ server appback-minio:9000 resolve;
388
+ }
389
+ ```
390
+
391
+ `/home/appback/deploy/damoa/media-edge.conf` is mounted read-only at
392
+ `/etc/nginx/conf.d/default.conf`. After recreating either MinIO or the Damoa
393
+ API, validation must request at least one known uncached media object through
394
+ the edge and confirm a 200 response. Container health checks alone are not an
395
+ acceptable continuity test.
396
+
397
+ ## Validation Evidence Required
398
+
399
+ For every standby:
400
+
401
+ ```text
402
+ pg_is_in_recovery() = true
403
+ transaction_read_only = on for application access
404
+ receive/replay LSN is advancing
405
+ replay delay is within the accepted limit
406
+ write test fails
407
+ read query succeeds
408
+ restart preserves recovery mode
409
+ primary outage read test succeeds
410
+ primary recovery resumes replication without reverse sync
411
+ ```
@@ -66,7 +66,7 @@ Current policy:
66
66
 
67
67
  ## Runtime model
68
68
 
69
- Server 30 runs RemoteAgent as a `systemd` service.
69
+ Servers 30 and 40 run RemoteAgent as a `systemd` service.
70
70
 
71
71
  - unit: `remoteagent.service`
72
72
  - working directory: the installed `appback-remoteagent` package root
@@ -76,6 +76,12 @@ Server 30 runs RemoteAgent as a `systemd` service.
76
76
  The service environment is loaded from:
77
77
 
78
78
  - `/home/au2223/.remoteagent/.env`
79
+ - `/home/appback/.remoteagent/.env` on server 40
80
+
81
+ Server 40 uses the global npm package under Node.js `v22.23.2`. Its unit has
82
+ `Restart=always`, so a transient disk or process failure is recovered without
83
+ waiting for a manual Telegram health check. Deployment restarts this unit with
84
+ the `SUDO_APPBACK_33_40` RemoteAgent secret and never prints the secret value.
79
85
 
80
86
  ## Single-instance rule
81
87
 
package/docs/RELEASING.md CHANGED
@@ -74,7 +74,7 @@ The publish script performs:
74
74
  - `npm run build`
75
75
  - `npm pack`
76
76
  - guarded `npm publish`
77
- - exact published version and `latest` dist-tag verification
77
+ - exact published version and `latest` dist-tag verification with bounded propagation retries
78
78
 
79
79
  The package publish entrypoint is `npm run release:publish`.
80
80
  `scripts/prepublish-guard.mjs` routes manual publish attempts back to that entrypoint.
@@ -114,8 +114,10 @@ npm run release:deploy -- 0.15.5 all
114
114
  The deploy script performs:
115
115
 
116
116
  - npm registry version check for `appback-remoteagent@<version>`
117
+ - bounded retry when a target server's npm registry edge has not received the new version yet
118
+ - fail-fast validation for a broken or non-directory `~/.npm` cache path before remote installation
117
119
  - server 30 npm install, install hook, systemd restart, version/log verification
118
- - server 40 npm install, install hook, user-process restart, version/log verification
120
+ - server 40 npm install, install hook, systemd restart using `SUDO_APPBACK_33_40`, version/log verification
119
121
  - server 26 npm install, install hook, user-process restart, version/log verification
120
122
 
121
123
  ## 6. Verify
@@ -153,7 +155,10 @@ Server 40:
153
155
  ssh appback@192.168.33.40 'bash -lc '"'"'
154
156
  export PATH="$HOME/.local/bin:$HOME/.nvm/versions/node/v22.23.2/bin:$PATH"
155
157
  npm list -g appback-remoteagent --depth=0
156
- pgrep -af "appback-remoteagent/dist/index.js"
158
+ systemctl is-enabled remoteagent
159
+ systemctl is-active remoteagent
160
+ systemctl show remoteagent -p MainPID -p NRestarts
161
+ systemctl status remoteagent --no-pager -n 20
157
162
  tail -80 ~/.remoteagent/logs/agent.log
158
163
  '"'"''
159
164
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "appback-remoteagent",
3
- "version": "0.22.1",
3
+ "version": "0.22.3",
4
4
  "description": "Personal installable session server for continuing local AI work across PC and Telegram",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -0,0 +1,80 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ usage() {
5
+ cat <<'EOF'
6
+ Usage:
7
+ cleanup-aged-backup-entries.sh --root <absolute-directory> --days <count> [--apply]
8
+
9
+ Without --apply, matching entries are printed but not removed. Only immediate
10
+ children of --root are considered.
11
+ EOF
12
+ }
13
+
14
+ root=""
15
+ days=""
16
+ apply=false
17
+
18
+ while (($# > 0)); do
19
+ case "$1" in
20
+ --root)
21
+ [[ $# -ge 2 ]] || { usage >&2; exit 2; }
22
+ root="$2"
23
+ shift 2
24
+ ;;
25
+ --days)
26
+ [[ $# -ge 2 ]] || { usage >&2; exit 2; }
27
+ days="$2"
28
+ shift 2
29
+ ;;
30
+ --apply)
31
+ apply=true
32
+ shift
33
+ ;;
34
+ -h|--help)
35
+ usage
36
+ exit 0
37
+ ;;
38
+ *)
39
+ usage >&2
40
+ exit 2
41
+ ;;
42
+ esac
43
+ done
44
+
45
+ [[ "$root" == /* && "$root" != "/" ]] || {
46
+ printf 'cleanup_refused reason=invalid_root root=%q\n' "$root" >&2
47
+ exit 2
48
+ }
49
+ [[ "$days" =~ ^[1-9][0-9]*$ ]] || {
50
+ printf 'cleanup_refused reason=invalid_days days=%q\n' "$days" >&2
51
+ exit 2
52
+ }
53
+ [[ -d "$root" && ! -L "$root" ]] || {
54
+ printf 'cleanup_refused reason=root_not_directory root=%q\n' "$root" >&2
55
+ exit 2
56
+ }
57
+
58
+ root="$(realpath -e -- "$root")"
59
+ removed=0
60
+ matched=0
61
+
62
+ while IFS= read -r -d '' candidate; do
63
+ candidate="$(realpath -m -- "$candidate")"
64
+ [[ "$(dirname -- "$candidate")" == "$root" && "$candidate" != "$root" ]] || {
65
+ printf 'cleanup_refused reason=candidate_outside_root candidate=%q\n' "$candidate" >&2
66
+ exit 1
67
+ }
68
+
69
+ matched=$((matched + 1))
70
+ if [[ "$apply" == true ]]; then
71
+ rm -rf --one-file-system -- "$candidate"
72
+ removed=$((removed + 1))
73
+ printf 'cleanup_removed path=%q\n' "$candidate"
74
+ else
75
+ printf 'cleanup_candidate path=%q\n' "$candidate"
76
+ fi
77
+ done < <(find "$root" -mindepth 1 -maxdepth 1 -mtime "+$days" -print0)
78
+
79
+ printf 'cleanup_complete root=%q days=%s apply=%s matched=%s removed=%s\n' \
80
+ "$root" "$days" "$apply" "$matched" "$removed"
@@ -39,6 +39,14 @@ deploy_30() {
39
39
  ssh au2223@192.168.33.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
+ if [[ -L "$HOME/.npm" && ! -e "$HOME/.npm" ]]; then
43
+ echo "Broken npm cache symlink: $HOME/.npm -> $(readlink "$HOME/.npm")" >&2
44
+ exit 1
45
+ fi
46
+ if [[ -e "$HOME/.npm" && ! -d "$HOME/.npm" ]]; then
47
+ echo "npm cache path is not a directory: $HOME/.npm" >&2
48
+ exit 1
49
+ fi
42
50
  node - <<'NODE'
43
51
  const fs = require("fs");
44
52
  const path = "/home/au2223/.remoteagent/bot-polling-state.json";
@@ -52,7 +60,17 @@ if (running.length > 0) {
52
60
  process.exit(2);
53
61
  }
54
62
  NODE
55
- npm install -g "appback-remoteagent@$VERSION"
63
+ for ATTEMPT in {1..12}; do
64
+ if npm install -g "appback-remoteagent@$VERSION"; then
65
+ break
66
+ fi
67
+ if [[ "$ATTEMPT" -eq 12 ]]; then
68
+ echo "Remote npm install failed after $ATTEMPT attempts." >&2
69
+ exit 1
70
+ fi
71
+ echo "Remote npm registry has not propagated yet; retrying in 5s ($ATTEMPT/12)."
72
+ sleep 5
73
+ done
56
74
  remoteagent-install
57
75
  sudo -n systemctl restart remoteagent
58
76
  sleep 5
@@ -66,6 +84,14 @@ deploy_26() {
66
84
  ssh ospadmin@192.168.33.26 "VERSION=$VERSION bash -s" <<'REMOTE'
67
85
  set -euo pipefail
68
86
  export PATH="$HOME/.local/bin:$PATH"
87
+ if [[ -L "$HOME/.npm" && ! -e "$HOME/.npm" ]]; then
88
+ echo "Broken npm cache symlink: $HOME/.npm -> $(readlink "$HOME/.npm")" >&2
89
+ exit 1
90
+ fi
91
+ if [[ -e "$HOME/.npm" && ! -d "$HOME/.npm" ]]; then
92
+ echo "npm cache path is not a directory: $HOME/.npm" >&2
93
+ exit 1
94
+ fi
69
95
  node - <<'NODE'
70
96
  const fs = require("fs");
71
97
  const path = `${process.env.HOME}/.remoteagent/bot-polling-state.json`;
@@ -81,7 +107,17 @@ if (fs.existsSync(path)) {
81
107
  }
82
108
  }
83
109
  NODE
84
- npm install -g "appback-remoteagent@$VERSION"
110
+ for ATTEMPT in {1..12}; do
111
+ if npm install -g "appback-remoteagent@$VERSION"; then
112
+ break
113
+ fi
114
+ if [[ "$ATTEMPT" -eq 12 ]]; then
115
+ echo "Remote npm install failed after $ATTEMPT attempts." >&2
116
+ exit 1
117
+ fi
118
+ echo "Remote npm registry has not propagated yet; retrying in 5s ($ATTEMPT/12)."
119
+ sleep 5
120
+ done
85
121
  remoteagent-install
86
122
  ~/.remoteagent/stop-remoteagent.sh || true
87
123
  sleep 2
@@ -97,6 +133,14 @@ deploy_40() {
97
133
  ssh appback@192.168.33.40 "VERSION=$VERSION bash -s" <<'REMOTE'
98
134
  set -euo pipefail
99
135
  export PATH="$HOME/.local/bin:$HOME/.nvm/versions/node/v22.23.2/bin:$PATH"
136
+ if [[ -L "$HOME/.npm" && ! -e "$HOME/.npm" ]]; then
137
+ echo "Broken npm cache symlink: $HOME/.npm -> $(readlink "$HOME/.npm")" >&2
138
+ exit 1
139
+ fi
140
+ if [[ -e "$HOME/.npm" && ! -d "$HOME/.npm" ]]; then
141
+ echo "npm cache path is not a directory: $HOME/.npm" >&2
142
+ exit 1
143
+ fi
100
144
  node - <<'NODE'
101
145
  const fs = require("fs");
102
146
  const path = `${process.env.HOME}/.remoteagent/bot-polling-state.json`;
@@ -112,15 +156,39 @@ if (fs.existsSync(path)) {
112
156
  }
113
157
  }
114
158
  NODE
115
- npm install -g "appback-remoteagent@$VERSION"
159
+ for ATTEMPT in {1..12}; do
160
+ if npm install -g "appback-remoteagent@$VERSION"; then
161
+ break
162
+ fi
163
+ if [[ "$ATTEMPT" -eq 12 ]]; then
164
+ echo "Remote npm install failed after $ATTEMPT attempts." >&2
165
+ exit 1
166
+ fi
167
+ echo "Remote npm registry has not propagated yet; retrying in 5s ($ATTEMPT/12)."
168
+ sleep 5
169
+ done
116
170
  remoteagent-install
117
- ~/.remoteagent/stop-remoteagent.sh || true
118
- sleep 2
119
- ~/.remoteagent/start-remoteagent.sh
120
- sleep 5
171
+ if systemctl cat remoteagent >/dev/null 2>&1; then
172
+ HELPER="$HOME/.nvm/versions/node/v22.23.2/lib/node_modules/appback-remoteagent/dist/secret-helper.js"
173
+ SUDO_PASSWORD="$(node "$HELPER" get SUDO_APPBACK_33_40)"
174
+ printf '%s\n' "$SUDO_PASSWORD" | sudo -S -p '' systemctl restart remoteagent
175
+ unset SUDO_PASSWORD
176
+ sleep 7
177
+ systemctl is-active remoteagent
178
+ else
179
+ ~/.remoteagent/stop-remoteagent.sh || true
180
+ sleep 2
181
+ ~/.remoteagent/start-remoteagent.sh
182
+ sleep 5
183
+ fi
121
184
  npm list -g appback-remoteagent --depth=0
122
185
  pgrep -af 'appback-remoteagent/dist/index.js'
123
- tail -80 ~/.remoteagent/logs/agent.log
186
+ if systemctl cat remoteagent >/dev/null 2>&1; then
187
+ systemctl status remoteagent --no-pager -n 20
188
+ tail -80 ~/.remoteagent/logs/agent.log
189
+ else
190
+ tail -80 ~/.remoteagent/logs/agent.log
191
+ fi
124
192
  REMOTE
125
193
  }
126
194
 
@@ -57,10 +57,17 @@ REMOTEAGENT_PUBLISH_GUARD_OK=1 npm publish "$TARBALL" --access public
57
57
 
58
58
  echo
59
59
  echo "Verifying registry version:"
60
- PUBLISHED_VERSION="$(npm view "$PACKAGE_NAME@$VERSION" version --prefer-online)"
61
- LATEST_VERSION="$(npm view "$PACKAGE_NAME@latest" version --prefer-online)"
62
- echo "published=$PUBLISHED_VERSION"
63
- echo "latest=$LATEST_VERSION"
60
+ PUBLISHED_VERSION=""
61
+ LATEST_VERSION=""
62
+ for ATTEMPT in {1..12}; do
63
+ PUBLISHED_VERSION="$(npm view "$PACKAGE_NAME@$VERSION" version --prefer-online 2>/dev/null || true)"
64
+ LATEST_VERSION="$(npm view "$PACKAGE_NAME@latest" version --prefer-online 2>/dev/null || true)"
65
+ echo "attempt=$ATTEMPT published=${PUBLISHED_VERSION:-missing} latest=${LATEST_VERSION:-missing}"
66
+ if [[ "$PUBLISHED_VERSION" == "$VERSION" && "$LATEST_VERSION" == "$VERSION" ]]; then
67
+ break
68
+ fi
69
+ sleep 5
70
+ done
64
71
 
65
72
  if [[ "$PUBLISHED_VERSION" != "$VERSION" || "$LATEST_VERSION" != "$VERSION" ]]; then
66
73
  echo "Registry verification failed: expected published/latest=$VERSION" >&2
@@ -556,6 +556,50 @@ await click(macroButton.callback_data);
556
556
  await send("/batch send");
557
557
  await waitForTelegramCall((call) => call.text.includes("mock provider completed"));
558
558
 
559
+ const longTextProviderCallsBefore = providerCalls.length;
560
+ const longPartOne = `LONG_PART_ONE:${"a".repeat(2200)}`;
561
+ const longPartTwo = `LONG_PART_TWO:${"b".repeat(2200)}`;
562
+ providerMode = "success";
563
+ await send("/batch start");
564
+ await send(longPartOne);
565
+ await send(longPartTwo);
566
+ await send("/batch send");
567
+ await waitForTelegramCall((call) => call.text.includes("mock provider completed"));
568
+
569
+ const longTextProviderCalls = providerCalls.slice(longTextProviderCallsBefore);
570
+ if (longTextProviderCalls.length !== 1) {
571
+ throw new Error(`Split long Telegram input should make one provider call, got ${longTextProviderCalls.length}`);
572
+ }
573
+ const longTextProviderMessage = longTextProviderCalls[0]?.message ?? "";
574
+ if (!longTextProviderMessage.includes("stored as a UTF-8 text file")) {
575
+ throw new Error(`Long Telegram input was not replaced with a file prompt: ${longTextProviderMessage}`);
576
+ }
577
+ if (longTextProviderMessage.includes(longPartOne) || longTextProviderMessage.includes(longPartTwo)) {
578
+ throw new Error("Long Telegram input was copied into the provider prompt instead of being file-backed");
579
+ }
580
+ const longTextFile = longTextProviderMessage.match(/^File: (.+\.txt)$/m)?.[1];
581
+ if (!longTextFile) {
582
+ throw new Error(`Long Telegram input prompt did not include an absolute text file path: ${longTextProviderMessage}`);
583
+ }
584
+ const expectedLongTextDirectory = path.join(
585
+ dataDir,
586
+ "uploads",
587
+ "telegram",
588
+ "remoteagent_test_bot",
589
+ "111222333",
590
+ );
591
+ if (path.dirname(longTextFile) !== expectedLongTextDirectory) {
592
+ throw new Error(`Long Telegram input was stored outside the managed upload directory: ${longTextFile}`);
593
+ }
594
+ const storedLongText = await fs.readFile(longTextFile, "utf8");
595
+ if (storedLongText !== `${longPartOne}\n${longPartTwo}`) {
596
+ throw new Error("Stored Telegram text did not preserve all batched message parts in order");
597
+ }
598
+ const longTextMode = (await fs.stat(longTextFile)).mode & 0o777;
599
+ if (longTextMode !== 0o600) {
600
+ throw new Error(`Stored Telegram text permissions should be 0600, got ${longTextMode.toString(8)}`);
601
+ }
602
+
559
603
  await fs.appendFile(path.join(dataDir, ".env"), [
560
604
  "TELEGRAM_BOT_TOKENS=000000:test-token",
561
605
  "TELEGRAM_BOT_USERNAMES=remoteagent_test_bot",
@@ -797,6 +841,7 @@ console.log(JSON.stringify({
797
841
  queueRemoveLatest: secondQueueId,
798
842
  timeoutFinalMessage: true,
799
843
  usageLimitFallback: true,
844
+ longTelegramTextStoredAsFile: true,
800
845
  telegramSendMessages: evidenceCalls.filter((call) => call.method === "sendMessage").length,
801
846
  }, null, 2));
802
847