doubao-cli 0.2.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Fullstop000
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,65 @@
1
+ # doubao CLI
2
+
3
+ Programmatic access to local sessions in the macOS Doubao desktop app.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install --global doubao-cli
9
+ ```
10
+
11
+ ## Commands
12
+
13
+ ```bash
14
+ doubao status
15
+ doubao profiles
16
+ doubao sessions list
17
+ doubao sessions current
18
+ doubao sessions open 38439138239851266
19
+ doubao sessions read 38439138239851266 --limit 5
20
+ doubao sessions send 38439138239851266 "hello"
21
+ doubao sessions send 38439138239851266 "hello" --wait
22
+ doubao cdp status
23
+ doubao cdp launch
24
+ doubao capabilities
25
+ ```
26
+
27
+ Every data-returning command supports `--json`. Select a non-default local profile with `--profile "Profile 1"` or its display name.
28
+
29
+ Message automation requires Doubao to be launched with local Chrome DevTools Protocol enabled:
30
+
31
+ Quit any running Doubao process first, then run `doubao cdp launch`. The equivalent manual command is `open -a /Applications/Doubao.app --args --remote-debugging-port=9225`.
32
+
33
+ Set `DOUBAO_CDP_ENDPOINT` if using another port. `sessions send --wait` waits for and returns the completed assistant reply.
34
+
35
+ CDP is unauthenticated but bound to `127.0.0.1`. Quit and relaunch Doubao normally when automation is no longer needed.
36
+
37
+ ## How it works
38
+
39
+ - Session ids and titles are read directly from Doubao's local IndexedDB cache.
40
+ - The current session is recovered from Chromium's local session store.
41
+ - Opening a session uses Doubao's registered `doubao://doubaoapp/open-url` deep-link router.
42
+
43
+ No UI coordinates, image recognition, Cookie extraction, or private credential copying are involved.
44
+
45
+ ## Limits
46
+
47
+ Message read/send uses stable DOM test ids in the authenticated Doubao renderer over localhost CDP. A Doubao update can change these selectors. The CLI verifies that the exact user message appears in the target conversation before reporting success.
48
+
49
+ ## Development
50
+
51
+ Requires macOS and Node.js 22 or newer.
52
+
53
+ ```bash
54
+ npm test
55
+ ```
56
+
57
+ Override discovery paths when testing:
58
+
59
+ ```bash
60
+ DOUBAO_APP=/path/to/Doubao.app DOUBAO_DATA_DIR=/path/to/user-data doubao status
61
+ ```
62
+
63
+ ## License
64
+
65
+ MIT © 2026 Fullstop000
package/bin/doubao.mjs ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { main } from '../src/cli.mjs';
4
+
5
+ main(process.argv.slice(2)).catch((error) => {
6
+ console.error(`doubao: ${error.message}`);
7
+ process.exitCode = 1;
8
+ });
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "doubao-cli",
3
+ "version": "0.2.0",
4
+ "description": "Programmatic control for local Doubao desktop sessions on macOS",
5
+ "type": "module",
6
+ "bin": {
7
+ "doubao": "bin/doubao.mjs"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src"
12
+ ],
13
+ "scripts": {
14
+ "test": "node --test test/*.test.mjs"
15
+ },
16
+ "engines": {
17
+ "node": ">=22"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/Fullstop000/doubao-cli.git"
22
+ },
23
+ "bugs": {
24
+ "url": "https://github.com/Fullstop000/doubao-cli/issues"
25
+ },
26
+ "homepage": "https://github.com/Fullstop000/doubao-cli#readme",
27
+ "keywords": [
28
+ "doubao",
29
+ "cli",
30
+ "macos",
31
+ "automation",
32
+ "cdp"
33
+ ],
34
+ "license": "MIT"
35
+ }
@@ -0,0 +1,117 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { withChatClient } from './cdp.mjs';
3
+
4
+ const CHAT_INPUT = '[data-testid="chat_input_input"] [contenteditable="true"]';
5
+ const SEND_BUTTON = '[data-testid="chat_input_send_button"]';
6
+
7
+ function delay(milliseconds) {
8
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
9
+ }
10
+
11
+ export function conversationDeepLink(id) {
12
+ const webUrl = `https://www.doubao.com/chat/${id}`;
13
+ return `doubao://doubaoapp/open-url?url=${encodeURIComponent(webUrl)}`;
14
+ }
15
+
16
+ export function openConversation(id) {
17
+ const url = conversationDeepLink(id);
18
+ const result = spawnSync('/usr/bin/open', [url], { encoding: 'utf8' });
19
+ if (result.status !== 0) throw new Error(result.stderr.trim() || `failed to open ${url}`);
20
+ return url;
21
+ }
22
+
23
+ async function waitForConversation(client, id, timeoutMs) {
24
+ const deadline = Date.now() + timeoutMs;
25
+ while (Date.now() < deadline) {
26
+ const state = await client.evaluate(`({ href: location.href, ready: Boolean(document.querySelector(${JSON.stringify(CHAT_INPUT)})) })`);
27
+ if (state?.ready && new RegExp(`/chat/${id}(?:[?#]|$)`, 'u').test(state.href)) return state.href;
28
+ await delay(200);
29
+ }
30
+ throw new Error(`Doubao did not open conversation ${id} within ${timeoutMs} ms`);
31
+ }
32
+
33
+ const READ_MESSAGES_EXPRESSION = `(() => [...document.querySelectorAll('[data-testid="union_message"]')]
34
+ .map((element) => {
35
+ const role = element.querySelector('[data-testid="send_message"]')
36
+ ? 'user'
37
+ : element.querySelector('[data-testid="receive_message"]') ? 'assistant' : null;
38
+ const parts = [...element.querySelectorAll('[data-testid="message_text_content"]')]
39
+ .map((part) => (part.innerText || '').trim())
40
+ .filter(Boolean);
41
+ return role && parts.length ? { role, text: parts.join('\\n') } : null;
42
+ })
43
+ .filter(Boolean))()`;
44
+
45
+ async function readFromClient(client) {
46
+ return await client.evaluate(READ_MESSAGES_EXPRESSION) || [];
47
+ }
48
+
49
+ export async function readConversation(id, options = {}) {
50
+ const timeoutMs = options.timeoutMs || 10_000;
51
+ openConversation(id);
52
+ return withChatClient(async (client) => {
53
+ await waitForConversation(client, id, timeoutMs);
54
+ const messages = await readFromClient(client);
55
+ return options.limit ? messages.slice(-options.limit) : messages;
56
+ });
57
+ }
58
+
59
+ export async function sendMessage(id, message, options = {}) {
60
+ const timeoutMs = options.timeoutMs || 120_000;
61
+ const waitForReply = options.waitForReply || false;
62
+ if (typeof message !== 'string' || !message.trim()) throw new Error('message cannot be empty');
63
+ if (message.length > 100_000) throw new Error('message exceeds the 100000 character limit');
64
+
65
+ openConversation(id);
66
+ return withChatClient(async (client) => {
67
+ await waitForConversation(client, id, Math.min(timeoutMs, 15_000));
68
+ const before = await readFromClient(client);
69
+ const assistantCountBefore = before.filter((item) => item.role === 'assistant').length;
70
+ const matchingUserCountBefore = before.filter((item) => item.role === 'user' && item.text === message).length;
71
+ const encodedMessage = JSON.stringify(message);
72
+
73
+ const draft = await client.evaluate(`(async () => {
74
+ const editor = document.querySelector(${JSON.stringify(CHAT_INPUT)});
75
+ if (!editor) throw new Error('Doubao message editor was not found');
76
+ editor.focus();
77
+ document.execCommand('selectAll', false, null);
78
+ document.execCommand('insertText', false, ${encodedMessage});
79
+ await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
80
+ const button = document.querySelector(${JSON.stringify(SEND_BUTTON)});
81
+ if (!button || button.disabled) throw new Error('Doubao send button is unavailable');
82
+ const text = (editor.innerText || '').replace(/\\n$/, '');
83
+ button.click();
84
+ return text;
85
+ })()`);
86
+ if (draft !== message) throw new Error('Doubao editor did not accept the complete message');
87
+
88
+ const deadline = Date.now() + timeoutMs;
89
+ let messages = before;
90
+ while (Date.now() < deadline) {
91
+ messages = await readFromClient(client);
92
+ const matchingUserCount = messages.filter((item) => item.role === 'user' && item.text === message).length;
93
+ if (matchingUserCount > matchingUserCountBefore) break;
94
+ await delay(250);
95
+ }
96
+ const matchingUserMessages = messages.filter((item) => item.role === 'user' && item.text === message);
97
+ const sent = matchingUserMessages.length > matchingUserCountBefore ? matchingUserMessages.at(-1) : null;
98
+ if (!sent) throw new Error(`Doubao did not confirm a new sent message within ${timeoutMs} ms`);
99
+
100
+ if (!waitForReply) return { conversationId: id, sent, reply: null };
101
+
102
+ let stableText = '';
103
+ let stablePolls = 0;
104
+ while (Date.now() < deadline) {
105
+ messages = await readFromClient(client);
106
+ const assistantMessages = messages.filter((item) => item.role === 'assistant');
107
+ const reply = assistantMessages.length > assistantCountBefore ? assistantMessages.at(-1) : null;
108
+ const generating = await client.evaluate(`Boolean(document.querySelector('[data-testid="chat_input_local_break_button"], [data-testid="chat_input_end_button"]'))`);
109
+ if (reply?.text && reply.text === stableText && !generating) stablePolls += 1;
110
+ else stablePolls = 0;
111
+ stableText = reply?.text || '';
112
+ if (reply && stablePolls >= 2) return { conversationId: id, sent, reply };
113
+ await delay(500);
114
+ }
115
+ throw new Error(`Doubao reply did not complete within ${timeoutMs} ms`);
116
+ });
117
+ }
package/src/cdp.mjs ADDED
@@ -0,0 +1,106 @@
1
+ const DEFAULT_ENDPOINT = 'http://127.0.0.1:9225';
2
+
3
+ export function cdpEndpoint(env = process.env) {
4
+ return (env.DOUBAO_CDP_ENDPOINT || DEFAULT_ENDPOINT).replace(/\/$/u, '');
5
+ }
6
+
7
+ async function fetchJson(url, timeoutMs = 3000) {
8
+ const controller = new AbortController();
9
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
10
+ try {
11
+ const response = await fetch(url, { signal: controller.signal });
12
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
13
+ return await response.json();
14
+ } finally {
15
+ clearTimeout(timer);
16
+ }
17
+ }
18
+
19
+ export async function cdpStatus(endpoint = cdpEndpoint()) {
20
+ try {
21
+ const version = await fetchJson(`${endpoint}/json/version`);
22
+ return { available: true, endpoint, browser: version.Browser, protocolVersion: version['Protocol-Version'] };
23
+ } catch (error) {
24
+ return { available: false, endpoint, error: error.message };
25
+ }
26
+ }
27
+
28
+ export async function findChatTarget(endpoint = cdpEndpoint()) {
29
+ const targets = await fetchJson(`${endpoint}/json/list`);
30
+ const target = targets.find(
31
+ (item) => item.type === 'page' && /^(?:doubao|chrome):\/\/doubao-chat\/chat(?:\/|$)/u.test(item.url),
32
+ );
33
+ if (!target?.webSocketDebuggerUrl) throw new Error(`no Doubao chat page found at ${endpoint}`);
34
+ return target;
35
+ }
36
+
37
+ export class CdpClient {
38
+ constructor(webSocketUrl) {
39
+ this.webSocketUrl = webSocketUrl;
40
+ this.nextId = 1;
41
+ this.pending = new Map();
42
+ this.socket = null;
43
+ }
44
+
45
+ async connect() {
46
+ this.socket = new WebSocket(this.webSocketUrl);
47
+ await new Promise((resolve, reject) => {
48
+ this.socket.addEventListener('open', resolve, { once: true });
49
+ this.socket.addEventListener('error', () => reject(new Error('CDP WebSocket connection failed')), { once: true });
50
+ });
51
+ this.socket.addEventListener('message', (event) => {
52
+ const message = JSON.parse(String(event.data));
53
+ if (!message.id) return;
54
+ const pending = this.pending.get(message.id);
55
+ if (!pending) return;
56
+ this.pending.delete(message.id);
57
+ if (message.error) pending.reject(new Error(message.error.message));
58
+ else pending.resolve(message.result);
59
+ });
60
+ this.socket.addEventListener('close', () => {
61
+ for (const pending of this.pending.values()) pending.reject(new Error('CDP WebSocket closed'));
62
+ this.pending.clear();
63
+ });
64
+ return this;
65
+ }
66
+
67
+ send(method, params = {}) {
68
+ if (!this.socket || this.socket.readyState !== WebSocket.OPEN) throw new Error('CDP client is not connected');
69
+ const id = this.nextId;
70
+ this.nextId += 1;
71
+ const promise = new Promise((resolve, reject) => this.pending.set(id, { resolve, reject }));
72
+ this.socket.send(JSON.stringify({ id, method, params }));
73
+ return promise;
74
+ }
75
+
76
+ async evaluate(expression) {
77
+ const result = await this.send('Runtime.evaluate', {
78
+ expression,
79
+ awaitPromise: true,
80
+ returnByValue: true,
81
+ userGesture: true,
82
+ });
83
+ if (result.exceptionDetails) {
84
+ throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text || 'JavaScript evaluation failed');
85
+ }
86
+ return result.result?.value;
87
+ }
88
+
89
+ close() {
90
+ this.socket?.close();
91
+ }
92
+ }
93
+
94
+ export async function withChatClient(callback, endpoint = cdpEndpoint()) {
95
+ const status = await cdpStatus(endpoint);
96
+ if (!status.available) {
97
+ throw new Error(`Doubao CDP is unavailable at ${endpoint}. Restart Doubao with --remote-debugging-port=9225.`);
98
+ }
99
+ const target = await findChatTarget(endpoint);
100
+ const client = await new CdpClient(target.webSocketDebuggerUrl).connect();
101
+ try {
102
+ return await callback(client, target);
103
+ } finally {
104
+ client.close();
105
+ }
106
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,256 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { currentSession, getDataDir, listSessions, resolveProfile } from './storage.mjs';
5
+ import { cdpStatus } from './cdp.mjs';
6
+ import { openConversation, readConversation, sendMessage } from './automation.mjs';
7
+
8
+ const DEFAULT_APP = '/Applications/Doubao.app';
9
+ const CLI_VERSION = '0.2.0';
10
+
11
+ const HELP = `Usage:
12
+ doubao status [--profile <name>] [--json]
13
+ doubao profiles [--json]
14
+ doubao sessions list [--profile <name>] [--json]
15
+ doubao sessions current [--profile <name>] [--json]
16
+ doubao sessions open <conversation-id>
17
+ doubao sessions read <conversation-id> [--limit <count>] [--json]
18
+ doubao sessions send <conversation-id> <message> [--wait] [--timeout <seconds>] [--json]
19
+ doubao cdp status [--json]
20
+ doubao cdp launch [--json]
21
+ doubao capabilities [--json]
22
+
23
+ Environment:
24
+ DOUBAO_APP Override the Doubao.app path
25
+ DOUBAO_DATA_DIR Override the Doubao user-data directory
26
+ DOUBAO_CDP_ENDPOINT CDP endpoint (default: http://127.0.0.1:9225)
27
+ `;
28
+
29
+ function parseOptions(argv) {
30
+ const args = [];
31
+ let profile;
32
+ let json = false;
33
+ let wait = false;
34
+ let timeoutSeconds = 120;
35
+ let limit = 20;
36
+ for (let index = 0; index < argv.length; index += 1) {
37
+ if (argv[index] === '--json') {
38
+ json = true;
39
+ } else if (argv[index] === '--wait') {
40
+ wait = true;
41
+ } else if (argv[index] === '--profile') {
42
+ profile = argv[index + 1];
43
+ if (!profile) throw new Error('--profile requires a value');
44
+ index += 1;
45
+ } else if (argv[index] === '--timeout') {
46
+ timeoutSeconds = Number(argv[index + 1]);
47
+ if (!Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0) throw new Error('--timeout requires a positive number of seconds');
48
+ index += 1;
49
+ } else if (argv[index] === '--limit') {
50
+ limit = Number(argv[index + 1]);
51
+ if (!Number.isInteger(limit) || limit <= 0 || limit > 1000) throw new Error('--limit requires an integer from 1 to 1000');
52
+ index += 1;
53
+ } else {
54
+ args.push(argv[index]);
55
+ }
56
+ }
57
+ return { args, profile, json, wait, timeoutMs: timeoutSeconds * 1000, limit };
58
+ }
59
+
60
+ function output(value, json) {
61
+ if (json) console.log(JSON.stringify(value, null, 2));
62
+ else console.log(value);
63
+ }
64
+
65
+ function appVersion(appPath) {
66
+ const plist = path.join(appPath, 'Contents', 'Info.plist');
67
+ const result = spawnSync('/usr/bin/plutil', ['-extract', 'CFBundleShortVersionString', 'raw', plist], {
68
+ encoding: 'utf8',
69
+ });
70
+ return result.status === 0 ? result.stdout.trim() : null;
71
+ }
72
+
73
+ function appRunning(appPath) {
74
+ const executable = path.join(appPath, 'Contents', 'MacOS', 'Doubao');
75
+ return spawnSync('/usr/bin/pgrep', ['-f', executable]).status === 0;
76
+ }
77
+
78
+ function validateId(value) {
79
+ if (!/^\d{12,24}$/u.test(value || '')) throw new Error('conversation id must contain 12 to 24 digits');
80
+ return value;
81
+ }
82
+
83
+ function sessionWithTitle(profilePath, id) {
84
+ return listSessions(profilePath).find((session) => session.id === id) || { id, title: null };
85
+ }
86
+
87
+ export async function main(argv) {
88
+ const { args, profile: requestedProfile, json, wait, timeoutMs, limit } = parseOptions(argv);
89
+ const [command, subcommand, operand] = args;
90
+ const dataDir = getDataDir();
91
+
92
+ if (!command || command === 'help' || command === '--help' || command === '-h') {
93
+ console.log(HELP);
94
+ return;
95
+ }
96
+ if (command === 'version' || command === '--version' || command === '-v') {
97
+ console.log(CLI_VERSION);
98
+ return;
99
+ }
100
+
101
+ if (command === 'profiles') {
102
+ const { readProfiles } = await import('./storage.mjs');
103
+ const profiles = readProfiles(dataDir);
104
+ if (json) output(profiles, true);
105
+ else {
106
+ for (const item of profiles.profiles) {
107
+ const active = item.directory === profiles.lastUsed ? '*' : ' ';
108
+ console.log(`${active} ${item.directory}\t${item.name}`);
109
+ }
110
+ }
111
+ return;
112
+ }
113
+
114
+ if (command === 'capabilities') {
115
+ const cdp = await cdpStatus();
116
+ const capabilities = {
117
+ status: true,
118
+ listSessions: true,
119
+ detectCurrentSession: true,
120
+ openSession: true,
121
+ readMessages: cdp.available,
122
+ sendMessages: cdp.available,
123
+ cdp,
124
+ note: cdp.available
125
+ ? 'Message automation is available through the authenticated Doubao renderer over local CDP.'
126
+ : 'Restart Doubao with --remote-debugging-port=9225 to enable message automation.',
127
+ };
128
+ if (json) output(capabilities, true);
129
+ else {
130
+ console.log('status\tyes');
131
+ console.log('sessions list\tyes');
132
+ console.log('sessions current\tyes');
133
+ console.log('sessions open\tyes');
134
+ console.log(`messages read\t${capabilities.readMessages ? 'yes' : 'no'}`);
135
+ console.log(`messages send\t${capabilities.sendMessages ? 'yes' : 'no'}`);
136
+ console.log(`note\t${capabilities.note}`);
137
+ }
138
+ return;
139
+ }
140
+
141
+ if (command === 'cdp' && subcommand === 'status') {
142
+ const status = await cdpStatus();
143
+ if (json) output(status, true);
144
+ else {
145
+ console.log(`available\t${status.available ? 'yes' : 'no'}`);
146
+ console.log(`endpoint\t${status.endpoint}`);
147
+ if (status.browser) console.log(`browser\t${status.browser}`);
148
+ if (status.error) console.log(`error\t${status.error}`);
149
+ }
150
+ if (!status.available) process.exitCode = 1;
151
+ return;
152
+ }
153
+
154
+ if (command === 'cdp' && subcommand === 'launch') {
155
+ const existing = await cdpStatus();
156
+ if (existing.available) {
157
+ if (json) output(existing, true);
158
+ else console.log(`available\tyes\nendpoint\t${existing.endpoint}`);
159
+ return;
160
+ }
161
+ const appPath = process.env.DOUBAO_APP || DEFAULT_APP;
162
+ if (appRunning(appPath)) throw new Error('Doubao is already running without CDP. Quit it completely, then run this command again.');
163
+ const endpoint = new URL(existing.endpoint);
164
+ if (endpoint.hostname !== '127.0.0.1' && endpoint.hostname !== 'localhost') {
165
+ throw new Error('cdp launch only supports a localhost DOUBAO_CDP_ENDPOINT');
166
+ }
167
+ const port = endpoint.port || '9225';
168
+ const result = spawnSync('/usr/bin/open', ['-a', appPath, '--args', `--remote-debugging-port=${port}`], { encoding: 'utf8' });
169
+ if (result.status !== 0) throw new Error(result.stderr.trim() || 'failed to launch Doubao with CDP');
170
+ let launched = existing;
171
+ for (let attempt = 0; attempt < 30 && !launched.available; attempt += 1) {
172
+ await new Promise((resolve) => setTimeout(resolve, 250));
173
+ launched = await cdpStatus();
174
+ }
175
+ if (!launched.available) throw new Error(`Doubao launched, but CDP did not become available at ${existing.endpoint}`);
176
+ if (json) output(launched, true);
177
+ else console.log(`available\tyes\nendpoint\t${launched.endpoint}`);
178
+ return;
179
+ }
180
+
181
+ const profile = resolveProfile(dataDir, requestedProfile);
182
+
183
+ if (command === 'status') {
184
+ const appPath = process.env.DOUBAO_APP || DEFAULT_APP;
185
+ const sessions = listSessions(profile.path);
186
+ const status = {
187
+ installed: fs.existsSync(appPath),
188
+ running: appRunning(appPath),
189
+ appVersion: appVersion(appPath),
190
+ appPath,
191
+ dataDir,
192
+ profile: { directory: profile.directory, name: profile.name },
193
+ cachedSessions: sessions.length,
194
+ };
195
+ if (json) output(status, true);
196
+ else {
197
+ console.log(`installed\t${status.installed ? 'yes' : 'no'}`);
198
+ console.log(`running\t${status.running ? 'yes' : 'no'}`);
199
+ console.log(`app version\t${status.appVersion || 'unknown'}`);
200
+ console.log(`profile\t${profile.directory} (${profile.name})`);
201
+ console.log(`cached sessions\t${status.cachedSessions}`);
202
+ }
203
+ return;
204
+ }
205
+
206
+ if (command !== 'sessions') throw new Error(`unknown command "${command}". Run "doubao help".`);
207
+
208
+ if (subcommand === 'list') {
209
+ const sessions = listSessions(profile.path);
210
+ if (json) output(sessions, true);
211
+ else {
212
+ console.log('CONVERSATION ID\tTITLE');
213
+ for (const session of sessions) console.log(`${session.id}\t${session.title}`);
214
+ }
215
+ return;
216
+ }
217
+
218
+ if (subcommand === 'current') {
219
+ const id = currentSession(profile.path);
220
+ if (!id) throw new Error('current Doubao session was not found in the local session store');
221
+ const session = sessionWithTitle(profile.path, id);
222
+ if (json) output(session, true);
223
+ else console.log(`${session.id}\t${session.title || ''}`);
224
+ return;
225
+ }
226
+
227
+ if (subcommand === 'open') {
228
+ const id = validateId(operand);
229
+ const url = openConversation(id);
230
+ if (json) output({ id, url, opened: true }, true);
231
+ else console.log(`opened\t${id}`);
232
+ return;
233
+ }
234
+
235
+ if (subcommand === 'read') {
236
+ const id = validateId(operand);
237
+ const messages = await readConversation(id, { limit, timeoutMs });
238
+ if (json) output({ conversationId: id, messages }, true);
239
+ else for (const item of messages) console.log(`${item.role}\t${item.text.replaceAll('\n', '\\n')}`);
240
+ return;
241
+ }
242
+
243
+ if (subcommand === 'send') {
244
+ const id = validateId(operand);
245
+ const message = args.slice(3).join(' ');
246
+ const result = await sendMessage(id, message, { waitForReply: wait, timeoutMs });
247
+ if (json) output(result, true);
248
+ else {
249
+ console.log(`sent\t${result.sent.text}`);
250
+ if (result.reply) console.log(`reply\t${result.reply.text.replaceAll('\n', '\\n')}`);
251
+ }
252
+ return;
253
+ }
254
+
255
+ throw new Error(`unknown sessions command "${subcommand || ''}". Run "doubao help".`);
256
+ }
@@ -0,0 +1,193 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ const SNAPSHOT_MARKER = Buffer.from('pull_recent_conv_chain_downlink_body');
6
+ const CONVERSATION_ID = Buffer.from('conversation_id');
7
+ const NAME = Buffer.from('name');
8
+
9
+ export function getDataDir(env = process.env) {
10
+ return env.DOUBAO_DATA_DIR || path.join(os.homedir(), 'Library', 'Application Support', 'Doubao');
11
+ }
12
+
13
+ export function readProfiles(dataDir = getDataDir()) {
14
+ const localStatePath = path.join(dataDir, 'Local State');
15
+ let state;
16
+ try {
17
+ state = JSON.parse(fs.readFileSync(localStatePath, 'utf8'));
18
+ } catch (error) {
19
+ throw new Error(`cannot read Doubao profile metadata at ${localStatePath}: ${error.message}`);
20
+ }
21
+
22
+ const infoCache = state.profile?.info_cache || {};
23
+ return {
24
+ lastUsed: state.profile?.last_used || null,
25
+ profiles: Object.entries(infoCache).map(([directory, info]) => ({
26
+ directory,
27
+ name: info.name || directory,
28
+ })),
29
+ };
30
+ }
31
+
32
+ export function resolveProfile(dataDir = getDataDir(), requested) {
33
+ const metadata = readProfiles(dataDir);
34
+ let selected;
35
+
36
+ if (requested) {
37
+ selected = metadata.profiles.find(
38
+ (profile) => profile.directory === requested || profile.name === requested,
39
+ );
40
+ if (!selected && fs.existsSync(path.join(dataDir, requested))) {
41
+ selected = { directory: requested, name: requested };
42
+ }
43
+ if (!selected) {
44
+ const available = metadata.profiles.map((profile) => `${profile.directory} (${profile.name})`).join(', ');
45
+ throw new Error(`unknown profile "${requested}". Available profiles: ${available || 'none'}`);
46
+ }
47
+ } else {
48
+ selected = metadata.profiles.find((profile) => profile.directory === metadata.lastUsed)
49
+ || metadata.profiles[0];
50
+ }
51
+
52
+ if (!selected) {
53
+ throw new Error(`no Doubao profiles found under ${dataDir}`);
54
+ }
55
+
56
+ return { ...selected, path: path.join(dataDir, selected.directory) };
57
+ }
58
+
59
+ function readUnsignedLeb128(buffer, offset) {
60
+ let value = 0;
61
+ let shift = 0;
62
+ for (let cursor = offset; cursor < buffer.length && cursor < offset + 5; cursor += 1) {
63
+ const byte = buffer[cursor];
64
+ value |= (byte & 0x7f) << shift;
65
+ if ((byte & 0x80) === 0) return { value, bytes: cursor - offset + 1 };
66
+ shift += 7;
67
+ }
68
+ return null;
69
+ }
70
+
71
+ function cleanTitle(value) {
72
+ const title = value.replaceAll('\u0000', '').trim();
73
+ if (!title || title.length > 240 || /[\u0001-\u0008\u000b\u000c\u000e-\u001f]/u.test(title)) return null;
74
+ return title;
75
+ }
76
+
77
+ function decodeNameValue(buffer, offset, limit) {
78
+ for (let cursor = offset; cursor < Math.min(limit, offset + 18); cursor += 1) {
79
+ const marker = buffer[cursor];
80
+ if (marker !== 0x63 && marker !== 0x22) continue;
81
+
82
+ const length = readUnsignedLeb128(buffer, cursor + 1);
83
+ if (!length || length.value <= 0 || length.value > 1024) continue;
84
+ const start = cursor + 1 + length.bytes;
85
+ const end = start + length.value;
86
+ if (end > limit || end > buffer.length) continue;
87
+
88
+ const encoding = marker === 0x63 ? 'utf16le' : 'utf8';
89
+ const title = cleanTitle(buffer.subarray(start, end).toString(encoding));
90
+ if (title) return title;
91
+ }
92
+ return null;
93
+ }
94
+
95
+ export function parseSessionSnapshot(buffer, startOffset = 0) {
96
+ const sessions = [];
97
+ const seen = new Set();
98
+ let cursor = startOffset;
99
+
100
+ while (cursor < buffer.length) {
101
+ const keyOffset = buffer.indexOf(CONVERSATION_ID, cursor);
102
+ if (keyOffset < 0) break;
103
+
104
+ const searchStart = keyOffset + CONVERSATION_ID.length;
105
+ const searchEnd = Math.min(buffer.length, searchStart + 48);
106
+ const nearby = buffer.subarray(searchStart, searchEnd).toString('latin1');
107
+ const idMatch = nearby.match(/\d{12,24}/u);
108
+ if (!idMatch) {
109
+ cursor = searchStart;
110
+ continue;
111
+ }
112
+
113
+ const idOffset = searchStart + (idMatch.index || 0);
114
+ const id = idMatch[0];
115
+ const nextConversation = buffer.indexOf(CONVERSATION_ID, idOffset + id.length);
116
+ const recordEnd = nextConversation < 0 ? Math.min(buffer.length, idOffset + 320) : nextConversation;
117
+ const nameOffset = buffer.indexOf(NAME, idOffset + id.length);
118
+
119
+ if (nameOffset >= 0 && nameOffset < recordEnd && !seen.has(id)) {
120
+ const title = decodeNameValue(buffer, nameOffset + NAME.length, recordEnd);
121
+ if (title) {
122
+ sessions.push({ id, title });
123
+ seen.add(id);
124
+ }
125
+ }
126
+ cursor = idOffset + id.length;
127
+ }
128
+
129
+ return sessions;
130
+ }
131
+
132
+ function cacheFiles(profilePath) {
133
+ const directory = path.join(profilePath, 'IndexedDB', 'chrome_doubao-chat_0.indexeddb.leveldb');
134
+ let entries;
135
+ try {
136
+ entries = fs.readdirSync(directory, { withFileTypes: true });
137
+ } catch (error) {
138
+ throw new Error(`cannot read Doubao session cache at ${directory}: ${error.message}`);
139
+ }
140
+
141
+ return entries
142
+ .filter((entry) => entry.isFile() && /\.(?:log|ldb)$/u.test(entry.name))
143
+ .map((entry) => {
144
+ const filePath = path.join(directory, entry.name);
145
+ return { path: filePath, mtimeMs: fs.statSync(filePath).mtimeMs };
146
+ })
147
+ .sort((left, right) => right.mtimeMs - left.mtimeMs);
148
+ }
149
+
150
+ export function listSessions(profilePath) {
151
+ for (const file of cacheFiles(profilePath)) {
152
+ const buffer = fs.readFileSync(file.path);
153
+ let markerOffset = buffer.lastIndexOf(SNAPSHOT_MARKER);
154
+ while (markerOffset >= 0) {
155
+ const sessions = parseSessionSnapshot(buffer, markerOffset + SNAPSHOT_MARKER.length);
156
+ if (sessions.length) return sessions;
157
+ markerOffset = buffer.lastIndexOf(SNAPSHOT_MARKER, markerOffset - 1);
158
+ }
159
+ }
160
+ return [];
161
+ }
162
+
163
+ function idsInBuffer(buffer) {
164
+ const ids = [];
165
+ for (const encoding of ['utf8', 'utf16le']) {
166
+ const text = buffer.toString(encoding);
167
+ const pattern = /(?:doubao|chrome):\/\/doubao-chat\/chat\/(\d{12,24})/gu;
168
+ for (const match of text.matchAll(pattern)) ids.push({ id: match[1], index: match.index || 0 });
169
+ }
170
+ return ids.sort((left, right) => left.index - right.index);
171
+ }
172
+
173
+ export function currentSession(profilePath) {
174
+ const directory = path.join(profilePath, 'Sessions');
175
+ let files;
176
+ try {
177
+ files = fs.readdirSync(directory, { withFileTypes: true })
178
+ .filter((entry) => entry.isFile() && /^(?:Session|Tabs)_/u.test(entry.name))
179
+ .map((entry) => {
180
+ const filePath = path.join(directory, entry.name);
181
+ return { path: filePath, mtimeMs: fs.statSync(filePath).mtimeMs };
182
+ })
183
+ .sort((left, right) => right.mtimeMs - left.mtimeMs);
184
+ } catch {
185
+ return null;
186
+ }
187
+
188
+ for (const file of files) {
189
+ const ids = idsInBuffer(fs.readFileSync(file.path));
190
+ if (ids.length) return ids.at(-1).id;
191
+ }
192
+ return null;
193
+ }