ai_co_discuss 0.3.11 → 0.3.13
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/skills/discuss-participant/SKILL.md +1 -1
- package/backend/free_room.py +2410 -2274
- package/backend/schema.sql +27 -0
- package/backend/server.py +21 -11
- package/docs/architecture.md +2 -1
- package/docs/deployment.md +10 -0
- package/docs/en/deployment.md +10 -0
- package/docs/en/workflow.md +1 -1
- package/docs/runner.md +1 -1
- package/docs/workflow.md +2 -2
- package/package.json +3 -2
- package/runner/adapters/antigravity_app.mjs +91 -0
- package/runner/adapters/antigravity_cli.mjs +320 -0
- package/runner/adapters/antigravity_sidecar.mjs +50 -0
- package/runner/{api_participant.mjs → adapters/api.mjs} +19 -5
- package/runner/adapters/claude.mjs +313 -0
- package/runner/adapters/cli.mjs +126 -0
- package/runner/adapters/codex_app.mjs +70 -0
- package/runner/adapters/codex_cli.mjs +286 -0
- package/runner/adapters/common.mjs +196 -0
- package/runner/adapters/index.mjs +143 -0
- package/runner/adapters/profiles.mjs +116 -0
- package/runner/admin_account.mjs +1 -1
- package/runner/discussion_launch.mjs +69 -79
- package/runner/discussion_local.mjs +1 -1
- package/runner/discussion_runner.mjs +14 -13
- package/runner/discussion_service.mjs +3 -3
- package/runner/discussion_setup.mjs +1 -1
- package/runner/discussion_sidecar.mjs +5 -50
- package/runner/discussion_skills.mjs +1 -1
- package/runner/execution_progress.mjs +99 -0
- package/runner/expert_prompts.mjs +19 -29
- package/runner/files.mjs +10 -0
- package/runner/free_runner.mjs +136 -30
- package/runner/remote_launch.mjs +10 -5
- package/runner/scheduler.mjs +124 -32
- package/runner/session_driver.mjs +5 -3
- package/web/app.js +116 -23
- package/web/backup.js +6 -6
- package/web/index.html +2 -0
- package/web/modern.css +1 -1
- package/web/modern.js +1699 -1353
- package/runner/discussion_adapters.mjs +0 -959
- package/runner/discussion_models.mjs +0 -271
- package/runner/session_cut.mjs +0 -167
- /package/runner/{codex_app_client.mjs → adapters/codex_app_client.mjs} +0 -0
|
@@ -1,271 +0,0 @@
|
|
|
1
|
-
/** Discover model selectors from the installed software without starting an AI turn. */
|
|
2
|
-
import { spawn } from 'node:child_process';
|
|
3
|
-
import { closeSync, existsSync, fstatSync, openSync, readdirSync, readFileSync, readSync } from 'node:fs';
|
|
4
|
-
import { isAbsolute, join } from 'node:path';
|
|
5
|
-
import { homedir, tmpdir } from 'node:os';
|
|
6
|
-
import { createInterface } from 'node:readline';
|
|
7
|
-
import { DatabaseSync } from 'node:sqlite';
|
|
8
|
-
|
|
9
|
-
const QUERY_TIMEOUT_MS = 30000;
|
|
10
|
-
const MAX_OUTPUT_BYTES = 1024 * 1024;
|
|
11
|
-
const SOURCES = { codex_cli: 'codex-app-server', claude: 'claude-cli', antigravity_cli: 'antigravity-cli' };
|
|
12
|
-
const MODEL_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:/@[\]-]*$/;
|
|
13
|
-
const SESSION_PATTERN = /^[0-9A-Za-z_-]{8,128}$/;
|
|
14
|
-
// Only the end of a session log is read; the latest request is there and long sessions stay cheap.
|
|
15
|
-
const SESSION_TAIL_BYTES = 4 * 1024 * 1024;
|
|
16
|
-
// Claude Code logs side queries (titles, compaction, subagents) too; only these sources are the member's own turns.
|
|
17
|
-
const CLAUDE_MAIN_SOURCES = ['sdk', 'repl_main_thread'];
|
|
18
|
-
// agy stores its generation settings as protobuf; field 28 (tag bytes 0xe2 0x01) holds the model actually used.
|
|
19
|
-
const AGY_MODEL_TAG = [0xe2, 0x01];
|
|
20
|
-
|
|
21
|
-
/** Read the string keys of a TOML file by table; Codex settings need nothing richer. */
|
|
22
|
-
function toml_strings(text) {
|
|
23
|
-
const tables = { '': {} };
|
|
24
|
-
let table = '';
|
|
25
|
-
for (const line of text.split(/\r?\n/)) {
|
|
26
|
-
const header = line.match(/^\s*\[\s*([^\]]+?)\s*\]\s*(?:#.*)?$/);
|
|
27
|
-
if (header) { table = header[1].replaceAll('"', ''); tables[table] ||= {}; continue; }
|
|
28
|
-
const pair = line.match(/^\s*([A-Za-z0-9_-]+)\s*=\s*"([^"]*)"/);
|
|
29
|
-
if (pair) tables[table][pair[1]] = pair[2];
|
|
30
|
-
}
|
|
31
|
-
return tables;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* What Codex or Claude Code runs with when the card leaves model or effort empty, read from the software's own user settings.
|
|
36
|
-
* Empty where the settings say nothing.
|
|
37
|
-
*/
|
|
38
|
-
export function cli_defaults(adapter, { env = process.env, home = homedir() } = {}) {
|
|
39
|
-
try {
|
|
40
|
-
if (adapter === 'codex_cli') {
|
|
41
|
-
const tables = toml_strings(readFileSync(join(env.CODEX_HOME || join(home, '.codex'), 'config.toml'), 'utf8'));
|
|
42
|
-
const top = tables[''], chosen = top.profile ? tables[`profiles.${top.profile}`] || {} : {};
|
|
43
|
-
return { model: chosen.model || top.model || '', effort: chosen.model_reasoning_effort || top.model_reasoning_effort || '' };
|
|
44
|
-
}
|
|
45
|
-
if (adapter === 'claude') {
|
|
46
|
-
const settings = JSON.parse(readFileSync(join(env.CLAUDE_CONFIG_DIR || join(home, '.claude'), 'settings.json'), 'utf8'));
|
|
47
|
-
return { model: typeof settings.model === 'string' ? settings.model : '', effort: typeof settings.effortLevel === 'string' ? settings.effortLevel : '' };
|
|
48
|
-
}
|
|
49
|
-
} catch { /* Missing or unreadable settings mean the software's built-in default. */ }
|
|
50
|
-
return { model: '', effort: '' };
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** The lines at the end of a file, newest first; a line cut at the start of the tail fails to parse and is skipped. */
|
|
54
|
-
function tail_lines(file) {
|
|
55
|
-
const fd = openSync(file, 'r');
|
|
56
|
-
try {
|
|
57
|
-
const size = fstatSync(fd).size, length = Math.min(size, SESSION_TAIL_BYTES), buffer = Buffer.alloc(length);
|
|
58
|
-
readSync(fd, buffer, 0, length, size - length);
|
|
59
|
-
return buffer.toString('utf8').split('\n').reverse();
|
|
60
|
-
} finally { closeSync(fd); }
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
/** Parse the newest log record that `pick` turns into settings. */
|
|
64
|
-
function newest_record(file, marker, pick) {
|
|
65
|
-
for (const line of tail_lines(file)) {
|
|
66
|
-
if (!line.includes(marker)) continue;
|
|
67
|
-
try { const found = pick(JSON.parse(line)); if (found) return found; } catch { /* A cut or foreign line says nothing. */ }
|
|
68
|
-
}
|
|
69
|
-
return null;
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** The model string after agy's model tag in one protobuf blob. */
|
|
73
|
-
function agy_model(blob) {
|
|
74
|
-
const bytes = Buffer.from(blob);
|
|
75
|
-
for (let at = bytes.indexOf(Buffer.from(AGY_MODEL_TAG)); at >= 0; at = bytes.indexOf(Buffer.from(AGY_MODEL_TAG), at + 1)) {
|
|
76
|
-
const length = bytes[at + 2], text = bytes.subarray(at + 3, at + 3 + length).toString('latin1');
|
|
77
|
-
if (length && text.length === length && MODEL_PATTERN.test(text)) return text;
|
|
78
|
-
}
|
|
79
|
-
return '';
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** Find a session's log file, or '' when this machine never stored it. */
|
|
83
|
-
function session_file(adapter, session_id, env, home) {
|
|
84
|
-
if (['codex', 'codex_cli'].includes(adapter)) {
|
|
85
|
-
const sessions = join(env.CODEX_HOME || join(home, '.codex'), 'sessions');
|
|
86
|
-
if (!existsSync(sessions)) return '';
|
|
87
|
-
const found = readdirSync(sessions, { recursive: true }).map(String).find(path => path.endsWith(`-${session_id}.jsonl`));
|
|
88
|
-
return found ? join(sessions, found) : '';
|
|
89
|
-
}
|
|
90
|
-
if (adapter === 'claude') {
|
|
91
|
-
const projects = join(env.CLAUDE_CONFIG_DIR || join(home, '.claude'), 'projects');
|
|
92
|
-
if (!existsSync(projects)) return '';
|
|
93
|
-
const found = readdirSync(projects).map(dir => join(projects, dir, `${session_id}.jsonl`)).find(existsSync);
|
|
94
|
-
return found || '';
|
|
95
|
-
}
|
|
96
|
-
if (['antigravity', 'antigravity_cli'].includes(adapter)) {
|
|
97
|
-
const file = join(home, '.gemini', 'antigravity-cli', 'conversations', `${session_id}.db`);
|
|
98
|
-
return existsSync(file) ? file : '';
|
|
99
|
-
}
|
|
100
|
-
return '';
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* The model and effort a session last ran with, read from the software's own session log; no AI turn is started.
|
|
105
|
-
* These are each software's internal formats, so anything unreadable yields empty strings rather than an error.
|
|
106
|
-
*/
|
|
107
|
-
export function session_settings(adapter, session_id, { env = process.env, home = homedir() } = {}) {
|
|
108
|
-
const none = { model: '', effort: '' };
|
|
109
|
-
if (typeof session_id !== 'string' || !SESSION_PATTERN.test(session_id)) return none;
|
|
110
|
-
try {
|
|
111
|
-
const file = session_file(adapter, session_id, env, home);
|
|
112
|
-
if (!file) return none;
|
|
113
|
-
if (adapter === 'claude') {
|
|
114
|
-
return newest_record(file, '"type":"api-request"', record => record.type === 'api-request' && CLAUDE_MAIN_SOURCES.includes(record.querySource)
|
|
115
|
-
&& typeof record.params?.model === 'string' ? { model: record.params.model, effort: String(record.params.output_config?.effort || '') } : null) || none;
|
|
116
|
-
}
|
|
117
|
-
if (file.endsWith('.jsonl')) {
|
|
118
|
-
return newest_record(file, '"turn_context"', record => record.type === 'turn_context' && typeof record.payload?.model === 'string'
|
|
119
|
-
? { model: record.payload.model, effort: String(record.payload.effort || record.payload.collaboration_mode?.settings?.reasoning_effort || '') } : null) || none;
|
|
120
|
-
}
|
|
121
|
-
const db = new DatabaseSync(file, { readOnly: true });
|
|
122
|
-
try {
|
|
123
|
-
for (const { data } of db.prepare('SELECT data FROM gen_metadata ORDER BY idx DESC LIMIT 5').all()) {
|
|
124
|
-
const model = data ? agy_model(data) : '';
|
|
125
|
-
// agy names the effort inside the model ID (gemini-…-medium).
|
|
126
|
-
if (model) return { model, effort: '' };
|
|
127
|
-
}
|
|
128
|
-
} finally { db.close(); }
|
|
129
|
-
} catch { /* Missing, locked or changed formats mean nothing is known. */ }
|
|
130
|
-
return none;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/** Return only model identifiers, display names and supported effort values. */
|
|
134
|
-
function model_entry(id, label, efforts) {
|
|
135
|
-
if (typeof id !== 'string' || !MODEL_PATTERN.test(id)) return null;
|
|
136
|
-
return { id, label: typeof label === 'string' && label.trim() ? label : id,
|
|
137
|
-
...(Array.isArray(efforts) ? { efforts: [...new Set(efforts.filter(item => typeof item === 'string'))] } : {}) };
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/** Keep all query processes noninteractive and avoid user/project startup hooks; agy's native slash commands answer without an agent turn. */
|
|
141
|
-
function query_arguments({ adapter, model }, command = '') {
|
|
142
|
-
if (command) return ['-p', `/${command}`, '--output-format', 'json', ...(command === 'effort' && model ? ['--model', model] : [])];
|
|
143
|
-
if (adapter === 'codex_cli') return ['app-server', '--listen', 'stdio://'];
|
|
144
|
-
if (adapter === 'antigravity_cli') return ['models'];
|
|
145
|
-
return ['--safe-mode', '-p', '--input-format', 'stream-json', '--output-format', 'stream-json',
|
|
146
|
-
'--verbose', '--no-session-persistence', '--strict-mcp-config', '--mcp-config', '{"mcpServers":{}}'];
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
/** Read one catalog, kill its temporary process on every outcome, and bound response size/time. */
|
|
150
|
-
function query_catalog(profile, options) {
|
|
151
|
-
return new Promise((resolve_result, reject) => {
|
|
152
|
-
const env = { ...process.env };
|
|
153
|
-
delete env.CLAUDECODE;
|
|
154
|
-
const child = (options.spawn || spawn)(profile.executable, query_arguments(profile, options.command), {
|
|
155
|
-
cwd: tmpdir(), env, shell: false, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'],
|
|
156
|
-
});
|
|
157
|
-
let finished = false, bytes = 0;
|
|
158
|
-
const finish = (error, models) => {
|
|
159
|
-
if (finished) return;
|
|
160
|
-
finished = true; clearTimeout(timer); lines.close(); child.stdin.destroy(); child.kill();
|
|
161
|
-
if (error) reject(error); else resolve_result(models);
|
|
162
|
-
};
|
|
163
|
-
const timer = setTimeout(() => finish(new Error('读取模型列表超时,请检查软件登录和网络后重试')), options.timeout_ms ?? QUERY_TIMEOUT_MS);
|
|
164
|
-
const lines = createInterface({ input: child.stdout });
|
|
165
|
-
const send = value => child.stdin.write(`${JSON.stringify(value)}\n`);
|
|
166
|
-
const receive = catalog_reader(profile.adapter, send, finish, options.command);
|
|
167
|
-
child.once('error', () => finish(new Error('无法启动模型查询,请检查 CLI 路径')));
|
|
168
|
-
child.stdin.on('error', () => finish(new Error('模型查询连接已关闭')));
|
|
169
|
-
child.stderr.on('data', () => {}); // CLI diagnostics can contain private account or configuration data.
|
|
170
|
-
child.stdout.on('data', chunk => {
|
|
171
|
-
bytes += chunk.length;
|
|
172
|
-
if (bytes > MAX_OUTPUT_BYTES) finish(new Error('模型目录响应超出大小限制'));
|
|
173
|
-
});
|
|
174
|
-
lines.on('line', line => {
|
|
175
|
-
if (finished) return;
|
|
176
|
-
try { receive.line(line); } catch { finish(new Error('软件模型目录格式无法识别,请检查 CLI 版本')); }
|
|
177
|
-
});
|
|
178
|
-
child.once('close', code => {
|
|
179
|
-
if (!finished && code === 0 && receive.complete) finish(null, receive.complete());
|
|
180
|
-
else if (!finished) finish(new Error(`软件未返回模型目录(退出码 ${code ?? '未知'}),请检查版本、登录与网络`));
|
|
181
|
-
});
|
|
182
|
-
receive.start();
|
|
183
|
-
});
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/** Codex's catalog is paginated; initialize before querying and never create a thread or turn. */
|
|
187
|
-
function codex_reader(send, finish) {
|
|
188
|
-
const models = [];
|
|
189
|
-
let request_id = 1;
|
|
190
|
-
return {
|
|
191
|
-
start: () => send({ id: 0, method: 'initialize', params: { clientInfo: { name: 'codiscuss', title: 'AI CoDiscuss', version: '0.2.1' } } }),
|
|
192
|
-
line: line => {
|
|
193
|
-
const reply = json_line(line);
|
|
194
|
-
if (!reply || (reply.id !== 0 && reply.id !== request_id)) return;
|
|
195
|
-
if (reply.error) return finish(new Error('Codex 模型目录查询失败,请检查 CLI 版本与登录'));
|
|
196
|
-
if (reply.id === 0) {
|
|
197
|
-
send({ method: 'initialized', params: {} });
|
|
198
|
-
send({ id: request_id, method: 'model/list', params: { limit: 100, includeHidden: false } });
|
|
199
|
-
return;
|
|
200
|
-
}
|
|
201
|
-
for (const model of reply.result?.data || []) {
|
|
202
|
-
if (!model.hidden) models.push(model_entry(model.model || model.id, model.displayName,
|
|
203
|
-
model.supportedReasoningEfforts?.map(item => item.reasoningEffort)));
|
|
204
|
-
}
|
|
205
|
-
const cursor = reply.result?.nextCursor;
|
|
206
|
-
if (cursor) send({ id: ++request_id, method: 'model/list', params: { limit: 100, includeHidden: false, cursor } });
|
|
207
|
-
else finish(null, models);
|
|
208
|
-
},
|
|
209
|
-
};
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
/** Ignore non-protocol diagnostics while leaving malformed or missing catalogs visibly unavailable. */
|
|
213
|
-
function json_line(line) {
|
|
214
|
-
try { return JSON.parse(line); } catch { return null; }
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
/** Claude initialization exposes its picker; agy emits tab-separated ids and labels. */
|
|
218
|
-
function catalog_reader(adapter, send, finish, command = '') {
|
|
219
|
-
if (command) return { start: () => {}, line: line => {
|
|
220
|
-
const reply = json_line(line), data = reply?.command?.data;
|
|
221
|
-
if (reply?.status !== 'SUCCESS' || reply.command?.name !== command || !data) return;
|
|
222
|
-
if (command === 'effort' && Array.isArray(data.available)) finish(null, data.adjustable ? data.available.filter(level => typeof level === 'string') : []);
|
|
223
|
-
if (command === 'model') finish(null, { model: MODEL_PATTERN.test(data.id || '') ? data.id : '', effort: typeof data.effort === 'string' ? data.effort : '' });
|
|
224
|
-
} };
|
|
225
|
-
if (adapter === 'codex_cli') return codex_reader(send, finish);
|
|
226
|
-
if (adapter === 'claude') return {
|
|
227
|
-
start: () => send({ type: 'control_request', request_id: 'catalog', request: { subtype: 'initialize' } }),
|
|
228
|
-
line: line => {
|
|
229
|
-
const reply = json_line(line);
|
|
230
|
-
if (reply?.type !== 'control_response' || reply.response?.request_id !== 'catalog') return;
|
|
231
|
-
if (reply.response.subtype !== 'success') return finish(new Error('Claude 模型目录查询失败,请检查 CLI 版本与登录'));
|
|
232
|
-
finish(null, (reply.response.response?.models || []).map(model => model_entry(model.value, model.displayName,
|
|
233
|
-
model.supportedEffortLevels || (model.supportsEffort ? undefined : []))));
|
|
234
|
-
},
|
|
235
|
-
};
|
|
236
|
-
const models = [];
|
|
237
|
-
return { start: () => {}, complete: () => models, line: line => {
|
|
238
|
-
const fields = line.trim().split(/\t+| {2,}/);
|
|
239
|
-
if (fields.length >= 2) models.push(model_entry(fields[0], fields.slice(1).join(' ')));
|
|
240
|
-
} };
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
/** Public model metadata only; failures leave the original-software default available to the UI. */
|
|
244
|
-
export async function software_models(profile, options = {}) {
|
|
245
|
-
const { adapter, executable } = profile;
|
|
246
|
-
const result = { adapter, models: [], source: SOURCES[adapter] || 'original-software' };
|
|
247
|
-
if (!SOURCES[adapter]) return { ...result, error: '此入口的模型在原软件内设置' };
|
|
248
|
-
if (typeof executable !== 'string' || !isAbsolute(executable)) return { ...result, error: '请先配置 CLI 的完整路径,再读取模型列表' };
|
|
249
|
-
try {
|
|
250
|
-
const models = (await query_catalog(profile, options)).filter(Boolean);
|
|
251
|
-
result.models = [...new Map(models.map(model => [model.id, model])).values()];
|
|
252
|
-
if (!result.models.length) result.error = '软件未返回可选模型,请检查版本、登录与网络;仍可使用软件默认模型';
|
|
253
|
-
} catch (error) { result.error = error.message; }
|
|
254
|
-
return result;
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
/** Read native effort capabilities for the selected agy model, without an agent turn or saved setting changes. */
|
|
258
|
-
export async function software_effort(profile, options = {}) {
|
|
259
|
-
if (profile.adapter !== 'antigravity_cli' || !isAbsolute(profile.executable || '')
|
|
260
|
-
|| (profile.model && !MODEL_PATTERN.test(profile.model))) return { error: '无法读取所选模型的思考档位' };
|
|
261
|
-
try { return { levels: await query_catalog(profile, { ...options, command: 'effort' }) }; }
|
|
262
|
-
catch { return { error: '无法读取原生思考档位;保留 CLI 默认值,或检查软件版本后重试' }; }
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
/** What a CLI runs with when the card leaves model or effort empty: agy answers its native /model, the others keep it in settings files. */
|
|
266
|
-
export async function software_defaults(profile, options = {}) {
|
|
267
|
-
if (profile.adapter !== 'antigravity_cli') return cli_defaults(profile.adapter);
|
|
268
|
-
if (!isAbsolute(profile.executable || '')) return { model: '', effort: '' };
|
|
269
|
-
try { return await query_catalog(profile, { ...options, command: 'model' }); }
|
|
270
|
-
catch { return { model: '', effort: '' }; }
|
|
271
|
-
}
|
package/runner/session_cut.mjs
DELETED
|
@@ -1,167 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Branch a member's software session so a rewound or forked room continues from an earlier point. The original
|
|
3
|
-
* session is never changed: Claude forks through its own CLI on the next turn (--fork-session), Codex through its app
|
|
4
|
-
* server's thread/fork, and agy by copying its conversation database. Each execution's prompt names its task file
|
|
5
|
-
* (`free-<id>.task.json`), which marks where that execution entered the session.
|
|
6
|
-
*/
|
|
7
|
-
import { spawn } from 'node:child_process';
|
|
8
|
-
import { randomUUID } from 'node:crypto';
|
|
9
|
-
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
10
|
-
import { homedir } from 'node:os';
|
|
11
|
-
import { join } from 'node:path';
|
|
12
|
-
import { createInterface } from 'node:readline';
|
|
13
|
-
import { DatabaseSync } from 'node:sqlite';
|
|
14
|
-
|
|
15
|
-
const FORK_TIMEOUT_MS = 60_000;
|
|
16
|
-
// Software whose session this machine can branch; the website lists taken-back executions only for these members.
|
|
17
|
-
export const BRANCHABLE_ADAPTERS = ['claude', 'codex', 'codex_cli', 'antigravity_cli'];
|
|
18
|
-
|
|
19
|
-
/** The task file name an execution's prompt carries. */
|
|
20
|
-
export const execution_marker = id => `free-${id}.task.json`;
|
|
21
|
-
|
|
22
|
-
/** Where each software keeps one session, or '' when this machine has none by that ID. */
|
|
23
|
-
function session_path(adapter, session_id, env, home) {
|
|
24
|
-
if (adapter === 'claude') {
|
|
25
|
-
const projects = join(env.CLAUDE_CONFIG_DIR || join(home, '.claude'), 'projects');
|
|
26
|
-
if (!existsSync(projects)) return '';
|
|
27
|
-
return readdirSync(projects).map(dir => join(projects, dir, `${session_id}.jsonl`)).find(existsSync) || '';
|
|
28
|
-
}
|
|
29
|
-
if (adapter === 'codex_cli') {
|
|
30
|
-
const sessions = join(env.CODEX_HOME || join(home, '.codex'), 'sessions');
|
|
31
|
-
if (!existsSync(sessions)) return '';
|
|
32
|
-
const found = readdirSync(sessions, { recursive: true }).map(String).find(path => path.endsWith(`-${session_id}.jsonl`));
|
|
33
|
-
return found ? join(sessions, found) : '';
|
|
34
|
-
}
|
|
35
|
-
const file = join(home, '.gemini', 'antigravity-cli', 'conversations', `${session_id}.db`);
|
|
36
|
-
return existsSync(file) ? file : '';
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/** Whether a session exists on this machine, e.g. to tell if a pending Claude fork already happened. */
|
|
40
|
-
export function session_exists(adapter, session_id, { env = process.env, home = homedir() } = {}) {
|
|
41
|
-
return Boolean(session_id && session_path(adapter, session_id, env, home));
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** JSON lines of a session log; lines the software wrote in another shape are skipped. */
|
|
45
|
-
function log_entries(file) {
|
|
46
|
-
return readFileSync(file, 'utf8').split('\n').flatMap(line => { try { return [JSON.parse(line)]; } catch { return []; } });
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/** The text of a prompt as the software stored it: a string, or text parts (tool results carry no prompt). */
|
|
50
|
-
function prompt_text(content) {
|
|
51
|
-
if (typeof content === 'string') return content;
|
|
52
|
-
return Array.isArray(content) ? content.filter(part => ['text', 'input_text'].includes(part?.type)).map(part => part.text || '').join('\n') : '';
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
const marked = (text, markers) => markers.some(marker => text.includes(marker));
|
|
56
|
-
|
|
57
|
-
/** Claude: fork on the next turn at the last answer before the first marked prompt; a cut before any answer starts anew. */
|
|
58
|
-
function claude_branch(session_id, markers, always, env, home) {
|
|
59
|
-
const file = session_path('claude', session_id, env, home);
|
|
60
|
-
if (!file) throw new Error(`本机找不到 Claude 会话 ${session_id}`);
|
|
61
|
-
const entries = log_entries(file);
|
|
62
|
-
const index = entries.findIndex(entry => entry.type === 'user' && marked(prompt_text(entry.message?.content), markers));
|
|
63
|
-
if (index < 0) return always ? { session_id: randomUUID(), fork: { from: session_id, at: '' } } : null;
|
|
64
|
-
const at = entries.slice(0, index).findLast(entry => entry.type === 'assistant' && entry.uuid)?.uuid;
|
|
65
|
-
return at ? { session_id: randomUUID(), fork: { from: session_id, at } } : { session_id: '' };
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/** Codex turns in order, and the index of the first turn whose user prompt carries a marker (-1 when none does). */
|
|
69
|
-
function codex_turns(file, markers) {
|
|
70
|
-
const turns = [];
|
|
71
|
-
let cut = -1;
|
|
72
|
-
for (const entry of log_entries(file)) {
|
|
73
|
-
const payload = entry.payload || {};
|
|
74
|
-
if (entry.type === 'event_msg' && payload.type === 'task_started' && payload.turn_id) turns.push(payload.turn_id);
|
|
75
|
-
else if (cut < 0 && turns.length && entry.type === 'response_item' && payload.type === 'message' && payload.role === 'user'
|
|
76
|
-
&& marked(prompt_text(payload.content), markers)) cut = turns.length - 1;
|
|
77
|
-
}
|
|
78
|
-
return { turns, cut };
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/** Ask Codex's app server to fork a thread, through `last_turn` when given; returns the new thread's ID. */
|
|
82
|
-
function codex_fork(profile, thread_id, last_turn, spawn_process) {
|
|
83
|
-
return new Promise((resolve_fork, reject) => {
|
|
84
|
-
const child = spawn_process(profile.executable, ['app-server'], { windowsHide: true, shell: false, stdio: ['pipe', 'pipe', 'pipe'] });
|
|
85
|
-
const lines = createInterface({ input: child.stdout, crlfDelay: Infinity });
|
|
86
|
-
let settled = false;
|
|
87
|
-
const finish = (error, value) => {
|
|
88
|
-
if (settled) return;
|
|
89
|
-
settled = true;
|
|
90
|
-
clearTimeout(timer); lines.close(); child.stdin.destroy(); child.kill();
|
|
91
|
-
if (error) reject(error); else resolve_fork(value);
|
|
92
|
-
};
|
|
93
|
-
const timer = setTimeout(() => finish(new Error('Codex 分叉会话超时')), FORK_TIMEOUT_MS);
|
|
94
|
-
const send = value => child.stdin.write(`${JSON.stringify(value)}\n`);
|
|
95
|
-
child.once('error', error => finish(error));
|
|
96
|
-
child.once('close', () => finish(new Error('Codex app-server 未完成分叉就退出了')));
|
|
97
|
-
child.stdin.on('error', error => finish(error));
|
|
98
|
-
child.stderr.resume();
|
|
99
|
-
lines.on('line', line => {
|
|
100
|
-
let reply;
|
|
101
|
-
try { reply = JSON.parse(line); } catch { return; }
|
|
102
|
-
if (reply.error && [1, 2].includes(reply.id)) return finish(new Error(`Codex 分叉会话失败:${reply.error.message}`));
|
|
103
|
-
if (reply.id === 1) {
|
|
104
|
-
send({ method: 'initialized', params: {} });
|
|
105
|
-
send({ id: 2, method: 'thread/fork', params: { threadId: thread_id, excludeTurns: true, ...(last_turn ? { lastTurnId: last_turn } : {}) } });
|
|
106
|
-
} else if (reply.id === 2) {
|
|
107
|
-
const id = reply.result?.thread?.id;
|
|
108
|
-
finish(id ? null : new Error('Codex 分叉会话没有返回新会话 ID'), id);
|
|
109
|
-
}
|
|
110
|
-
});
|
|
111
|
-
send({ id: 1, method: 'initialize', params: { clientInfo: { name: 'codiscuss', version: '0.1.0' } } });
|
|
112
|
-
});
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/** Codex: fork through the turn before the first marked one; a cut at the first turn starts anew. */
|
|
116
|
-
async function codex_branch(profile, session_id, markers, always, env, home, spawn_process) {
|
|
117
|
-
const file = session_path('codex_cli', session_id, env, home);
|
|
118
|
-
if (!file) throw new Error(`本机找不到 Codex 会话 ${session_id}`);
|
|
119
|
-
const { turns, cut } = codex_turns(file, markers);
|
|
120
|
-
if (cut < 0 && !always) return null;
|
|
121
|
-
if (cut === 0) return { session_id: '' };
|
|
122
|
-
return { session_id: await codex_fork(profile, session_id, cut > 0 ? turns[cut - 1] : '', spawn_process) };
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/** agy: copy the conversation database under a new ID, without the steps from the first marked one on. */
|
|
126
|
-
function agy_branch(session_id, markers, always, home) {
|
|
127
|
-
const file = session_path('antigravity_cli', session_id, process.env, home);
|
|
128
|
-
if (!file) throw new Error(`本机找不到 agy 会话 ${session_id}`);
|
|
129
|
-
const encoded = markers.map(marker => Buffer.from(marker, 'utf8'));
|
|
130
|
-
const source = new DatabaseSync(file, { readOnly: true });
|
|
131
|
-
const target = join(file, '..', `${randomUUID()}.db`);
|
|
132
|
-
let cut;
|
|
133
|
-
try {
|
|
134
|
-
// The prompt is stored inside the step's protobuf payload, as UTF-8 text.
|
|
135
|
-
cut = source.prepare('SELECT idx,step_payload FROM steps ORDER BY idx').all()
|
|
136
|
-
.find(row => row.step_payload && encoded.some(marker => Buffer.from(row.step_payload).includes(marker)))?.idx;
|
|
137
|
-
if (cut === undefined && !always) return null;
|
|
138
|
-
if (cut === 0) return { session_id: '' };
|
|
139
|
-
source.exec(`VACUUM INTO '${target.replaceAll("'", "''")}'`);
|
|
140
|
-
} finally { source.close(); }
|
|
141
|
-
const id = target.slice(-39, -3);
|
|
142
|
-
const copy = new DatabaseSync(target);
|
|
143
|
-
try {
|
|
144
|
-
if (cut !== undefined) copy.prepare('DELETE FROM steps WHERE idx>=?').run(cut);
|
|
145
|
-
copy.prepare('UPDATE trajectory_meta SET cascade_id=?').run(id);
|
|
146
|
-
} finally { copy.close(); }
|
|
147
|
-
return { session_id: id };
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
/**
|
|
151
|
-
* Branch a session before the first of some taken-back executions it contains, leaving the original untouched.
|
|
152
|
-
*
|
|
153
|
-
* @param {object} profile The member's local software profile (`adapter`, `executable`).
|
|
154
|
-
* @param {string} session_id The session the member is bound to.
|
|
155
|
-
* @param {number[]} executions Execution IDs whose turns the branch must not contain.
|
|
156
|
-
* @param {object} options `always` branches even when none of them is in the session (a forked room needs its own copy).
|
|
157
|
-
* @returns {Promise<null|{session_id: string, fork?: {from: string, at: string}}>} null when nothing needs cutting;
|
|
158
|
-
* an empty session_id starts a new conversation; `fork` asks Claude's next turn to fork into session_id.
|
|
159
|
-
*/
|
|
160
|
-
export async function branch_session(profile, session_id, executions, { always = false, env = process.env, home = homedir(), spawn_process = spawn } = {}) {
|
|
161
|
-
const markers = executions.map(execution_marker);
|
|
162
|
-
if (profile.adapter === 'claude') return claude_branch(session_id, markers, always, env, home);
|
|
163
|
-
// A Codex App chat is stored like a CLI one; the App loads the fork when the first message is sent to it.
|
|
164
|
-
if (['codex', 'codex_cli'].includes(profile.adapter)) return codex_branch(profile, session_id, markers, always, env, home, spawn_process);
|
|
165
|
-
if (profile.adapter === 'antigravity_cli') return agy_branch(session_id, markers, always, home);
|
|
166
|
-
throw new Error(`${profile.adapter} 的会话不能由本机截断`);
|
|
167
|
-
}
|
|
File without changes
|