@miruzero/zgpt 0.1.0 → 0.1.1
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/docs/architecture.md +29 -3
- package/package.json +1 -1
- package/src/auth/index.js +111 -84
- package/src/cli/conversations-tui.js +301 -0
- package/src/cli/formatter.js +39 -1
- package/src/cli.js +177 -113
- package/src/config/paths.js +3 -1
- package/src/connector/read.js +28 -4
- package/src/connector/write.js +7 -7
- package/src/conversation/context-config.js +68 -0
- package/src/conversation/index.js +11 -5
- package/src/conversation/read.js +10 -3
- package/src/conversation/recovery-state.js +51 -0
- package/src/conversation/recovery.js +85 -0
- package/src/conversation/temporary.js +3 -1
- package/src/conversation/write.js +118 -27
- package/src/file/index.js +151 -0
- package/src/index.js +5 -1
- package/src/memory/index.js +64 -0
- package/src/protocol/sse.js +2 -1
- package/src/runtime-context.js +27 -0
- package/src/session/direct-transport.js +68 -0
- package/src/session/exchange.js +9 -4
- package/src/session/index.js +100 -68
- package/src/session/local-storage.js +99 -68
- package/src/settings/index.js +18 -0
- package/src/share/index.js +25 -0
- package/src/transport/direct/http-client.js +189 -164
package/docs/architecture.md
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
## Runtime
|
|
8
8
|
|
|
9
9
|
```text
|
|
10
|
-
auth / session / conversation / project / connector
|
|
10
|
+
auth / session / conversation / project / connector / file / memory / settings / share
|
|
11
11
|
|
|
|
12
12
|
protocol
|
|
13
13
|
sentinel / proof / conduit / payload / SSE
|
|
@@ -20,6 +20,11 @@ sentinel / proof / conduit / payload / SSE
|
|
|
20
20
|
|
|
21
21
|
Only ChatGPT runtime concerns belong in this package. Zero Core, MCP, Agent orchestration, devices, task routing, and adapter layers stay outside it.
|
|
22
22
|
|
|
23
|
+
## Surface scope
|
|
24
|
+
|
|
25
|
+
Only the normal ChatGPT Chat runtime is in scope. A route is eligible when it is Chat-only or when it is shared infrastructure with evidence that normal Chat uses it. Work-only and Codex-only surfaces are excluded, including routes or request variants that are only evidenced under `web_frontend=codex_webview`, `product=work_tab`, `wham`, `hermes`, `agent-deployments`, or `hosting`.
|
|
26
|
+
|
|
27
|
+
Route path alone is not enough to classify a capability. Surface evidence and relevant request context are part of the contract.
|
|
23
28
|
## State
|
|
24
29
|
|
|
25
30
|
Default mutable state is under `M:\Zero_ChatGPT\state`:
|
|
@@ -35,11 +40,29 @@ The state directory is the only default writer for ChatGPT runtime state.
|
|
|
35
40
|
|
|
36
41
|
- `conversation` owns conversation lifecycle and message/stream behavior.
|
|
37
42
|
- `project` owns project CRUD and project conversation grouping.
|
|
38
|
-
- `connector` owns ChatGPT connector discovery, actions, refresh, and mention resolution.
|
|
43
|
+
- `connector` owns ChatGPT Chat connector discovery, app metadata, system hints, actions, refresh, and mention resolution.
|
|
44
|
+
- `file` owns Chat composer file reservation, file-entry creation, upload processing, and reservation finalization.
|
|
45
|
+
- `memory` owns Chat memory reads used by the normal Chat surface.
|
|
46
|
+
- `settings` owns normal Chat user settings and explicit user-setting mutations.
|
|
47
|
+
- `share` owns normal Chat conversation share lifecycle.
|
|
39
48
|
- `protocol` owns ChatGPT Web payload, Sentinel, conduit, and SSE mechanics.
|
|
40
49
|
- `transport` owns HTTP, headers, retries, cooldown, and session-cookie persistence.
|
|
41
50
|
- `session` owns ChatGPT session acquisition/exchange; it has no implicit browser/CDP transport.
|
|
42
51
|
|
|
52
|
+
## Interrupted stream recovery
|
|
53
|
+
|
|
54
|
+
conversation is the only owner of stream diagnosis and repair. Callers request an operation or retry a frozen operation; they do not choose stream_status, offsets, resume tokens, or resume endpoints.
|
|
55
|
+
|
|
56
|
+
1. A normal persisted send is demand-driven. There is no background stream-status polling.
|
|
57
|
+
2. If the response stream ends before terminal completion, the conversation owner records the observed conversation id, acknowledged offset, server-issued continuation token when present, turn trace, parent node, and observed current node.
|
|
58
|
+
3. The owner performs at most one status diagnosis for that interruption.
|
|
59
|
+
4. COMPLETE, IS_STOP_REQUESTED, and UNAVAILABLE move to canonical-state verification; they do not replay the user prompt.
|
|
60
|
+
5. FAILURE is terminal for that retry attempt.
|
|
61
|
+
6. IS_STREAMING may perform at most one resume request and only with a server-issued continuation token. Tokenless resume is never guessed.
|
|
62
|
+
7. If resume or verification still cannot finish, the checkpoint remains frozen. A new send to that conversation is rejected until the frozen operation is resolved.
|
|
63
|
+
8. conversation.retry(id) is the owner-level recovery capability. It continues from the saved phase/cursor and never resubmits the original prompt.
|
|
64
|
+
9. The checkpoint is removed only after canonical conversation state proves that the current node advanced beyond the saved parent node.
|
|
65
|
+
|
|
43
66
|
## Invariants
|
|
44
67
|
|
|
45
68
|
1. Direct transport is the only conversation transport.
|
|
@@ -48,4 +71,7 @@ The state directory is the only default writer for ChatGPT runtime state.
|
|
|
48
71
|
4. Secrets are never embedded in source or diagnostics.
|
|
49
72
|
5. External ChatGPT responses are validated before state mutation.
|
|
50
73
|
6. This package owns only ChatGPT runtime state.
|
|
51
|
-
7. No Zero Core, MCP, Agent, device, task, or adapter implementation lives here.
|
|
74
|
+
7. No Zero Core, MCP, Agent, device, task, or adapter implementation lives here.
|
|
75
|
+
8. No background polling or hidden retry loop is allowed; recovery work occurs only on the active demand or an explicit retry demand.
|
|
76
|
+
|
|
77
|
+
|
package/package.json
CHANGED
package/src/auth/index.js
CHANGED
|
@@ -1,87 +1,114 @@
|
|
|
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
|
-
};
|
|
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
21
|
exports.file = (env = process.env) => paths.authFile(env);
|
|
22
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.
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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, fresh = false } = {}) => {
|
|
64
|
+
const flow = typeof acquireSession === 'function' ? acquireSession : require('../session').acquire;
|
|
65
|
+
const result = await flow({ env, fresh });
|
|
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.login = async ({ env = process.env, acquireSession } = {}) => {
|
|
80
|
+
await exports.refresh({ env, fresh: true, ...(typeof acquireSession === 'function' ? { acquireSession } : {}) });
|
|
81
|
+
return exports.status({ env });
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
exports.logout = ({ env = process.env } = {}) => {
|
|
85
|
+
const authFile = paths.authFile(env);
|
|
86
|
+
const sessionFile = paths.sessionFile(env);
|
|
87
|
+
const remove = (file) => {
|
|
88
|
+
const existed = fs.existsSync(file);
|
|
89
|
+
if (existed) fs.rmSync(file, { force: true });
|
|
90
|
+
return existed;
|
|
91
|
+
};
|
|
92
|
+
const authRemoved = remove(authFile);
|
|
93
|
+
const sessionRemoved = remove(sessionFile);
|
|
94
|
+
const envOverridePresent = typeof env.ZERO_CHATGPT_ACCESS_TOKEN === 'string' && Boolean(env.ZERO_CHATGPT_ACCESS_TOKEN.trim());
|
|
95
|
+
return {
|
|
96
|
+
status: envOverridePresent ? 'env-active' : 'logged-out',
|
|
97
|
+
redacted: true,
|
|
98
|
+
authRemoved,
|
|
99
|
+
sessionRemoved,
|
|
100
|
+
envOverridePresent
|
|
101
|
+
};
|
|
102
|
+
};
|
|
103
|
+
exports.ensure = async ({ env = process.env, acquireSession } = {}) => {
|
|
104
|
+
const envAuth = rawFromEnv(env);
|
|
105
|
+
if (envAuth && !isExpired(envAuth.expires)) return envAuth;
|
|
106
|
+
const fileAuth = rawFromFile(env);
|
|
107
|
+
if (fileAuth && !isExpired(fileAuth.expires)) return fileAuth;
|
|
108
|
+
if (fileAuth && isExpired(fileAuth.expires)) {
|
|
109
|
+
try { fs.rmSync(paths.authFile(env), { force: true }); } catch {}
|
|
110
|
+
}
|
|
111
|
+
if (acquireSession === false) throw Object.assign(new Error('ChatGPT authentication required'), { code: 'AUTH_REQUIRED' });
|
|
112
|
+
try { return await exports.refresh({ env, ...(typeof acquireSession === 'function' ? { acquireSession } : {}) }); }
|
|
113
|
+
catch (error) { if (error?.code) throw error; throw Object.assign(new Error('ChatGPT authentication required'), { code: 'AUTH_REQUIRED', cause: error }); }
|
|
87
114
|
};
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const readline = require('node:readline');
|
|
4
|
+
|
|
5
|
+
const ESC = '\u001b[';
|
|
6
|
+
const H = '\u2500';
|
|
7
|
+
const TL = '\u256d';
|
|
8
|
+
const TR = '\u256e';
|
|
9
|
+
const BL = '\u2570';
|
|
10
|
+
const BR = '\u256f';
|
|
11
|
+
const V = '\u2502';
|
|
12
|
+
const ELLIPSIS = '\u2026';
|
|
13
|
+
|
|
14
|
+
const MAX_PANEL_WIDTH = 108;
|
|
15
|
+
const MIN_PANEL_WIDTH = 72;
|
|
16
|
+
const clamp = (value, min, max) => Math.max(min, Math.min(max, value));
|
|
17
|
+
const clean = (value) => String(value ?? '-').replace(/[\r\n\t]+/g, ' ');
|
|
18
|
+
const isWide = (ch) => {
|
|
19
|
+
if (/\p{Extended_Pictographic}/u.test(ch)) return true;
|
|
20
|
+
const cp = ch.codePointAt(0);
|
|
21
|
+
return cp >= 0x1100 && (
|
|
22
|
+
cp <= 0x115f || cp === 0x2329 || cp === 0x232a
|
|
23
|
+
|| (cp >= 0x2e80 && cp <= 0xa4cf)
|
|
24
|
+
|| (cp >= 0xac00 && cp <= 0xd7a3)
|
|
25
|
+
|| (cp >= 0xf900 && cp <= 0xfaff)
|
|
26
|
+
|| (cp >= 0xfe10 && cp <= 0xfe6f)
|
|
27
|
+
|| (cp >= 0xff01 && cp <= 0xff60)
|
|
28
|
+
|| (cp >= 0xffe0 && cp <= 0xffe6)
|
|
29
|
+
|| (cp >= 0x20000 && cp <= 0x3fffd)
|
|
30
|
+
);
|
|
31
|
+
};
|
|
32
|
+
const charWidth = (ch) => {
|
|
33
|
+
const cp = ch.codePointAt(0);
|
|
34
|
+
if (/\p{Mark}/u.test(ch) || cp === 0x200d || (cp >= 0xfe00 && cp <= 0xfe0f)) return 0;
|
|
35
|
+
if (cp < 0x20 || (cp >= 0x7f && cp < 0xa0)) return 0;
|
|
36
|
+
return isWide(ch) ? 2 : 1;
|
|
37
|
+
};
|
|
38
|
+
const textWidth = (value) => Array.from(clean(value)).reduce((sum, ch) => sum + charWidth(ch), 0);
|
|
39
|
+
|
|
40
|
+
const fit = (value, width) => {
|
|
41
|
+
const text = clean(value);
|
|
42
|
+
if (width <= 0) return '';
|
|
43
|
+
if (textWidth(text) <= width) return text + ' '.repeat(width - textWidth(text));
|
|
44
|
+
let out = '';
|
|
45
|
+
let used = 0;
|
|
46
|
+
const limit = Math.max(1, width - 1);
|
|
47
|
+
for (const ch of Array.from(text)) {
|
|
48
|
+
const step = charWidth(ch);
|
|
49
|
+
if (used + step > limit) break;
|
|
50
|
+
out += ch;
|
|
51
|
+
used += step;
|
|
52
|
+
}
|
|
53
|
+
out += ELLIPSIS;
|
|
54
|
+
return out + ' '.repeat(Math.max(0, width - textWidth(out)));
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const idOf = (item) => item?.id || item?.conversation_id || '-';
|
|
58
|
+
const projectOf = (item) => item?.gizmo_id || item?.conversation_template_id || '-';
|
|
59
|
+
const titleOf = (item) => item?.title || '(untitled)';
|
|
60
|
+
const updatedOf = (item) => {
|
|
61
|
+
const raw = item?.update_time;
|
|
62
|
+
if (raw == null) return '-';
|
|
63
|
+
if (typeof raw === 'number') {
|
|
64
|
+
const milliseconds = raw < 10_000_000_000 ? raw * 1000 : raw;
|
|
65
|
+
const date = new Date(milliseconds);
|
|
66
|
+
if (!Number.isNaN(date.getTime())) return date.toISOString().replace('T', ' ').slice(0, 16);
|
|
67
|
+
}
|
|
68
|
+
return String(raw).replace('T', ' ').replace(/Z$/, '').slice(0, 16);
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const panelWidth = (state) => {
|
|
72
|
+
const terminal = Math.max(40, Number(state?.width) || 120);
|
|
73
|
+
return Math.min(MAX_PANEL_WIDTH, Math.max(Math.min(MIN_PANEL_WIDTH, terminal - 2), terminal - 6));
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const marginFor = (state, width) => ' '.repeat(Math.max(0, Math.floor(((Number(state?.width) || width) - width) / 2)));
|
|
77
|
+
const boxed = (margin, inner, width) => `${margin}${V} ${fit(inner, width - 4)} ${V}`;
|
|
78
|
+
const topBorder = (margin, width) => `${margin}${TL}${H.repeat(width - 2)}${TR}`;
|
|
79
|
+
const bottomBorder = (margin, width) => `${margin}${BL}${H.repeat(width - 2)}${BR}`;
|
|
80
|
+
|
|
81
|
+
exports.createState = (items = [], size = {}) => ({
|
|
82
|
+
items: Array.isArray(items) ? [...items] : [],
|
|
83
|
+
index: 0,
|
|
84
|
+
search: '',
|
|
85
|
+
searchMode: false,
|
|
86
|
+
view: 'list',
|
|
87
|
+
detailText: '',
|
|
88
|
+
detailOffset: 0,
|
|
89
|
+
status: '',
|
|
90
|
+
width: Math.max(40, Number(size.width) || 120),
|
|
91
|
+
height: Math.max(10, Number(size.height) || 30)
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
exports.visibleItems = (state) => {
|
|
95
|
+
const query = String(state?.search || '').trim().toLowerCase();
|
|
96
|
+
if (!query) return state.items;
|
|
97
|
+
return state.items.filter((item) => [idOf(item), titleOf(item), projectOf(item)]
|
|
98
|
+
.some((value) => String(value).toLowerCase().includes(query)));
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
exports.selected = (state) => {
|
|
102
|
+
const items = exports.visibleItems(state);
|
|
103
|
+
if (!items.length) return null;
|
|
104
|
+
return items[clamp(state.index, 0, items.length - 1)] || null;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
exports.reduce = (state, key = {}) => {
|
|
108
|
+
const next = { ...state };
|
|
109
|
+
if (next.view === 'detail') {
|
|
110
|
+
if (key.name === 'escape') { next.view = 'list'; next.detailText = ''; next.detailOffset = 0; }
|
|
111
|
+
else if (key.name === 'down' || key.sequence === 'j') next.detailOffset += 1;
|
|
112
|
+
else if (key.name === 'up' || key.sequence === 'k') next.detailOffset = Math.max(0, next.detailOffset - 1);
|
|
113
|
+
return next;
|
|
114
|
+
}
|
|
115
|
+
if (next.searchMode) {
|
|
116
|
+
if (key.name === 'escape' || key.name === 'return') next.searchMode = false;
|
|
117
|
+
else if (key.name === 'backspace') next.search = Array.from(next.search).slice(0, -1).join('');
|
|
118
|
+
else if (key.ctrl && key.name === 'u') next.search = '';
|
|
119
|
+
else if (!key.ctrl && !key.meta && typeof key.sequence === 'string' && key.sequence.length === 1 && key.sequence >= ' ') next.search += key.sequence;
|
|
120
|
+
next.index = 0;
|
|
121
|
+
return next;
|
|
122
|
+
}
|
|
123
|
+
const count = exports.visibleItems(next).length;
|
|
124
|
+
if (key.name === 'down' || key.sequence === 'j') next.index = clamp(next.index + 1, 0, Math.max(0, count - 1));
|
|
125
|
+
else if (key.name === 'up' || key.sequence === 'k') next.index = clamp(next.index - 1, 0, Math.max(0, count - 1));
|
|
126
|
+
else if (key.name === 'home' || (key.sequence === 'g' && !key.shift)) next.index = 0;
|
|
127
|
+
else if (key.name === 'end' || key.sequence === 'G' || (key.name === 'g' && key.shift)) next.index = Math.max(0, count - 1);
|
|
128
|
+
else if (key.sequence === '/') next.searchMode = true;
|
|
129
|
+
else if (key.name === 'escape' && next.search) { next.search = ''; next.index = 0; }
|
|
130
|
+
return next;
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
const layout = (state) => {
|
|
134
|
+
const width = panelWidth(state);
|
|
135
|
+
const inner = width - 4;
|
|
136
|
+
const fixed = 2 + 16 + 2 + 14 + 2 + 14 + 2;
|
|
137
|
+
const title = Math.max(18, inner - fixed);
|
|
138
|
+
return { width, inner, title, updated: 16, project: 14, id: 14 };
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const renderList = (state) => {
|
|
142
|
+
const items = exports.visibleItems(state);
|
|
143
|
+
const selectedIndex = items.length ? clamp(state.index, 0, items.length - 1) : 0;
|
|
144
|
+
const { width, inner, title, updated, project, id } = layout(state);
|
|
145
|
+
const margin = marginFor(state, width);
|
|
146
|
+
const fixedRows = 8 + (state.status ? 1 : 0);
|
|
147
|
+
const maxRows = Math.max(1, Math.min(14, state.height - fixedRows));
|
|
148
|
+
const start = clamp(selectedIndex - Math.floor(maxRows / 2), 0, Math.max(0, items.length - maxRows));
|
|
149
|
+
const end = Math.min(items.length, start + maxRows);
|
|
150
|
+
const countText = items.length === state.items.length ? `${items.length}` : `${items.length}/${state.items.length}`;
|
|
151
|
+
const header = `Zero ChatGPT \u00b7 Conversations ${countText}`;
|
|
152
|
+
const search = state.searchMode
|
|
153
|
+
? `\u203a ${state.search || 'Search conversations\u2026'}`
|
|
154
|
+
: state.search
|
|
155
|
+
? `Search: ${state.search}`
|
|
156
|
+
: 'Press / to search conversations';
|
|
157
|
+
|
|
158
|
+
const lines = [
|
|
159
|
+
topBorder(margin, width),
|
|
160
|
+
boxed(margin, header, width),
|
|
161
|
+
boxed(margin, search, width),
|
|
162
|
+
boxed(margin, H.repeat(inner), width),
|
|
163
|
+
boxed(margin, ` ${fit('Title', title)} ${fit('Updated', updated)} ${fit('Project', project)} ${fit('ID', id)}`, width)
|
|
164
|
+
];
|
|
165
|
+
|
|
166
|
+
if (!items.length) {
|
|
167
|
+
lines.push(boxed(margin, ' No conversations match the current search.', width));
|
|
168
|
+
} else {
|
|
169
|
+
for (let index = start; index < end; index += 1) {
|
|
170
|
+
const item = items[index];
|
|
171
|
+
const cursor = index === selectedIndex ? '>' : ' ';
|
|
172
|
+
const row = `${cursor} ${fit(titleOf(item), title)} ${fit(updatedOf(item), updated)} ${fit(projectOf(item), project)} ${fit(idOf(item), id)}`;
|
|
173
|
+
lines.push(boxed(margin, row, width));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (state.status) lines.push(boxed(margin, state.status, width));
|
|
178
|
+
lines.push(
|
|
179
|
+
boxed(margin, H.repeat(inner), width),
|
|
180
|
+
boxed(margin, '\u2191\u2193/jk Move Enter Open / Search r Refresh q Quit', width),
|
|
181
|
+
bottomBorder(margin, width)
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
const available = Math.max(0, state.height - lines.length);
|
|
185
|
+
const topPad = Math.min(3, Math.max(0, Math.floor(available / 3)));
|
|
186
|
+
return [...Array(topPad).fill(''), ...lines].join('\n');
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const renderDetail = (state) => {
|
|
190
|
+
const width = panelWidth(state);
|
|
191
|
+
const margin = marginFor(state, width);
|
|
192
|
+
const inner = width - 4;
|
|
193
|
+
const bodyRows = Math.max(1, Math.min(18, state.height - 6));
|
|
194
|
+
const source = String(state.detailText || '').split(/\r\n?/);
|
|
195
|
+
const maxOffset = Math.max(0, source.length - bodyRows);
|
|
196
|
+
const offset = clamp(state.detailOffset, 0, maxOffset);
|
|
197
|
+
const lines = [
|
|
198
|
+
topBorder(margin, width),
|
|
199
|
+
boxed(margin, `Zero ChatGPT \u00b7 Conversation${state.status ? ` \u00b7 ${state.status}` : ''}`, width),
|
|
200
|
+
boxed(margin, H.repeat(inner), width),
|
|
201
|
+
...source.slice(offset, offset + bodyRows).map((line) => boxed(margin, line, width)),
|
|
202
|
+
boxed(margin, H.repeat(inner), width),
|
|
203
|
+
boxed(margin, '\u2191\u2193/jk Scroll Esc Back q Quit', width),
|
|
204
|
+
bottomBorder(margin, width)
|
|
205
|
+
];
|
|
206
|
+
return lines.join('\n');
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
exports.render = (state) => state.view === 'detail' ? renderDetail(state) : renderList(state);
|
|
210
|
+
|
|
211
|
+
exports.run = ({
|
|
212
|
+
items = [], loadItems, openConversation, formatConversation,
|
|
213
|
+
input = process.stdin, output = process.stdout
|
|
214
|
+
} = {}) => {
|
|
215
|
+
let state = exports.createState(items, { width: output.columns, height: output.rows });
|
|
216
|
+
if (!input?.isTTY || !output?.isTTY) {
|
|
217
|
+
output.write(`${exports.render(state)}\n`);
|
|
218
|
+
return Promise.resolve(0);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
readline.emitKeypressEvents(input);
|
|
222
|
+
input.setRawMode?.(true);
|
|
223
|
+
input.resume?.();
|
|
224
|
+
output.write(`${ESC}?1049h${ESC}?25l`);
|
|
225
|
+
|
|
226
|
+
let busy = false;
|
|
227
|
+
let finished = false;
|
|
228
|
+
let resolveRun;
|
|
229
|
+
const result = new Promise((resolve) => { resolveRun = resolve; });
|
|
230
|
+
const syncSize = () => {
|
|
231
|
+
state.width = Math.max(40, Number(output.columns) || state.width);
|
|
232
|
+
state.height = Math.max(10, Number(output.rows) || state.height);
|
|
233
|
+
};
|
|
234
|
+
const draw = () => {
|
|
235
|
+
syncSize();
|
|
236
|
+
output.write(`${ESC}2J${ESC}H${exports.render(state)}`);
|
|
237
|
+
};
|
|
238
|
+
const onResize = () => { if (!finished) draw(); };
|
|
239
|
+
|
|
240
|
+
const finish = () => {
|
|
241
|
+
if (finished) return;
|
|
242
|
+
finished = true;
|
|
243
|
+
input.removeListener('keypress', onKey);
|
|
244
|
+
output.removeListener?.('resize', onResize);
|
|
245
|
+
input.setRawMode?.(false);
|
|
246
|
+
input.pause?.();
|
|
247
|
+
output.write(`${ESC}?25h${ESC}?1049l`);
|
|
248
|
+
resolveRun(0);
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const onKey = async (_value, key = {}) => {
|
|
252
|
+
if (finished) return;
|
|
253
|
+
if ((key.ctrl && key.name === 'c') || (!state.searchMode && (key.name === 'q' || key.sequence === 'q'))) return finish();
|
|
254
|
+
if (state.searchMode) { state = exports.reduce(state, key); draw(); return; }
|
|
255
|
+
|
|
256
|
+
if (key.name === 'r' || key.sequence === 'r') {
|
|
257
|
+
if (busy || typeof loadItems !== 'function') return;
|
|
258
|
+
busy = true;
|
|
259
|
+
state.status = 'Refreshing\u2026';
|
|
260
|
+
draw();
|
|
261
|
+
try {
|
|
262
|
+
const fresh = await loadItems();
|
|
263
|
+
const search = state.search;
|
|
264
|
+
state = exports.createState(fresh, { width: output.columns, height: output.rows });
|
|
265
|
+
state.search = search;
|
|
266
|
+
state.status = `Refreshed ${state.items.length}`;
|
|
267
|
+
} catch (error) {
|
|
268
|
+
state.status = `Refresh failed: ${error?.code || error?.message || error}`;
|
|
269
|
+
} finally { busy = false; draw(); }
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if ((key.name === 'return' || key.name === 'enter') && state.view === 'list') {
|
|
274
|
+
const item = exports.selected(state);
|
|
275
|
+
if (!item || busy || typeof openConversation !== 'function') return;
|
|
276
|
+
busy = true;
|
|
277
|
+
state.status = 'Opening\u2026';
|
|
278
|
+
draw();
|
|
279
|
+
try {
|
|
280
|
+
const conversation = await openConversation(idOf(item));
|
|
281
|
+
state.view = 'detail';
|
|
282
|
+
state.detailText = typeof formatConversation === 'function' ? formatConversation(conversation) : JSON.stringify(conversation, null, 2);
|
|
283
|
+
state.detailOffset = 0;
|
|
284
|
+
state.status = '';
|
|
285
|
+
} catch (error) {
|
|
286
|
+
state.status = `Open failed: ${error?.code || error?.message || error}`;
|
|
287
|
+
} finally { busy = false; draw(); }
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
state = exports.reduce(state, key);
|
|
292
|
+
state.width = Math.max(40, Number(output.columns) || state.width);
|
|
293
|
+
state.height = Math.max(10, Number(output.rows) || state.height);
|
|
294
|
+
draw();
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
input.on('keypress', onKey);
|
|
298
|
+
output.on?.('resize', onResize);
|
|
299
|
+
draw();
|
|
300
|
+
return result;
|
|
301
|
+
};
|
package/src/cli/formatter.js
CHANGED
|
@@ -108,4 +108,42 @@ exports.markdown = (conversation, options = {}) => {
|
|
|
108
108
|
return `${lines.join('\n').trimEnd()}\n`;
|
|
109
109
|
};
|
|
110
110
|
|
|
111
|
-
exports.json = (conversation) => `${JSON.stringify(conversation, null, 2)}\n`;
|
|
111
|
+
exports.json = (conversation) => `${JSON.stringify(conversation, null, 2)}\n`;
|
|
112
|
+
const terminalWidth = (value) => Array.from(String(value ?? '')).reduce((width, ch) => width + (/\p{Mark}/u.test(ch) ? 0 : 1), 0);
|
|
113
|
+
const fitTerminalCell = (value, width) => {
|
|
114
|
+
const text = String(value ?? '-').replace(/[\r\n\t]+/g, ' ');
|
|
115
|
+
let out = '';
|
|
116
|
+
let used = 0;
|
|
117
|
+
for (const ch of Array.from(text)) {
|
|
118
|
+
const step = /\p{Mark}/u.test(ch) ? 0 : 1;
|
|
119
|
+
if (used + step > width) break;
|
|
120
|
+
out += ch;
|
|
121
|
+
used += step;
|
|
122
|
+
}
|
|
123
|
+
if (terminalWidth(text) > width && width > 1) {
|
|
124
|
+
while (terminalWidth(out) >= width) out = Array.from(out).slice(0, -1).join('');
|
|
125
|
+
out += '\u2026';
|
|
126
|
+
used = terminalWidth(out);
|
|
127
|
+
}
|
|
128
|
+
return out + ' '.repeat(Math.max(0, width - used));
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
exports.conversationsTable = (items = []) => {
|
|
132
|
+
const rows = Array.isArray(items) ? items : [];
|
|
133
|
+
const widths = [36, 38, 20, 18];
|
|
134
|
+
const top = '\u250c' + widths.map((w) => '\u2500'.repeat(w + 2)).join('\u252c') + '\u2510';
|
|
135
|
+
const mid = '\u251c' + widths.map((w) => '\u2500'.repeat(w + 2)).join('\u253c') + '\u2524';
|
|
136
|
+
const bottom = '\u2514' + widths.map((w) => '\u2500'.repeat(w + 2)).join('\u2534') + '\u2518';
|
|
137
|
+
const row = (cells) => '\u2502 ' + cells.map((cell, i) => fitTerminalCell(cell, widths[i])).join(' \u2502 ') + ' \u2502';
|
|
138
|
+
const lines = [`Conversations (${rows.length})`, top, row(['ID', 'Title', 'Updated', 'Project']), mid];
|
|
139
|
+
for (const item of rows) {
|
|
140
|
+
lines.push(row([
|
|
141
|
+
item?.id || item?.conversation_id || '-',
|
|
142
|
+
item?.title || '(untitled)',
|
|
143
|
+
item?.update_time ?? '-',
|
|
144
|
+
item?.gizmo_id || item?.conversation_template_id || '-'
|
|
145
|
+
]));
|
|
146
|
+
}
|
|
147
|
+
lines.push(bottom);
|
|
148
|
+
return `${lines.join('\n')}\n`;
|
|
149
|
+
};
|