@inceptionstack/roundhouse 0.3.8 → 0.3.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/agents/pi.ts +17 -3
- package/src/cli/cli.ts +13 -9
- package/src/cli/setup.ts +41 -13
- package/src/config.ts +1 -1
- package/src/cron/scheduler.ts +4 -1
- package/src/gateway.ts +37 -8
- package/src/memory/state.ts +13 -1
- package/src/types.ts +2 -0
- package/src/util.ts +13 -5
package/package.json
CHANGED
package/src/agents/pi.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* ~/.pi/agent/gateway-sessions/<thread_id>/<session>.jsonl
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { mkdir } from "node:fs/promises";
|
|
9
|
+
import { mkdir, stat } from "node:fs/promises";
|
|
10
10
|
import { readFileSync } from "node:fs";
|
|
11
11
|
import { join, dirname } from "node:path";
|
|
12
12
|
import { homedir } from "node:os";
|
|
@@ -24,7 +24,7 @@ import {
|
|
|
24
24
|
} from "@mariozechner/pi-coding-agent";
|
|
25
25
|
|
|
26
26
|
import type { AgentAdapter, AgentAdapterFactory, AgentMessage, AgentResponse, AgentStreamEvent } from "../types";
|
|
27
|
-
import { DEBUG_STREAM, threadIdToDir } from "../util";
|
|
27
|
+
import { DEBUG_STREAM, threadIdToDir, threadIdToDirLegacy } from "../util";
|
|
28
28
|
|
|
29
29
|
interface SessionEntry {
|
|
30
30
|
session: AgentSession;
|
|
@@ -159,7 +159,21 @@ export const createPiAgentAdapter: AgentAdapterFactory = (config) => {
|
|
|
159
159
|
}
|
|
160
160
|
|
|
161
161
|
async function createSession(threadId: string): Promise<SessionEntry> {
|
|
162
|
-
|
|
162
|
+
let dirName = threadIdToDir(threadId);
|
|
163
|
+
const newPath = join(sessionsDir, dirName);
|
|
164
|
+
const legacyPath = join(sessionsDir, threadIdToDirLegacy(threadId));
|
|
165
|
+
// Migrate: if legacy dir exists but new doesn't, use legacy
|
|
166
|
+
if (dirName !== threadIdToDirLegacy(threadId)) {
|
|
167
|
+
try {
|
|
168
|
+
await stat(legacyPath);
|
|
169
|
+
// Legacy exists — check if new exists
|
|
170
|
+
try { await stat(newPath); } catch {
|
|
171
|
+
// New doesn't exist, use legacy to preserve session history
|
|
172
|
+
dirName = threadIdToDirLegacy(threadId);
|
|
173
|
+
}
|
|
174
|
+
} catch { /* legacy doesn't exist either, use new */ }
|
|
175
|
+
}
|
|
176
|
+
const threadDir = join(sessionsDir, dirName);
|
|
163
177
|
await mkdir(threadDir, { recursive: true });
|
|
164
178
|
|
|
165
179
|
let sessionManager: InstanceType<typeof SessionManager>;
|
package/src/cli/cli.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { homedir } from "node:os";
|
|
|
9
9
|
import { readFile, writeFile, mkdir, mkdtemp } from "node:fs/promises";
|
|
10
10
|
import { tmpdir } from "node:os";
|
|
11
11
|
import { readdirSync, statSync } from "node:fs";
|
|
12
|
-
import { execSync, spawn } from "node:child_process";
|
|
12
|
+
import { execSync, execFileSync, spawnSync, spawn } from "node:child_process";
|
|
13
13
|
import { fileURLToPath } from "node:url";
|
|
14
14
|
|
|
15
15
|
import {
|
|
@@ -40,12 +40,16 @@ function run(cmd: string, opts?: { silent?: boolean }): string {
|
|
|
40
40
|
}
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
function runSudo(
|
|
44
|
-
|
|
43
|
+
function runSudo(...args: string[]): void {
|
|
44
|
+
// Try non-interactive first, fall back to interactive for password prompts
|
|
45
|
+
const result = spawnSync("sudo", ["-n", ...args], { stdio: "inherit" });
|
|
46
|
+
if (result.status !== 0) {
|
|
47
|
+
execFileSync("sudo", args, { stdio: "inherit" });
|
|
48
|
+
}
|
|
45
49
|
}
|
|
46
50
|
|
|
47
51
|
function systemctl(verb: string, message?: string): void {
|
|
48
|
-
runSudo(
|
|
52
|
+
runSudo("systemctl", verb, SERVICE_NAME);
|
|
49
53
|
if (message) console.log(` ✅ ${message}`);
|
|
50
54
|
}
|
|
51
55
|
|
|
@@ -151,9 +155,9 @@ WantedBy=multi-user.target
|
|
|
151
155
|
const tmpDir = await mkdtemp(resolve(tmpdir(), "roundhouse-"));
|
|
152
156
|
const tmpUnit = resolve(tmpDir, `${SERVICE_NAME}.service`);
|
|
153
157
|
await writeFile(tmpUnit, unit, { mode: 0o600 });
|
|
154
|
-
runSudo(
|
|
155
|
-
runSudo(
|
|
156
|
-
runSudo("systemctl daemon-reload");
|
|
158
|
+
runSudo("cp", tmpUnit, SERVICE_PATH);
|
|
159
|
+
runSudo("rm", "-rf", "--", tmpDir);
|
|
160
|
+
runSudo("systemctl", "daemon-reload");
|
|
157
161
|
systemctl("enable");
|
|
158
162
|
systemctl("start", "Daemon installed and started.");
|
|
159
163
|
|
|
@@ -174,8 +178,8 @@ async function cmdUninstall() {
|
|
|
174
178
|
console.log("[roundhouse] Removing systemd daemon...");
|
|
175
179
|
try { systemctl("stop"); } catch {}
|
|
176
180
|
try { systemctl("disable"); } catch {}
|
|
177
|
-
try { runSudo(
|
|
178
|
-
runSudo("systemctl daemon-reload");
|
|
181
|
+
try { runSudo("rm", "-f", SERVICE_PATH); } catch {}
|
|
182
|
+
runSudo("systemctl", "daemon-reload");
|
|
179
183
|
console.log(" ✅ Daemon removed. Config preserved at:", CONFIG_PATH);
|
|
180
184
|
}
|
|
181
185
|
|
package/src/cli/setup.ts
CHANGED
|
@@ -189,7 +189,10 @@ export function parseSetupArgs(argv: string[]): SetupOptions {
|
|
|
189
189
|
);
|
|
190
190
|
}
|
|
191
191
|
if (opts.users.length === 0) {
|
|
192
|
-
throw new Error(
|
|
192
|
+
throw new Error(
|
|
193
|
+
"At least one --user USERNAME is required.\n" +
|
|
194
|
+
"This is your Telegram username (without @).",
|
|
195
|
+
);
|
|
193
196
|
}
|
|
194
197
|
for (const ext of opts.extensions) {
|
|
195
198
|
if (!EXTENSION_NAME_RE.test(ext)) {
|
|
@@ -254,9 +257,23 @@ async function stepPreflight(opts: SetupOptions): Promise<void> {
|
|
|
254
257
|
const hasAws =
|
|
255
258
|
process.env.AWS_ACCESS_KEY_ID ||
|
|
256
259
|
process.env.AWS_PROFILE ||
|
|
257
|
-
await fileExists(resolve(homedir(), ".aws", "credentials"))
|
|
260
|
+
await fileExists(resolve(homedir(), ".aws", "credentials")) ||
|
|
261
|
+
await fileExists(resolve(homedir(), ".aws", "config"));
|
|
262
|
+
|
|
263
|
+
// Also check instance metadata (EC2 IAM role)
|
|
264
|
+
let hasInstanceRole = false;
|
|
265
|
+
if (!hasAws) {
|
|
266
|
+
try {
|
|
267
|
+
const result = execSafe("curl", ["-sf", "--max-time", "2",
|
|
268
|
+
"http://169.254.169.254/latest/meta-data/iam/security-credentials/"], { silent: true });
|
|
269
|
+
hasInstanceRole = result.length > 0;
|
|
270
|
+
} catch {}
|
|
271
|
+
}
|
|
272
|
+
|
|
258
273
|
if (hasAws) {
|
|
259
274
|
ok("AWS credentials found");
|
|
275
|
+
} else if (hasInstanceRole) {
|
|
276
|
+
ok("AWS credentials found (instance IAM role)");
|
|
260
277
|
} else {
|
|
261
278
|
warn("AWS credentials not found — configure before first use");
|
|
262
279
|
}
|
|
@@ -436,7 +453,7 @@ async function stepInstallPackages(opts: SetupOptions): Promise<void> {
|
|
|
436
453
|
async function stepStoreSecrets(opts: SetupOptions, botInfo: BotInfo): Promise<void> {
|
|
437
454
|
if (!opts.psst) return;
|
|
438
455
|
|
|
439
|
-
step("
|
|
456
|
+
step("⑥", "Storing secrets in psst...");
|
|
440
457
|
|
|
441
458
|
const secrets: [string, string][] = [
|
|
442
459
|
["TELEGRAM_BOT_TOKEN", opts.botToken],
|
|
@@ -477,7 +494,7 @@ async function stepConfigure(
|
|
|
477
494
|
botInfo: BotInfo,
|
|
478
495
|
pairResult: PairResult | null,
|
|
479
496
|
): Promise<void> {
|
|
480
|
-
step("
|
|
497
|
+
step("⑦", "Configuring...");
|
|
481
498
|
|
|
482
499
|
await mkdir(ROUNDHOUSE_DIR, { recursive: true });
|
|
483
500
|
await mkdir(dirname(PI_SETTINGS_PATH), { recursive: true });
|
|
@@ -602,6 +619,10 @@ async function stepConfigure(
|
|
|
602
619
|
if (!envLines.some((l) => l.startsWith("AWS_DEFAULT_REGION="))) {
|
|
603
620
|
envLines.push(`AWS_DEFAULT_REGION=${existingEnv.AWS_DEFAULT_REGION ?? '"us-east-1"'}`);
|
|
604
621
|
}
|
|
622
|
+
// Pi agent requires AWS_REGION (not just AWS_DEFAULT_REGION) to discover Bedrock models
|
|
623
|
+
if (!envLines.some((l) => l.startsWith("AWS_REGION="))) {
|
|
624
|
+
envLines.push(`AWS_REGION=${existingEnv.AWS_REGION ?? existingEnv.AWS_DEFAULT_REGION ?? '"us-east-1"'}`);
|
|
625
|
+
}
|
|
605
626
|
}
|
|
606
627
|
|
|
607
628
|
await atomicWriteText(ENV_PATH, envLines.join("\n") + "\n");
|
|
@@ -609,7 +630,7 @@ async function stepConfigure(
|
|
|
609
630
|
}
|
|
610
631
|
|
|
611
632
|
async function stepPair(opts: SetupOptions, botInfo: BotInfo): Promise<PairResult | null> {
|
|
612
|
-
step("
|
|
633
|
+
step("⑤", "Pairing with Telegram...");
|
|
613
634
|
|
|
614
635
|
// Skip if chat IDs already known
|
|
615
636
|
if (opts.notifyChatIds.length > 0) {
|
|
@@ -651,6 +672,11 @@ async function stepPair(opts: SetupOptions, botInfo: BotInfo): Promise<PairResul
|
|
|
651
672
|
|
|
652
673
|
if (result) {
|
|
653
674
|
ok(`Paired with @${result.username} (user id: ${result.userId}, chat: ${result.chatId})`);
|
|
675
|
+
// Add paired username to allowedUsers if not already present
|
|
676
|
+
const lcUsername = result.username.toLowerCase();
|
|
677
|
+
if (!opts.users.some((u) => u.toLowerCase() === lcUsername)) {
|
|
678
|
+
opts.users.push(result.username);
|
|
679
|
+
}
|
|
654
680
|
return result;
|
|
655
681
|
}
|
|
656
682
|
|
|
@@ -814,12 +840,12 @@ export async function cmdSetup(argv: string[]): Promise<void> {
|
|
|
814
840
|
// Phase 2: Install packages
|
|
815
841
|
await stepInstallPackages(opts);
|
|
816
842
|
|
|
817
|
-
// Phase 3:
|
|
818
|
-
await stepStoreSecrets(opts, botInfo);
|
|
819
|
-
|
|
820
|
-
// Phase 4: Pair (before config, so we can include chat ID)
|
|
843
|
+
// Phase 3: Pair (before secrets/config, so paired username is included)
|
|
821
844
|
const pairResult = await stepPair(opts, botInfo);
|
|
822
845
|
|
|
846
|
+
// Phase 4: Store secrets (after pairing, so ALLOWED_USERS includes paired user)
|
|
847
|
+
await stepStoreSecrets(opts, botInfo);
|
|
848
|
+
|
|
823
849
|
// Phase 5: Write config (includes pair data)
|
|
824
850
|
await stepConfigure(opts, botInfo, pairResult);
|
|
825
851
|
|
|
@@ -951,18 +977,20 @@ function printDryRun(opts: SetupOptions): void {
|
|
|
951
977
|
log(`Would install: npm install -g psst-cli`);
|
|
952
978
|
log(`Would initialize psst vault`);
|
|
953
979
|
log(`Would install: pi-psst extension`);
|
|
954
|
-
log(`Would store TELEGRAM_BOT_TOKEN, BOT_USERNAME, ALLOWED_USERS in psst`);
|
|
955
980
|
}
|
|
956
981
|
for (const ext of opts.extensions) log(`Would install extension: ${ext}`);
|
|
982
|
+
if (!opts.nonInteractive && opts.notifyChatIds.length === 0) {
|
|
983
|
+
log(`Would pair via Telegram (interactive)`);
|
|
984
|
+
}
|
|
985
|
+
if (opts.psst) {
|
|
986
|
+
log(`Would store TELEGRAM_BOT_TOKEN, BOT_USERNAME, ALLOWED_USERS in psst`);
|
|
987
|
+
}
|
|
957
988
|
log(`Would configure: ~/.pi/agent/settings.json`);
|
|
958
989
|
log(` Set defaultProvider: ${opts.provider}`);
|
|
959
990
|
log(` Set defaultModel: ${opts.model}`);
|
|
960
991
|
log(`Would write: ~/.roundhouse/gateway.config.json`);
|
|
961
992
|
log(`Would write: ~/.roundhouse/env${opts.psst ? " (non-secret config only)" : ""}`);
|
|
962
993
|
log(`Would register ${BOT_COMMANDS.length} bot commands`);
|
|
963
|
-
if (!opts.nonInteractive && opts.notifyChatIds.length === 0) {
|
|
964
|
-
log(`Would pair via Telegram (interactive)`);
|
|
965
|
-
}
|
|
966
994
|
if (opts.systemd) log(`Would install systemd service`);
|
|
967
995
|
log("\nNo changes made.\n");
|
|
968
996
|
}
|
package/src/config.ts
CHANGED
package/src/cron/scheduler.ts
CHANGED
|
@@ -276,7 +276,10 @@ export class CronSchedulerService {
|
|
|
276
276
|
// Skip if already queued — don't write state that could race with the runner
|
|
277
277
|
if (this.queuedJobIds.has(job.id)) continue;
|
|
278
278
|
|
|
279
|
-
// Update lastScheduledAt to prevent re-queueing next tick
|
|
279
|
+
// Update lastScheduledAt to prevent re-queueing next tick.
|
|
280
|
+
// NOTE: If the process crashes between here and run completion, this
|
|
281
|
+
// occurrence won't be retried on restart. Acceptable for periodic jobs;
|
|
282
|
+
// critical one-shot jobs should use external orchestration.
|
|
280
283
|
state.lastScheduledAt = dueAt.toISOString();
|
|
281
284
|
await this.store.writeState(state);
|
|
282
285
|
|
package/src/gateway.ts
CHANGED
|
@@ -22,13 +22,26 @@ import { maxPressure } from "./memory/policy";
|
|
|
22
22
|
import type { PressureLevel } from "./memory/types";
|
|
23
23
|
|
|
24
24
|
/** Match a Telegram command, handling optional @botname suffix */
|
|
25
|
+
/** Bot username for command suffix validation (set during gateway init) */
|
|
26
|
+
let _botUsername = "";
|
|
27
|
+
|
|
25
28
|
function isCommand(text: string, cmd: string): boolean {
|
|
26
|
-
|
|
29
|
+
if (text === cmd) return true;
|
|
30
|
+
if (!text.startsWith(`${cmd}@`)) return false;
|
|
31
|
+
if (!_botUsername) return false; // no bot name configured, reject suffixed commands
|
|
32
|
+
const suffix = text.slice(cmd.length + 1).toLowerCase();
|
|
33
|
+
return suffix === _botUsername.toLowerCase();
|
|
27
34
|
}
|
|
28
35
|
|
|
29
36
|
/** Match a command that accepts subcommands (e.g. /crons trigger <id>) */
|
|
30
37
|
function isCommandWithArgs(text: string, cmd: string): boolean {
|
|
31
|
-
|
|
38
|
+
if (text === cmd || text.startsWith(`${cmd} `)) return true;
|
|
39
|
+
if (!text.startsWith(`${cmd}@`)) return false;
|
|
40
|
+
if (!_botUsername) return false;
|
|
41
|
+
const rest = text.slice(cmd.length + 1);
|
|
42
|
+
const spaceIdx = rest.indexOf(" ");
|
|
43
|
+
const suffix = spaceIdx === -1 ? rest : rest.slice(0, spaceIdx);
|
|
44
|
+
return suffix.toLowerCase() === _botUsername.toLowerCase();
|
|
32
45
|
}
|
|
33
46
|
import { hostname, loadavg, totalmem, freemem, cpus } from "node:os";
|
|
34
47
|
import { homedir } from "node:os";
|
|
@@ -166,10 +179,25 @@ async function saveAttachments(threadId: string, attachments: any[]): Promise<At
|
|
|
166
179
|
continue;
|
|
167
180
|
}
|
|
168
181
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
182
|
+
// Convert to Buffer with size cap to prevent memory exhaustion
|
|
183
|
+
let buf: Buffer;
|
|
184
|
+
if (Buffer.isBuffer(data)) {
|
|
185
|
+
buf = data;
|
|
186
|
+
} else if (data instanceof Blob) {
|
|
187
|
+
// Stream blobs with size cap
|
|
188
|
+
if (data.size > MAX_FILE_SIZE) {
|
|
189
|
+
skipped.push(`${att.name ?? att.type} (${(data.size / 1024 / 1024).toFixed(1)} MB) exceeds size limit`);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
buf = Buffer.from(await data.arrayBuffer());
|
|
193
|
+
} else if (ArrayBuffer.isView(data)) {
|
|
194
|
+
buf = Buffer.from(data.buffer, data.byteOffset, data.byteLength);
|
|
195
|
+
} else if (data instanceof ArrayBuffer) {
|
|
196
|
+
buf = Buffer.from(data);
|
|
197
|
+
} else {
|
|
198
|
+
console.warn(`[roundhouse] unknown attachment data type, skipping: ${att.name ?? att.type}`);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
173
201
|
|
|
174
202
|
if (buf.length > MAX_FILE_SIZE) {
|
|
175
203
|
const sizeMB = (buf.length / 1024 / 1024).toFixed(1);
|
|
@@ -220,6 +248,7 @@ export class Gateway {
|
|
|
220
248
|
constructor(router: AgentRouter, config: GatewayConfig) {
|
|
221
249
|
this.router = router;
|
|
222
250
|
this.config = config;
|
|
251
|
+
_botUsername = config.chat.botUsername || "";
|
|
223
252
|
}
|
|
224
253
|
|
|
225
254
|
async start() {
|
|
@@ -305,8 +334,8 @@ export class Gateway {
|
|
|
305
334
|
// Handle /restart command — restart the gateway process
|
|
306
335
|
// Only available when an allowlist is configured (all allowed users can restart)
|
|
307
336
|
if (isCommand(userText.trim(), "/restart")) {
|
|
308
|
-
if (allowedUsers.length === 0) {
|
|
309
|
-
await thread.post("⚠️ /restart requires an allowedUsers
|
|
337
|
+
if (allowedUsers.length === 0 && allowedUserIds.length === 0) {
|
|
338
|
+
await thread.post("⚠️ /restart requires an allowlist (allowedUsers or allowedUserIds) to be configured.");
|
|
310
339
|
return;
|
|
311
340
|
}
|
|
312
341
|
console.log(`[roundhouse] /restart requested by @${authorName} in thread=${thread.id}`);
|
package/src/memory/state.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { readFile, writeFile, mkdir, rename, unlink } from "node:fs/promises";
|
|
|
9
9
|
import { resolve, dirname } from "node:path";
|
|
10
10
|
import { randomBytes } from "node:crypto";
|
|
11
11
|
import { ROUNDHOUSE_DIR } from "../config";
|
|
12
|
-
import { threadIdToDir } from "../util";
|
|
12
|
+
import { threadIdToDir, threadIdToDirLegacy } from "../util";
|
|
13
13
|
import type { ThreadMemoryState } from "./types";
|
|
14
14
|
|
|
15
15
|
const STATE_DIR = resolve(ROUNDHOUSE_DIR, "memory-state");
|
|
@@ -18,12 +18,24 @@ function stateFilePath(threadId: string): string {
|
|
|
18
18
|
return resolve(STATE_DIR, `${threadIdToDir(threadId)}.json`);
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
function legacyStateFilePath(threadId: string): string {
|
|
22
|
+
return resolve(STATE_DIR, `${threadIdToDirLegacy(threadId)}.json`);
|
|
23
|
+
}
|
|
24
|
+
|
|
21
25
|
/** Load per-thread memory state (returns empty state if none exists) */
|
|
22
26
|
export async function loadThreadMemoryState(threadId: string): Promise<ThreadMemoryState> {
|
|
23
27
|
try {
|
|
24
28
|
const raw = await readFile(stateFilePath(threadId), "utf8");
|
|
25
29
|
return JSON.parse(raw) as ThreadMemoryState;
|
|
26
30
|
} catch {
|
|
31
|
+
// Fallback to legacy encoding for pre-v0.4 state files
|
|
32
|
+
try {
|
|
33
|
+
const legacyPath = legacyStateFilePath(threadId);
|
|
34
|
+
if (legacyPath !== stateFilePath(threadId)) {
|
|
35
|
+
const raw = await readFile(legacyPath, "utf8");
|
|
36
|
+
return JSON.parse(raw) as ThreadMemoryState;
|
|
37
|
+
}
|
|
38
|
+
} catch {}
|
|
27
39
|
return {};
|
|
28
40
|
}
|
|
29
41
|
}
|
package/src/types.ts
CHANGED
|
@@ -98,6 +98,8 @@ export interface AgentRouter {
|
|
|
98
98
|
// ── Gateway config ───────────────────────────────────
|
|
99
99
|
|
|
100
100
|
export interface GatewayConfig {
|
|
101
|
+
/** Config schema version for future migrations */
|
|
102
|
+
_version?: number;
|
|
101
103
|
agent: {
|
|
102
104
|
type: string;
|
|
103
105
|
[key: string]: unknown;
|
package/src/util.ts
CHANGED
|
@@ -97,12 +97,20 @@ export function startTypingLoop(
|
|
|
97
97
|
* Uses a scheme that avoids collisions between different separators.
|
|
98
98
|
*/
|
|
99
99
|
export function threadIdToDir(threadId: string): string {
|
|
100
|
-
//
|
|
101
|
-
// "
|
|
100
|
+
// Injective filesystem-safe encoding:
|
|
101
|
+
// "_" → "__", ":" → "_c", other special → "_xNN" (hex code)
|
|
102
102
|
return threadId
|
|
103
|
-
.replace(/_/g, "
|
|
104
|
-
.replace(/:/g, "_c") // encode colons
|
|
105
|
-
.replace(/[^a-zA-Z0-9_-]/g,
|
|
103
|
+
.replace(/_/g, "__") // escape existing underscores first
|
|
104
|
+
.replace(/:/g, "_c") // encode colons (common in thread IDs)
|
|
105
|
+
.replace(/[^a-zA-Z0-9_-]/g, (ch) => `_x${ch.charCodeAt(0).toString(16).padStart(4, "0")}`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Legacy encoding (v0.3.x) for migration fallback */
|
|
109
|
+
export function threadIdToDirLegacy(threadId: string): string {
|
|
110
|
+
return threadId
|
|
111
|
+
.replace(/_/g, "_u")
|
|
112
|
+
.replace(/:/g, "_c")
|
|
113
|
+
.replace(/[^a-zA-Z0-9_-]/g, "_x");
|
|
106
114
|
}
|
|
107
115
|
|
|
108
116
|
/**
|