@miruzero/zgpt 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/README.md +38 -0
- package/docs/architecture.md +51 -0
- package/package.json +29 -0
- package/src/api/catalog.json +17002 -0
- package/src/api/index.js +153 -0
- package/src/auth/index.js +87 -0
- package/src/cli/formatter.js +111 -0
- package/src/cli.js +117 -0
- package/src/config/paths.js +19 -0
- package/src/connector/index.js +6 -0
- package/src/connector/read.js +56 -0
- package/src/connector/write.js +26 -0
- package/src/conversation/attachments.js +77 -0
- package/src/conversation/index.js +65 -0
- package/src/conversation/model.js +117 -0
- package/src/conversation/read.js +378 -0
- package/src/conversation/temp-state.js +43 -0
- package/src/conversation/temporary.js +59 -0
- package/src/conversation/write.js +508 -0
- package/src/index.js +8 -0
- package/src/project/index.js +6 -0
- package/src/project/read.js +91 -0
- package/src/project/write.js +50 -0
- package/src/protocol/conduit.js +26 -0
- package/src/protocol/conversation-payload.js +214 -0
- package/src/protocol/sentinel-client.js +35 -0
- package/src/protocol/sentinel-proof.js +19 -0
- package/src/protocol/sentinel-requirements.js +25 -0
- package/src/protocol/sse.js +56 -0
- package/src/session/exchange.js +12 -0
- package/src/session/index.js +70 -0
- package/src/session/local-storage.js +75 -0
- package/src/transport/direct/http-client.js +166 -0
package/src/api/index.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const auth = require('../auth');
|
|
5
|
+
const paths = require('../config/paths');
|
|
6
|
+
const direct = require('../transport/direct/http-client');
|
|
7
|
+
const catalogData = require('./catalog.json');
|
|
8
|
+
|
|
9
|
+
const catalog = Object.freeze(catalogData.entries.map((entry) => Object.freeze({ ...entry })));
|
|
10
|
+
const summary = Object.freeze({ ...catalogData.summary, source: catalogData.source });
|
|
11
|
+
|
|
12
|
+
const readSessionHeaders = (env) => {
|
|
13
|
+
const file = paths.sessionFile(env);
|
|
14
|
+
let state;
|
|
15
|
+
try { state = JSON.parse(fs.readFileSync(file, 'utf8')); }
|
|
16
|
+
catch {
|
|
17
|
+
throw Object.assign(new Error('ChatGPT session context required'), { code: 'SESSION_REQUIRED' });
|
|
18
|
+
}
|
|
19
|
+
if (!state?.headers || typeof state.headers !== 'object' || Array.isArray(state.headers)) {
|
|
20
|
+
throw Object.assign(new Error('ChatGPT session context required'), { code: 'SESSION_REQUIRED' });
|
|
21
|
+
}
|
|
22
|
+
return { ...state.headers };
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const bindContext = async (entry, options) => {
|
|
26
|
+
if (entry.client === 'PublicRequest' || options.auth === false) {
|
|
27
|
+
return {
|
|
28
|
+
token: options.token ?? null,
|
|
29
|
+
sessionHeaders: options.sessionHeaders ? { ...options.sessionHeaders } : {}
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
const env = options.env || process.env;
|
|
33
|
+
let token = options.token;
|
|
34
|
+
if (!token) {
|
|
35
|
+
const current = await auth.ensure({ env, acquireSession: false });
|
|
36
|
+
token = current.accessToken;
|
|
37
|
+
}
|
|
38
|
+
const sessionHeaders = options.sessionHeaders ? { ...options.sessionHeaders } : readSessionHeaders(env);
|
|
39
|
+
return { token, sessionHeaders };
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const missingPath = (name, route) => Object.assign(
|
|
43
|
+
new Error(`Missing path parameter ${name} for ${route}`),
|
|
44
|
+
{ code: 'API_PATH_PARAMETER_REQUIRED', parameter: name, route }
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const renderRoute = (route, options = {}) => {
|
|
48
|
+
const pathValues = Array.isArray(options.pathValues) ? options.pathValues : null;
|
|
49
|
+
const path = options.path && typeof options.path === 'object' ? options.path : {};
|
|
50
|
+
const used = Object.create(null);
|
|
51
|
+
let index = 0;
|
|
52
|
+
const rendered = route.replace(/\{([^}]+)\}/g, (_token, name) => {
|
|
53
|
+
let value;
|
|
54
|
+
if (pathValues) value = pathValues[index];
|
|
55
|
+
else if (Object.hasOwn(path, name)) {
|
|
56
|
+
const candidate = path[name];
|
|
57
|
+
if (Array.isArray(candidate)) {
|
|
58
|
+
const at = used[name] || 0;
|
|
59
|
+
value = candidate[at];
|
|
60
|
+
used[name] = at + 1;
|
|
61
|
+
} else value = candidate;
|
|
62
|
+
}
|
|
63
|
+
index += 1;
|
|
64
|
+
if (value === undefined || value === null) throw missingPath(name, route);
|
|
65
|
+
return encodeURIComponent(String(value));
|
|
66
|
+
});
|
|
67
|
+
if (pathValues && pathValues.length !== index) {
|
|
68
|
+
throw Object.assign(new Error(`Expected ${index} path values for ${route}, received ${pathValues.length}`), {
|
|
69
|
+
code: 'API_PATH_VALUE_COUNT', expected: index, received: pathValues.length, route
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
return rendered;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const appendQuery = (target, query) => {
|
|
76
|
+
if (!query || typeof query !== 'object') return target;
|
|
77
|
+
const params = new URLSearchParams();
|
|
78
|
+
for (const [name, input] of Object.entries(query)) {
|
|
79
|
+
const values = Array.isArray(input) ? input : [input];
|
|
80
|
+
for (const value of values) {
|
|
81
|
+
if (value === undefined || value === null) continue;
|
|
82
|
+
params.append(name, typeof value === 'object' ? JSON.stringify(value) : String(value));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const encoded = params.toString();
|
|
86
|
+
return encoded ? `${target}${target.includes('?') ? '&' : '?'}${encoded}` : target;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const byKeyEntry = new Map(catalog.map((entry) => [entry.key, entry]));
|
|
90
|
+
const byNameEntry = new Map(catalog.map((entry) => [entry.name, entry]));
|
|
91
|
+
const resolveEntry = (reference) => {
|
|
92
|
+
if (reference && typeof reference === 'object' && reference.key && reference.route) return reference;
|
|
93
|
+
if (typeof reference !== 'string') throw Object.assign(new Error('API route reference is required'), { code: 'API_REFERENCE_REQUIRED' });
|
|
94
|
+
const entry = byKeyEntry.get(reference) || byNameEntry.get(reference);
|
|
95
|
+
if (!entry) throw Object.assign(new Error(`Unknown ChatGPT API capability: ${reference}`), { code: 'API_CAPABILITY_UNKNOWN', reference });
|
|
96
|
+
return entry;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
exports.catalog = catalog;
|
|
100
|
+
exports.summary = summary;
|
|
101
|
+
exports.list = (criteria = {}) => catalog.filter((entry) => Object.entries(criteria).every(([key, value]) => value === undefined || entry[key] === value));
|
|
102
|
+
exports.find = (criteria = {}) => exports.list(criteria)[0] || null;
|
|
103
|
+
|
|
104
|
+
exports.invoke = async (reference, options = {}) => {
|
|
105
|
+
const entry = resolveEntry(reference);
|
|
106
|
+
const target = appendQuery(renderRoute(entry.route, options), options.query);
|
|
107
|
+
const context = await bindContext(entry, options);
|
|
108
|
+
const responseType = options.responseType === 'text' ? 'text' : 'json';
|
|
109
|
+
const request = responseType === 'text'
|
|
110
|
+
? (options.requestText || direct.requestText)
|
|
111
|
+
: (options.requestJson || direct.requestJson);
|
|
112
|
+
const requestOptions = {
|
|
113
|
+
method: entry.method,
|
|
114
|
+
route: entry.route,
|
|
115
|
+
retry: false,
|
|
116
|
+
...(options.body === undefined ? {} : { body: options.body }),
|
|
117
|
+
...(options.headers ? { headers: options.headers } : {}),
|
|
118
|
+
...(options.onChunk ? { onChunk: options.onChunk } : {}),
|
|
119
|
+
...(Number.isFinite(options.idleTimeoutMs) ? { idleTimeoutMs: options.idleTimeoutMs } : {}),
|
|
120
|
+
...(options.env ? { env: options.env } : {}),
|
|
121
|
+
...(options.sessionFile ? { sessionFile: options.sessionFile } : {})
|
|
122
|
+
};
|
|
123
|
+
const response = await request(target, context.token, context.sessionHeaders, requestOptions);
|
|
124
|
+
if (!response || response.status < 200 || response.status >= 300) {
|
|
125
|
+
const status = response?.status || null;
|
|
126
|
+
const error = new Error(`ChatGPT API request failed (${status || 'no-status'}): ${entry.method} ${target}`);
|
|
127
|
+
error.code = status === 429 ? 'RATE_LIMITED' : 'API_REQUEST_FAILED';
|
|
128
|
+
error.status = status;
|
|
129
|
+
error.target = target;
|
|
130
|
+
error.route = entry.route;
|
|
131
|
+
error.method = entry.method;
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
if (options.rawResponse === true) return response;
|
|
135
|
+
if (responseType === 'text') return response.text ?? '';
|
|
136
|
+
return response.json ?? { success: true };
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const byKey = Object.create(null);
|
|
140
|
+
const functions = Object.create(null);
|
|
141
|
+
const groups = Object.create(null);
|
|
142
|
+
for (const entry of catalog) {
|
|
143
|
+
const fn = (options = {}) => exports.invoke(entry, options);
|
|
144
|
+
Object.defineProperty(fn, 'meta', { value: entry, enumerable: true });
|
|
145
|
+
byKey[entry.key] = fn;
|
|
146
|
+
functions[entry.name] = fn;
|
|
147
|
+
if (!groups[entry.family]) groups[entry.family] = Object.create(null);
|
|
148
|
+
groups[entry.family][entry.name] = fn;
|
|
149
|
+
}
|
|
150
|
+
for (const family of Object.keys(groups)) Object.freeze(groups[family]);
|
|
151
|
+
exports.byKey = Object.freeze(byKey);
|
|
152
|
+
exports.functions = Object.freeze(functions);
|
|
153
|
+
exports.groups = Object.freeze(groups);
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const paths = require('../config/paths');
|
|
6
|
+
|
|
7
|
+
const readJson = (file) => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } };
|
|
8
|
+
const writeJson = (file, value) => { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 }); };
|
|
9
|
+
const tokenExpiry = (token) => {
|
|
10
|
+
try {
|
|
11
|
+
const payload = JSON.parse(Buffer.from(String(token).split('.')[1], 'base64url').toString('utf8'));
|
|
12
|
+
return Number.isFinite(payload.exp) ? new Date(payload.exp * 1000).toISOString() : null;
|
|
13
|
+
} catch { return null; }
|
|
14
|
+
};
|
|
15
|
+
const isExpired = (expires) => { const at = Date.parse(expires || ''); return Number.isFinite(at) && at <= Date.now(); };
|
|
16
|
+
const rawFromEnv = (env) => {
|
|
17
|
+
const token = typeof env.ZERO_CHATGPT_ACCESS_TOKEN === 'string' ? env.ZERO_CHATGPT_ACCESS_TOKEN.trim() : '';
|
|
18
|
+
if (!token) return null;
|
|
19
|
+
return { tokenName: 'env', accessToken: token, expires: tokenExpiry(token), source: 'env' };
|
|
20
|
+
};
|
|
21
|
+
exports.file = (env = process.env) => paths.authFile(env);
|
|
22
|
+
|
|
23
|
+
const rawFromFile = (env) => {
|
|
24
|
+
const file = paths.authFile(env);
|
|
25
|
+
const data = readJson(file);
|
|
26
|
+
if (!data || typeof data.accessToken !== 'string' || !data.accessToken) return null;
|
|
27
|
+
return { tokenName: data.tokenName || 'ACCESS', accessToken: data.accessToken, expires: data.expires || tokenExpiry(data.accessToken), updatedAt: data.updatedAt || null, source: 'file', file };
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
exports.status = ({ env = process.env } = {}) => {
|
|
31
|
+
const raw = rawFromEnv(env) || rawFromFile(env);
|
|
32
|
+
const file = paths.authFile(env);
|
|
33
|
+
if (!raw) return { present: false, status: fs.existsSync(file) ? 'invalid' : 'missing', redacted: true, source: 'none', file };
|
|
34
|
+
return { present: true, status: isExpired(raw.expires) ? 'expired' : 'valid', redacted: true, source: raw.source, tokenName: raw.tokenName, expires: raw.expires || null, updatedAt: raw.updatedAt || null, file: raw.file || null };
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
exports.importSession = ({ body, env = process.env, source = 'session' } = {}) => {
|
|
38
|
+
const accessToken = body?.accessToken || body?.access_token;
|
|
39
|
+
if (!accessToken || typeof accessToken !== 'string') {
|
|
40
|
+
throw Object.assign(new Error('ChatGPT session did not provide an access token'), { code: 'AUTH_REQUIRED' });
|
|
41
|
+
}
|
|
42
|
+
const expires = body?.expires || body?.expires_at || tokenExpiry(accessToken);
|
|
43
|
+
const value = {
|
|
44
|
+
tokenName: 'ACCESS',
|
|
45
|
+
accessToken,
|
|
46
|
+
expires: expires || null,
|
|
47
|
+
updatedAt: new Date().toISOString(),
|
|
48
|
+
source
|
|
49
|
+
};
|
|
50
|
+
writeJson(paths.authFile(env), value);
|
|
51
|
+
return {
|
|
52
|
+
present: true,
|
|
53
|
+
status: isExpired(value.expires) ? 'expired' : 'valid',
|
|
54
|
+
redacted: true,
|
|
55
|
+
source,
|
|
56
|
+
tokenName: value.tokenName,
|
|
57
|
+
expires: value.expires,
|
|
58
|
+
updatedAt: value.updatedAt,
|
|
59
|
+
file: paths.authFile(env)
|
|
60
|
+
};
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
exports.refresh = async ({ env = process.env, acquireSession } = {}) => {
|
|
64
|
+
const flow = typeof acquireSession === 'function' ? acquireSession : require('../session').acquire;
|
|
65
|
+
const result = await flow({ env });
|
|
66
|
+
const body = result?.body ?? result;
|
|
67
|
+
const accessToken = body?.accessToken || body?.access_token;
|
|
68
|
+
if (!accessToken || typeof accessToken !== 'string') throw Object.assign(new Error('ChatGPT session did not provide an access token'), { code: 'AUTH_REQUIRED' });
|
|
69
|
+
const value = {
|
|
70
|
+
tokenName: 'ACCESS',
|
|
71
|
+
accessToken,
|
|
72
|
+
expires: body?.expires || body?.expires_at || tokenExpiry(accessToken),
|
|
73
|
+
updatedAt: new Date().toISOString()
|
|
74
|
+
};
|
|
75
|
+
writeJson(paths.authFile(env), value);
|
|
76
|
+
return { ...value, source: 'session' };
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
exports.ensure = async ({ env = process.env, acquireSession } = {}) => {
|
|
80
|
+
const envAuth = rawFromEnv(env);
|
|
81
|
+
if (envAuth && !isExpired(envAuth.expires)) return envAuth;
|
|
82
|
+
const fileAuth = rawFromFile(env);
|
|
83
|
+
if (fileAuth && !isExpired(fileAuth.expires)) return fileAuth;
|
|
84
|
+
if (acquireSession === false) throw Object.assign(new Error('ChatGPT authentication required'), { code: 'AUTH_REQUIRED' });
|
|
85
|
+
try { return await exports.refresh({ env, ...(typeof acquireSession === 'function' ? { acquireSession } : {}) }); }
|
|
86
|
+
catch (error) { if (error?.code) throw error; throw Object.assign(new Error('ChatGPT authentication required'), { code: 'AUTH_REQUIRED', cause: error }); }
|
|
87
|
+
};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const textOf = (message) => {
|
|
4
|
+
const content = message?.content;
|
|
5
|
+
if (!content) return '(non-text)';
|
|
6
|
+
if (typeof content.content === 'string') return content.content;
|
|
7
|
+
if (typeof content.text === 'string') return content.text;
|
|
8
|
+
if (Array.isArray(content.parts)) {
|
|
9
|
+
const parts = content.parts
|
|
10
|
+
.map((part) => typeof part === 'string' ? part : part?.text)
|
|
11
|
+
.filter(Boolean);
|
|
12
|
+
if (parts.length) return parts.join('\n');
|
|
13
|
+
}
|
|
14
|
+
return '(non-text)';
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
exports.parseLimitSpec = (value) => {
|
|
18
|
+
if (/^[1-9]\d*$/.test(value || '')) return { type: 'latest', count: Number(value) };
|
|
19
|
+
const match = /^([1-9]\d*)-([1-9]\d*)$/.exec(value || '');
|
|
20
|
+
if (!match) return null;
|
|
21
|
+
const start = Number(match[1]);
|
|
22
|
+
const end = Number(match[2]);
|
|
23
|
+
return end >= start ? { type: 'range', start, end } : null;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const groupTurns = (messages = []) => {
|
|
27
|
+
const turns = [];
|
|
28
|
+
let current = null;
|
|
29
|
+
for (const message of messages) {
|
|
30
|
+
if (message?.author?.role === 'user') {
|
|
31
|
+
current = { number: turns.length + 1, messages: [] };
|
|
32
|
+
turns.push(current);
|
|
33
|
+
}
|
|
34
|
+
if (current) current.messages.push(message);
|
|
35
|
+
}
|
|
36
|
+
return turns;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const selectTurns = (turns, spec) => {
|
|
40
|
+
if (!spec) return turns;
|
|
41
|
+
if (spec.type === 'latest') return turns.slice(-spec.count);
|
|
42
|
+
return turns.slice(spec.start - 1, spec.end);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const debugLines = (message) => {
|
|
46
|
+
const metadata = message?.metadata || {};
|
|
47
|
+
const value = (item) => item == null ? '-' : typeof item === 'object' ? JSON.stringify(item) : String(item);
|
|
48
|
+
return [
|
|
49
|
+
'[DEBUG]',
|
|
50
|
+
`id=${message?.id || '-'}`,
|
|
51
|
+
`parent_id=${message?.parent_id || '-'}`,
|
|
52
|
+
`status=${message?.status || '-'}`,
|
|
53
|
+
`end_turn=${value(message?.end_turn)}`,
|
|
54
|
+
`content_type=${message?.content?.content_type || '-'}`,
|
|
55
|
+
`author_name=${message?.author?.name || '-'}`,
|
|
56
|
+
`recipient=${message?.recipient || '-'}`,
|
|
57
|
+
`channel=${message?.channel || '-'}`,
|
|
58
|
+
`request_id=${metadata.request_id || '-'}`,
|
|
59
|
+
`turn_id=${metadata.turn_id || '-'}`,
|
|
60
|
+
`turn_exchange_id=${metadata.turn_exchange_id || '-'}`,
|
|
61
|
+
`working_turn_id=${metadata.working_turn_id || '-'}`,
|
|
62
|
+
`model_slug=${metadata.model_slug || '-'}`,
|
|
63
|
+
`finish_details=${value(metadata.finish_details)}`
|
|
64
|
+
];
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const moderationLines = (conversation) => {
|
|
68
|
+
const results = Array.isArray(conversation?.moderation_results) ? conversation.moderation_results : [];
|
|
69
|
+
const lines = [];
|
|
70
|
+
for (const result of results) {
|
|
71
|
+
const metadata = result?.metadata || {};
|
|
72
|
+
lines.push('## MODERATION', `message_id=${result?.message_id || '-'}`);
|
|
73
|
+
lines.push(`blocked=${result?.blocked ?? '-'}`, `flagged=${result?.flagged ?? '-'}`);
|
|
74
|
+
lines.push(`safety_limited=${metadata.safety_limited ?? '-'}`);
|
|
75
|
+
lines.push(`protection_type=${metadata.protection_type || '-'}`);
|
|
76
|
+
lines.push(`block_reason=${metadata.safety_plugin_block_reason || result?.block_reason || '-'}`, '');
|
|
77
|
+
}
|
|
78
|
+
return lines;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
exports.markdown = (conversation, options = {}) => {
|
|
82
|
+
const debug = options.debug === true;
|
|
83
|
+
const messages = Array.isArray(conversation?.messages) ? conversation.messages : [];
|
|
84
|
+
const turns = groupTurns(messages);
|
|
85
|
+
const selected = selectTurns(turns, options.limit || null);
|
|
86
|
+
const lines = [
|
|
87
|
+
`# ${conversation?.title || '(untitled)'}`,
|
|
88
|
+
'',
|
|
89
|
+
`conversation_id: ${conversation?.conversation_id || conversation?.id || '-'}`,
|
|
90
|
+
`project_id: ${conversation?.gizmo_id || conversation?.conversation_template_id || '-'}`,
|
|
91
|
+
`current_node: ${conversation?.current_node || '-'}`,
|
|
92
|
+
`messages: ${messages.length}`,
|
|
93
|
+
`user_turns: ${turns.length}`,
|
|
94
|
+
''
|
|
95
|
+
];
|
|
96
|
+
|
|
97
|
+
for (const turn of selected) {
|
|
98
|
+
lines.push(`## Turn ${turn.number}`, '');
|
|
99
|
+
for (const message of turn.messages) {
|
|
100
|
+
const role = String(message?.author?.role || 'unknown').toUpperCase();
|
|
101
|
+
if (!debug && role !== 'USER' && role !== 'ASSISTANT') continue;
|
|
102
|
+
const name = message?.author?.name ? ` (${message.author.name})` : '';
|
|
103
|
+
lines.push(`### ${role}${name}`, textOf(message), '');
|
|
104
|
+
if (debug) lines.push(...debugLines(message), '');
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (debug) lines.push(...moderationLines(conversation));
|
|
108
|
+
return `${lines.join('\n').trimEnd()}\n`;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
exports.json = (conversation) => `${JSON.stringify(conversation, null, 2)}\n`;
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const formatter = require('./cli/formatter');
|
|
7
|
+
|
|
8
|
+
const usage = () => [
|
|
9
|
+
'Zero ChatGPT CLI',
|
|
10
|
+
'',
|
|
11
|
+
'Usage:',
|
|
12
|
+
' zgpt get <conversation-id> [--limit N|A-B] [--debug|--json]',
|
|
13
|
+
' zgpt export <conversation-id> [--limit N|A-B] [--debug|--json] [--out <path>]',
|
|
14
|
+
' zgpt help'
|
|
15
|
+
].join('\n');
|
|
16
|
+
|
|
17
|
+
const parseOptions = (argv) => {
|
|
18
|
+
const result = { json: false, debug: false, limit: null, out: null };
|
|
19
|
+
for (let index = 2; index < argv.length; index += 1) {
|
|
20
|
+
const arg = argv[index];
|
|
21
|
+
if (arg === '--json' || arg === 'json') result.json = true;
|
|
22
|
+
else if (arg === '--debug' || arg === 'debug') result.debug = true;
|
|
23
|
+
else if (arg === '--limit' || arg === 'limit') {
|
|
24
|
+
const value = argv[++index];
|
|
25
|
+
result.limit = formatter.parseLimitSpec(value);
|
|
26
|
+
if (!result.limit) throw new Error(`Invalid limit: ${value || '(missing)'}`);
|
|
27
|
+
} else if (arg === '--out' || arg === 'out') {
|
|
28
|
+
result.out = argv[++index] || null;
|
|
29
|
+
if (!result.out) throw new Error('Export path is required after --out');
|
|
30
|
+
} else throw new Error(`Unknown option: ${arg}`);
|
|
31
|
+
}
|
|
32
|
+
if (result.json && result.debug) throw new Error('--json and --debug are mutually exclusive');
|
|
33
|
+
return result;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const defaultExportPath = (conversationId, options, cwd) => {
|
|
37
|
+
const safeId = String(conversationId).replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
38
|
+
const suffix = options.json ? '.json' : options.debug ? '.debug.md' : '.md';
|
|
39
|
+
return path.join(cwd, 'exports', `conversation-${safeId}${suffix}`);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
exports.run = async (argv = [], options = {}) => {
|
|
43
|
+
const stdout = options.stdout || console.log;
|
|
44
|
+
const stderr = options.stderr || console.error;
|
|
45
|
+
const command = argv[0] || 'help';
|
|
46
|
+
if (command === 'help' || command === '--help' || command === '-h') {
|
|
47
|
+
stdout(usage());
|
|
48
|
+
return 0;
|
|
49
|
+
}
|
|
50
|
+
if (!['get', 'export', 'stream-status', 'resume'].includes(command)) {
|
|
51
|
+
stderr(`Unknown command: ${command}\n\n${usage()}`);
|
|
52
|
+
return 2;
|
|
53
|
+
}
|
|
54
|
+
const conversationId = argv[1];
|
|
55
|
+
if (!conversationId) {
|
|
56
|
+
stderr(`Conversation id is required.\n\n${usage()}`);
|
|
57
|
+
return 2;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let parsed = null;
|
|
61
|
+
if (command === 'get' || command === 'export') {
|
|
62
|
+
try { parsed = parseOptions(argv); }
|
|
63
|
+
catch (error) {
|
|
64
|
+
stderr(`${error.message}\n\n${usage()}`);
|
|
65
|
+
return 2;
|
|
66
|
+
}
|
|
67
|
+
} else if (argv.length !== 2) {
|
|
68
|
+
stderr(`Unknown option for ${command}\n\n${usage()}`);
|
|
69
|
+
return 2;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
const runtime = options.runtime || require('./index');
|
|
74
|
+
if (command === 'stream-status') {
|
|
75
|
+
const state = await runtime.conversation.streamStatus(conversationId);
|
|
76
|
+
stdout(`status=${state?.status || '-'}\nconversation_id=${state?.conversation_id || conversationId}`);
|
|
77
|
+
return 0;
|
|
78
|
+
}
|
|
79
|
+
if (command === 'resume') {
|
|
80
|
+
const state = await runtime.conversation.streamStatus(conversationId);
|
|
81
|
+
if (state?.status !== 'IS_STREAMING') {
|
|
82
|
+
stdout(`status=${state?.status || '-'}\nconversation_id=${conversationId}\nresume=SKIPPED`);
|
|
83
|
+
return 0;
|
|
84
|
+
}
|
|
85
|
+
const resumed = await runtime.conversation.resume(conversationId);
|
|
86
|
+
let text = `status=${state.status}\nconversation_id=${conversationId}\nresume=OK\nhttp_status=${resumed?.status ?? '-'}\ncontent_type=${resumed?.content_type || '-'}`;
|
|
87
|
+
if (resumed?.text) text += `\n${String(resumed.text).trimEnd()}`;
|
|
88
|
+
stdout(text);
|
|
89
|
+
return 0;
|
|
90
|
+
}
|
|
91
|
+
const conversation = await runtime.conversation.get(conversationId);
|
|
92
|
+
const rendered = parsed.json
|
|
93
|
+
? formatter.json(conversation)
|
|
94
|
+
: formatter.markdown(conversation, { debug: parsed.debug, limit: parsed.limit });
|
|
95
|
+
|
|
96
|
+
if (command === 'get') {
|
|
97
|
+
stdout(rendered.trimEnd());
|
|
98
|
+
return 0;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const cwd = options.cwd || process.cwd();
|
|
102
|
+
const target = parsed.out
|
|
103
|
+
? path.resolve(cwd, parsed.out)
|
|
104
|
+
: defaultExportPath(conversationId, parsed, cwd);
|
|
105
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
106
|
+
fs.writeFileSync(target, rendered, 'utf8');
|
|
107
|
+
stdout(`exported=${target}`);
|
|
108
|
+
return 0;
|
|
109
|
+
} catch (error) {
|
|
110
|
+
stderr(`${error?.code ? `${error.code}: ` : ''}${error?.message || String(error)}`);
|
|
111
|
+
return 1;
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
if (require.main === module) {
|
|
116
|
+
exports.run(process.argv.slice(2)).then((code) => { process.exitCode = code; });
|
|
117
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const crypto = require('node:crypto');
|
|
5
|
+
|
|
6
|
+
const root = path.resolve(__dirname, '../..');
|
|
7
|
+
|
|
8
|
+
exports.stateDir = (env = process.env) => path.resolve(env.ZERO_CHATGPT_STATE_DIR || path.join(root, 'state'));
|
|
9
|
+
exports.authFile = (env = process.env) => path.resolve(env.ZERO_CHATGPT_AUTH_FILE || path.join(exports.stateDir(env), 'auth-context.json'));
|
|
10
|
+
exports.sessionFile = (env = process.env) => path.resolve(env.ZERO_CHATGPT_SESSION_FILE || path.join(exports.stateDir(env), 'session-context.json'));
|
|
11
|
+
exports.tempConversationFile = (env = process.env) => path.resolve(env.ZERO_CHATGPT_TEMP_STATE_FILE || path.join(exports.stateDir(env), 'temp-conversations.json'));
|
|
12
|
+
exports.rateLimitDir = (env = process.env) => env.ZERO_CHATGPT_RATE_LIMIT_FILE
|
|
13
|
+
? path.dirname(path.resolve(env.ZERO_CHATGPT_RATE_LIMIT_FILE))
|
|
14
|
+
: exports.stateDir(env);
|
|
15
|
+
exports.rateLimitFile = (env = process.env, token = 'anonymous') => {
|
|
16
|
+
if (env.ZERO_CHATGPT_RATE_LIMIT_FILE) return path.resolve(env.ZERO_CHATGPT_RATE_LIMIT_FILE);
|
|
17
|
+
const key = crypto.createHash('sha256').update(String(token || 'anonymous')).digest('hex').slice(0, 16);
|
|
18
|
+
return path.join(exports.rateLimitDir(env), `rate-limit-${key}.json`);
|
|
19
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const direct = require('../transport/direct/http-client');
|
|
4
|
+
|
|
5
|
+
const requireOk = (response, target) => {
|
|
6
|
+
if (!response || response.status !== 200 || !response.json) {
|
|
7
|
+
const error = new Error(`ChatGPT request failed (${response?.status || 'no-status'}): ${target}`);
|
|
8
|
+
error.status = response?.status || null;
|
|
9
|
+
error.target = target;
|
|
10
|
+
error.retryAfterMs = direct.parseRetryAfterMs(response?.headers || {});
|
|
11
|
+
if (error.status === 429) error.code = 'RATE_LIMITED';
|
|
12
|
+
throw error;
|
|
13
|
+
}
|
|
14
|
+
return response.json;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
exports.listConnectors = async ({ token, sessionHeaders, requestJson = direct.requestJson }) => {
|
|
18
|
+
const target = '/backend-api/ps/plugins/installed?limit=1000';
|
|
19
|
+
const raw = requireOk(await requestJson(target, token, sessionHeaders, { route: '/backend-api/ps/plugins/installed', headers: { connection: 'close' } }), target);
|
|
20
|
+
const plugins = Array.isArray(raw?.plugins) ? raw.plugins : [];
|
|
21
|
+
return plugins.map((plugin) => ({
|
|
22
|
+
id: plugin?.id || null,
|
|
23
|
+
name: plugin?.name || null,
|
|
24
|
+
displayName: plugin?.release?.display_name || plugin?.name || null,
|
|
25
|
+
shortDescription: plugin?.release?.interface?.short_description || plugin?.release?.description || null,
|
|
26
|
+
status: plugin?.status || null,
|
|
27
|
+
enabled: plugin?.enabled ?? null,
|
|
28
|
+
disabledSkillNames: Array.isArray(plugin?.disabled_skill_names) ? plugin.disabled_skill_names : []
|
|
29
|
+
}));
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
exports.getConnectorActions = async ({ appId, token, sessionHeaders, requestJson = direct.requestJson }) => {
|
|
33
|
+
if (!appId || typeof appId !== 'string') throw Object.assign(new Error('appId is required'), { code: 'BAD_ARGS' });
|
|
34
|
+
const target = `/backend-api/aip/connectors/${encodeURIComponent(appId)}/actions`;
|
|
35
|
+
return requireOk(await requestJson(target, token, sessionHeaders, { route: '/backend-api/aip/connectors/{app_id}/actions', headers: { connection: 'close' } }), target);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
exports.resolveSystemHintSelections = async ({ message, token, sessionHeaders, listConnectors = exports.listConnectors }) => {
|
|
39
|
+
const text = String(message || '');
|
|
40
|
+
if (!text.includes('@')) return { systemHints: [], systemHintMentions: [] };
|
|
41
|
+
const connectors = await listConnectors({ token, sessionHeaders });
|
|
42
|
+
const hints = new Set();
|
|
43
|
+
const systemHintMentions = [];
|
|
44
|
+
for (const item of connectors) {
|
|
45
|
+
if (item?.enabled === false || !item?.displayName || !item?.id?.startsWith('plugin_')) continue;
|
|
46
|
+
const mentionText = '@' + item.displayName;
|
|
47
|
+
const hint = 'plugin:' + item.id.slice('plugin_'.length);
|
|
48
|
+
let startIndex = text.indexOf(mentionText);
|
|
49
|
+
while (startIndex >= 0) {
|
|
50
|
+
hints.add(hint);
|
|
51
|
+
systemHintMentions.push({ id: hint, startIndex, endIndex: startIndex + mentionText.length });
|
|
52
|
+
startIndex = text.indexOf(mentionText, startIndex + mentionText.length);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return { systemHints: [...hints], systemHintMentions };
|
|
56
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const direct = require('../transport/direct/http-client');
|
|
4
|
+
|
|
5
|
+
const requireSuccess = (response, target) => {
|
|
6
|
+
if (!response || response.status < 200 || response.status >= 300) {
|
|
7
|
+
const error = new Error(`ChatGPT request failed (${response?.status || 'no-status'}): ${target}`);
|
|
8
|
+
error.status = response?.status || null;
|
|
9
|
+
error.target = target;
|
|
10
|
+
error.retryAfterMs = direct.parseRetryAfterMs(response?.headers || {});
|
|
11
|
+
if (error.status === 429) error.code = 'RATE_LIMITED';
|
|
12
|
+
throw error;
|
|
13
|
+
}
|
|
14
|
+
return response.json || { success: true };
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
exports.refreshConnectorActions = async ({ linkId, token, sessionHeaders, requestJson = direct.requestJson }) => {
|
|
18
|
+
if (!linkId || typeof linkId !== 'string') throw Object.assign(new Error('linkId is required'), { code: 'BAD_ARGS' });
|
|
19
|
+
const target = '/backend-api/aip/connectors/mcp/refresh_actions';
|
|
20
|
+
return requireSuccess(await requestJson(target, token, sessionHeaders, {
|
|
21
|
+
method: 'POST',
|
|
22
|
+
body: { link_id: linkId },
|
|
23
|
+
route: target,
|
|
24
|
+
retry: false
|
|
25
|
+
}), target);
|
|
26
|
+
};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const requiredString = (value, name) => {
|
|
4
|
+
if (typeof value !== 'string' || !value) throw new TypeError(`${name} is required`);
|
|
5
|
+
return value;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
const classify = (mimeType) => {
|
|
9
|
+
if (mimeType === 'image/png') return 'image';
|
|
10
|
+
if (mimeType === 'text/plain') return 'document';
|
|
11
|
+
throw new Error(`unverified attachment mime type: ${mimeType}`);
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
exports.normalizeReservation = (input) => {
|
|
15
|
+
if (!input || input.eligible !== true) throw new Error('upload reservation is not eligible');
|
|
16
|
+
const fileId = requiredString(input.reservation_id, 'reservation_id');
|
|
17
|
+
const uploadUrl = requiredString(input.upload_url, 'upload_url');
|
|
18
|
+
const uploadUrlExpiresAt = requiredString(input.upload_url_expires_at, 'upload_url_expires_at');
|
|
19
|
+
const reservationExpiresAt = requiredString(input.reservation_expires_at, 'reservation_expires_at');
|
|
20
|
+
new URL(uploadUrl);
|
|
21
|
+
if (Number.isNaN(Date.parse(uploadUrlExpiresAt))) throw new TypeError('invalid upload_url_expires_at');
|
|
22
|
+
if (Number.isNaN(Date.parse(reservationExpiresAt))) throw new TypeError('invalid reservation_expires_at');
|
|
23
|
+
return { fileId, uploadUrl, uploadUrlExpiresAt, reservationExpiresAt };
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
exports.sanitizeReservationForLog = (input) => {
|
|
27
|
+
const normalized = exports.normalizeReservation(input);
|
|
28
|
+
const url = new URL(normalized.uploadUrl);
|
|
29
|
+
return {
|
|
30
|
+
...normalized,
|
|
31
|
+
uploadUrl: `${url.origin}${url.pathname}${url.search ? '?<redacted>' : ''}`
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
exports.buildProcessPayload = ({ fileId, name, mimeType, temporary = false }) => {
|
|
36
|
+
const kind = classify(mimeType);
|
|
37
|
+
return {
|
|
38
|
+
entry_surface: 'chat_composer',
|
|
39
|
+
file_id: requiredString(fileId, 'fileId'),
|
|
40
|
+
file_name: requiredString(name, 'name'),
|
|
41
|
+
index_for_retrieval: kind === 'document',
|
|
42
|
+
library_persistence_mode: 'opportunistic',
|
|
43
|
+
metadata: {
|
|
44
|
+
is_temporary_chat: Boolean(temporary),
|
|
45
|
+
store_in_library: false,
|
|
46
|
+
is_project_thread: false
|
|
47
|
+
},
|
|
48
|
+
mime_type: mimeType,
|
|
49
|
+
use_case: kind === 'image' ? 'multimodal' : 'my_files'
|
|
50
|
+
};
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const projectAttachment = ({ fileId, name, mimeType, size, width, height }) => {
|
|
54
|
+
const kind = classify(mimeType);
|
|
55
|
+
requiredString(fileId, 'fileId');
|
|
56
|
+
requiredString(name, 'name');
|
|
57
|
+
if (kind === 'image') {
|
|
58
|
+
return {
|
|
59
|
+
kind,
|
|
60
|
+
part: {
|
|
61
|
+
asset_pointer: `sediment://${fileId}`,
|
|
62
|
+
content_type: 'image_asset_pointer',
|
|
63
|
+
height,
|
|
64
|
+
size_bytes: size,
|
|
65
|
+
width
|
|
66
|
+
},
|
|
67
|
+
attachment: { id: fileId, mime_type: mimeType, name, size, width, height }
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
kind,
|
|
72
|
+
attachment: { id: fileId, is_big_paste: true, mime_type: mimeType, name, size }
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
exports.project = projectAttachment;
|
|
77
|
+
exports.projectAttachment = projectAttachment;
|