@thegitai/cli 1.0.0-preview.5 → 1.0.0-preview.51

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.
Files changed (76) hide show
  1. package/README.md +93 -4
  2. package/dist/bin/ai.js +142 -112
  3. package/dist/bin/browser-host.js +265 -0
  4. package/dist/src/agent-mode.js +32 -7
  5. package/dist/src/api/auth.js +10 -4
  6. package/dist/src/api/browser-login.js +149 -37
  7. package/dist/src/api/chat.js +680 -166
  8. package/dist/src/api/contracts.js +55 -1
  9. package/dist/src/api/default-host.js +1 -0
  10. package/dist/src/api/http.js +151 -7
  11. package/dist/src/api/models.js +29 -10
  12. package/dist/src/api/startup-handshake.js +36 -0
  13. package/dist/src/api/turn-recovery.js +189 -0
  14. package/dist/src/background-jobs.js +4 -2
  15. package/dist/src/browser/bridge.js +262 -0
  16. package/dist/src/browser/framing.js +42 -0
  17. package/dist/src/browser/native-host.js +219 -0
  18. package/dist/src/browser/protocol.js +46 -0
  19. package/dist/src/browser/session-bridge.js +130 -0
  20. package/dist/src/cli-args.js +19 -5
  21. package/dist/src/client-environment.js +2 -0
  22. package/dist/src/core/clipboard.js +7 -13
  23. package/dist/src/core/image-limits.js +56 -0
  24. package/dist/src/core/image-path-extractor.js +70 -3
  25. package/dist/src/core/session-image-store.js +246 -0
  26. package/dist/src/edit-journal.js +33 -2
  27. package/dist/src/executor.js +25 -3
  28. package/dist/src/help-text.js +112 -21
  29. package/dist/src/patcher.js +43 -4
  30. package/dist/src/permissions.js +288 -0
  31. package/dist/src/preferences.js +39 -0
  32. package/dist/src/quota-notice.js +35 -0
  33. package/dist/src/session-safety.js +0 -12
  34. package/dist/src/session-store.js +121 -20
  35. package/dist/src/session.js +19 -4
  36. package/dist/src/signin.js +58 -0
  37. package/dist/src/tool-executor.js +13 -46
  38. package/dist/src/tools/browser.js +641 -0
  39. package/dist/src/tools/delete-file.js +20 -4
  40. package/dist/src/tools/index.js +32 -10
  41. package/dist/src/tools/patch-file.js +12 -26
  42. package/dist/src/tools/read-file.js +5 -0
  43. package/dist/src/tools/read-image-file.js +85 -0
  44. package/dist/src/tools/replace-document-text.js +28 -18
  45. package/dist/src/tools/restore-checkpoint.js +0 -1
  46. package/dist/src/tools/run-command.js +14 -71
  47. package/dist/src/tools/run-node-script.js +12 -81
  48. package/dist/src/tools/save-generated-image.js +120 -0
  49. package/dist/src/tools/str-replace.js +12 -26
  50. package/dist/src/tools/undo-edit.js +1 -6
  51. package/dist/src/tools/write-file.js +67 -11
  52. package/dist/src/ui/prompt-history-store.js +1 -1
  53. package/dist/src/ui/repl.js +949 -279
  54. package/dist/src/ui/slash-commands.js +121 -0
  55. package/dist/src/ui/tui/attention.js +25 -0
  56. package/dist/src/ui/tui/bridge.js +38 -5
  57. package/dist/src/ui/tui/build-frame.js +686 -223
  58. package/dist/src/ui/tui/composer-layout.js +76 -0
  59. package/dist/src/ui/tui/desktop-notification.js +118 -0
  60. package/dist/src/ui/tui/markdown-render.js +11 -2
  61. package/dist/src/ui/tui/shell-input.js +256 -73
  62. package/dist/src/ui/tui/shortcuts.js +61 -0
  63. package/dist/src/ui/tui/terminal-theme.js +28 -0
  64. package/dist/src/ui/tui/user-input.js +593 -0
  65. package/dist/src/update-check.js +205 -0
  66. package/dist/src/usage.js +5 -2
  67. package/dist/src/utils.js +13 -0
  68. package/dist/src/version.js +52 -2
  69. package/package.json +22 -9
  70. package/dist/src/project-index.js +0 -233
  71. package/dist/src/tools/code-intel.js +0 -472
  72. package/dist/src/tools/find-symbol.js +0 -70
  73. package/dist/src/tools/hover-symbol.js +0 -95
  74. package/dist/src/tools/list-symbols.js +0 -55
  75. package/dist/src/tools/search-code.js +0 -37
  76. package/dist/src/tools/signature-help.js +0 -118
@@ -0,0 +1,130 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { getClientStateDir } from '../client-state.js';
5
+ import { pruneStaleSessions, startBrowserBridge, } from './bridge.js';
6
+ import { chromiumProcessRunning } from './native-host.js';
7
+ let bridge = null;
8
+ let recentEvents = [];
9
+ const processKey = `${process.pid.toString(36)}${crypto.randomBytes(3).toString('hex')}`;
10
+ export function ensureBrowserBridge(options) {
11
+ if (bridge)
12
+ return bridge;
13
+ const env = options.env ?? process.env;
14
+ pruneStaleSessions(env);
15
+ bridge = startBrowserBridge({
16
+ sessionKey: processKey,
17
+ label: path.basename(options.cwd) || 'TheGitAI',
18
+ cwd: options.cwd,
19
+ env,
20
+ });
21
+ bridge.onEvent((event, data) => {
22
+ if (event === 'connected')
23
+ rememberBrowserExtensionSeen(env);
24
+ recentEvents.push({ event, data, at: Date.now() });
25
+ if (recentEvents.length > 50)
26
+ recentEvents.splice(0, recentEvents.length - 50);
27
+ });
28
+ return bridge;
29
+ }
30
+ function browserExtensionMarkerPath(env) {
31
+ return path.join(getClientStateDir(env), 'browser-extension-seen');
32
+ }
33
+ function rememberBrowserExtensionSeen(env) {
34
+ try {
35
+ const marker = browserExtensionMarkerPath(env);
36
+ if (fs.existsSync(marker))
37
+ return;
38
+ fs.mkdirSync(path.dirname(marker), { recursive: true });
39
+ fs.writeFileSync(marker, `${new Date().toISOString()}\n`);
40
+ }
41
+ catch {
42
+ }
43
+ }
44
+ export function browserExtensionSeen(env = process.env) {
45
+ try {
46
+ return fs.existsSync(browserExtensionMarkerPath(env));
47
+ }
48
+ catch {
49
+ return true;
50
+ }
51
+ }
52
+ export function startBrowserDiscoveryInBackground(options) {
53
+ setImmediate(() => {
54
+ try {
55
+ ensureBrowserBridge(options);
56
+ }
57
+ catch {
58
+ }
59
+ });
60
+ }
61
+ export function currentBrowserBridge() {
62
+ return bridge;
63
+ }
64
+ export function releaseBrowserAfterTurn() {
65
+ const active = bridge;
66
+ if (!active?.status().connected)
67
+ return;
68
+ void active
69
+ .request('release', {}, { timeoutMs: 5000 })
70
+ .catch(() => {
71
+ });
72
+ }
73
+ export function closeBrowserBridge() {
74
+ bridge?.close();
75
+ bridge = null;
76
+ recentEvents = [];
77
+ }
78
+ export function drainBrowserEvents() {
79
+ const events = recentEvents.map(({ event, data }) => ({ event, data }));
80
+ recentEvents = [];
81
+ return events;
82
+ }
83
+ export function browserAvailability(options) {
84
+ const active = ensureBrowserBridge(options);
85
+ const status = active.status();
86
+ if (status.connected) {
87
+ return {
88
+ connected: true,
89
+ everConnected: true,
90
+ browsers: status.browsers,
91
+ selected: status.selected,
92
+ };
93
+ }
94
+ const hint = connectionHint({
95
+ registrationError: status.registrationErrors[0] ?? null,
96
+ everConnected: status.everConnected,
97
+ chromeRunning: () => chromiumProcessRunning(),
98
+ extensionSeen: () => browserExtensionSeen(options.env),
99
+ });
100
+ return {
101
+ connected: false,
102
+ everConnected: status.everConnected,
103
+ browsers: [],
104
+ selected: null,
105
+ hint,
106
+ };
107
+ }
108
+ export function connectionHint(state) {
109
+ if (state.registrationError) {
110
+ return `TheGitAI could not register its browser bridge with Chrome: ${state.registrationError}`;
111
+ }
112
+ if (state.everConnected) {
113
+ return 'The browser was connected to this session but is disconnected now. Check Chrome and the connection status shown by the TheGitAI extension.';
114
+ }
115
+ if (!state.chromeRunning()) {
116
+ return 'Chrome does not appear to be running. Open Chrome with the TheGitAI extension enabled and check its connection status.';
117
+ }
118
+ if (state.extensionSeen()) {
119
+ return 'The TheGitAI extension has connected on this machine before, but has not connected to this session yet. Check the connection status shown by the extension.';
120
+ }
121
+ return 'Chrome is running, but no browser connection reached this session. Check that the TheGitAI extension is installed and enabled, and inspect its connection status.';
122
+ }
123
+ export function resyncBrowserBridge(options) {
124
+ try {
125
+ return ensureBrowserBridge(options).resync();
126
+ }
127
+ catch {
128
+ return false;
129
+ }
130
+ }
@@ -1,9 +1,24 @@
1
1
  export const AUTH_COMMANDS = new Set(['login', 'whoami', 'logout']);
2
+ function hasPromptWordsAfter(args) {
3
+ for (let i = 1; i < args.length; i++) {
4
+ const arg = args[i];
5
+ if (arg === '--session' || arg === '--resume') {
6
+ i += 1;
7
+ continue;
8
+ }
9
+ if (arg.startsWith('-'))
10
+ continue;
11
+ return true;
12
+ }
13
+ return false;
14
+ }
2
15
  export function parseArgs(argv) {
3
16
  const args = argv.slice(2);
4
17
  const firstArg = args[0];
5
- const command = firstArg && AUTH_COMMANDS.has(firstArg) ? firstArg : null;
6
- const commandArgs = command ? args.slice(1) : [];
18
+ const command = firstArg && AUTH_COMMANDS.has(firstArg) &&
19
+ (firstArg !== 'login' || !hasPromptWordsAfter(args))
20
+ ? firstArg
21
+ : null;
7
22
  let autoYes = false;
8
23
  let help = false;
9
24
  let version = false;
@@ -12,7 +27,7 @@ export function parseArgs(argv) {
12
27
  let listSessions = false;
13
28
  let unknownOption = null;
14
29
  const promptParts = [];
15
- for (let i = 0; i < args.length; i++) {
30
+ for (let i = command ? 1 : 0; i < args.length; i++) {
16
31
  const arg = args[i];
17
32
  if (arg === '--yes' || arg === '-y') {
18
33
  autoYes = true;
@@ -39,7 +54,7 @@ export function parseArgs(argv) {
39
54
  usage = true;
40
55
  continue;
41
56
  }
42
- if (command === null && unknownOption === null && /^-/.test(arg)) {
57
+ if (unknownOption === null && /^-/.test(arg)) {
43
58
  unknownOption = arg;
44
59
  continue;
45
60
  }
@@ -47,7 +62,6 @@ export function parseArgs(argv) {
47
62
  }
48
63
  return {
49
64
  command,
50
- commandArgs,
51
65
  autoYes,
52
66
  help,
53
67
  version,
@@ -2,6 +2,7 @@ import { accessSync, constants, readFileSync } from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { ensureSessionScratchDir } from './scratch-dir.js';
5
+ import { browserExtensionSeen } from './browser/session-bridge.js';
5
6
  const PACKAGE_MANAGER_CANDIDATES = [
6
7
  'apt',
7
8
  'apt-get',
@@ -125,5 +126,6 @@ export function collectClientEnvironment(options = {}) {
125
126
  ...linuxDistro,
126
127
  packageManagers: detectPackageManagers(env, platform, executableExists),
127
128
  scratchDir: options.scratchDir ?? ensureSessionScratchDir(),
129
+ browserExtensionSeen: options.browserExtensionSeen ?? browserExtensionSeen(env),
128
130
  };
129
131
  }
@@ -1,15 +1,7 @@
1
1
  import { execFileSync } from 'node:child_process';
2
2
  import { existsSync, readFileSync, statSync } from 'node:fs';
3
3
  import path from 'node:path';
4
- const MAX_IMAGE_SIZE_BYTES = 10 * 1024 * 1024;
5
- const MIME_BY_EXT = {
6
- '.png': 'image/png',
7
- '.jpg': 'image/jpeg',
8
- '.jpeg': 'image/jpeg',
9
- '.gif': 'image/gif',
10
- '.webp': 'image/webp',
11
- };
12
- const SUPPORTED_MIME_TYPES = new Set(Object.values(MIME_BY_EXT));
4
+ import { MAX_IMAGE_SIZE_BYTES, SUPPORTED_IMAGE_MIME_TYPES as SUPPORTED_MIME_TYPES, sniffImageMimeType, } from './image-limits.js';
13
5
  export class ClipboardError extends Error {
14
6
  code;
15
7
  constructor(message, code) {
@@ -273,11 +265,13 @@ export function loadImageFromFile(filePath) {
273
265
  if (stat.size > MAX_IMAGE_SIZE_BYTES) {
274
266
  throw new ClipboardError(`Image file exceeds 10MB limit (${(stat.size / 1024 / 1024).toFixed(1)}MB): ${resolved}`, 'READ_FAILED');
275
267
  }
276
- const ext = path.extname(resolved).toLowerCase();
277
- const mimeType = MIME_BY_EXT[ext];
268
+ const buf = readFileSync(resolved);
269
+ const mimeType = sniffImageMimeType(buf);
278
270
  if (!mimeType) {
279
- throw new ClipboardError(`Unsupported image format "${ext}". Supported: PNG, JPEG, GIF, WebP.`, 'READ_FAILED');
271
+ const ext = path.extname(resolved).toLowerCase();
272
+ throw new ClipboardError(ext
273
+ ? `"${path.basename(resolved)}" is named ${ext} but its contents are not a supported image. Supported: PNG, JPEG, GIF, WebP.`
274
+ : `Not a supported image file: ${resolved}. Supported: PNG, JPEG, GIF, WebP.`, 'READ_FAILED');
280
275
  }
281
- const buf = readFileSync(resolved);
282
276
  return { base64Data: buf.toString('base64'), mimeType };
283
277
  }
@@ -0,0 +1,56 @@
1
+ export const MAX_IMAGES_PER_MESSAGE = 5;
2
+ export const MAX_IMAGE_SIZE_BYTES = 10 * 1024 * 1024;
3
+ export const MAX_TOTAL_IMAGE_BYTES_PER_MESSAGE = 20 * 1024 * 1024;
4
+ export function approximateBase64DecodedBytes(base64) {
5
+ const padding = base64.endsWith('==') ? 2 : base64.endsWith('=') ? 1 : 0;
6
+ return Math.max(0, Math.floor((base64.length * 3) / 4) - padding);
7
+ }
8
+ export function totalAttachmentBytes(attachments) {
9
+ return attachments.reduce((sum, attachment) => sum + approximateBase64DecodedBytes(attachment.base64Data), 0);
10
+ }
11
+ export const SUPPORTED_IMAGE_MIME_TYPES = new Set([
12
+ 'image/png',
13
+ 'image/jpeg',
14
+ 'image/gif',
15
+ 'image/webp',
16
+ ]);
17
+ export const IMAGE_MIME_BY_EXT = {
18
+ '.png': 'image/png',
19
+ '.jpg': 'image/jpeg',
20
+ '.jpeg': 'image/jpeg',
21
+ '.gif': 'image/gif',
22
+ '.webp': 'image/webp',
23
+ };
24
+ export const IMAGE_EXT_BY_MIME = {
25
+ 'image/png': '.png',
26
+ 'image/jpeg': '.jpg',
27
+ 'image/gif': '.gif',
28
+ 'image/webp': '.webp',
29
+ };
30
+ export function isSupportedImageMimeType(mime) {
31
+ return SUPPORTED_IMAGE_MIME_TYPES.has(mime);
32
+ }
33
+ export function sniffImageMimeType(bytes) {
34
+ if (bytes.length < 12)
35
+ return null;
36
+ if (bytes[0] === 0x89 &&
37
+ bytes[1] === 0x50 &&
38
+ bytes[2] === 0x4e &&
39
+ bytes[3] === 0x47) {
40
+ return 'image/png';
41
+ }
42
+ if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
43
+ return 'image/jpeg';
44
+ }
45
+ if (bytes.subarray(0, 3).toString('latin1') === 'GIF') {
46
+ return 'image/gif';
47
+ }
48
+ if (bytes.subarray(0, 4).toString('latin1') === 'RIFF' &&
49
+ bytes.subarray(8, 12).toString('latin1') === 'WEBP') {
50
+ return 'image/webp';
51
+ }
52
+ return null;
53
+ }
54
+ export function imageExtensionForMime(mime) {
55
+ return IMAGE_EXT_BY_MIME[mime] ?? '.png';
56
+ }
@@ -1,7 +1,9 @@
1
- import { existsSync, statSync } from 'node:fs';
1
+ import { closeSync, existsSync, openSync, readSync, statSync } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { loadImageFromFile } from './clipboard.js';
5
+ import { MAX_IMAGES_PER_MESSAGE, MAX_IMAGE_SIZE_BYTES, MAX_TOTAL_IMAGE_BYTES_PER_MESSAGE, approximateBase64DecodedBytes, sniffImageMimeType, totalAttachmentBytes, } from './image-limits.js';
6
+ import { tryCacheAttachmentBytes } from './session-image-store.js';
5
7
  const EXT = '(?:png|jpe?g|gif|webp)';
6
8
  const BARE_CHAR = "[^\\s\"'<>,:;!?()\\[\\]{}]";
7
9
  const BARE_PATH = `(?:[A-Za-z]:[\\\\/])?(?:\\\\ |${BARE_CHAR})+\\.${EXT}`;
@@ -110,13 +112,67 @@ function detectImagePaths(input, cwd) {
110
112
  }
111
113
  return rawsByPath;
112
114
  }
115
+ const EXTENSIONLESS_CANDIDATE = new RegExp(`"([^"]*[\\\\/][^"]*)"` +
116
+ `|'([^']*[\\\\/][^']*)'` +
117
+ `|((?:[A-Za-z]:[\\\\/])?(?:\\\\ |${BARE_CHAR})*[\\\\/](?:\\\\ |${BARE_CHAR})+)`, 'g');
118
+ function sniffFileHeader(resolvedPath) {
119
+ let fd = null;
120
+ try {
121
+ const stat = statSync(resolvedPath, { throwIfNoEntry: false });
122
+ if (!stat?.isFile() || stat.size > MAX_IMAGE_SIZE_BYTES)
123
+ return null;
124
+ fd = openSync(resolvedPath, 'r');
125
+ const header = Buffer.alloc(12);
126
+ const read = readSync(fd, header, 0, 12, 0);
127
+ return read === 12 ? header : null;
128
+ }
129
+ catch {
130
+ return null;
131
+ }
132
+ finally {
133
+ if (fd !== null) {
134
+ try {
135
+ closeSync(fd);
136
+ }
137
+ catch {
138
+ }
139
+ }
140
+ }
141
+ }
142
+ function detectExtensionlessImagePaths(input, cwd, alreadyDetected) {
143
+ const found = new Map();
144
+ const regex = new RegExp(EXTENSIONLESS_CANDIDATE.source, EXTENSIONLESS_CANDIDATE.flags);
145
+ let match;
146
+ while ((match = regex.exec(input)) !== null) {
147
+ const raw = match[0];
148
+ const inner = (match[1] ?? match[2] ?? match[3] ?? '').replace(/\\ /g, ' ');
149
+ if (!inner || inner.includes('://'))
150
+ continue;
151
+ if (/\.(?:png|jpe?g|gif|webp)$/i.test(inner))
152
+ continue;
153
+ const resolvedPath = path.isAbsolute(inner)
154
+ ? inner
155
+ : path.resolve(cwd, inner);
156
+ if (alreadyDetected.has(resolvedPath) || found.has(resolvedPath))
157
+ continue;
158
+ const header = sniffFileHeader(resolvedPath);
159
+ if (!header || !sniffImageMimeType(header))
160
+ continue;
161
+ found.set(resolvedPath, [raw]);
162
+ }
163
+ return found;
164
+ }
113
165
  export function autoAttachImages(input, cwd, existing = []) {
114
- const max = 2;
166
+ const max = MAX_IMAGES_PER_MESSAGE;
115
167
  const rawsByPath = detectImagePaths(input, cwd);
168
+ for (const [resolvedPath, rawForms] of detectExtensionlessImagePaths(input, cwd, new Set(rawsByPath.keys()))) {
169
+ rawsByPath.set(resolvedPath, rawForms);
170
+ }
116
171
  let sanitizedInput = input;
117
172
  const attachments = [];
118
173
  const errors = [];
119
174
  const maxExistingIndex = existing.reduce((highest, a) => Math.max(highest, a.index ?? 0), 0);
175
+ let budgetUsed = totalAttachmentBytes(existing);
120
176
  for (const [resolvedPath, rawForms] of rawsByPath) {
121
177
  if (existing.length + attachments.length >= max)
122
178
  break;
@@ -124,13 +180,24 @@ export function autoAttachImages(input, cwd, existing = []) {
124
180
  continue;
125
181
  try {
126
182
  const loaded = loadImageFromFile(resolvedPath);
127
- const idx = maxExistingIndex + attachments.length + 1;
183
+ const bytes = approximateBase64DecodedBytes(loaded.base64Data);
184
+ if (budgetUsed + bytes > MAX_TOTAL_IMAGE_BYTES_PER_MESSAGE) {
185
+ errors.push(`${path.basename(resolvedPath)} was not attached: it would put this message over the ${Math.round(MAX_TOTAL_IMAGE_BYTES_PER_MESSAGE / 1024 / 1024)}MB combined image limit.`);
186
+ continue;
187
+ }
188
+ budgetUsed += bytes;
189
+ const cached = tryCacheAttachmentBytes({
190
+ base64Data: loaded.base64Data,
191
+ mimeType: loaded.mimeType,
192
+ });
193
+ const idx = cached?.index ?? maxExistingIndex + attachments.length + 1;
128
194
  attachments.push({
129
195
  index: idx,
130
196
  mimeType: loaded.mimeType,
131
197
  base64Data: loaded.base64Data,
132
198
  source: 'file',
133
199
  filePath: resolvedPath,
200
+ ...(cached ? { cachePath: cached.cachePath } : {}),
134
201
  });
135
202
  for (const raw of rawForms) {
136
203
  sanitizedInput = sanitizedInput.replace(raw, `[Image #${idx}]`);
@@ -0,0 +1,246 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync, } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createHash } from 'node:crypto';
4
+ import { getClientStateDir } from '../client-state.js';
5
+ import { MAX_IMAGE_SIZE_BYTES, imageExtensionForMime, isSupportedImageMimeType, sniffImageMimeType, } from './image-limits.js';
6
+ const IMAGE_FILE_MODE = 0o600;
7
+ const IMAGE_DIR_MODE = 0o700;
8
+ export const SESSION_IMAGE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
9
+ let activeSessionId = null;
10
+ export function setImageStoreSession(sessionId) {
11
+ activeSessionId = String(sessionId ?? '').trim() || null;
12
+ contentIndex.clear();
13
+ }
14
+ const contentIndex = new Map();
15
+ function imageContentKey(base64Data) {
16
+ return createHash('sha256').update(base64Data).digest('hex');
17
+ }
18
+ function rememberStoredImage(base64Data, cachePath) {
19
+ contentIndex.set(imageContentKey(base64Data), cachePath);
20
+ }
21
+ export function findStoredImageByContent(base64Data) {
22
+ const cachePath = contentIndex.get(imageContentKey(base64Data));
23
+ if (!cachePath)
24
+ return undefined;
25
+ if (!existsSync(cachePath)) {
26
+ contentIndex.delete(imageContentKey(base64Data));
27
+ return undefined;
28
+ }
29
+ return cachePath;
30
+ }
31
+ export function getImageStoreSession() {
32
+ return activeSessionId;
33
+ }
34
+ function safeSessionDirName(sessionId) {
35
+ const normalized = String(sessionId ?? '').trim();
36
+ if (!/^[a-zA-Z0-9_-]+$/.test(normalized)) {
37
+ throw new Error(`Invalid session id "${sessionId}".`);
38
+ }
39
+ return normalized;
40
+ }
41
+ export function getSessionImageBaseDir(env = process.env) {
42
+ return path.join(getClientStateDir(env), 'sessions', 'images');
43
+ }
44
+ export function getSessionImageDir(sessionId, env = process.env) {
45
+ return path.join(getSessionImageBaseDir(env), safeSessionDirName(sessionId));
46
+ }
47
+ function nextImageNumber(dir) {
48
+ if (!existsSync(dir))
49
+ return 1;
50
+ let highest = 0;
51
+ for (const name of readdirSync(dir)) {
52
+ const parsed = Number.parseInt(path.basename(name, path.extname(name)), 10);
53
+ if (Number.isInteger(parsed) && parsed > highest)
54
+ highest = parsed;
55
+ }
56
+ return highest + 1;
57
+ }
58
+ function ensureSessionImageDir(sessionId, env) {
59
+ const dir = getSessionImageDir(sessionId, env);
60
+ mkdirSync(dir, { recursive: true, mode: IMAGE_DIR_MODE });
61
+ return dir;
62
+ }
63
+ export function storeSessionImageBytes({ sessionId, base64Data, mimeType, env = process.env, }) {
64
+ const dir = ensureSessionImageDir(sessionId, env);
65
+ const index = nextImageNumber(dir);
66
+ const cachePath = path.join(dir, `${index}${imageExtensionForMime(mimeType)}`);
67
+ writeFileSync(cachePath, Buffer.from(base64Data, 'base64'), {
68
+ mode: IMAGE_FILE_MODE,
69
+ });
70
+ rememberStoredImage(base64Data, cachePath);
71
+ return { cachePath, mimeType, base64Data, index };
72
+ }
73
+ export function tryCacheAttachmentBytes({ base64Data, mimeType, env = process.env, }) {
74
+ if (!activeSessionId)
75
+ return undefined;
76
+ try {
77
+ const stored = storeSessionImageBytes({
78
+ sessionId: activeSessionId,
79
+ base64Data,
80
+ mimeType,
81
+ env,
82
+ });
83
+ return { cachePath: stored.cachePath, index: stored.index };
84
+ }
85
+ catch {
86
+ return undefined;
87
+ }
88
+ }
89
+ export function isSessionStorePath(candidate, env = process.env) {
90
+ if (!activeSessionId)
91
+ return false;
92
+ const dir = getSessionImageDir(activeSessionId, env);
93
+ const resolved = path.resolve(candidate);
94
+ return (resolved.startsWith(dir + path.sep) && path.dirname(resolved) === dir);
95
+ }
96
+ export function readSessionImageByIndex(index, env = process.env) {
97
+ if (!activeSessionId || !Number.isInteger(index) || index < 1)
98
+ return null;
99
+ const dir = getSessionImageDir(activeSessionId, env);
100
+ if (!existsSync(dir))
101
+ return null;
102
+ try {
103
+ for (const name of readdirSync(dir)) {
104
+ if (Number.parseInt(path.basename(name, path.extname(name)), 10) === index) {
105
+ return readSessionImage(path.join(dir, name));
106
+ }
107
+ }
108
+ }
109
+ catch {
110
+ return null;
111
+ }
112
+ return null;
113
+ }
114
+ export function describeSessionImageStore(env = process.env) {
115
+ if (!activeSessionId)
116
+ return null;
117
+ const dir = getSessionImageDir(activeSessionId, env);
118
+ if (!existsSync(dir))
119
+ return null;
120
+ const indices = [];
121
+ try {
122
+ for (const name of readdirSync(dir)) {
123
+ const parsed = Number.parseInt(path.basename(name, path.extname(name)), 10);
124
+ if (Number.isInteger(parsed) && parsed >= 1)
125
+ indices.push(parsed);
126
+ }
127
+ }
128
+ catch {
129
+ return null;
130
+ }
131
+ if (indices.length === 0)
132
+ return null;
133
+ return { dir, indices: indices.sort((a, b) => a - b) };
134
+ }
135
+ const MAX_STORE_DIR_CHARS = 512;
136
+ const MAX_STORE_INDICES = 200;
137
+ const STORE_CONTROL_CHARS = /[\u0000-\u001f\u007f]/g;
138
+ export function normalizeSessionImageStore(value) {
139
+ if (!value || typeof value !== 'object')
140
+ return null;
141
+ const record = value;
142
+ const dir = String(record.dir ?? '')
143
+ .replace(STORE_CONTROL_CHARS, ' ')
144
+ .trim()
145
+ .slice(0, MAX_STORE_DIR_CHARS);
146
+ if (!dir)
147
+ return null;
148
+ const seen = new Set();
149
+ for (const raw of Array.isArray(record.indices) ? record.indices : []) {
150
+ const parsed = Number(raw);
151
+ if (!Number.isInteger(parsed) || parsed < 1)
152
+ continue;
153
+ seen.add(parsed);
154
+ if (seen.size >= MAX_STORE_INDICES)
155
+ break;
156
+ }
157
+ if (seen.size === 0)
158
+ return null;
159
+ return { dir, indices: [...seen].sort((a, b) => a - b) };
160
+ }
161
+ export class SessionImageError extends Error {
162
+ code;
163
+ constructor(message, code) {
164
+ super(message);
165
+ this.code = code;
166
+ this.name = 'SessionImageError';
167
+ }
168
+ }
169
+ export function storeSessionImageFromPath({ sessionId, sourcePath, env = process.env, }) {
170
+ const resolved = path.resolve(sourcePath);
171
+ if (!existsSync(resolved) || !statSync(resolved).isFile()) {
172
+ throw new SessionImageError(`Image file not found: ${resolved}`, 'NOT_FOUND');
173
+ }
174
+ const size = statSync(resolved).size;
175
+ if (size > MAX_IMAGE_SIZE_BYTES) {
176
+ throw new SessionImageError(`Image file exceeds ${Math.round(MAX_IMAGE_SIZE_BYTES / 1024 / 1024)}MB limit (${(size / 1024 / 1024).toFixed(1)}MB): ${resolved}`, 'TOO_LARGE');
177
+ }
178
+ const bytes = readFileSync(resolved);
179
+ const sniffed = sniffImageMimeType(bytes);
180
+ if (!sniffed || !isSupportedImageMimeType(sniffed)) {
181
+ throw new SessionImageError(`Not a supported image file: ${resolved}. Supported: PNG, JPEG, GIF, WebP.`, 'UNSUPPORTED');
182
+ }
183
+ const dir = ensureSessionImageDir(sessionId, env);
184
+ const index = nextImageNumber(dir);
185
+ const cachePath = path.join(dir, `${index}${imageExtensionForMime(sniffed)}`);
186
+ writeFileSync(cachePath, bytes, { mode: IMAGE_FILE_MODE });
187
+ const base64Data = bytes.toString('base64');
188
+ rememberStoredImage(base64Data, cachePath);
189
+ return { cachePath, mimeType: sniffed, base64Data, index };
190
+ }
191
+ export function readSessionImage(cachePath) {
192
+ try {
193
+ if (!existsSync(cachePath))
194
+ return null;
195
+ const bytes = readFileSync(cachePath);
196
+ const sniffed = sniffImageMimeType(bytes);
197
+ if (!sniffed)
198
+ return null;
199
+ const parsed = Number.parseInt(path.basename(cachePath, path.extname(cachePath)), 10);
200
+ const base64Data = bytes.toString('base64');
201
+ rememberStoredImage(base64Data, cachePath);
202
+ return {
203
+ cachePath,
204
+ mimeType: sniffed,
205
+ base64Data,
206
+ index: Number.isInteger(parsed) ? parsed : 0,
207
+ };
208
+ }
209
+ catch {
210
+ return null;
211
+ }
212
+ }
213
+ export function pruneSessionImages(sessionId, env = process.env) {
214
+ try {
215
+ rmSync(getSessionImageDir(sessionId, env), { recursive: true, force: true });
216
+ }
217
+ catch {
218
+ }
219
+ }
220
+ export function sweepOrphanSessionImages({ activeSessionIds, maxAgeMs = SESSION_IMAGE_MAX_AGE_MS, env = process.env, now = Date.now(), }) {
221
+ const baseDir = getSessionImageBaseDir(env);
222
+ if (!existsSync(baseDir))
223
+ return 0;
224
+ let removed = 0;
225
+ let entries;
226
+ try {
227
+ entries = readdirSync(baseDir);
228
+ }
229
+ catch {
230
+ return 0;
231
+ }
232
+ for (const entry of entries) {
233
+ if (activeSessionIds.has(entry))
234
+ continue;
235
+ const dir = path.join(baseDir, entry);
236
+ try {
237
+ if (now - statSync(dir).mtimeMs < maxAgeMs)
238
+ continue;
239
+ rmSync(dir, { recursive: true, force: true });
240
+ removed += 1;
241
+ }
242
+ catch {
243
+ }
244
+ }
245
+ return removed;
246
+ }