@muyichengshayu/promptx 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.
@@ -0,0 +1,585 @@
1
+ import fs from 'node:fs'
2
+ import os from 'node:os'
3
+ import path from 'node:path'
4
+ import { createRequire } from 'node:module'
5
+ import { execFileSync, spawn } from 'node:child_process'
6
+ import iconv from 'iconv-lite'
7
+ import initSqlJs from 'sql.js'
8
+
9
+ const CODEX_BIN = process.env.CODEX_BIN || 'codex'
10
+ const CODEX_HOME = process.env.CODEX_HOME || path.join(os.homedir(), '.codex')
11
+ const STATE_DB_PATH = path.join(CODEX_HOME, 'state_5.sqlite')
12
+ const TMP_DIR = path.join(CODEX_HOME, 'tmp')
13
+ const MAX_THREAD_COUNT = 120
14
+ const MAX_OUTPUT_TAIL_LENGTH = 64 * 1024
15
+ const CODEX_DEFAULT_ARGS = ['--dangerously-bypass-approvals-and-sandbox', '--skip-git-repo-check']
16
+ const RESOLVED_CODEX_BIN = resolveCodexBinary()
17
+ const require = createRequire(import.meta.url)
18
+ const sqlWasmPath = require.resolve('sql.js/dist/sql-wasm.wasm')
19
+ const SQL = await initSqlJs({
20
+ locateFile: () => sqlWasmPath,
21
+ })
22
+
23
+ function ensureCodexHome() {
24
+ fs.mkdirSync(TMP_DIR, { recursive: true })
25
+ }
26
+
27
+ function resolveCodexBinary() {
28
+ if (process.platform !== 'win32') {
29
+ return CODEX_BIN
30
+ }
31
+
32
+ if (path.extname(CODEX_BIN)) {
33
+ return CODEX_BIN
34
+ }
35
+
36
+ if (fs.existsSync(`${CODEX_BIN}.cmd`)) {
37
+ return `${CODEX_BIN}.cmd`
38
+ }
39
+
40
+ if (fs.existsSync(`${CODEX_BIN}.bat`)) {
41
+ return `${CODEX_BIN}.bat`
42
+ }
43
+
44
+ if (fs.existsSync(CODEX_BIN)) {
45
+ return CODEX_BIN
46
+ }
47
+
48
+ try {
49
+ const output = execFileSync('where.exe', [CODEX_BIN], {
50
+ encoding: 'utf8',
51
+ stdio: ['ignore', 'pipe', 'ignore'],
52
+ }).trim()
53
+
54
+ if (!output) {
55
+ return CODEX_BIN
56
+ }
57
+
58
+ const candidates = output
59
+ .split(/\r?\n/g)
60
+ .map((line) => line.trim())
61
+ .filter(Boolean)
62
+
63
+ return candidates.find((item) => /\.(cmd|bat)$/i.test(item))
64
+ || candidates.find((item) => /\.(exe|com)$/i.test(item))
65
+ || candidates[0]
66
+ || CODEX_BIN
67
+ } catch {
68
+ return CODEX_BIN
69
+ }
70
+ }
71
+
72
+ function createCodexSpawn(commandArgs = [], cwd = '') {
73
+ const options = {
74
+ env: process.env,
75
+ stdio: ['pipe', 'pipe', 'pipe'],
76
+ }
77
+ const normalizedCwd = String(cwd || '').trim()
78
+
79
+ if (normalizedCwd) {
80
+ options.cwd = normalizedCwd
81
+ }
82
+
83
+ if (process.platform === 'win32' && /\.(cmd|bat)$/i.test(RESOLVED_CODEX_BIN)) {
84
+ return spawn(
85
+ process.env.ComSpec || 'cmd.exe',
86
+ ['/d', '/s', '/c', RESOLVED_CODEX_BIN, ...commandArgs],
87
+ options
88
+ )
89
+ }
90
+
91
+ return spawn(RESOLVED_CODEX_BIN, commandArgs, options)
92
+ }
93
+
94
+ function normalizeSpawnError(error) {
95
+ if (error?.code === 'ENOENT') {
96
+ const attempted = RESOLVED_CODEX_BIN === CODEX_BIN
97
+ ? CODEX_BIN
98
+ : `${CODEX_BIN} -> ${RESOLVED_CODEX_BIN}`
99
+ return new Error(
100
+ `找不到 Codex CLI(尝试执行:${attempted})。请先确认终端里可以运行 \`codex --version\`,或设置环境变量 \`CODEX_BIN\` 指向可执行文件。Windows 常见路径是 \`%APPDATA%\\npm\\codex.cmd\`。`
101
+ )
102
+ }
103
+
104
+ return error
105
+ }
106
+
107
+ function trimOutput(value = '', maxLength = 12000) {
108
+ const text = String(value || '').trim()
109
+ if (text.length <= maxLength) {
110
+ return text
111
+ }
112
+ return text.slice(text.length - maxLength)
113
+ }
114
+
115
+ function appendOutputTail(current = '', chunk = '', maxLength = MAX_OUTPUT_TAIL_LENGTH) {
116
+ const text = `${String(current || '')}${String(chunk || '')}`
117
+ if (text.length <= maxLength) {
118
+ return text
119
+ }
120
+ return text.slice(text.length - maxLength)
121
+ }
122
+
123
+ function countSuspiciousMojibakeChars(value = '') {
124
+ return (String(value || '').match(/[鑾彇娴嬭瘯鏁嵁璺緞璇诲彇鍒嗛鍚庡墠杩欎釜閫夋嫨鎻掑叆鍖哄伐浣滃櫒]/g) || []).length
125
+ }
126
+
127
+ function countReadableCjkChars(value = '') {
128
+ return (String(value || '').match(/[\u4e00-\u9fff]/g) || []).length
129
+ }
130
+
131
+ function repairPossibleMojibake(value = '') {
132
+ const text = String(value || '')
133
+ if (!text) {
134
+ return text
135
+ }
136
+
137
+ const suspiciousCount = countSuspiciousMojibakeChars(text)
138
+ if (suspiciousCount < 2) {
139
+ return text
140
+ }
141
+
142
+ let repaired = text
143
+ try {
144
+ repaired = iconv.decode(iconv.encode(text, 'gb18030'), 'utf8')
145
+ } catch {
146
+ return text
147
+ }
148
+
149
+ if (!repaired || repaired === text) {
150
+ return text
151
+ }
152
+
153
+ const repairedSuspiciousCount = countSuspiciousMojibakeChars(repaired)
154
+
155
+ if (repairedSuspiciousCount < suspiciousCount && countReadableCjkChars(repaired) > 0) {
156
+ return repaired
157
+ }
158
+
159
+ return text
160
+ }
161
+
162
+ function sanitizeCodexPayload(value) {
163
+ if (typeof value === 'string') {
164
+ return repairPossibleMojibake(value)
165
+ }
166
+
167
+ if (Array.isArray(value)) {
168
+ return value.map((item) => sanitizeCodexPayload(item))
169
+ }
170
+
171
+ if (!value || typeof value !== 'object') {
172
+ return value
173
+ }
174
+
175
+ return Object.fromEntries(
176
+ Object.entries(value).map(([key, item]) => [key, sanitizeCodexPayload(item)])
177
+ )
178
+ }
179
+
180
+ function parseJsonLine(line = '') {
181
+ const text = String(line || '').trim()
182
+ if (!text) {
183
+ return null
184
+ }
185
+
186
+ try {
187
+ return JSON.parse(text)
188
+ } catch {
189
+ return null
190
+ }
191
+ }
192
+
193
+ function splitBufferedLines(buffer = '') {
194
+ const text = String(buffer || '')
195
+ if (!text) {
196
+ return { lines: [], rest: '' }
197
+ }
198
+
199
+ const normalized = text.replace(/\r\n/g, '\n')
200
+ const parts = normalized.split('\n')
201
+ const rest = parts.pop() || ''
202
+ const lines = parts.map((line) => line.trim()).filter(Boolean)
203
+ return { lines, rest }
204
+ }
205
+
206
+ function flushBufferedText(buffer = '') {
207
+ const { lines, rest } = splitBufferedLines(buffer)
208
+ const tail = String(rest || '').trim()
209
+ return tail ? [...lines, tail] : lines
210
+ }
211
+
212
+ function extractTextFromUnknownError(input, depth = 0) {
213
+ if (!input || depth > 4) {
214
+ return ''
215
+ }
216
+
217
+ if (typeof input === 'string') {
218
+ return input.trim()
219
+ }
220
+
221
+ if (typeof input !== 'object') {
222
+ return ''
223
+ }
224
+
225
+ const priorityKeys = [
226
+ 'message',
227
+ 'detail',
228
+ 'error',
229
+ 'last_error',
230
+ 'cause',
231
+ 'reason',
232
+ 'stderr',
233
+ 'text',
234
+ 'summary',
235
+ ]
236
+
237
+ for (const key of priorityKeys) {
238
+ if (!Object.prototype.hasOwnProperty.call(input, key)) {
239
+ continue
240
+ }
241
+
242
+ const text = extractTextFromUnknownError(input[key], depth + 1)
243
+ if (text) {
244
+ return text
245
+ }
246
+ }
247
+
248
+ for (const value of Object.values(input)) {
249
+ const text = extractTextFromUnknownError(value, depth + 1)
250
+ if (text) {
251
+ return text
252
+ }
253
+ }
254
+
255
+ return ''
256
+ }
257
+
258
+ function extractLatestCodexEventError(stdout = '') {
259
+ const lines = flushBufferedText(stdout)
260
+ let latestError = ''
261
+
262
+ for (const line of lines) {
263
+ const event = parseJsonLine(line)
264
+ if (!event || (event.type !== 'error' && event.type !== 'turn.failed')) {
265
+ continue
266
+ }
267
+
268
+ const text = extractTextFromUnknownError(sanitizeCodexPayload(event))
269
+ if (text) {
270
+ latestError = text
271
+ }
272
+ }
273
+
274
+ return latestError
275
+ }
276
+
277
+ function normalizeManagedSession(sessionInput) {
278
+ if (!sessionInput || typeof sessionInput !== 'object') {
279
+ return null
280
+ }
281
+
282
+ const id = String(sessionInput.id || '').trim()
283
+ const cwd = String(sessionInput.cwd || '').trim()
284
+ if (!id || !cwd) {
285
+ return null
286
+ }
287
+
288
+ return {
289
+ id,
290
+ title: String(sessionInput.title || '').trim(),
291
+ cwd,
292
+ codexThreadId: String(sessionInput.codexThreadId || '').trim(),
293
+ }
294
+ }
295
+
296
+ function createExecArgs(session) {
297
+ const baseArgs = ['exec', ...CODEX_DEFAULT_ARGS, ...(session.cwd ? ['-C', session.cwd] : [])]
298
+
299
+ if (session.codexThreadId) {
300
+ return [...baseArgs, 'resume', session.codexThreadId, '-', '--json']
301
+ }
302
+
303
+ return [...baseArgs, '-', '--json']
304
+ }
305
+
306
+ function extractCodexError(stderr = '', stdout = '') {
307
+ const eventError = extractLatestCodexEventError(stdout)
308
+ if (eventError) {
309
+ return eventError
310
+ }
311
+
312
+ const stderrText = trimOutput(stderr)
313
+ if (stderrText) {
314
+ const lines = stderrText.split('\n').map((line) => line.trim()).filter(Boolean)
315
+ return lines[lines.length - 1] || stderrText
316
+ }
317
+
318
+ const stdoutText = trimOutput(stdout)
319
+ if (!stdoutText) {
320
+ return 'Codex 执行失败。'
321
+ }
322
+
323
+ const lines = stdoutText.split('\n').map((line) => line.trim()).filter(Boolean)
324
+ return lines[lines.length - 1] || stdoutText
325
+ }
326
+
327
+ function trackThreadId(event, setThreadId) {
328
+ if (event?.type === 'thread.started' && event.thread_id) {
329
+ setThreadId(String(event.thread_id))
330
+ }
331
+ }
332
+
333
+ function parseThreadIdFromStdout(stdout = '') {
334
+ const lines = String(stdout || '')
335
+ .replace(/\r\n/g, '\n')
336
+ .split('\n')
337
+ .map((line) => line.trim())
338
+ .filter(Boolean)
339
+
340
+ for (const line of lines) {
341
+ const event = parseJsonLine(line)
342
+ if (event?.type === 'thread.started' && event.thread_id) {
343
+ return String(event.thread_id)
344
+ }
345
+ }
346
+
347
+ return ''
348
+ }
349
+
350
+ function loadCodexThreads(limit = MAX_THREAD_COUNT) {
351
+ if (!fs.existsSync(STATE_DB_PATH)) {
352
+ return []
353
+ }
354
+
355
+ try {
356
+ const sql = `select id, cwd, title, updated_at from threads order by updated_at desc limit ${Math.max(1, Number(limit) || MAX_THREAD_COUNT)};`
357
+ const db = new SQL.Database(new Uint8Array(fs.readFileSync(STATE_DB_PATH)))
358
+
359
+ try {
360
+ const statement = db.prepare(sql)
361
+ const rows = []
362
+
363
+ try {
364
+ while (statement.step()) {
365
+ rows.push(statement.getAsObject())
366
+ }
367
+ } finally {
368
+ statement.free()
369
+ }
370
+
371
+ return rows
372
+ } finally {
373
+ db.close()
374
+ }
375
+ } catch {
376
+ return []
377
+ }
378
+ }
379
+
380
+ export function listKnownCodexWorkspaces(limit = MAX_THREAD_COUNT) {
381
+ const seen = new Set()
382
+ const items = []
383
+
384
+ loadCodexThreads(limit).forEach((thread) => {
385
+ const cwd = String(thread.cwd || '').trim()
386
+ if (!cwd || seen.has(cwd)) {
387
+ return
388
+ }
389
+ seen.add(cwd)
390
+ items.push(cwd)
391
+ })
392
+
393
+ return items
394
+ }
395
+
396
+ export function streamPromptToCodexSession(sessionInput, prompt, callbacks = {}) {
397
+ const session = normalizeManagedSession(sessionInput)
398
+ const normalizedPrompt = String(prompt || '').trim()
399
+
400
+ if (!session) {
401
+ throw new Error('缺少 PromptX 项目。')
402
+ }
403
+ if (!normalizedPrompt) {
404
+ throw new Error('没有可发送的提示词。')
405
+ }
406
+
407
+ ensureCodexHome()
408
+
409
+ const outputFile = path.join(TMP_DIR, `promptx-codex-${Date.now()}-${process.pid}.txt`)
410
+ const onEvent = typeof callbacks.onEvent === 'function' ? callbacks.onEvent : () => {}
411
+ const onThreadStarted = typeof callbacks.onThreadStarted === 'function' ? callbacks.onThreadStarted : () => {}
412
+
413
+ const child = createCodexSpawn(
414
+ [
415
+ ...createExecArgs(session),
416
+ '--output-last-message',
417
+ outputFile,
418
+ ],
419
+ session.cwd
420
+ )
421
+
422
+ let stdoutBuffer = ''
423
+ let stderrBuffer = ''
424
+ let stdoutRaw = ''
425
+ let stderrRaw = ''
426
+ let finalMessage = ''
427
+ let finalThreadId = session.codexThreadId || ''
428
+
429
+ const emit = (event) => {
430
+ try {
431
+ onEvent(event)
432
+ } catch {
433
+ // Ignore observer failures to avoid breaking the process lifecycle.
434
+ }
435
+ }
436
+
437
+ const rememberThreadId = (threadId) => {
438
+ const value = String(threadId || '').trim()
439
+ if (!value || value === finalThreadId) {
440
+ return
441
+ }
442
+ finalThreadId = value
443
+ try {
444
+ onThreadStarted(value)
445
+ } catch {
446
+ // Ignore observer failures to avoid breaking the process lifecycle.
447
+ }
448
+ }
449
+
450
+ emit({
451
+ type: 'status',
452
+ stage: session.codexThreadId ? 'resuming' : 'starting',
453
+ message: session.codexThreadId
454
+ ? '已连接 PromptX 项目,正在继续这轮执行。'
455
+ : '已创建 PromptX 项目,正在启动第一轮执行。',
456
+ })
457
+
458
+ child.stdout.on('data', (chunk) => {
459
+ const text = chunk.toString()
460
+ stdoutRaw = appendOutputTail(stdoutRaw, text)
461
+ stdoutBuffer += text
462
+ const { lines, rest } = splitBufferedLines(stdoutBuffer)
463
+ stdoutBuffer = rest
464
+
465
+ for (const line of lines) {
466
+ const event = parseJsonLine(line)
467
+ if (event) {
468
+ trackThreadId(event, rememberThreadId)
469
+ emit({
470
+ type: 'codex',
471
+ event: sanitizeCodexPayload(event),
472
+ })
473
+ continue
474
+ }
475
+
476
+ emit({
477
+ type: 'stdout',
478
+ text: repairPossibleMojibake(line),
479
+ })
480
+ }
481
+ })
482
+
483
+ child.stderr.on('data', (chunk) => {
484
+ const text = chunk.toString()
485
+ stderrRaw = appendOutputTail(stderrRaw, text)
486
+ stderrBuffer += text
487
+ const { lines, rest } = splitBufferedLines(stderrBuffer)
488
+ stderrBuffer = rest
489
+
490
+ for (const line of lines) {
491
+ emit({
492
+ type: 'stderr',
493
+ text: repairPossibleMojibake(line),
494
+ })
495
+ }
496
+ })
497
+
498
+ child.stdin.write(normalizedPrompt)
499
+ child.stdin.end()
500
+
501
+ const result = new Promise((resolve, reject) => {
502
+ child.on('error', (error) => {
503
+ reject(normalizeSpawnError(error))
504
+ })
505
+
506
+ child.on('close', (code) => {
507
+ const stdoutTail = flushBufferedText(stdoutBuffer)
508
+ const stderrTail = flushBufferedText(stderrBuffer)
509
+
510
+ stdoutTail.forEach((line) => {
511
+ const event = parseJsonLine(line)
512
+ if (event) {
513
+ trackThreadId(event, rememberThreadId)
514
+ emit({
515
+ type: 'codex',
516
+ event: sanitizeCodexPayload(event),
517
+ })
518
+ } else {
519
+ emit({
520
+ type: 'stdout',
521
+ text: repairPossibleMojibake(line),
522
+ })
523
+ }
524
+ })
525
+
526
+ stderrTail.forEach((line) => {
527
+ emit({
528
+ type: 'stderr',
529
+ text: repairPossibleMojibake(line),
530
+ })
531
+ })
532
+
533
+ if (fs.existsSync(outputFile)) {
534
+ finalMessage = repairPossibleMojibake(fs.readFileSync(outputFile, 'utf8').trim())
535
+ }
536
+
537
+ if (!finalThreadId) {
538
+ finalThreadId = parseThreadIdFromStdout(stdoutRaw)
539
+ }
540
+
541
+ if (code !== 0) {
542
+ reject(new Error(repairPossibleMojibake(extractCodexError(stderrRaw, stdoutRaw))))
543
+ return
544
+ }
545
+
546
+ emit({
547
+ type: 'completed',
548
+ message: finalMessage,
549
+ })
550
+
551
+ resolve({
552
+ sessionId: session.id,
553
+ message: finalMessage,
554
+ threadId: finalThreadId,
555
+ })
556
+ })
557
+ }).finally(() => {
558
+ fs.rmSync(outputFile, { force: true })
559
+ })
560
+
561
+ return {
562
+ child,
563
+ result,
564
+ cancel() {
565
+ if (child.killed) {
566
+ return
567
+ }
568
+
569
+ if (process.platform === 'win32' && child.pid) {
570
+ try {
571
+ execFileSync('taskkill.exe', ['/PID', String(child.pid), '/T', '/F'], {
572
+ stdio: 'ignore',
573
+ })
574
+ return
575
+ } catch {
576
+ // Fall through to the default child kill when taskkill is unavailable.
577
+ }
578
+ }
579
+
580
+ if (!child.killed) {
581
+ child.kill('SIGTERM')
582
+ }
583
+ },
584
+ }
585
+ }