viber-channel 0.6.0 → 0.7.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/lib/agent_tools.ts +201 -0
- package/lib/bridge_core.ts +1019 -0
- package/lib/bridge_spawn.ts +18 -1
- package/lib/bridge_tool_host.ts +191 -0
- package/lib/control_stream.ts +44 -0
- package/lib/dm_stream.ts +287 -0
- package/lib/fingerprint.ts +1 -1
- package/lib/heartbeat.ts +95 -0
- package/lib/instance.ts +29 -2
- package/lib/lockfile.ts +36 -0
- package/lib/messages.ts +8 -0
- package/lib/ollama_chat.ts +106 -0
- package/lib/parent_watchdog.ts +276 -0
- package/lib/peers.ts +113 -0
- package/lib/supervisor.ts +11 -0
- package/lib/supervisor_config.ts +20 -1
- package/package.json +3 -1
- package/viber-channel.ts +252 -69
- package/viber-codex-bridge.ts +487 -473
- package/viber-gemma-bridge.ts +169 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* viber-gemma-bridge — Gemma 4 runtime bridge (#318 step-02).
|
|
4
|
+
*
|
|
5
|
+
* Gemma is a "bare LLM" runtime: unlike Codex it hosts no MCP tools and runs no
|
|
6
|
+
* app-server. The bridge joins a Viber conversation as an instance, forwards each
|
|
7
|
+
* inbound message to ollama (`/api/chat`, model `gemma4:e4b`), and posts the
|
|
8
|
+
* assistant's reply back. ALL the lifecycle — await-invite join, prime-on-join,
|
|
9
|
+
* lock, token refresh, SSE loop, post — is the SHARED `bridge_core`
|
|
10
|
+
* (runAwaitInviteBridge); this file is only the Gemma adapter: a per-conversation
|
|
11
|
+
* chat history + the ollama call. No tool host, no anti-double-post, no
|
|
12
|
+
* elicitation (a non-tool runtime never posts out-of-band, so outboundHandled is
|
|
13
|
+
* always false).
|
|
14
|
+
*
|
|
15
|
+
* The ollama endpoint is configurable (VIBER_OLLAMA_URL): server-hosted Gemma by
|
|
16
|
+
* default, or a client's own local ollama.
|
|
17
|
+
*/
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import { loadAuth } from "./lib/auth.ts";
|
|
20
|
+
import { defaultLabel } from "./lib/conversation.ts";
|
|
21
|
+
import { clientFingerprint } from "./lib/fingerprint.ts";
|
|
22
|
+
import {
|
|
23
|
+
agentIdentityFromEnv,
|
|
24
|
+
BridgeShutdownError,
|
|
25
|
+
type ConversationRuntime,
|
|
26
|
+
type MakeRunTurn,
|
|
27
|
+
type RunTurn,
|
|
28
|
+
runAwaitInviteBridge,
|
|
29
|
+
} from "./lib/bridge_core.ts";
|
|
30
|
+
import {
|
|
31
|
+
DEFAULT_GEMMA_MODEL,
|
|
32
|
+
type ChatMessage,
|
|
33
|
+
ollamaChat,
|
|
34
|
+
OllamaResponseError,
|
|
35
|
+
OllamaUnavailableError,
|
|
36
|
+
} from "./lib/ollama_chat.ts";
|
|
37
|
+
|
|
38
|
+
const LOG_PREFIX = "[viber-gemma-bridge]";
|
|
39
|
+
const BASE_URL = process.env.VIBER_BASE_URL ?? "https://viber.dgypx.dev";
|
|
40
|
+
const LOCK_DIR =
|
|
41
|
+
process.env.APPDATA
|
|
42
|
+
? join(process.env.APPDATA, "viber")
|
|
43
|
+
: join(process.env.HOME ?? "/tmp", ".config", "viber");
|
|
44
|
+
|
|
45
|
+
/** Concise voice-friendly system prompt (ported from viber/standalone_ai.py). */
|
|
46
|
+
const SYSTEM_PROMPT =
|
|
47
|
+
"You are a friendly, concise voice assistant. Reply naturally, as in a spoken " +
|
|
48
|
+
"conversation — short sentences, no markdown. Answer in the user's language. " +
|
|
49
|
+
"Keep replies to 2-3 sentences unless more detail is explicitly requested.";
|
|
50
|
+
// Append the agent identity (#309 step-17) after the base prompt so a spawned
|
|
51
|
+
// gemma agent knows its own name/role. Empty when neither was provided.
|
|
52
|
+
const EFFECTIVE_SYSTEM_PROMPT = [SYSTEM_PROMPT, agentIdentityFromEnv()]
|
|
53
|
+
.filter((line) => line.length > 0)
|
|
54
|
+
.join("\n");
|
|
55
|
+
/** Keep the last N turns (user+assistant) of per-conversation history. */
|
|
56
|
+
const MAX_HISTORY_MESSAGES = 40;
|
|
57
|
+
|
|
58
|
+
interface GemmaBridgeOptions {
|
|
59
|
+
once: boolean;
|
|
60
|
+
includeAgentMessages: boolean;
|
|
61
|
+
awaitInvite: boolean;
|
|
62
|
+
reuseAuthInstance: boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function parseArgs(argv: string[]): GemmaBridgeOptions {
|
|
66
|
+
const opts: GemmaBridgeOptions = {
|
|
67
|
+
once: false,
|
|
68
|
+
includeAgentMessages: false,
|
|
69
|
+
awaitInvite: false,
|
|
70
|
+
reuseAuthInstance: false,
|
|
71
|
+
};
|
|
72
|
+
for (const arg of argv) {
|
|
73
|
+
if (arg === "--once") opts.once = true;
|
|
74
|
+
else if (arg === "--include-agent-messages") opts.includeAgentMessages = true;
|
|
75
|
+
else if (arg === "--await-invite") opts.awaitInvite = true;
|
|
76
|
+
else if (arg === "--reuse-auth-instance") opts.reuseAuthInstance = true;
|
|
77
|
+
else if (arg === "-h" || arg === "--help") {
|
|
78
|
+
process.stdout.write(
|
|
79
|
+
"viber-gemma-bridge — join a Viber conversation as a Gemma (ollama) agent\n\n" +
|
|
80
|
+
"USAGE\n viber-gemma-bridge --await-invite\n\n" +
|
|
81
|
+
"OPTIONS\n --await-invite Wait for server-pushed conversation joins.\n" +
|
|
82
|
+
" --once Exit after the first handled message.\n" +
|
|
83
|
+
" --include-agent-messages React to other agents' messages too.\n" +
|
|
84
|
+
" --reuse-auth-instance Reuse auth.json's persisted instance.\n\n" +
|
|
85
|
+
"ENV\n VIBER_OLLAMA_URL ollama base URL (default http://127.0.0.1:11434).\n" +
|
|
86
|
+
" VIBER_GEMMA_MODEL ollama model (default gemma4:e4b).\n",
|
|
87
|
+
);
|
|
88
|
+
process.exit(0);
|
|
89
|
+
} else throw new Error(`Unknown argument: ${arg}`);
|
|
90
|
+
}
|
|
91
|
+
if (!opts.awaitInvite) {
|
|
92
|
+
throw new Error("viber-gemma-bridge requires --await-invite (server-pushed joins).");
|
|
93
|
+
}
|
|
94
|
+
return opts;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Build the Gemma per-conversation adapter: a local chat history + ollama call. */
|
|
98
|
+
export function makeGemmaRunTurn(): MakeRunTurn {
|
|
99
|
+
const ollamaUrl = process.env.VIBER_OLLAMA_URL;
|
|
100
|
+
const model = process.env.VIBER_GEMMA_MODEL ?? DEFAULT_GEMMA_MODEL;
|
|
101
|
+
// Per-conversation history, keyed by conversation id (each DM is independent).
|
|
102
|
+
const histories = new Map<string, ChatMessage[]>();
|
|
103
|
+
|
|
104
|
+
return async (convId: string, _runtime: ConversationRuntime, _signal: AbortSignal): Promise<RunTurn> => {
|
|
105
|
+
if (!histories.has(convId)) histories.set(convId, []);
|
|
106
|
+
return async (content, _msg, signal) => {
|
|
107
|
+
const history = histories.get(convId)!;
|
|
108
|
+
const messages: ChatMessage[] = [
|
|
109
|
+
{ role: "system", content: EFFECTIVE_SYSTEM_PROMPT },
|
|
110
|
+
...history,
|
|
111
|
+
{ role: "user", content },
|
|
112
|
+
];
|
|
113
|
+
let reply: string;
|
|
114
|
+
try {
|
|
115
|
+
reply = await ollamaChat(messages, { baseUrl: ollamaUrl, model, signal });
|
|
116
|
+
} catch (err) {
|
|
117
|
+
if (err instanceof Error && err.name === "AbortError") throw err; // stream torn down
|
|
118
|
+
// Surface a clean spoken error instead of crashing the turn.
|
|
119
|
+
if (err instanceof OllamaUnavailableError) {
|
|
120
|
+
process.stderr.write(`${LOG_PREFIX} ollama unavailable: ${err.message}\n`);
|
|
121
|
+
return { reply: "Sorry, my local model isn't reachable right now.", outboundHandled: false };
|
|
122
|
+
}
|
|
123
|
+
if (err instanceof OllamaResponseError) {
|
|
124
|
+
process.stderr.write(`${LOG_PREFIX} ollama error: ${err.message}\n`);
|
|
125
|
+
return { reply: "Sorry, I hit an error generating a reply.", outboundHandled: false };
|
|
126
|
+
}
|
|
127
|
+
throw err;
|
|
128
|
+
}
|
|
129
|
+
// Record the turn + trim history (only on success).
|
|
130
|
+
history.push({ role: "user", content }, { role: "assistant", content: reply });
|
|
131
|
+
if (history.length > MAX_HISTORY_MESSAGES) {
|
|
132
|
+
histories.set(convId, history.slice(-MAX_HISTORY_MESSAGES));
|
|
133
|
+
}
|
|
134
|
+
return { reply, outboundHandled: false };
|
|
135
|
+
};
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function main(argv: string[] = process.argv.slice(2)): Promise<void> {
|
|
140
|
+
const options = parseArgs(argv);
|
|
141
|
+
const auth = loadAuth();
|
|
142
|
+
const fingerprint = clientFingerprint(process.cwd());
|
|
143
|
+
if (fingerprint !== auth.client_fingerprint) {
|
|
144
|
+
throw new Error("Folder fingerprint mismatch — auth file was issued for a different machine/folder.");
|
|
145
|
+
}
|
|
146
|
+
const label = process.env.VIBER_CODEX_BRIDGE_LABEL ?? `Gemma bridge • ${defaultLabel(process.cwd())}`;
|
|
147
|
+
|
|
148
|
+
await runAwaitInviteBridge({
|
|
149
|
+
baseUrl: BASE_URL,
|
|
150
|
+
lockDir: LOCK_DIR,
|
|
151
|
+
logPrefix: LOG_PREFIX,
|
|
152
|
+
sessionTag: "gemma",
|
|
153
|
+
label,
|
|
154
|
+
kind: "gemma",
|
|
155
|
+
options,
|
|
156
|
+
auth,
|
|
157
|
+
fingerprint,
|
|
158
|
+
makeRunTurn: makeGemmaRunTurn(),
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (import.meta.main) {
|
|
163
|
+
try {
|
|
164
|
+
await main();
|
|
165
|
+
} catch (err) {
|
|
166
|
+
process.stderr.write(`${LOG_PREFIX} ${err instanceof Error ? err.message : String(err)}\n`);
|
|
167
|
+
process.exit(err instanceof BridgeShutdownError ? err.exitCode : 1);
|
|
168
|
+
}
|
|
169
|
+
}
|