@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
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Maps persisted AI SDK UI messages into {@link ChatLine} rows for the terminal chat transcript.
|
|
3
|
+
*/
|
|
4
|
+
import type { DynamicToolUIPart, ToolUIPart, UIMessage, UITools } from "ai";
|
|
5
|
+
import {
|
|
6
|
+
getToolName,
|
|
7
|
+
isFileUIPart,
|
|
8
|
+
isReasoningUIPart,
|
|
9
|
+
isTextUIPart,
|
|
10
|
+
isToolUIPart,
|
|
11
|
+
} from "ai";
|
|
12
|
+
import {
|
|
13
|
+
formatTuiToolDeniedMarkdown,
|
|
14
|
+
formatTuiToolErrorMarkdown,
|
|
15
|
+
formatTuiToolInputMarkdown,
|
|
16
|
+
formatTuiToolOutputMarkdown,
|
|
17
|
+
truncateJson,
|
|
18
|
+
} from "../../agent/tool-stream-format";
|
|
19
|
+
import type { AssistantSegment, ChatLine } from "./chat-transcript-types";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Builds a short summary line for a tool part (plain transcript / non-markdown rows).
|
|
23
|
+
*/
|
|
24
|
+
function formatToolPartSummary(part: ToolUIPart<UITools> | DynamicToolUIPart): string {
|
|
25
|
+
const name = getToolName(part);
|
|
26
|
+
switch (part.state) {
|
|
27
|
+
case "input-streaming":
|
|
28
|
+
return `[Tool ${name}] …`;
|
|
29
|
+
case "input-available":
|
|
30
|
+
return `[Tool ${name}] ${truncateJson(part.input)}`;
|
|
31
|
+
case "output-available":
|
|
32
|
+
return `[Tool ${name}] → ${truncateJson(part.output)}`;
|
|
33
|
+
case "output-error":
|
|
34
|
+
return `[Tool ${name}] error: ${part.errorText}`;
|
|
35
|
+
case "output-denied":
|
|
36
|
+
return `[Tool ${name}] (denied)`;
|
|
37
|
+
case "approval-requested":
|
|
38
|
+
return `[Tool ${name}] (approval requested)`;
|
|
39
|
+
case "approval-responded":
|
|
40
|
+
return `[Tool ${name}] (approval ${part.approval.approved ? "ok" : "rejected"})`;
|
|
41
|
+
default:
|
|
42
|
+
return `[Tool ${name}]`;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Same tool display as the live TUI stream: markdown headings + fenced YAML blocks.
|
|
48
|
+
*/
|
|
49
|
+
function formatToolPartForAssistantTui(part: ToolUIPart<UITools> | DynamicToolUIPart): string {
|
|
50
|
+
const name = getToolName(part);
|
|
51
|
+
switch (part.state) {
|
|
52
|
+
case "input-streaming":
|
|
53
|
+
return `🔧 **Tool · ${name}**\n\n_(input streaming…)_`;
|
|
54
|
+
case "input-available":
|
|
55
|
+
return formatTuiToolInputMarkdown(name, part.input);
|
|
56
|
+
case "output-available":
|
|
57
|
+
return `${formatTuiToolInputMarkdown(name, part.input)}\n\n${formatTuiToolOutputMarkdown(part.output)}`;
|
|
58
|
+
case "output-error":
|
|
59
|
+
return `${formatTuiToolInputMarkdown(name, part.input)}\n\n${formatTuiToolErrorMarkdown(name, part.errorText)}`;
|
|
60
|
+
case "output-denied":
|
|
61
|
+
return `${formatTuiToolInputMarkdown(name, part.input)}\n\n${formatTuiToolDeniedMarkdown(name)}`;
|
|
62
|
+
case "approval-requested":
|
|
63
|
+
return `🔧 **Tool · ${name}**\n\n_(approval requested)_`;
|
|
64
|
+
case "approval-responded":
|
|
65
|
+
return `🔧 **Tool · ${name}**\n\n_(approval ${part.approval.approved ? "granted" : "rejected"})_`;
|
|
66
|
+
default:
|
|
67
|
+
return `🔧 **Tool · ${name}**`;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Merges a new string into the tail segment when `kind` matches, else appends a segment.
|
|
73
|
+
*/
|
|
74
|
+
function mergeAssistantSegment(
|
|
75
|
+
segments: AssistantSegment[],
|
|
76
|
+
kind: AssistantSegment["kind"],
|
|
77
|
+
text: string,
|
|
78
|
+
): AssistantSegment[] {
|
|
79
|
+
const trimmed = text.trim();
|
|
80
|
+
if (!trimmed) {
|
|
81
|
+
return segments;
|
|
82
|
+
}
|
|
83
|
+
const last = segments[segments.length - 1];
|
|
84
|
+
if (last?.kind === kind) {
|
|
85
|
+
return [...segments.slice(0, -1), { kind, text: `${last.text}\n\n${trimmed}` }];
|
|
86
|
+
}
|
|
87
|
+
return [...segments, { kind, text: trimmed }];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Converts assistant message parts into ordered reasoning vs text segments for the TUI.
|
|
92
|
+
*/
|
|
93
|
+
function partsToAssistantSegments(parts: UIMessage["parts"]): AssistantSegment[] {
|
|
94
|
+
let segments: AssistantSegment[] = [];
|
|
95
|
+
for (const part of parts) {
|
|
96
|
+
if (isTextUIPart(part) && part.text.trim()) {
|
|
97
|
+
segments = mergeAssistantSegment(segments, "text", part.text);
|
|
98
|
+
} else if (isReasoningUIPart(part) && part.text.trim()) {
|
|
99
|
+
segments = mergeAssistantSegment(segments, "reasoning", part.text);
|
|
100
|
+
} else if (isFileUIPart(part)) {
|
|
101
|
+
segments = mergeAssistantSegment(
|
|
102
|
+
segments,
|
|
103
|
+
"text",
|
|
104
|
+
`[file: ${part.filename ?? part.mediaType}]`,
|
|
105
|
+
);
|
|
106
|
+
} else if (isToolUIPart(part)) {
|
|
107
|
+
segments = mergeAssistantSegment(segments, "tool", formatToolPartForAssistantTui(part));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return segments;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Joins user/system message parts into a single transcript string (no reasoning label).
|
|
115
|
+
*/
|
|
116
|
+
function partsToPlainTranscriptText(parts: UIMessage["parts"]): string {
|
|
117
|
+
const chunks: string[] = [];
|
|
118
|
+
for (const part of parts) {
|
|
119
|
+
if (isTextUIPart(part) && part.text.trim()) {
|
|
120
|
+
chunks.push(part.text);
|
|
121
|
+
} else if (isReasoningUIPart(part) && part.text.trim()) {
|
|
122
|
+
chunks.push(part.text);
|
|
123
|
+
} else if (isFileUIPart(part)) {
|
|
124
|
+
chunks.push(`[file: ${part.filename ?? part.mediaType}]`);
|
|
125
|
+
} else if (isToolUIPart(part)) {
|
|
126
|
+
chunks.push(formatToolPartSummary(part));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return chunks.join("\n\n").trim();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Maps validated session `UIMessage[]` into {@link ChatLine} rows for the terminal chat transcript.
|
|
134
|
+
* Skips messages that have no displayable content after stripping empty parts.
|
|
135
|
+
*/
|
|
136
|
+
export function uiMessagesToChatLines(messages: UIMessage[]): ChatLine[] {
|
|
137
|
+
const out: ChatLine[] = [];
|
|
138
|
+
for (const msg of messages) {
|
|
139
|
+
if (msg.role !== "user" && msg.role !== "assistant" && msg.role !== "system") {
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (msg.role === "assistant") {
|
|
143
|
+
const segments = partsToAssistantSegments(msg.parts);
|
|
144
|
+
if (segments.length === 0) {
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
out.push({ role: "assistant", segments });
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
const text = partsToPlainTranscriptText(msg.parts);
|
|
151
|
+
if (!text) {
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
out.push({ role: msg.role, text });
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Subscribes to OpenTUI text selection updates and copies the final selection to the
|
|
3
|
+
* system clipboard using OSC 52 when the terminal supports it.
|
|
4
|
+
*/
|
|
5
|
+
import { useEffect } from "react";
|
|
6
|
+
import { CliRenderEvents } from "@opentui/core";
|
|
7
|
+
import { useRenderer } from "@opentui/react";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Registers a renderer listener so that when the user finishes selecting text in the TUI,
|
|
11
|
+
* the selected string is sent to the clipboard (via OSC 52), then the selection is cleared.
|
|
12
|
+
*/
|
|
13
|
+
export function useAutoCopySelection(): void {
|
|
14
|
+
const renderer = useRenderer();
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
const onSelection = (): void => {
|
|
18
|
+
if (!renderer.isOsc52Supported()) {
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const sel = renderer.getSelection();
|
|
22
|
+
if (!sel || sel.isDragging) {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const text = sel.getSelectedText();
|
|
26
|
+
if (!text) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
renderer.copyToClipboardOSC52(text);
|
|
30
|
+
renderer.clearSelection();
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
renderer.on(CliRenderEvents.SELECTION, onSelection);
|
|
34
|
+
return () => {
|
|
35
|
+
renderer.off(CliRenderEvents.SELECTION, onSelection);
|
|
36
|
+
};
|
|
37
|
+
}, [renderer]);
|
|
38
|
+
}
|
package/cli/onboard.tsx
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { createCliRenderer } from "@opentui/core";
|
|
3
|
+
import { createRoot, useKeyboard, useRenderer } from "@opentui/react";
|
|
4
|
+
import { useCallback, useEffect, useReducer, useState } from "react";
|
|
5
|
+
import {
|
|
6
|
+
configExists,
|
|
7
|
+
deleteConfig,
|
|
8
|
+
saveConfig,
|
|
9
|
+
PERSONALITIES,
|
|
10
|
+
type OpenPawConfig,
|
|
11
|
+
type Personality,
|
|
12
|
+
type ProviderConfig,
|
|
13
|
+
} from "../config";
|
|
14
|
+
import { ensureWorkspaceLayout } from "../agent/workspace-bootstrap";
|
|
15
|
+
import {
|
|
16
|
+
WelcomeScreen,
|
|
17
|
+
InputScreen,
|
|
18
|
+
PersonalityScreen,
|
|
19
|
+
ConfirmScreen,
|
|
20
|
+
StartChatScreen,
|
|
21
|
+
} from "./components/onboard-ui";
|
|
22
|
+
|
|
23
|
+
type Step =
|
|
24
|
+
| "provider-baseUrl"
|
|
25
|
+
| "provider-apiKey"
|
|
26
|
+
| "provider-model"
|
|
27
|
+
| "telegram"
|
|
28
|
+
| "personality"
|
|
29
|
+
| "confirm"
|
|
30
|
+
| "start-chat";
|
|
31
|
+
|
|
32
|
+
type WizardState = {
|
|
33
|
+
step: Step;
|
|
34
|
+
provider: ProviderConfig;
|
|
35
|
+
botToken: string;
|
|
36
|
+
personality: Personality;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
type WizardAction =
|
|
40
|
+
| { type: "SET_STEP"; step: Step }
|
|
41
|
+
| { type: "PATCH_PROVIDER"; patch: Partial<ProviderConfig> }
|
|
42
|
+
| { type: "SET_BOT_TOKEN"; value: string }
|
|
43
|
+
| { type: "SET_PERSONALITY"; value: Personality };
|
|
44
|
+
|
|
45
|
+
const initialWizardState: WizardState = {
|
|
46
|
+
step: "provider-baseUrl",
|
|
47
|
+
provider: { baseUrl: "", apiKey: "", model: "" },
|
|
48
|
+
botToken: "",
|
|
49
|
+
personality: "Assistant",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
function wizardReducer(state: WizardState, action: WizardAction): WizardState {
|
|
53
|
+
switch (action.type) {
|
|
54
|
+
case "SET_STEP":
|
|
55
|
+
return { ...state, step: action.step };
|
|
56
|
+
case "PATCH_PROVIDER":
|
|
57
|
+
return { ...state, provider: { ...state.provider, ...action.patch } };
|
|
58
|
+
case "SET_BOT_TOKEN":
|
|
59
|
+
return { ...state, botToken: action.value };
|
|
60
|
+
case "SET_PERSONALITY":
|
|
61
|
+
return { ...state, personality: action.value };
|
|
62
|
+
default: {
|
|
63
|
+
const _exhaustive: never = action;
|
|
64
|
+
return _exhaustive;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function OnboardingWizard({
|
|
70
|
+
onComplete,
|
|
71
|
+
}: {
|
|
72
|
+
onComplete: (startChat: boolean) => void;
|
|
73
|
+
}) {
|
|
74
|
+
const [state, dispatch] = useReducer(wizardReducer, initialWizardState);
|
|
75
|
+
const { step, provider, botToken, personality } = state;
|
|
76
|
+
|
|
77
|
+
const handleConfirm = async () => {
|
|
78
|
+
const config: OpenPawConfig = {
|
|
79
|
+
provider,
|
|
80
|
+
channels: botToken ? { telegram: { botToken } } : undefined,
|
|
81
|
+
personality,
|
|
82
|
+
};
|
|
83
|
+
await saveConfig(config);
|
|
84
|
+
ensureWorkspaceLayout();
|
|
85
|
+
dispatch({ type: "SET_STEP", step: "start-chat" });
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
switch (step) {
|
|
89
|
+
case "provider-baseUrl":
|
|
90
|
+
return (
|
|
91
|
+
<InputScreen
|
|
92
|
+
key={step}
|
|
93
|
+
title="Provider Configuration"
|
|
94
|
+
label="Enter the base URL for your LLM provider:"
|
|
95
|
+
value={provider.baseUrl}
|
|
96
|
+
onChange={(v) =>
|
|
97
|
+
dispatch({ type: "PATCH_PROVIDER", patch: { baseUrl: v } })
|
|
98
|
+
}
|
|
99
|
+
onSubmit={() =>
|
|
100
|
+
dispatch({ type: "SET_STEP", step: "provider-apiKey" })
|
|
101
|
+
}
|
|
102
|
+
placeholder="https://api.openai.com/v1"
|
|
103
|
+
/>
|
|
104
|
+
);
|
|
105
|
+
case "provider-apiKey":
|
|
106
|
+
return (
|
|
107
|
+
<InputScreen
|
|
108
|
+
key={step}
|
|
109
|
+
title="Provider Configuration"
|
|
110
|
+
label="Enter your API key:"
|
|
111
|
+
value={provider.apiKey}
|
|
112
|
+
onChange={(v) =>
|
|
113
|
+
dispatch({ type: "PATCH_PROVIDER", patch: { apiKey: v } })
|
|
114
|
+
}
|
|
115
|
+
onSubmit={() =>
|
|
116
|
+
dispatch({ type: "SET_STEP", step: "provider-model" })
|
|
117
|
+
}
|
|
118
|
+
onBack={() =>
|
|
119
|
+
dispatch({ type: "SET_STEP", step: "provider-baseUrl" })
|
|
120
|
+
}
|
|
121
|
+
placeholder="sk-..."
|
|
122
|
+
password
|
|
123
|
+
/>
|
|
124
|
+
);
|
|
125
|
+
case "provider-model":
|
|
126
|
+
return (
|
|
127
|
+
<InputScreen
|
|
128
|
+
key={step}
|
|
129
|
+
title="Provider Configuration"
|
|
130
|
+
label="Enter the model name:"
|
|
131
|
+
value={provider.model}
|
|
132
|
+
onChange={(v) =>
|
|
133
|
+
dispatch({ type: "PATCH_PROVIDER", patch: { model: v } })
|
|
134
|
+
}
|
|
135
|
+
onSubmit={() => dispatch({ type: "SET_STEP", step: "telegram" })}
|
|
136
|
+
onBack={() =>
|
|
137
|
+
dispatch({ type: "SET_STEP", step: "provider-apiKey" })
|
|
138
|
+
}
|
|
139
|
+
placeholder="gpt-4o"
|
|
140
|
+
/>
|
|
141
|
+
);
|
|
142
|
+
case "telegram":
|
|
143
|
+
return (
|
|
144
|
+
<InputScreen
|
|
145
|
+
key={step}
|
|
146
|
+
title="Channel Configuration"
|
|
147
|
+
label="Enter your Telegram bot token:"
|
|
148
|
+
value={botToken}
|
|
149
|
+
onChange={(v) => dispatch({ type: "SET_BOT_TOKEN", value: v })}
|
|
150
|
+
onSubmit={() =>
|
|
151
|
+
dispatch({ type: "SET_STEP", step: "personality" })
|
|
152
|
+
}
|
|
153
|
+
onBack={() =>
|
|
154
|
+
dispatch({ type: "SET_STEP", step: "provider-model" })
|
|
155
|
+
}
|
|
156
|
+
placeholder="123456789:ABCdefGHIjklMNOpqrsTUVwxyz"
|
|
157
|
+
password
|
|
158
|
+
/>
|
|
159
|
+
);
|
|
160
|
+
case "personality":
|
|
161
|
+
return (
|
|
162
|
+
<PersonalityScreen
|
|
163
|
+
key={step}
|
|
164
|
+
options={[...PERSONALITIES]}
|
|
165
|
+
onSelect={(index) => {
|
|
166
|
+
dispatch({
|
|
167
|
+
type: "SET_PERSONALITY",
|
|
168
|
+
value: PERSONALITIES[index] ?? "Assistant",
|
|
169
|
+
});
|
|
170
|
+
dispatch({ type: "SET_STEP", step: "confirm" });
|
|
171
|
+
}}
|
|
172
|
+
onBack={() => dispatch({ type: "SET_STEP", step: "telegram" })}
|
|
173
|
+
/>
|
|
174
|
+
);
|
|
175
|
+
case "confirm":
|
|
176
|
+
return (
|
|
177
|
+
<ConfirmScreen
|
|
178
|
+
key={step}
|
|
179
|
+
config={{
|
|
180
|
+
provider,
|
|
181
|
+
channels: botToken ? { telegram: { botToken } } : undefined,
|
|
182
|
+
personality,
|
|
183
|
+
}}
|
|
184
|
+
onConfirm={handleConfirm}
|
|
185
|
+
onRestart={() =>
|
|
186
|
+
dispatch({ type: "SET_STEP", step: "provider-baseUrl" })
|
|
187
|
+
}
|
|
188
|
+
onBack={() =>
|
|
189
|
+
dispatch({ type: "SET_STEP", step: "personality" })
|
|
190
|
+
}
|
|
191
|
+
/>
|
|
192
|
+
);
|
|
193
|
+
case "start-chat":
|
|
194
|
+
return (
|
|
195
|
+
<StartChatScreen
|
|
196
|
+
key={step}
|
|
197
|
+
onYes={() => onComplete(true)}
|
|
198
|
+
onNo={() => onComplete(false)}
|
|
199
|
+
/>
|
|
200
|
+
);
|
|
201
|
+
default: {
|
|
202
|
+
const _exhaustive: never = step;
|
|
203
|
+
return _exhaustive;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function App() {
|
|
209
|
+
const [showWelcome, setShowWelcome] = useState(true);
|
|
210
|
+
const renderer = useRenderer();
|
|
211
|
+
|
|
212
|
+
useEffect(() => {
|
|
213
|
+
if (configExists()) {
|
|
214
|
+
deleteConfig();
|
|
215
|
+
}
|
|
216
|
+
}, []);
|
|
217
|
+
|
|
218
|
+
useKeyboard((key) => {
|
|
219
|
+
if (key.name === "escape") {
|
|
220
|
+
renderer.destroy();
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const handleOnboardingComplete = (startChat: boolean) => {
|
|
225
|
+
renderer.destroy();
|
|
226
|
+
if (startChat) {
|
|
227
|
+
console.log("Starting chat...");
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const dismissWelcome = useCallback(() => {
|
|
232
|
+
setShowWelcome(false);
|
|
233
|
+
}, []);
|
|
234
|
+
|
|
235
|
+
if (showWelcome) {
|
|
236
|
+
return <WelcomeScreen onComplete={dismissWelcome} />;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return <OnboardingWizard onComplete={handleOnboardingComplete} />;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export async function handleOnboard(options: {}) {
|
|
243
|
+
const renderer = await createCliRenderer({
|
|
244
|
+
exitOnCtrlC: true,
|
|
245
|
+
});
|
|
246
|
+
const root = createRoot(renderer);
|
|
247
|
+
root.render(<App />);
|
|
248
|
+
}
|
package/cli/openpaw.tsx
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { program } from "commander";
|
|
3
|
+
import { startGateway } from "../gateway";
|
|
4
|
+
import { runOpenPawTui } from "./tui";
|
|
5
|
+
import { handleOnboard } from "./onboard";
|
|
6
|
+
import { handleReset } from "./reset";
|
|
7
|
+
import {
|
|
8
|
+
getGatewayDaemonStatus,
|
|
9
|
+
readGatewayDaemonLog,
|
|
10
|
+
startGatewayDaemon,
|
|
11
|
+
stopGatewayDaemon,
|
|
12
|
+
} from "../gateway/daemon-manager";
|
|
13
|
+
|
|
14
|
+
program.version("0.1.0").description("OpenPaw");
|
|
15
|
+
|
|
16
|
+
program
|
|
17
|
+
.command("reset")
|
|
18
|
+
.description(
|
|
19
|
+
"Delete ~/.openpaw/workspace (sessions and files) and recreate onboarding default layout",
|
|
20
|
+
)
|
|
21
|
+
.action(() => {
|
|
22
|
+
try {
|
|
23
|
+
handleReset();
|
|
24
|
+
} catch (e) {
|
|
25
|
+
console.error(e instanceof Error ? e.message : e);
|
|
26
|
+
process.exitCode = 1;
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
program
|
|
31
|
+
.command("onboard")
|
|
32
|
+
.description("Go through the onboarding setup")
|
|
33
|
+
.action(handleOnboard);
|
|
34
|
+
|
|
35
|
+
const gateway = program.command("gateway").description("Run messaging channel adapters (shared agent runtime)");
|
|
36
|
+
|
|
37
|
+
gateway
|
|
38
|
+
.command("dev")
|
|
39
|
+
.description("Start all configured channels in foreground mode (blocking)")
|
|
40
|
+
.action(async () => {
|
|
41
|
+
try {
|
|
42
|
+
await startGateway();
|
|
43
|
+
} catch (e) {
|
|
44
|
+
console.error(e instanceof Error ? e.message : e);
|
|
45
|
+
process.exitCode = 1;
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
gateway
|
|
50
|
+
.command("start")
|
|
51
|
+
.description("Start gateway daemon in background mode")
|
|
52
|
+
.action(() => {
|
|
53
|
+
try {
|
|
54
|
+
const result = startGatewayDaemon();
|
|
55
|
+
if (result.status === "already_running") {
|
|
56
|
+
console.log(
|
|
57
|
+
`OpenPaw gateway is already running (pid ${result.pid}). Logs: ${result.paths.logFile}`,
|
|
58
|
+
);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
console.log(
|
|
62
|
+
`OpenPaw gateway started in background (pid ${result.pid}). Logs: ${result.paths.logFile}`,
|
|
63
|
+
);
|
|
64
|
+
} catch (e) {
|
|
65
|
+
console.error(e instanceof Error ? e.message : e);
|
|
66
|
+
process.exitCode = 1;
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
gateway
|
|
71
|
+
.command("stop")
|
|
72
|
+
.description("Stop gateway daemon")
|
|
73
|
+
.action(() => {
|
|
74
|
+
try {
|
|
75
|
+
const result = stopGatewayDaemon();
|
|
76
|
+
if (result.status === "already_stopped") {
|
|
77
|
+
console.log("OpenPaw gateway is not running.");
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
console.log(`Stopped OpenPaw gateway daemon (pid ${result.pid}).`);
|
|
81
|
+
} catch (e) {
|
|
82
|
+
console.error(e instanceof Error ? e.message : e);
|
|
83
|
+
process.exitCode = 1;
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
gateway
|
|
88
|
+
.command("status")
|
|
89
|
+
.description("Show gateway daemon status")
|
|
90
|
+
.action(() => {
|
|
91
|
+
try {
|
|
92
|
+
const status = getGatewayDaemonStatus();
|
|
93
|
+
if (status.state === "running") {
|
|
94
|
+
console.log(`OpenPaw gateway is running (pid ${status.pid}).`);
|
|
95
|
+
console.log(`stdout log: ${status.paths.logFile}`);
|
|
96
|
+
console.log(`stderr log: ${status.paths.errFile}`);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (status.state === "stale") {
|
|
100
|
+
console.log(`OpenPaw gateway had a stale pid (${status.pid}); pid file was cleaned.`);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
console.log("OpenPaw gateway is stopped.");
|
|
104
|
+
} catch (e) {
|
|
105
|
+
console.error(e instanceof Error ? e.message : e);
|
|
106
|
+
process.exitCode = 1;
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
gateway
|
|
111
|
+
.command("logs")
|
|
112
|
+
.description("Print recent gateway daemon logs")
|
|
113
|
+
.option("--stderr", "Show stderr log instead of stdout")
|
|
114
|
+
.option("-n, --lines <count>", "Number of lines to show", "80")
|
|
115
|
+
.action((options: { stderr?: boolean; lines?: string }) => {
|
|
116
|
+
try {
|
|
117
|
+
const parsed = Number.parseInt(options.lines ?? "80", 10);
|
|
118
|
+
const lineCount = Number.isFinite(parsed) && parsed > 0 ? parsed : 80;
|
|
119
|
+
const stream = options.stderr ? "stderr" : "stdout";
|
|
120
|
+
const text = readGatewayDaemonLog(lineCount, stream);
|
|
121
|
+
if (!text) {
|
|
122
|
+
console.log("No daemon logs found yet.");
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
console.log(text);
|
|
126
|
+
} catch (e) {
|
|
127
|
+
console.error(e instanceof Error ? e.message : e);
|
|
128
|
+
process.exitCode = 1;
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
program
|
|
133
|
+
.command("tui")
|
|
134
|
+
.description("Terminal chat UI (OpenTUI / local session), separate from the gateway process")
|
|
135
|
+
.action(async () => {
|
|
136
|
+
try {
|
|
137
|
+
await runOpenPawTui();
|
|
138
|
+
} catch (e) {
|
|
139
|
+
console.error(e instanceof Error ? e.message : e);
|
|
140
|
+
process.exitCode = 1;
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
program.parse();
|
package/cli/reset.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { resetWorkspaceToOnboardingDefaults } from "../agent/workspace-bootstrap";
|
|
2
|
+
import { getWorkspaceRoot } from "../config/paths";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* CLI handler: delete `~/.openpaw/workspace` and recreate the same layout and
|
|
6
|
+
* default markdown files as onboarding (`agents.md`, `soul.md`, `user.md`, empty `sessions/`).
|
|
7
|
+
*/
|
|
8
|
+
export function handleReset(): void {
|
|
9
|
+
const root = getWorkspaceRoot();
|
|
10
|
+
resetWorkspaceToOnboardingDefaults();
|
|
11
|
+
console.log(`Reset workspace: removed and recreated ${root} with onboarding defaults.`);
|
|
12
|
+
}
|
package/cli/tui.tsx
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entry for the `openpaw tui` subcommand: full-screen terminal chat against
|
|
3
|
+
* the local agent runtime (separate from the gateway process).
|
|
4
|
+
*/
|
|
5
|
+
import { createCliRenderer } from "@opentui/core";
|
|
6
|
+
import { createRoot } from "@opentui/react";
|
|
7
|
+
import { loadSessionMessages } from "../agent/session-store";
|
|
8
|
+
import { createGatewayContext } from "../gateway/bootstrap";
|
|
9
|
+
import { getTuiPersistenceSessionId } from "../gateway/tui/tui-active-thread-store";
|
|
10
|
+
import { ChatApp } from "./components/chat-app";
|
|
11
|
+
import { uiMessagesToChatLines } from "./lib/ui-messages-to-chat-transcript";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Bootstraps config and workspace, then runs the OpenTUI chat until the user exits.
|
|
15
|
+
*/
|
|
16
|
+
export async function runOpenPawTui(): Promise<void> {
|
|
17
|
+
const ctx = await createGatewayContext();
|
|
18
|
+
const sessionId = await getTuiPersistenceSessionId();
|
|
19
|
+
const stored = await loadSessionMessages(sessionId, ctx.runtime.agent.tools);
|
|
20
|
+
const initialLines = uiMessagesToChatLines(stored);
|
|
21
|
+
const renderer = await createCliRenderer({
|
|
22
|
+
exitOnCtrlC: true,
|
|
23
|
+
});
|
|
24
|
+
createRoot(renderer).render(
|
|
25
|
+
<ChatApp
|
|
26
|
+
initialSessionId={sessionId}
|
|
27
|
+
initialLines={initialLines}
|
|
28
|
+
runtime={ctx.runtime}
|
|
29
|
+
/>,
|
|
30
|
+
);
|
|
31
|
+
}
|
package/config/index.ts
ADDED