@amanm/openpaw 0.1.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/AGENTS.md +1 -0
- package/README.md +144 -0
- package/agent/agent.ts +217 -0
- package/agent/context-scan.ts +81 -0
- package/agent/file-editor-store.ts +27 -0
- package/agent/index.ts +31 -0
- package/agent/memory-store.ts +404 -0
- package/agent/model.ts +14 -0
- package/agent/prompt-builder.ts +139 -0
- package/agent/prompt-context-files.ts +151 -0
- package/agent/sandbox-paths.ts +52 -0
- package/agent/session-store.ts +80 -0
- package/agent/skill-catalog.ts +25 -0
- package/agent/skills/discover.ts +100 -0
- package/agent/tool-stream-format.ts +126 -0
- package/agent/tool-yaml-like.ts +96 -0
- package/agent/tools/bash.ts +100 -0
- package/agent/tools/file-editor.ts +293 -0
- package/agent/tools/list-dir.ts +58 -0
- package/agent/tools/load-skill.ts +40 -0
- package/agent/tools/memory.ts +84 -0
- package/agent/turn-context.ts +46 -0
- package/agent/types.ts +37 -0
- package/agent/workspace-bootstrap.ts +98 -0
- package/bin/openpaw.cjs +177 -0
- package/bundled-skills/find-skills/SKILL.md +163 -0
- package/cli/components/chat-app.tsx +759 -0
- package/cli/components/onboard-ui.tsx +325 -0
- package/cli/components/theme.ts +16 -0
- package/cli/configure.tsx +0 -0
- package/cli/lib/chat-transcript-types.ts +11 -0
- package/cli/lib/markdown-render-node.ts +523 -0
- package/cli/lib/onboard-markdown-syntax-style.ts +55 -0
- package/cli/lib/ui-messages-to-chat-transcript.ts +157 -0
- package/cli/lib/use-auto-copy-selection.ts +38 -0
- package/cli/onboard.tsx +248 -0
- package/cli/openpaw.tsx +144 -0
- package/cli/reset.ts +12 -0
- package/cli/tui.tsx +31 -0
- package/config/index.ts +3 -0
- package/config/paths.ts +71 -0
- package/config/personality-copy.ts +68 -0
- package/config/storage.ts +80 -0
- package/config/types.ts +37 -0
- package/gateway/bootstrap.ts +25 -0
- package/gateway/channel-adapter.ts +8 -0
- package/gateway/daemon-manager.ts +191 -0
- package/gateway/index.ts +18 -0
- package/gateway/session-key.ts +13 -0
- package/gateway/slash-command-tokens.ts +39 -0
- package/gateway/start-messaging.ts +40 -0
- package/gateway/telegram/active-thread-store.ts +89 -0
- package/gateway/telegram/adapter.ts +290 -0
- package/gateway/telegram/assistant-markdown.ts +48 -0
- package/gateway/telegram/bot-commands.ts +40 -0
- package/gateway/telegram/chat-preferences.ts +100 -0
- package/gateway/telegram/constants.ts +5 -0
- package/gateway/telegram/index.ts +4 -0
- package/gateway/telegram/message-html.ts +138 -0
- package/gateway/telegram/message-queue.ts +19 -0
- package/gateway/telegram/reserved-command-filter.ts +33 -0
- package/gateway/telegram/session-file-discovery.ts +62 -0
- package/gateway/telegram/session-key.ts +13 -0
- package/gateway/telegram/session-label.ts +14 -0
- package/gateway/telegram/sessions-list-reply.ts +39 -0
- package/gateway/telegram/stream-delivery.ts +618 -0
- package/gateway/tui/constants.ts +2 -0
- package/gateway/tui/tui-active-thread-store.ts +103 -0
- package/gateway/tui/tui-session-discovery.ts +94 -0
- package/gateway/tui/tui-session-label.ts +22 -0
- package/gateway/tui/tui-sessions-list-message.ts +37 -0
- package/package.json +52 -0
package/config/paths.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
const CONFIG_DIR = join(homedir(), ".openpaw");
|
|
6
|
+
const CONFIG_PATH = join(CONFIG_DIR, "config.yaml");
|
|
7
|
+
const WORKSPACE_DIR = join(CONFIG_DIR, "workspace");
|
|
8
|
+
const SESSIONS_DIR = join(WORKSPACE_DIR, "sessions");
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Absolute path to the YAML config file, typically `~/.openpaw/config.yaml`.
|
|
12
|
+
*/
|
|
13
|
+
export function getConfigPath(): string {
|
|
14
|
+
return CONFIG_PATH;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Ensures the OpenPaw config directory exists (`~/.openpaw`), creating it if needed.
|
|
19
|
+
*
|
|
20
|
+
* @remarks Uses synchronous `mkdirSync` with `recursive: true`.
|
|
21
|
+
*/
|
|
22
|
+
export function ensureConfigDir(): void {
|
|
23
|
+
if (!existsSync(CONFIG_DIR)) {
|
|
24
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Returns whether the config file exists at {@link getConfigPath}.
|
|
30
|
+
*/
|
|
31
|
+
export function configExists(): boolean {
|
|
32
|
+
return existsSync(CONFIG_PATH);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Deletes the config file if it exists. No-op when the file is absent.
|
|
37
|
+
*
|
|
38
|
+
* @remarks Uses synchronous `unlinkSync`.
|
|
39
|
+
*/
|
|
40
|
+
export function deleteConfig(): void {
|
|
41
|
+
if (existsSync(CONFIG_PATH)) {
|
|
42
|
+
unlinkSync(CONFIG_PATH);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* OpenPaw workspace root, typically `~/.openpaw/workspace`.
|
|
48
|
+
*/
|
|
49
|
+
export function getWorkspaceRoot(): string {
|
|
50
|
+
return WORKSPACE_DIR;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Directory for persisted chat sessions, `~/.openpaw/workspace/sessions`.
|
|
55
|
+
*/
|
|
56
|
+
export function getSessionsDir(): string {
|
|
57
|
+
return SESSIONS_DIR;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Ensures workspace and `sessions/` exist on disk (directories only).
|
|
62
|
+
*/
|
|
63
|
+
export function ensureWorkspaceDirectories(): void {
|
|
64
|
+
ensureConfigDir();
|
|
65
|
+
if (!existsSync(WORKSPACE_DIR)) {
|
|
66
|
+
mkdirSync(WORKSPACE_DIR, { recursive: true });
|
|
67
|
+
}
|
|
68
|
+
if (!existsSync(SESSIONS_DIR)) {
|
|
69
|
+
mkdirSync(SESSIONS_DIR, { recursive: true });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fixed prose for each onboarding personality and shared identity copy for the system prompt.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { Personality } from "./types";
|
|
6
|
+
|
|
7
|
+
/** Core identity (Hermes-style default agent preamble, adapted for OpenPaw). */
|
|
8
|
+
export const OPENPAW_IDENTITY = [
|
|
9
|
+
"You are OpenPaw, a capable local AI assistant.",
|
|
10
|
+
"You are helpful, direct, and honest. You assist with questions, code, analysis, and actions via your tools.",
|
|
11
|
+
"Communicate clearly, admit uncertainty when appropriate, and prioritize being useful over being verbose unless the user or workspace says otherwise.",
|
|
12
|
+
"Be targeted and efficient in exploration and investigations.",
|
|
13
|
+
"In conversation, sound like a person who pays attention — not like software narrating where it read data from.",
|
|
14
|
+
].join(" ");
|
|
15
|
+
|
|
16
|
+
const PERSONALITY_PROSE: Record<Personality, string> = {
|
|
17
|
+
Assistant:
|
|
18
|
+
"Tone: casual but professional — warm, conversational, still clear and competent. " +
|
|
19
|
+
"Answer like someone who remembers the thread and the user: weave in context naturally (\"You said you're in India…\", \"Last time you mentioned…\") instead of quoting files, \"profiles\", or tools. " +
|
|
20
|
+
"Avoid stiff assistant-speak and avoid meta lines about what you \"loaded\" or \"checked\".",
|
|
21
|
+
Meowl:
|
|
22
|
+
"Tone: warm and playful with light cat-themed flair where it fits; stay substantive and never sacrifice clarity for whimsy.",
|
|
23
|
+
Coder:
|
|
24
|
+
"Tone: engineer-focused. Prefer concrete steps, code, and commands; minimize filler. When editing code, prefer small diffs and match existing style.",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Returns a short paragraph expanding the selected personality preset for the system prompt.
|
|
29
|
+
*/
|
|
30
|
+
export function getPersonalityProse(personality: Personality): string {
|
|
31
|
+
return PERSONALITY_PROSE[personality];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** When to use the memory tool vs files (aligned with Hermes MEMORY guidance intent). */
|
|
35
|
+
export const MEMORY_GUIDANCE = [
|
|
36
|
+
"You have persistent storage: use the `memory` tool for durable user facts (`user`) and your own notes (`memory`); use the file editor for workspace persona and long-form workspace rules when instructed below.",
|
|
37
|
+
"Save durable facts: preferences, location/timezone when relevant, environment details, tool quirks, stable conventions.",
|
|
38
|
+
"Curated memory is shown as a frozen block at session start; after you edit it, updates appear in tool results until a new session.",
|
|
39
|
+
"Memory tool: `add` only needs `content`. `replace` needs `old_text` + `content`. `remove` needs `old_text` only. Never use `replace` with content alone for a brand-new fact.",
|
|
40
|
+
"Prioritize what reduces future steering. Do NOT save ephemeral task progress, session logs, or huge dumps.",
|
|
41
|
+
"Internal only (never say this to the user): persona text may live in one workspace markdown file; rules in another. When saving or recalling, talk like a human: \"You told me…\", \"You're in IST, right?\" — not \"your user.md\", \"soul.md\", \"profile file\", \"MEMORY.md\", or \"I checked the memory tool\".",
|
|
42
|
+
].join("\n");
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Explicit rules for user-visible replies — keeps tooling invisible in natural language.
|
|
46
|
+
*/
|
|
47
|
+
export const USER_FACING_VOICE = [
|
|
48
|
+
"In every message meant for the user, speak naturally.",
|
|
49
|
+
"Good: \"You mentioned you're in India, so you're on IST (UTC+5:30).\" / \"Want me to remember your timezone for later?\"",
|
|
50
|
+
"Bad: \"Your user.md says…\", \"According to your profile file…\", \"I read soul.md…\", \"The memory block shows…\", \"I'll add that to MEMORY.md.\"",
|
|
51
|
+
"If something was never stated and you're inferring, say so briefly instead of pretending it came from a file.",
|
|
52
|
+
].join("\n");
|
|
53
|
+
|
|
54
|
+
/** Short note about conversation history (session persistence). */
|
|
55
|
+
export const SESSION_NOTE =
|
|
56
|
+
"Prior messages in this chat are persisted in the session; you can refer to them without saving everything to memory.";
|
|
57
|
+
|
|
58
|
+
/** Surface-specific hints (Hermes PLATFORM_HINTS style, adapted for OpenPaw). */
|
|
59
|
+
export const PLATFORM_HINTS: Record<"cli" | "telegram", string> = {
|
|
60
|
+
cli: [
|
|
61
|
+
"You are running in a terminal UI. Prefer plain, readable text over heavy markdown",
|
|
62
|
+
"(headings and short lists are fine if they render well in the TUI).",
|
|
63
|
+
].join(" "),
|
|
64
|
+
telegram: [
|
|
65
|
+
"You are on Telegram. Formatting is limited; avoid relying on complex markdown.",
|
|
66
|
+
"Keep messages readable as plain text when in doubt.",
|
|
67
|
+
].join(" "),
|
|
68
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { ensureConfigDir, getConfigPath } from "./paths";
|
|
3
|
+
import type { OpenPawConfig, Personality } from "./types";
|
|
4
|
+
|
|
5
|
+
function toYaml(config: OpenPawConfig): string {
|
|
6
|
+
const lines: string[] = [];
|
|
7
|
+
|
|
8
|
+
lines.push("provider:");
|
|
9
|
+
lines.push(` baseUrl: "${config.provider.baseUrl}"`);
|
|
10
|
+
lines.push(` apiKey: "${config.provider.apiKey}"`);
|
|
11
|
+
lines.push(` model: "${config.provider.model}"`);
|
|
12
|
+
|
|
13
|
+
if (config.channels?.telegram) {
|
|
14
|
+
lines.push("channels:");
|
|
15
|
+
lines.push(" telegram:");
|
|
16
|
+
lines.push(` botToken: "${config.channels.telegram.botToken}"`);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
lines.push(`personality: "${config.personality}"`);
|
|
20
|
+
|
|
21
|
+
return lines.join("\n") + "\n";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Writes the full config to disk, replacing any existing file at {@link getConfigPath}.
|
|
26
|
+
*
|
|
27
|
+
* @remarks Ensures the config directory exists before writing via {@link ensureConfigDir}.
|
|
28
|
+
*/
|
|
29
|
+
export async function saveConfig(config: OpenPawConfig): Promise<void> {
|
|
30
|
+
ensureConfigDir();
|
|
31
|
+
await Bun.write(getConfigPath(), toYaml(config));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Reads and parses the YAML config file from disk.
|
|
36
|
+
*
|
|
37
|
+
* @returns Parsed config, or `null` if the file is missing, unreadable, or required
|
|
38
|
+
* fields (`provider.*`, `personality`) cannot be extracted with the current parser.
|
|
39
|
+
*/
|
|
40
|
+
export async function loadConfig(): Promise<OpenPawConfig | null> {
|
|
41
|
+
const path = getConfigPath();
|
|
42
|
+
if (!existsSync(path)) {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
const file = Bun.file(path);
|
|
47
|
+
const content = await file.text();
|
|
48
|
+
|
|
49
|
+
const baseUrlMatch = content.match(/baseUrl:\s*"([^"]+)"/);
|
|
50
|
+
const apiKeyMatch = content.match(/apiKey:\s*"([^"]+)"/);
|
|
51
|
+
const modelMatch = content.match(/model:\s*"([^"]+)"/);
|
|
52
|
+
const botTokenMatch = content.match(/botToken:\s*"([^"]+)"/);
|
|
53
|
+
const personalityMatch = content.match(/personality:\s*"([^"]+)"/);
|
|
54
|
+
|
|
55
|
+
if (!baseUrlMatch?.[1] || !apiKeyMatch?.[1] || !modelMatch?.[1] || !personalityMatch?.[1]) {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const config: OpenPawConfig = {
|
|
60
|
+
provider: {
|
|
61
|
+
baseUrl: baseUrlMatch[1],
|
|
62
|
+
apiKey: apiKeyMatch[1],
|
|
63
|
+
model: modelMatch[1],
|
|
64
|
+
},
|
|
65
|
+
personality: personalityMatch[1] as Personality,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
if (botTokenMatch?.[1]) {
|
|
69
|
+
config.channels = {
|
|
70
|
+
telegram: {
|
|
71
|
+
botToken: botTokenMatch[1],
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return config;
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
package/config/types.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LLM provider settings used for API calls.
|
|
3
|
+
*/
|
|
4
|
+
export interface ProviderConfig {
|
|
5
|
+
/** Base URL of the provider API (e.g. OpenAI-compatible endpoint). */
|
|
6
|
+
baseUrl: string;
|
|
7
|
+
apiKey: string;
|
|
8
|
+
model: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Optional integrations for outbound channels (e.g. Telegram bot).
|
|
13
|
+
*/
|
|
14
|
+
export interface ChannelsConfig {
|
|
15
|
+
/** When set, enables Telegram with this bot token. */
|
|
16
|
+
telegram?: {
|
|
17
|
+
botToken: string;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Ordered list of personality labels shown in onboarding and persisted to `config.yaml`.
|
|
23
|
+
* Each value is written as the `personality` string field.
|
|
24
|
+
*/
|
|
25
|
+
export const PERSONALITIES = ["Assistant", "Meowl", "Coder"] as const;
|
|
26
|
+
|
|
27
|
+
/** One of the allowed `personality` values stored in config. */
|
|
28
|
+
export type Personality = (typeof PERSONALITIES)[number];
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Full OpenPaw user configuration as represented on disk and in the onboarding flow.
|
|
32
|
+
*/
|
|
33
|
+
export interface OpenPawConfig {
|
|
34
|
+
provider: ProviderConfig;
|
|
35
|
+
channels?: ChannelsConfig;
|
|
36
|
+
personality: Personality;
|
|
37
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { createAgentRuntime, type AgentRuntime } from "../agent/agent";
|
|
2
|
+
import { ensureWorkspaceLayout } from "../agent/workspace-bootstrap";
|
|
3
|
+
import { getWorkspaceRoot } from "../config/paths";
|
|
4
|
+
import type { OpenPawConfig } from "../config/types";
|
|
5
|
+
import { loadConfig } from "../config/storage";
|
|
6
|
+
|
|
7
|
+
export type OpenPawGatewayContext = {
|
|
8
|
+
config: OpenPawConfig;
|
|
9
|
+
workspacePath: string;
|
|
10
|
+
runtime: AgentRuntime;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Load config, ensure workspace layout, and create the shared agent runtime used by all channels.
|
|
15
|
+
*/
|
|
16
|
+
export async function createGatewayContext(): Promise<OpenPawGatewayContext> {
|
|
17
|
+
const config = await loadConfig();
|
|
18
|
+
if (!config) {
|
|
19
|
+
throw new Error("Config not found. Run `openpaw onboard` first.");
|
|
20
|
+
}
|
|
21
|
+
ensureWorkspaceLayout();
|
|
22
|
+
const workspacePath = getWorkspaceRoot();
|
|
23
|
+
const runtime = await createAgentRuntime(config, workspacePath);
|
|
24
|
+
return { config, workspacePath, runtime };
|
|
25
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A messaging channel (Telegram, future webhooks, etc.) that runs until stopped.
|
|
3
|
+
*/
|
|
4
|
+
export type ChannelAdapter = {
|
|
5
|
+
readonly id: string;
|
|
6
|
+
/** Runs until the channel stops (e.g. long polling ends). Normally does not resolve. */
|
|
7
|
+
run: () => Promise<void>;
|
|
8
|
+
};
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
/** Filesystem paths used by the gateway daemon lifecycle. */
|
|
8
|
+
export type GatewayDaemonPaths = {
|
|
9
|
+
stateDir: string;
|
|
10
|
+
pidFile: string;
|
|
11
|
+
logFile: string;
|
|
12
|
+
errFile: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** Current observed process state for the background gateway daemon. */
|
|
16
|
+
export type GatewayDaemonState = "running" | "stopped" | "stale";
|
|
17
|
+
|
|
18
|
+
/** Structured status payload returned by {@link getGatewayDaemonStatus}. */
|
|
19
|
+
export type GatewayDaemonStatus = {
|
|
20
|
+
state: GatewayDaemonState;
|
|
21
|
+
pid: number | null;
|
|
22
|
+
paths: GatewayDaemonPaths;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Result payload from {@link startGatewayDaemon}. */
|
|
26
|
+
export type StartGatewayDaemonResult = {
|
|
27
|
+
status: "started" | "already_running";
|
|
28
|
+
pid: number;
|
|
29
|
+
paths: GatewayDaemonPaths;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Result payload from {@link stopGatewayDaemon}. */
|
|
33
|
+
export type StopGatewayDaemonResult = {
|
|
34
|
+
status: "stopped" | "already_stopped";
|
|
35
|
+
pid: number | null;
|
|
36
|
+
paths: GatewayDaemonPaths;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** CLI entrypoint path for spawning `openpaw gateway dev` in a detached process. */
|
|
40
|
+
const OPENPAW_CLI_ENTRY = fileURLToPath(new URL("../cli/openpaw.tsx", import.meta.url));
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Canonical daemon state file locations under `~/.openpaw/gateway`.
|
|
44
|
+
*/
|
|
45
|
+
export function getGatewayDaemonPaths(): GatewayDaemonPaths {
|
|
46
|
+
const stateDir = join(homedir(), ".openpaw", "gateway");
|
|
47
|
+
return {
|
|
48
|
+
stateDir,
|
|
49
|
+
pidFile: join(stateDir, "gateway.pid"),
|
|
50
|
+
logFile: join(stateDir, "gateway.log"),
|
|
51
|
+
errFile: join(stateDir, "gateway.err.log"),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Ensures daemon state directory exists before reading/writing status files.
|
|
57
|
+
*/
|
|
58
|
+
function ensureDaemonStateDir(paths: GatewayDaemonPaths): void {
|
|
59
|
+
if (!existsSync(paths.stateDir)) {
|
|
60
|
+
mkdirSync(paths.stateDir, { recursive: true });
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Parses daemon pid from disk. Returns `null` when missing, invalid, or unreadable.
|
|
66
|
+
*/
|
|
67
|
+
function readPid(paths: GatewayDaemonPaths): number | null {
|
|
68
|
+
if (!existsSync(paths.pidFile)) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
const raw = readFileSync(paths.pidFile, "utf8").trim();
|
|
73
|
+
if (!/^\d+$/.test(raw)) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
const pid = Number.parseInt(raw, 10);
|
|
77
|
+
return Number.isFinite(pid) && pid > 0 ? pid : null;
|
|
78
|
+
} catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Best-effort liveness check using `kill(pid, 0)` semantics.
|
|
85
|
+
*/
|
|
86
|
+
function isProcessAlive(pid: number): boolean {
|
|
87
|
+
try {
|
|
88
|
+
process.kill(pid, 0);
|
|
89
|
+
return true;
|
|
90
|
+
} catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Deletes stale pid file when process is no longer alive.
|
|
97
|
+
*/
|
|
98
|
+
function cleanupStalePid(paths: GatewayDaemonPaths): void {
|
|
99
|
+
if (existsSync(paths.pidFile)) {
|
|
100
|
+
rmSync(paths.pidFile, { force: true });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Resolves current daemon status and auto-cleans stale pid state.
|
|
106
|
+
*/
|
|
107
|
+
export function getGatewayDaemonStatus(): GatewayDaemonStatus {
|
|
108
|
+
const paths = getGatewayDaemonPaths();
|
|
109
|
+
const pid = readPid(paths);
|
|
110
|
+
if (pid === null) {
|
|
111
|
+
return { state: "stopped", pid: null, paths };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (isProcessAlive(pid)) {
|
|
115
|
+
return { state: "running", pid, paths };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
cleanupStalePid(paths);
|
|
119
|
+
return { state: "stale", pid, paths };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Starts gateway in detached mode (`openpaw gateway dev`) if not already running.
|
|
124
|
+
*/
|
|
125
|
+
export function startGatewayDaemon(): StartGatewayDaemonResult {
|
|
126
|
+
const pre = getGatewayDaemonStatus();
|
|
127
|
+
if (pre.state === "running" && pre.pid !== null) {
|
|
128
|
+
return { status: "already_running", pid: pre.pid, paths: pre.paths };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const paths = pre.paths;
|
|
132
|
+
ensureDaemonStateDir(paths);
|
|
133
|
+
|
|
134
|
+
const outFd = openSync(paths.logFile, "a");
|
|
135
|
+
const errFd = openSync(paths.errFile, "a");
|
|
136
|
+
const child = spawn(process.execPath, [OPENPAW_CLI_ENTRY, "gateway", "dev"], {
|
|
137
|
+
detached: true,
|
|
138
|
+
stdio: ["ignore", outFd, errFd],
|
|
139
|
+
env: process.env,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
child.unref();
|
|
143
|
+
closeSync(outFd);
|
|
144
|
+
closeSync(errFd);
|
|
145
|
+
|
|
146
|
+
if (child.pid === undefined) {
|
|
147
|
+
throw new Error("Failed to start gateway daemon: child process pid was unavailable.");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
writeFileSync(paths.pidFile, `${child.pid}\n`, "utf8");
|
|
151
|
+
return { status: "started", pid: child.pid, paths };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Stops detached gateway daemon process if currently running.
|
|
156
|
+
*/
|
|
157
|
+
export function stopGatewayDaemon(): StopGatewayDaemonResult {
|
|
158
|
+
const status = getGatewayDaemonStatus();
|
|
159
|
+
const { paths } = status;
|
|
160
|
+
|
|
161
|
+
if (status.state !== "running" || status.pid === null) {
|
|
162
|
+
cleanupStalePid(paths);
|
|
163
|
+
return { status: "already_stopped", pid: status.pid, paths };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
process.kill(status.pid, "SIGTERM");
|
|
168
|
+
} catch (e) {
|
|
169
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
170
|
+
throw new Error(`Failed to stop gateway daemon (pid ${status.pid}): ${message}`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
cleanupStalePid(paths);
|
|
174
|
+
return { status: "stopped", pid: status.pid, paths };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Returns the last `lineCount` lines from the selected daemon log file.
|
|
179
|
+
*/
|
|
180
|
+
export function readGatewayDaemonLog(lineCount = 80, stream: "stdout" | "stderr" = "stdout"): string {
|
|
181
|
+
const paths = getGatewayDaemonPaths();
|
|
182
|
+
const logPath = stream === "stdout" ? paths.logFile : paths.errFile;
|
|
183
|
+
if (!existsSync(logPath)) {
|
|
184
|
+
return "";
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const raw = readFileSync(logPath, "utf8");
|
|
188
|
+
const lines = raw.split(/\r?\n/);
|
|
189
|
+
const tail = lines.slice(Math.max(lines.length - lineCount, 0));
|
|
190
|
+
return tail.join("\n").trim();
|
|
191
|
+
}
|
package/gateway/index.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export { createGatewayContext, type OpenPawGatewayContext } from "./bootstrap";
|
|
2
|
+
export type { ChannelAdapter } from "./channel-adapter";
|
|
3
|
+
export {
|
|
4
|
+
getGatewayDaemonPaths,
|
|
5
|
+
getGatewayDaemonStatus,
|
|
6
|
+
readGatewayDaemonLog,
|
|
7
|
+
startGatewayDaemon,
|
|
8
|
+
stopGatewayDaemon,
|
|
9
|
+
} from "./daemon-manager";
|
|
10
|
+
export { cliSessionKey, tuiSessionKey } from "./session-key";
|
|
11
|
+
export { createMessagingChannelAdapters, runGatewayMessagingChannels, startGateway } from "./start-messaging";
|
|
12
|
+
export {
|
|
13
|
+
createTelegramChannelAdapter,
|
|
14
|
+
deliverStreamingReply,
|
|
15
|
+
runTelegramGateway,
|
|
16
|
+
telegramSessionKey,
|
|
17
|
+
} from "./telegram";
|
|
18
|
+
export type { TelegramSessionListEntry } from "./telegram";
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session id for CLI / non-Telegram entrypoints.
|
|
3
|
+
*/
|
|
4
|
+
export function cliSessionKey(id = "main"): string {
|
|
5
|
+
return `cli:${id}`;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Session id for the local OpenTUI chat (separate from Telegram and other channels).
|
|
10
|
+
*/
|
|
11
|
+
export function tuiSessionKey(id = "main"): string {
|
|
12
|
+
return `tui:${id}`;
|
|
13
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared parsing for OpenPaw slash commands across channels (Telegram, TUI).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/** Registered bot commands and reserved slash tokens, in menu order. */
|
|
6
|
+
export const OPENPAW_SLASH_COMMAND_NAMES = [
|
|
7
|
+
"new",
|
|
8
|
+
"sessions",
|
|
9
|
+
"resume",
|
|
10
|
+
"reasoning",
|
|
11
|
+
"tool_calls",
|
|
12
|
+
"sandbox",
|
|
13
|
+
] as const;
|
|
14
|
+
|
|
15
|
+
export const RESERVED_SLASH_COMMANDS = new Set(
|
|
16
|
+
OPENPAW_SLASH_COMMAND_NAMES.map((name) => `/${name}`),
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Comma-separated list of supported slash commands for user-facing error text.
|
|
21
|
+
*/
|
|
22
|
+
export function formatAvailableOpenPawSlashCommandsForUser(): string {
|
|
23
|
+
return OPENPAW_SLASH_COMMAND_NAMES.map((n) => `/${n}`).join(", ");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* First whitespace-separated token of the message, lowercased; strips optional `@botname` suffix.
|
|
28
|
+
*/
|
|
29
|
+
export function firstCommandToken(text: string): string | undefined {
|
|
30
|
+
return text.trim().split(/\s/)[0]?.split("@")[0]?.toLowerCase();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Text after the first whitespace-separated token (command + args).
|
|
35
|
+
*/
|
|
36
|
+
export function restAfterCommand(text: string): string {
|
|
37
|
+
const tokens = text.trim().split(/\s+/).filter(Boolean);
|
|
38
|
+
return tokens.slice(1).join(" ").trim();
|
|
39
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { createGatewayContext, type OpenPawGatewayContext } from "./bootstrap";
|
|
2
|
+
import type { ChannelAdapter } from "./channel-adapter";
|
|
3
|
+
import { createTelegramChannelAdapter } from "./telegram/adapter";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Build all messaging adapters that are activated by the current config.
|
|
7
|
+
*/
|
|
8
|
+
export function createMessagingChannelAdapters(ctx: OpenPawGatewayContext): ChannelAdapter[] {
|
|
9
|
+
const adapters: ChannelAdapter[] = [];
|
|
10
|
+
|
|
11
|
+
const telegramToken = ctx.config.channels?.telegram?.botToken;
|
|
12
|
+
if (telegramToken) {
|
|
13
|
+
adapters.push(createTelegramChannelAdapter(ctx));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return adapters;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Start every configured messaging adapter in parallel (shared `AgentRuntime`).
|
|
21
|
+
*/
|
|
22
|
+
export async function runGatewayMessagingChannels(ctx: OpenPawGatewayContext): Promise<void> {
|
|
23
|
+
const adapters = createMessagingChannelAdapters(ctx);
|
|
24
|
+
if (adapters.length === 0) {
|
|
25
|
+
throw new Error(
|
|
26
|
+
"No messaging channels configured. Add e.g. channels.telegram.botToken to ~/.openpaw/config.yaml",
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
console.log(`OpenPaw gateway starting: ${adapters.map((a) => a.id).join(", ")}`);
|
|
31
|
+
await Promise.all(adapters.map((a) => a.run()));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Bootstrap and run all messaging channels.
|
|
36
|
+
*/
|
|
37
|
+
export async function startGateway(): Promise<void> {
|
|
38
|
+
const ctx = await createGatewayContext();
|
|
39
|
+
await runGatewayMessagingChannels(ctx);
|
|
40
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { getSessionsDir } from "../../config/paths";
|
|
4
|
+
import { TELEGRAM_ACTIVE_THREADS_FILENAME } from "./constants";
|
|
5
|
+
|
|
6
|
+
type ActiveThreadsState = Record<string, string>;
|
|
7
|
+
|
|
8
|
+
function activeThreadsPath(): string {
|
|
9
|
+
return join(getSessionsDir(), TELEGRAM_ACTIVE_THREADS_FILENAME);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function readActiveThreads(): Promise<ActiveThreadsState> {
|
|
13
|
+
const path = activeThreadsPath();
|
|
14
|
+
if (!existsSync(path)) {
|
|
15
|
+
return {};
|
|
16
|
+
}
|
|
17
|
+
try {
|
|
18
|
+
const raw = await Bun.file(path).text();
|
|
19
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
20
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
21
|
+
return {};
|
|
22
|
+
}
|
|
23
|
+
const out: ActiveThreadsState = {};
|
|
24
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
25
|
+
if (typeof v === "string" && v.length > 0) {
|
|
26
|
+
out[k] = v;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
} catch {
|
|
31
|
+
return {};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function writeActiveThreads(state: ActiveThreadsState): Promise<void> {
|
|
36
|
+
const dir = getSessionsDir();
|
|
37
|
+
if (!existsSync(dir)) {
|
|
38
|
+
mkdirSync(dir, { recursive: true });
|
|
39
|
+
}
|
|
40
|
+
await Bun.write(activeThreadsPath(), JSON.stringify(state, null, 2));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* OpenPaw persistence session id for this Telegram chat (legacy or threaded).
|
|
45
|
+
*/
|
|
46
|
+
export async function getTelegramPersistenceSessionId(chatId: number): Promise<string> {
|
|
47
|
+
const state = await readActiveThreads();
|
|
48
|
+
const uuid = state[String(chatId)];
|
|
49
|
+
if (uuid) {
|
|
50
|
+
return `telegram:${chatId}:${uuid}`;
|
|
51
|
+
}
|
|
52
|
+
return `telegram:${chatId}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Starts a new thread: new uuid in the active map. Returns the new persistence session id.
|
|
57
|
+
*/
|
|
58
|
+
export async function startNewTelegramThread(chatId: number): Promise<string> {
|
|
59
|
+
const uuid = crypto.randomUUID();
|
|
60
|
+
const state = await readActiveThreads();
|
|
61
|
+
state[String(chatId)] = uuid;
|
|
62
|
+
await writeActiveThreads(state);
|
|
63
|
+
return `telegram:${chatId}:${uuid}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Points the chat at an existing persistence session (legacy or thread file).
|
|
68
|
+
*/
|
|
69
|
+
export async function setActiveTelegramSession(
|
|
70
|
+
chatId: number,
|
|
71
|
+
persistenceSessionId: string,
|
|
72
|
+
): Promise<void> {
|
|
73
|
+
const legacy = `telegram:${chatId}`;
|
|
74
|
+
const state = await readActiveThreads();
|
|
75
|
+
if (persistenceSessionId === legacy) {
|
|
76
|
+
delete state[String(chatId)];
|
|
77
|
+
} else {
|
|
78
|
+
const prefix = `${legacy}:`;
|
|
79
|
+
if (!persistenceSessionId.startsWith(prefix)) {
|
|
80
|
+
throw new Error("Session does not belong to this chat");
|
|
81
|
+
}
|
|
82
|
+
const uuid = persistenceSessionId.slice(prefix.length);
|
|
83
|
+
if (!uuid) {
|
|
84
|
+
throw new Error("Invalid thread id");
|
|
85
|
+
}
|
|
86
|
+
state[String(chatId)] = uuid;
|
|
87
|
+
}
|
|
88
|
+
await writeActiveThreads(state);
|
|
89
|
+
}
|