pi-control-bridge 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.
Potentially problematic release.
This version of pi-control-bridge might be problematic. Click here for more details.
- package/README.md +98 -0
- package/bridge.config.example.json +10 -0
- package/dist/bridge/main.js +4173 -0
- package/extension/bridge_client.ts +76 -0
- package/extension/command_handler.ts +39 -0
- package/extension/ensure_bridge.ts +78 -0
- package/extension/hooks.ts +291 -0
- package/extension/index.ts +7 -0
- package/extension/messages.ts +132 -0
- package/extension/session_metadata.ts +84 -0
- package/extension/session_status.ts +19 -0
- package/package.json +47 -0
- package/shared/agent_dir.ts +17 -0
- package/shared/ansi.ts +9 -0
- package/shared/config.ts +152 -0
- package/shared/constants.ts +13 -0
- package/shared/device_state.ts +21 -0
- package/shared/locale.ts +30 -0
- package/shared/logger.ts +54 -0
- package/shared/migrate.ts +39 -0
- package/shared/telegram.ts +136 -0
- package/shared/types.ts +107 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
|
|
3
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
|
|
5
|
+
const DESCRIPTION_MAX_LEN = 240;
|
|
6
|
+
|
|
7
|
+
function truncate(text: string, maxLen: number): string {
|
|
8
|
+
const trimmed = text.trim();
|
|
9
|
+
if (trimmed.length <= maxLen) return trimmed;
|
|
10
|
+
return `${trimmed.slice(0, maxLen - 1)}…`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function extractTextContent(content: unknown): string | undefined {
|
|
14
|
+
if (typeof content === "string") {
|
|
15
|
+
const trimmed = content.trim();
|
|
16
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
17
|
+
}
|
|
18
|
+
if (!Array.isArray(content)) return undefined;
|
|
19
|
+
|
|
20
|
+
const parts: string[] = [];
|
|
21
|
+
for (const item of content) {
|
|
22
|
+
if (typeof item === "string") {
|
|
23
|
+
const trimmed = item.trim();
|
|
24
|
+
if (trimmed) parts.push(trimmed);
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (item && typeof item === "object") {
|
|
28
|
+
const block = item as { type?: string; text?: string };
|
|
29
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
30
|
+
const trimmed = block.text.trim();
|
|
31
|
+
if (trimmed) parts.push(trimmed);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (parts.length === 0) return undefined;
|
|
36
|
+
return parts.join("\n");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function extractUserMessageText(message: unknown): string | undefined {
|
|
40
|
+
if (!message || typeof message !== "object") return undefined;
|
|
41
|
+
const candidate = message as { role?: string; content?: unknown };
|
|
42
|
+
if (candidate.role !== "user") return undefined;
|
|
43
|
+
return extractTextContent(candidate.content);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function extractFirstUserPrompt(ctx: ExtensionContext): string | undefined {
|
|
47
|
+
for (const entry of ctx.sessionManager.getEntries()) {
|
|
48
|
+
if (entry.type !== "message") continue;
|
|
49
|
+
const text = extractUserMessageText(entry.message);
|
|
50
|
+
if (text) return truncate(text, DESCRIPTION_MAX_LEN);
|
|
51
|
+
}
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function extractLatestUserPromptFromMessages(messages: unknown[]): string | undefined {
|
|
56
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
57
|
+
const text = extractUserMessageText(messages[index]);
|
|
58
|
+
if (text) return truncate(text, DESCRIPTION_MAX_LEN);
|
|
59
|
+
}
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function buildSessionMetadata(
|
|
64
|
+
pi: ExtensionAPI,
|
|
65
|
+
ctx: ExtensionContext,
|
|
66
|
+
options?: { messages?: unknown[] },
|
|
67
|
+
): Record<string, string> {
|
|
68
|
+
const metadata: Record<string, string> = {};
|
|
69
|
+
|
|
70
|
+
const title = pi.getSessionName()?.trim();
|
|
71
|
+
if (title) metadata.title = title;
|
|
72
|
+
|
|
73
|
+
const description =
|
|
74
|
+
(options?.messages ? extractLatestUserPromptFromMessages(options.messages) : undefined) ??
|
|
75
|
+
extractFirstUserPrompt(ctx);
|
|
76
|
+
if (description) metadata.description = description;
|
|
77
|
+
|
|
78
|
+
const projectBasename = basename(ctx.cwd);
|
|
79
|
+
if (projectBasename) metadata.projectBasename = projectBasename;
|
|
80
|
+
|
|
81
|
+
metadata.mode = ctx.mode;
|
|
82
|
+
|
|
83
|
+
return metadata;
|
|
84
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
export type SessionStatus = "waiting_user" | "running";
|
|
4
|
+
|
|
5
|
+
/** Session start and other idle/busy boundaries where isIdle() reflects UI state. */
|
|
6
|
+
export function sessionStatusFromContext(ctx: Pick<ExtensionContext, "isIdle">): SessionStatus {
|
|
7
|
+
return ctx.isIdle() ? "waiting_user" : "running";
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Status after a user prompt completes (agent_end).
|
|
12
|
+
* Pi may still report isStreaming=true inside agent_end handlers, so use the
|
|
13
|
+
* pending message queue instead: no queued steer/follow-up means waiting for user.
|
|
14
|
+
*/
|
|
15
|
+
export function sessionStatusAfterAgentEnd(
|
|
16
|
+
ctx: Pick<ExtensionContext, "hasPendingMessages">,
|
|
17
|
+
): SessionStatus {
|
|
18
|
+
return ctx.hasPendingMessages() ? "running" : "waiting_user";
|
|
19
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-control-bridge",
|
|
3
|
+
"version": "0.3.10",
|
|
4
|
+
"description": "Pi agent bridge extension and singleton runtime for pi-control-hub integration.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"pi-extension",
|
|
8
|
+
"bridge",
|
|
9
|
+
"control-hub"
|
|
10
|
+
],
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"type": "module",
|
|
13
|
+
"peerDependencies": {
|
|
14
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@hono/node-server": "^1.14.1",
|
|
18
|
+
"hono": "^4.7.4"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@types/node": "^22.13.10",
|
|
22
|
+
"esbuild": "^0.25.1",
|
|
23
|
+
"typescript": "^5.8.2",
|
|
24
|
+
"vitest": "^3.0.9"
|
|
25
|
+
},
|
|
26
|
+
"bin": {
|
|
27
|
+
"pi-bridge": "./dist/bridge/main.js"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"dist/bridge/main.js",
|
|
31
|
+
"extension/",
|
|
32
|
+
"shared/",
|
|
33
|
+
"bridge.config.example.json",
|
|
34
|
+
"README.md"
|
|
35
|
+
],
|
|
36
|
+
"pi": {
|
|
37
|
+
"extensions": [
|
|
38
|
+
"./extension/index.ts"
|
|
39
|
+
]
|
|
40
|
+
},
|
|
41
|
+
"scripts": {
|
|
42
|
+
"build": "esbuild bridge/main.ts --bundle --platform=node --format=esm --outfile=dist/bridge/main.js --banner:js=\"#!/usr/bin/env node\"",
|
|
43
|
+
"prepublishOnly": "npm run build",
|
|
44
|
+
"test": "vitest run",
|
|
45
|
+
"check": "tsc --noEmit"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
/** Pi agent config directory (default `~/.pi/agent`, overridable via `PI_CODING_AGENT_DIR`). */
|
|
5
|
+
export function getAgentDir(): string {
|
|
6
|
+
const configured = process.env.PI_CODING_AGENT_DIR?.trim();
|
|
7
|
+
if (!configured) {
|
|
8
|
+
return join(homedir(), ".pi", "agent");
|
|
9
|
+
}
|
|
10
|
+
if (configured === "~") {
|
|
11
|
+
return homedir();
|
|
12
|
+
}
|
|
13
|
+
if (configured.startsWith("~/")) {
|
|
14
|
+
return resolve(homedir(), configured.slice(2));
|
|
15
|
+
}
|
|
16
|
+
return resolve(configured);
|
|
17
|
+
}
|
package/shared/ansi.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
const RESET = "\x1b[0m";
|
|
2
|
+
|
|
3
|
+
export const ansi = {
|
|
4
|
+
reset: RESET,
|
|
5
|
+
title: (text: string) => `\x1b[1;96m${text}${RESET}`,
|
|
6
|
+
label: (text: string) => `\x1b[97m${text}${RESET}`,
|
|
7
|
+
link: (text: string) => `\x1b[94;4m${text}${RESET}`,
|
|
8
|
+
accent: (text: string) => `\x1b[93m${text}${RESET}`,
|
|
9
|
+
};
|
package/shared/config.ts
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { getAgentDir } from "./agent_dir.ts";
|
|
6
|
+
import {
|
|
7
|
+
DEFAULT_HUB_URL,
|
|
8
|
+
DEFAULT_BRIDGE_LOG_LEVEL,
|
|
9
|
+
DEFAULT_COMMAND_BATCH_SIZE,
|
|
10
|
+
DEFAULT_HEARTBEAT_INTERVAL_SEC,
|
|
11
|
+
DEFAULT_IPC_PORT,
|
|
12
|
+
DEFAULT_POLL_INTERVAL_SEC,
|
|
13
|
+
PROJECT_CONFIG_RELATIVE_PATH,
|
|
14
|
+
} from "./constants.ts";
|
|
15
|
+
import { defaultBridgeDataDir, migrateLegacyBridgePaths } from "./migrate.ts";
|
|
16
|
+
import type { BridgeConfig, BridgeConfigFile } from "./types.ts";
|
|
17
|
+
|
|
18
|
+
function expandHome(path: string): string {
|
|
19
|
+
return path.startsWith("~/") ? join(homedir(), path.slice(2)) : path;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function positiveNumber(value: unknown, fallback: number): number {
|
|
23
|
+
if (typeof value === "number" && Number.isFinite(value) && value > 0) return value;
|
|
24
|
+
if (typeof value === "string") {
|
|
25
|
+
const parsed = Number(value);
|
|
26
|
+
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
|
27
|
+
}
|
|
28
|
+
return fallback;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function defaultConfig(): BridgeConfig {
|
|
32
|
+
return {
|
|
33
|
+
hubUrl: DEFAULT_HUB_URL,
|
|
34
|
+
pollIntervalSec: DEFAULT_POLL_INTERVAL_SEC,
|
|
35
|
+
heartbeatIntervalSec: DEFAULT_HEARTBEAT_INTERVAL_SEC,
|
|
36
|
+
commandBatchSize: DEFAULT_COMMAND_BATCH_SIZE,
|
|
37
|
+
bridgeLogLevel: DEFAULT_BRIDGE_LOG_LEVEL,
|
|
38
|
+
bridgeDataDir: defaultBridgeDataDir(),
|
|
39
|
+
ipcPort: DEFAULT_IPC_PORT,
|
|
40
|
+
autoStartBridge: true,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function readConfigFile(path: string): BridgeConfigFile | null {
|
|
45
|
+
if (!existsSync(path)) return null;
|
|
46
|
+
try {
|
|
47
|
+
return JSON.parse(readFileSync(path, "utf-8")) as BridgeConfigFile;
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function applyConfigFile(base: BridgeConfig, file: BridgeConfigFile): BridgeConfig {
|
|
54
|
+
return {
|
|
55
|
+
hubUrl:
|
|
56
|
+
typeof file.hub_url === "string" && file.hub_url.trim()
|
|
57
|
+
? file.hub_url.trim()
|
|
58
|
+
: base.hubUrl,
|
|
59
|
+
pollIntervalSec: positiveNumber(file.poll_interval_sec, base.pollIntervalSec),
|
|
60
|
+
heartbeatIntervalSec: positiveNumber(
|
|
61
|
+
file.heartbeat_interval_sec,
|
|
62
|
+
base.heartbeatIntervalSec,
|
|
63
|
+
),
|
|
64
|
+
commandBatchSize: positiveNumber(file.command_batch_size, base.commandBatchSize),
|
|
65
|
+
bridgeLogLevel:
|
|
66
|
+
typeof file.bridge_log_level === "string" && file.bridge_log_level.trim()
|
|
67
|
+
? file.bridge_log_level.trim()
|
|
68
|
+
: base.bridgeLogLevel,
|
|
69
|
+
bridgeDataDir:
|
|
70
|
+
typeof file.bridge_data_dir === "string" && file.bridge_data_dir.trim()
|
|
71
|
+
? expandHome(file.bridge_data_dir.trim())
|
|
72
|
+
: base.bridgeDataDir,
|
|
73
|
+
ipcPort: positiveNumber(file.ipc_port, base.ipcPort),
|
|
74
|
+
autoStartBridge:
|
|
75
|
+
typeof file.auto_start_bridge === "boolean"
|
|
76
|
+
? file.auto_start_bridge
|
|
77
|
+
: base.autoStartBridge,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function userConfigPath(): string {
|
|
82
|
+
return join(getAgentDir(), "bridge", "config.json");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Walk up from startDir and return the nearest project bridge config path. */
|
|
86
|
+
export function findProjectConfigPath(startDir: string = process.cwd()): string | null {
|
|
87
|
+
let current = startDir;
|
|
88
|
+
while (true) {
|
|
89
|
+
const candidate = join(current, PROJECT_CONFIG_RELATIVE_PATH);
|
|
90
|
+
if (existsSync(candidate)) return candidate;
|
|
91
|
+
const parent = dirname(current);
|
|
92
|
+
if (parent === current) return null;
|
|
93
|
+
current = parent;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface LoadBridgeConfigOptions {
|
|
98
|
+
/** Project directory used to locate `.pi/bridge.json` (walks up from here). */
|
|
99
|
+
cwd?: string;
|
|
100
|
+
/** Override project config path (tests). Pass `null` to skip project config. */
|
|
101
|
+
projectConfigPath?: string | null;
|
|
102
|
+
/** Override user config path (tests). Pass `null` to skip user config. */
|
|
103
|
+
userConfigPath?: string | null;
|
|
104
|
+
/** Skip legacy path migration (tests). */
|
|
105
|
+
skipMigration?: boolean;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Load bridge config. Priority (low → high):
|
|
110
|
+
* 1. defaults
|
|
111
|
+
* 2. `~/.pi/agent/bridge/config.json`
|
|
112
|
+
* 3. `.pi/bridge.json` in project (nearest ancestor of cwd)
|
|
113
|
+
*/
|
|
114
|
+
export function loadBridgeConfig(options: LoadBridgeConfigOptions = {}): BridgeConfig {
|
|
115
|
+
if (!options.skipMigration) {
|
|
116
|
+
migrateLegacyBridgePaths();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let config = defaultConfig();
|
|
120
|
+
|
|
121
|
+
const userPath =
|
|
122
|
+
options.userConfigPath === null
|
|
123
|
+
? null
|
|
124
|
+
: (options.userConfigPath ?? userConfigPath());
|
|
125
|
+
if (userPath) {
|
|
126
|
+
const userFile = readConfigFile(userPath);
|
|
127
|
+
if (userFile) config = applyConfigFile(config, userFile);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const projectPath =
|
|
131
|
+
options.projectConfigPath === null
|
|
132
|
+
? null
|
|
133
|
+
: (options.projectConfigPath ?? findProjectConfigPath(options.cwd));
|
|
134
|
+
if (projectPath) {
|
|
135
|
+
const projectFile = readConfigFile(projectPath);
|
|
136
|
+
if (projectFile) config = applyConfigFile(config, projectFile);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return config;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function ipcBaseUrl(port: number): string {
|
|
143
|
+
return `http://127.0.0.1:${port}`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function stateFilePath(dataDir: string): string {
|
|
147
|
+
return join(dataDir, "state.json");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function eventsQueuePath(dataDir: string): string {
|
|
151
|
+
return join(dataDir, "events-queue.jsonl");
|
|
152
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export const PACKAGE_VERSION = "0.3.3";
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_HUB_URL = "http://127.0.0.1:8000";
|
|
4
|
+
export const DEFAULT_POLL_INTERVAL_SEC = 2;
|
|
5
|
+
export const DEFAULT_HEARTBEAT_INTERVAL_SEC = 15;
|
|
6
|
+
export const DEFAULT_COMMAND_BATCH_SIZE = 10;
|
|
7
|
+
export const DEFAULT_BRIDGE_LOG_LEVEL = "INFO";
|
|
8
|
+
export const DEFAULT_IPC_PORT = 9473;
|
|
9
|
+
export const PROJECT_CONFIG_RELATIVE_PATH = ".pi/bridge.json";
|
|
10
|
+
|
|
11
|
+
export const IPC_COMMAND_WAIT_TIMEOUT_MS = 30_000;
|
|
12
|
+
export const COMMAND_RETRY_ATTEMPTS = 3;
|
|
13
|
+
export const COMMAND_RETRY_DELAY_MS = 2_000;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { DeviceState } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
/** Whether the bridge should call GET /me to probe Telegram link state. */
|
|
4
|
+
export function shouldProbeTelegramLink(state: DeviceState | null | undefined): boolean {
|
|
5
|
+
if (!state?.deviceToken) return false;
|
|
6
|
+
return state.telegramBindPending === true || state.telegramLinked === true;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function hasDeviceCredentials(state: DeviceState | null | undefined): boolean {
|
|
10
|
+
return Boolean(state?.deviceToken);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function clearDeviceCredentials(state: DeviceState): DeviceState {
|
|
14
|
+
return {
|
|
15
|
+
...state,
|
|
16
|
+
deviceId: "",
|
|
17
|
+
deviceToken: "",
|
|
18
|
+
telegramLinked: false,
|
|
19
|
+
telegramBindPending: false,
|
|
20
|
+
};
|
|
21
|
+
}
|
package/shared/locale.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export type AppLocale = "ru" | "en";
|
|
2
|
+
|
|
3
|
+
export function getSystemLocale(): AppLocale {
|
|
4
|
+
const candidates = [
|
|
5
|
+
process.env.LC_ALL,
|
|
6
|
+
process.env.LC_MESSAGES,
|
|
7
|
+
process.env.LANG,
|
|
8
|
+
Intl.DateTimeFormat().resolvedOptions().locale,
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
for (const raw of candidates) {
|
|
12
|
+
if (!raw) continue;
|
|
13
|
+
const normalized = raw.replace(/\.utf-8$/i, "").replace(/_/g, "-").toLowerCase();
|
|
14
|
+
if (normalized.startsWith("ru")) return "ru";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return "en";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function pluralMinutes(count: number, locale: AppLocale): string {
|
|
21
|
+
if (locale === "en") {
|
|
22
|
+
return count === 1 ? "minute" : "minutes";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const mod10 = count % 10;
|
|
26
|
+
const mod100 = count % 100;
|
|
27
|
+
if (mod10 === 1 && mod100 !== 11) return "минуту";
|
|
28
|
+
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 10 || mod100 >= 20)) return "минуты";
|
|
29
|
+
return "минут";
|
|
30
|
+
}
|
package/shared/logger.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
type LogLevel = "DEBUG" | "INFO" | "WARN" | "ERROR";
|
|
2
|
+
|
|
3
|
+
const LEVEL_ORDER: Record<LogLevel, number> = {
|
|
4
|
+
DEBUG: 10,
|
|
5
|
+
INFO: 20,
|
|
6
|
+
WARN: 30,
|
|
7
|
+
ERROR: 40,
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export interface LogContext {
|
|
11
|
+
deviceId?: string;
|
|
12
|
+
externalSessionId?: string;
|
|
13
|
+
hubSessionId?: string;
|
|
14
|
+
commandId?: string;
|
|
15
|
+
eventId?: string;
|
|
16
|
+
correlationId?: string;
|
|
17
|
+
[key: string]: unknown;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class Logger {
|
|
21
|
+
private readonly minLevel: number;
|
|
22
|
+
|
|
23
|
+
constructor(level: string) {
|
|
24
|
+
const normalized = level.toUpperCase() as LogLevel;
|
|
25
|
+
this.minLevel = LEVEL_ORDER[normalized] ?? LEVEL_ORDER.INFO;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
private write(level: LogLevel, message: string, context?: LogContext): void {
|
|
29
|
+
if (LEVEL_ORDER[level] < this.minLevel) return;
|
|
30
|
+
const entry = {
|
|
31
|
+
ts: new Date().toISOString(),
|
|
32
|
+
level,
|
|
33
|
+
message,
|
|
34
|
+
...context,
|
|
35
|
+
};
|
|
36
|
+
console.error(JSON.stringify(entry));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
debug(message: string, context?: LogContext): void {
|
|
40
|
+
this.write("DEBUG", message, context);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
info(message: string, context?: LogContext): void {
|
|
44
|
+
this.write("INFO", message, context);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
warn(message: string, context?: LogContext): void {
|
|
48
|
+
this.write("WARN", message, context);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
error(message: string, context?: LogContext): void {
|
|
52
|
+
this.write("ERROR", message, context);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync, renameSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { getAgentDir } from "./agent_dir.ts";
|
|
6
|
+
|
|
7
|
+
export function legacyBridgeDataDir(): string {
|
|
8
|
+
return join(homedir(), ".pi", "bridge");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function defaultBridgeDataDir(): string {
|
|
12
|
+
return join(getAgentDir(), "bridge");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Move `~/.pi/bridge` → `~/.pi/agent/bridge` when the new location is still empty. */
|
|
16
|
+
export function migrateLegacyBridgePaths(): void {
|
|
17
|
+
const oldDir = legacyBridgeDataDir();
|
|
18
|
+
const newDir = defaultBridgeDataDir();
|
|
19
|
+
if (oldDir === newDir || !existsSync(oldDir)) return;
|
|
20
|
+
|
|
21
|
+
if (!existsSync(newDir)) {
|
|
22
|
+
mkdirSync(getAgentDir(), { recursive: true });
|
|
23
|
+
try {
|
|
24
|
+
renameSync(oldDir, newDir);
|
|
25
|
+
return;
|
|
26
|
+
} catch {
|
|
27
|
+
// Fall through to per-file copy if rename fails (e.g. cross-device).
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
mkdirSync(newDir, { recursive: true, mode: 0o700 });
|
|
32
|
+
for (const name of readdirSync(oldDir)) {
|
|
33
|
+
const source = join(oldDir, name);
|
|
34
|
+
const target = join(newDir, name);
|
|
35
|
+
if (!existsSync(target)) {
|
|
36
|
+
copyFileSync(source, target);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
export interface TelegramLinkResponse {
|
|
2
|
+
token: string;
|
|
3
|
+
expiresAt: string;
|
|
4
|
+
botUsername?: string;
|
|
5
|
+
botLink?: string;
|
|
6
|
+
alreadyLinked?: boolean;
|
|
7
|
+
telegramUsername?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface HubTelegramConnection {
|
|
11
|
+
linked: boolean;
|
|
12
|
+
username?: string;
|
|
13
|
+
chatId?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface HubBotInfo {
|
|
17
|
+
username?: string;
|
|
18
|
+
link?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface HubConnectionInfo {
|
|
22
|
+
deviceId?: string;
|
|
23
|
+
telegram: HubTelegramConnection;
|
|
24
|
+
bot: HubBotInfo;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function buildTelegramBotLink(botUsername: string, token?: string): string {
|
|
28
|
+
const username = botUsername.replace(/^@/, "");
|
|
29
|
+
const base = `https://t.me/${username}`;
|
|
30
|
+
if (!token) return base;
|
|
31
|
+
return `${base}?start=${encodeURIComponent(token)}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function buildAlreadyLinkedTelegramResponse(connection: HubConnectionInfo): TelegramLinkResponse {
|
|
35
|
+
return {
|
|
36
|
+
token: "",
|
|
37
|
+
expiresAt: "",
|
|
38
|
+
alreadyLinked: true,
|
|
39
|
+
botUsername: connection.bot.username,
|
|
40
|
+
botLink: connection.bot.link,
|
|
41
|
+
telegramUsername: connection.telegram.username,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function parseTelegramLinkResponse(raw: Record<string, unknown>): TelegramLinkResponse {
|
|
46
|
+
const alreadyLinked = raw.already_linked === true || raw.alreadyLinked === true;
|
|
47
|
+
const token = String(raw.token ?? "");
|
|
48
|
+
const expiresAt = String(raw.expires_at ?? raw.expiresAt ?? "");
|
|
49
|
+
const telegramUsernameRaw = raw.telegram_username ?? raw.telegramUsername;
|
|
50
|
+
const telegramUsername =
|
|
51
|
+
typeof telegramUsernameRaw === "string" && telegramUsernameRaw.length > 0
|
|
52
|
+
? telegramUsernameRaw
|
|
53
|
+
: undefined;
|
|
54
|
+
const botUsernameRaw = raw.bot_username ?? raw.botUsername;
|
|
55
|
+
const botUsername =
|
|
56
|
+
typeof botUsernameRaw === "string" && botUsernameRaw.length > 0
|
|
57
|
+
? botUsernameRaw
|
|
58
|
+
: undefined;
|
|
59
|
+
const botLinkRaw = raw.bot_link ?? raw.botLink;
|
|
60
|
+
const botLink =
|
|
61
|
+
typeof botLinkRaw === "string" && botLinkRaw.length > 0
|
|
62
|
+
? botLinkRaw
|
|
63
|
+
: botUsername
|
|
64
|
+
? buildTelegramBotLink(botUsername, token)
|
|
65
|
+
: undefined;
|
|
66
|
+
|
|
67
|
+
return { token, expiresAt, botUsername, botLink, alreadyLinked, telegramUsername };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function readTelegramBlock(raw: Record<string, unknown>): Record<string, unknown> | undefined {
|
|
71
|
+
const telegram = raw.telegram;
|
|
72
|
+
return telegram && typeof telegram === "object" ? (telegram as Record<string, unknown>) : undefined;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function readBotBlock(raw: Record<string, unknown>): Record<string, unknown> | undefined {
|
|
76
|
+
const bot = raw.bot;
|
|
77
|
+
return bot && typeof bot === "object" ? (bot as Record<string, unknown>) : undefined;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function parseHubConnectionInfo(raw: Record<string, unknown>): HubConnectionInfo {
|
|
81
|
+
const telegramBlock = readTelegramBlock(raw);
|
|
82
|
+
const botBlock = readBotBlock(raw);
|
|
83
|
+
|
|
84
|
+
const linked =
|
|
85
|
+
telegramBlock?.linked === true ||
|
|
86
|
+
raw.telegram_linked === true ||
|
|
87
|
+
raw.telegramLinked === true;
|
|
88
|
+
|
|
89
|
+
const telegramUsernameRaw =
|
|
90
|
+
telegramBlock?.username ?? raw.telegram_username ?? raw.telegramUsername;
|
|
91
|
+
const telegramUsername =
|
|
92
|
+
typeof telegramUsernameRaw === "string" && telegramUsernameRaw.length > 0
|
|
93
|
+
? telegramUsernameRaw
|
|
94
|
+
: undefined;
|
|
95
|
+
|
|
96
|
+
const chatIdRaw = telegramBlock?.chat_id ?? telegramBlock?.chatId ?? raw.telegram_chat_id;
|
|
97
|
+
const chatId =
|
|
98
|
+
typeof chatIdRaw === "number"
|
|
99
|
+
? chatIdRaw
|
|
100
|
+
: typeof chatIdRaw === "string" && chatIdRaw.length > 0
|
|
101
|
+
? Number(chatIdRaw)
|
|
102
|
+
: undefined;
|
|
103
|
+
|
|
104
|
+
const botUsernameRaw =
|
|
105
|
+
botBlock?.username ?? raw.bot_username ?? raw.botUsername;
|
|
106
|
+
const botUsername =
|
|
107
|
+
typeof botUsernameRaw === "string" && botUsernameRaw.length > 0
|
|
108
|
+
? botUsernameRaw
|
|
109
|
+
: undefined;
|
|
110
|
+
|
|
111
|
+
const botLinkRaw = botBlock?.link ?? raw.bot_link ?? raw.botLink;
|
|
112
|
+
const botLink =
|
|
113
|
+
typeof botLinkRaw === "string" && botLinkRaw.length > 0
|
|
114
|
+
? botLinkRaw
|
|
115
|
+
: botUsername
|
|
116
|
+
? buildTelegramBotLink(botUsername)
|
|
117
|
+
: undefined;
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
deviceId:
|
|
121
|
+
typeof raw.device_id === "string"
|
|
122
|
+
? raw.device_id
|
|
123
|
+
: typeof raw.deviceId === "string"
|
|
124
|
+
? raw.deviceId
|
|
125
|
+
: undefined,
|
|
126
|
+
telegram: {
|
|
127
|
+
linked,
|
|
128
|
+
username: telegramUsername,
|
|
129
|
+
chatId: Number.isFinite(chatId) ? chatId : undefined,
|
|
130
|
+
},
|
|
131
|
+
bot: {
|
|
132
|
+
username: botUsername,
|
|
133
|
+
link: botLink,
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|