goatchain 0.0.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/cli/clack.mjs ADDED
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Optional clack integration.
3
+ *
4
+ * We load `@clack/prompts` dynamically so the CLI can run without it.
5
+ * This file intentionally has no static import of clack.
6
+ */
7
+ export async function loadClack() {
8
+ try {
9
+ const mod = await import('@clack/prompts')
10
+ if (mod && typeof mod.select === 'function')
11
+ return mod
12
+ return null
13
+ }
14
+ catch {
15
+ return null
16
+ }
17
+ }
18
+
19
+ export async function selectWithClack({ message, options, initialValue }) {
20
+ const clack = await loadClack()
21
+ if (!clack)
22
+ return { ok: false, reason: 'missing' }
23
+
24
+ const res = await clack.select({
25
+ message,
26
+ options,
27
+ initialValue,
28
+ })
29
+
30
+ if (clack.isCancel(res))
31
+ return { ok: false, reason: 'cancel' }
32
+
33
+ return { ok: true, value: res }
34
+ }
35
+
36
+ export async function multiselectWithClack({ message, options, required = false }) {
37
+ const clack = await loadClack()
38
+ if (!clack)
39
+ return { ok: false, reason: 'missing' }
40
+
41
+ if (typeof clack.multiselect !== 'function')
42
+ return { ok: false, reason: 'unsupported' }
43
+
44
+ const res = await clack.multiselect({
45
+ message,
46
+ options,
47
+ required,
48
+ })
49
+
50
+ if (clack.isCancel(res))
51
+ return { ok: false, reason: 'cancel' }
52
+
53
+ return { ok: true, value: res }
54
+ }
55
+
56
+ export async function confirmWithClack({ message, initialValue = false }) {
57
+ const clack = await loadClack()
58
+ if (!clack)
59
+ return { ok: false, reason: 'missing' }
60
+
61
+ if (typeof clack.confirm !== 'function')
62
+ return { ok: false, reason: 'unsupported' }
63
+
64
+ const res = await clack.confirm({
65
+ message,
66
+ initialValue,
67
+ })
68
+
69
+ if (clack.isCancel(res))
70
+ return { ok: false, reason: 'cancel' }
71
+
72
+ return { ok: true, value: Boolean(res) }
73
+ }
74
+
75
+ export async function textWithClack({ message, placeholder }) {
76
+ const clack = await loadClack()
77
+ if (!clack)
78
+ return { ok: false, reason: 'missing' }
79
+
80
+ if (typeof clack.text !== 'function')
81
+ return { ok: false, reason: 'unsupported' }
82
+
83
+ const res = await clack.text({
84
+ message,
85
+ placeholder,
86
+ })
87
+
88
+ if (clack.isCancel(res))
89
+ return { ok: false, reason: 'cancel' }
90
+
91
+ return { ok: true, value: String(res ?? '') }
92
+ }
93
+
94
+ export async function passwordWithClack({ message, placeholder }) {
95
+ const clack = await loadClack()
96
+ if (!clack)
97
+ return { ok: false, reason: 'missing' }
98
+
99
+ if (typeof clack.password !== 'function')
100
+ return { ok: false, reason: 'unsupported' }
101
+
102
+ const res = await clack.password({
103
+ message,
104
+ placeholder,
105
+ })
106
+
107
+ if (clack.isCancel(res))
108
+ return { ok: false, reason: 'cancel' }
109
+
110
+ return { ok: true, value: String(res ?? '') }
111
+ }
@@ -0,0 +1,320 @@
1
+ import { execFile } from 'node:child_process'
2
+ import { promisify } from 'node:util'
3
+ import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
4
+ import path from 'node:path'
5
+ import os from 'node:os'
6
+
7
+ const execFileAsync = promisify(execFile)
8
+
9
+ async function runAppleScript(lines, maxBuffer = 1024 * 1024) {
10
+ if (process.platform !== 'darwin')
11
+ return null
12
+ try {
13
+ const { stdout } = await execFileAsync('osascript', lines.flatMap(l => ['-e', l]), { encoding: 'utf8', maxBuffer })
14
+ return String(stdout ?? '')
15
+ }
16
+ catch {
17
+ return null
18
+ }
19
+ }
20
+
21
+ export function parseClipboardFileUrl(text) {
22
+ const urls = parseClipboardFileUrls(text)
23
+ return urls.length > 0 ? urls[0] : null
24
+ }
25
+
26
+ export function parseClipboardFileUrls(text) {
27
+ const s = String(text ?? '').trim()
28
+ if (!s)
29
+ return []
30
+
31
+ // Finder sometimes provides multiple lines or null-separated records.
32
+ const parts = s
33
+ .split(/\u0000|\r?\n/)
34
+ .map(x => x.trim())
35
+ .filter(Boolean)
36
+
37
+ /** @type {string[]} */
38
+ const out = []
39
+ for (const part of parts) {
40
+ if (part.startsWith('file://')) {
41
+ try {
42
+ const u = new URL(part)
43
+ if (u.protocol !== 'file:')
44
+ continue
45
+ const p = decodeURIComponent(u.pathname)
46
+ if (p)
47
+ out.push(p)
48
+ continue
49
+ }
50
+ catch {
51
+ continue
52
+ }
53
+ }
54
+
55
+ // Some apps paste absolute paths directly.
56
+ if (part.startsWith('/'))
57
+ out.push(part)
58
+ }
59
+ return out
60
+ }
61
+
62
+ function sniffMimeFromBuffer(buf) {
63
+ if (!Buffer.isBuffer(buf) || buf.length < 12)
64
+ return null
65
+ // PNG
66
+ if (buf.length >= 8 && buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4E && buf[3] === 0x47 && buf[4] === 0x0D && buf[5] === 0x0A && buf[6] === 0x1A && buf[7] === 0x0A)
67
+ return { mime: 'image/png', ext: '.png' }
68
+ // JPEG
69
+ if (buf[0] === 0xFF && buf[1] === 0xD8 && buf[2] === 0xFF)
70
+ return { mime: 'image/jpeg', ext: '.jpg' }
71
+ // GIF
72
+ if (buf[0] === 0x47 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x38)
73
+ return { mime: 'image/gif', ext: '.gif' }
74
+ // WEBP
75
+ if (buf.subarray(0, 4).toString('ascii') === 'RIFF' && buf.subarray(8, 12).toString('ascii') === 'WEBP')
76
+ return { mime: 'image/webp', ext: '.webp' }
77
+ // TIFF: II*\0 or MM\0*
78
+ if ((buf[0] === 0x49 && buf[1] === 0x49 && buf[2] === 0x2A && buf[3] === 0x00) || (buf[0] === 0x4D && buf[1] === 0x4D && buf[2] === 0x00 && buf[3] === 0x2A))
79
+ return { mime: 'image/tiff', ext: '.tiff' }
80
+ // BMP: "BM"
81
+ if (buf[0] === 0x42 && buf[1] === 0x4D)
82
+ return { mime: 'image/bmp', ext: '.bmp' }
83
+ return null
84
+ }
85
+
86
+ export async function readClipboardTextDarwin() {
87
+ if (process.platform !== 'darwin')
88
+ return null
89
+ try {
90
+ const { stdout } = await execFileAsync('pbpaste', [], { encoding: 'utf8', maxBuffer: 2 * 1024 * 1024 })
91
+ return String(stdout ?? '')
92
+ }
93
+ catch {
94
+ return null
95
+ }
96
+ }
97
+
98
+ export async function readClipboardTypesDarwin() {
99
+ if (process.platform !== 'darwin')
100
+ return null
101
+ const out = await runAppleScript(['clipboard info'])
102
+ const s = String(out ?? '').trim()
103
+ return s || null
104
+ }
105
+
106
+ async function readClipboardPreferredTextDarwin(uti) {
107
+ if (process.platform !== 'darwin')
108
+ return null
109
+ try {
110
+ const { stdout } = await execFileAsync('pbpaste', ['-Prefer', String(uti)], { encoding: 'utf8', maxBuffer: 2 * 1024 * 1024 })
111
+ return String(stdout ?? '')
112
+ }
113
+ catch {
114
+ return null
115
+ }
116
+ }
117
+
118
+ async function readClipboardFilePathsDarwinViaOsa() {
119
+ if (process.platform !== 'darwin')
120
+ return []
121
+ const out = await runAppleScript([
122
+ 'try',
123
+ 'set thePaths to {}',
124
+ 'try',
125
+ 'set theItems to the clipboard as alias list',
126
+ 'on error',
127
+ 'set theItems to {(the clipboard as alias)}',
128
+ 'end try',
129
+ 'repeat with a in theItems',
130
+ 'set end of thePaths to POSIX path of a',
131
+ 'end repeat',
132
+ 'set AppleScript\'s text item delimiters to "\\n"',
133
+ 'return thePaths as text',
134
+ 'end try',
135
+ ])
136
+ const raw = String(out ?? '').trim()
137
+ if (!raw)
138
+ return []
139
+ return raw.split(/\r?\n/).map(s => s.trim()).filter(Boolean)
140
+ }
141
+
142
+ export async function readFinderSelectionPathsDarwin() {
143
+ if (process.platform !== 'darwin')
144
+ return []
145
+ const out = await runAppleScript([
146
+ 'try',
147
+ 'tell application "Finder"',
148
+ 'if (count of selection) is 0 then return ""',
149
+ 'set thePaths to {}',
150
+ 'repeat with i in selection',
151
+ 'try',
152
+ 'set end of thePaths to POSIX path of (i as alias)',
153
+ 'end try',
154
+ 'end repeat',
155
+ 'set AppleScript\'s text item delimiters to "\\n"',
156
+ 'return thePaths as text',
157
+ 'end tell',
158
+ 'end try',
159
+ ])
160
+ const raw = String(out ?? '').trim()
161
+ if (!raw)
162
+ return []
163
+ return raw.split(/\r?\n/).map(s => s.trim()).filter(Boolean)
164
+ }
165
+
166
+ async function readClipboardPreferredBinaryDarwin(uti, maxBytes = 12 * 1024 * 1024) {
167
+ if (process.platform !== 'darwin')
168
+ return null
169
+ try {
170
+ const { stdout } = await execFileAsync('pbpaste', ['-Prefer', String(uti)], { encoding: 'buffer', maxBuffer: maxBytes })
171
+ if (!Buffer.isBuffer(stdout) || stdout.length < 8)
172
+ return null
173
+ return stdout
174
+ }
175
+ catch {
176
+ return null
177
+ }
178
+ }
179
+
180
+ export async function readClipboardPngDarwin() {
181
+ if (process.platform !== 'darwin')
182
+ return null
183
+ try {
184
+ const { stdout } = await execFileAsync('pbpaste', ['-Prefer', 'png'], { encoding: 'buffer', maxBuffer: 12 * 1024 * 1024 })
185
+ if (!Buffer.isBuffer(stdout) || stdout.length < 8)
186
+ return null
187
+ // Confirm it looks like a PNG.
188
+ const sniff = sniffMimeFromBuffer(stdout)
189
+ if (!sniff || sniff.mime !== 'image/png')
190
+ return null
191
+ return { buf: stdout, mime: sniff.mime, ext: sniff.ext }
192
+ }
193
+ catch {
194
+ return null
195
+ }
196
+ }
197
+
198
+ async function convertWithSipsToPng(inputBuf, inExt) {
199
+ if (process.platform !== 'darwin')
200
+ return null
201
+ if (!Buffer.isBuffer(inputBuf) || inputBuf.length === 0)
202
+ return null
203
+ const ext = String(inExt ?? '').toLowerCase() || '.bin'
204
+ const dir = await mkdtemp(path.join(os.tmpdir(), 'goatchain-clip-'))
205
+ const inPath = path.join(dir, `in${ext}`)
206
+ const outPath = path.join(dir, 'out.png')
207
+ try {
208
+ await writeFile(inPath, inputBuf)
209
+ await execFileAsync('sips', ['-s', 'format', 'png', inPath, '--out', outPath], { encoding: 'utf8', maxBuffer: 1024 * 1024 })
210
+ const out = await readFile(outPath)
211
+ const sniff = sniffMimeFromBuffer(out)
212
+ if (!sniff || sniff.mime !== 'image/png')
213
+ return null
214
+ const dataUrl = `data:${sniff.mime};base64,${out.toString('base64')}`
215
+ return { buf: out, mime: sniff.mime, ext: sniff.ext, dataUrl }
216
+ }
217
+ catch {
218
+ return null
219
+ }
220
+ finally {
221
+ try {
222
+ await rm(dir, { recursive: true, force: true })
223
+ }
224
+ catch {
225
+ // ignore
226
+ }
227
+ }
228
+ }
229
+
230
+ export async function readImageFileAsDataUrl(absPath, maxBytes = 4 * 1024 * 1024) {
231
+ const st = await stat(absPath)
232
+ if (!st.isFile())
233
+ return null
234
+ if (st.size > maxBytes)
235
+ return null
236
+ const buf = await readFile(absPath)
237
+ const sniff = sniffMimeFromBuffer(buf)
238
+ if (!sniff || !sniff.mime.startsWith('image/'))
239
+ return null
240
+ const dataUrl = `data:${sniff.mime};base64,${buf.toString('base64')}`
241
+ return { buf, mime: sniff.mime, ext: sniff.ext, dataUrl }
242
+ }
243
+
244
+ export async function readClipboardImageCandidatesDarwin(maxBytes = 4 * 1024 * 1024) {
245
+ if (process.platform !== 'darwin')
246
+ return []
247
+
248
+ /** @type {Array<{ source: string, absPath: string | null, buf: Buffer, mime: string, ext: string, dataUrl: string }>} */
249
+ const out = []
250
+
251
+ // 1) Prefer file-url(s) if present.
252
+ const preferred = await readClipboardPreferredTextDarwin('public.file-url')
253
+ const txt = preferred && preferred.trim() ? preferred : await readClipboardTextDarwin()
254
+ let paths = parseClipboardFileUrls(txt)
255
+ if (paths.length === 0) {
256
+ // Some environments don't expose file-url to pbpaste; use AppleScript as a fallback.
257
+ paths = await readClipboardFilePathsDarwinViaOsa()
258
+ }
259
+ if (paths.length > 0) {
260
+ for (const p of paths) {
261
+ try {
262
+ // eslint-disable-next-line no-await-in-loop
263
+ const file = await readImageFileAsDataUrl(p, maxBytes)
264
+ if (file)
265
+ out.push({ source: 'file', absPath: p, ...file })
266
+ }
267
+ catch {
268
+ // ignore
269
+ }
270
+ }
271
+ }
272
+
273
+ if (out.length > 0)
274
+ return out
275
+
276
+ // 2) Try common binary clipboard formats.
277
+ const formats = [
278
+ 'png',
279
+ 'public.png',
280
+ 'jpeg',
281
+ 'public.jpeg',
282
+ 'jpg',
283
+ 'public.jpg',
284
+ 'public.tiff',
285
+ 'tiff',
286
+ 'public.bmp',
287
+ 'bmp',
288
+ ]
289
+
290
+ for (const fmt of formats) {
291
+ // eslint-disable-next-line no-await-in-loop
292
+ const buf = await readClipboardPreferredBinaryDarwin(fmt, Math.max(12 * 1024 * 1024, maxBytes))
293
+ if (!buf)
294
+ continue
295
+ const sniff = sniffMimeFromBuffer(buf)
296
+ if (!sniff || !sniff.mime.startsWith('image/'))
297
+ continue
298
+
299
+ if (sniff.mime === 'image/tiff' || sniff.mime === 'image/bmp') {
300
+ // Try to convert unsupported formats into PNG for model compatibility.
301
+ // eslint-disable-next-line no-await-in-loop
302
+ const converted = await convertWithSipsToPng(buf, sniff.ext)
303
+ if (converted) {
304
+ out.push({ source: fmt, absPath: null, ...converted })
305
+ break
306
+ }
307
+ }
308
+
309
+ const dataUrl = `data:${sniff.mime};base64,${buf.toString('base64')}`
310
+ out.push({ source: fmt, absPath: null, buf, mime: sniff.mime, ext: sniff.ext, dataUrl })
311
+ break
312
+ }
313
+
314
+ return out
315
+ }
316
+
317
+ export async function readClipboardImageCandidateDarwin(maxBytes = 4 * 1024 * 1024) {
318
+ const all = await readClipboardImageCandidatesDarwin(maxBytes)
319
+ return all.length > 0 ? all[0] : null
320
+ }
package/cli/files.mjs ADDED
@@ -0,0 +1,247 @@
1
+ import path from 'node:path'
2
+ import { open, readdir, stat } from 'node:fs/promises'
3
+
4
+ const DEFAULT_IGNORE_DIRS = new Set([
5
+ 'node_modules',
6
+ '.git',
7
+ '.goatchain',
8
+ 'dist',
9
+ 'tmp',
10
+ ])
11
+
12
+ function isSubpath(parent, child) {
13
+ const rel = path.relative(path.resolve(parent), path.resolve(child))
14
+ if (rel === '')
15
+ return true
16
+ if (rel === '..')
17
+ return false
18
+ return !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel)
19
+ }
20
+
21
+ export function ensureAllowedFile(workspaceCwd, filePath) {
22
+ const abs = path.isAbsolute(filePath) ? filePath : path.resolve(workspaceCwd, filePath)
23
+ if (!isSubpath(workspaceCwd, abs)) {
24
+ throw new Error(`Access denied: ${abs}\nAllowed directory: ${workspaceCwd}`)
25
+ }
26
+ // block cache dir by default
27
+ const cacheDir = path.join(workspaceCwd, '.goatchain')
28
+ if (isSubpath(cacheDir, abs)) {
29
+ throw new Error(`Access denied: ${abs}\nRestricted: ${cacheDir}`)
30
+ }
31
+ return abs
32
+ }
33
+
34
+ export async function listWorkspaceFiles(workspaceCwd, options = {}) {
35
+ const maxFiles = typeof options.maxFiles === 'number' ? options.maxFiles : 5000
36
+ const ignoreDirs = new Set(options.ignoreDirs ?? DEFAULT_IGNORE_DIRS)
37
+
38
+ /** @type {string[]} */
39
+ const out = []
40
+ /** @type {string[]} */
41
+ const queue = ['.']
42
+
43
+ while (queue.length > 0) {
44
+ const relDir = queue.shift()
45
+ if (!relDir)
46
+ break
47
+
48
+ const absDir = path.resolve(workspaceCwd, relDir)
49
+ let entries
50
+ try {
51
+ // eslint-disable-next-line no-await-in-loop
52
+ entries = await readdir(absDir, { withFileTypes: true })
53
+ }
54
+ catch {
55
+ continue
56
+ }
57
+
58
+ for (const ent of entries) {
59
+ const name = ent.name
60
+ if (!name)
61
+ continue
62
+
63
+ if (ent.isDirectory()) {
64
+ if (ignoreDirs.has(name))
65
+ continue
66
+ if (name.startsWith('.env'))
67
+ continue
68
+ const nextRel = relDir === '.' ? name : path.join(relDir, name)
69
+ queue.push(nextRel)
70
+ continue
71
+ }
72
+
73
+ if (!ent.isFile())
74
+ continue
75
+
76
+ const rel = relDir === '.' ? name : path.join(relDir, name)
77
+ out.push(rel)
78
+ if (out.length >= maxFiles)
79
+ return out
80
+ }
81
+ }
82
+
83
+ return out
84
+ }
85
+
86
+ function safeSliceLines(lines, n) {
87
+ if (n <= 0)
88
+ return []
89
+ return lines.slice(0, Math.min(lines.length, n))
90
+ }
91
+
92
+ export function summarizeText(text, opts = {}) {
93
+ const headLines = typeof opts.headLines === 'number' ? opts.headLines : 40
94
+ const tailLines = typeof opts.tailLines === 'number' ? opts.tailLines : 20
95
+ const maxBytes = typeof opts.maxBytes === 'number' ? opts.maxBytes : 60_000
96
+ const s = String(text ?? '')
97
+
98
+ const asBytes = Buffer.byteLength(s, 'utf8')
99
+ const clipped = asBytes > maxBytes ? s.slice(0, Math.max(0, maxBytes)) : s
100
+ const lines = clipped.split('\n')
101
+
102
+ if (lines.length <= headLines + tailLines)
103
+ return { text: clipped, truncated: asBytes > maxBytes }
104
+
105
+ const head = safeSliceLines(lines, headLines)
106
+ const tail = lines.slice(Math.max(headLines, lines.length - tailLines))
107
+ return {
108
+ text: `${head.join('\n')}\n…\n${tail.join('\n')}`,
109
+ truncated: true,
110
+ }
111
+ }
112
+
113
+ async function readTextFile(absPath, maxBytes, totalBytes) {
114
+ const toRead = Math.max(0, Math.min(Number(maxBytes) || 0, totalBytes))
115
+ const fh = await open(absPath, 'r')
116
+ try {
117
+ const buf = Buffer.allocUnsafe(toRead)
118
+ const { bytesRead } = await fh.read(buf, 0, toRead, 0)
119
+ const text = buf.subarray(0, bytesRead).toString('utf8')
120
+ return { text, truncated: totalBytes > maxBytes, bytes: totalBytes }
121
+ }
122
+ finally {
123
+ await fh.close()
124
+ }
125
+ }
126
+
127
+ function parseFileMentions(input) {
128
+ const s = String(input ?? '')
129
+ /** @type {Array<{ raw: string, mode: 'path'|'summary'|'full', filePath: string }>} */
130
+ const mentions = []
131
+ const re = /@file(?:\((path|summary|full)\))?:([^\s]+)/g
132
+ let m
133
+ while ((m = re.exec(s)) != null) {
134
+ const raw = m[0]
135
+ const mode = (m[1] || 'summary')
136
+ const filePath = m[2]
137
+ mentions.push({ raw, mode, filePath })
138
+ }
139
+ return mentions
140
+ }
141
+
142
+ export async function expandFileMentions(workspaceCwd, input, options = {}) {
143
+ const maxTotalBytes = typeof options.maxTotalBytes === 'number' ? options.maxTotalBytes : 220_000
144
+ const maxBytesPerFile = typeof options.maxBytesPerFile === 'number' ? options.maxBytesPerFile : 120_000
145
+ const maxBytesPerFileSummary = typeof options.maxBytesPerFileSummary === 'number' ? options.maxBytesPerFileSummary : 40_000
146
+ const headLines = typeof options.headLines === 'number' ? options.headLines : 60
147
+ const tailLines = typeof options.tailLines === 'number' ? options.tailLines : 20
148
+
149
+ const mentions = parseFileMentions(input)
150
+ if (mentions.length === 0)
151
+ return { text: String(input ?? ''), files: [], omitted: [] }
152
+
153
+ const unique = new Map()
154
+ for (const m of mentions) {
155
+ const key = `${m.mode}:${m.filePath}`
156
+ if (!unique.has(key))
157
+ unique.set(key, m)
158
+ }
159
+
160
+ /** @type {Array<{ mode: string, relPath: string, absPath: string, bytes: number, truncated: boolean, content?: string }>} */
161
+ const files = []
162
+ /** @type {Array<{ mode: string, filePath: string, reason: 'budget'|'not_file' }>} */
163
+ const omitted = []
164
+ let used = 0
165
+
166
+ const uniqMentions = [...unique.values()]
167
+ for (let idx = 0; idx < uniqMentions.length; idx++) {
168
+ const m = uniqMentions[idx]
169
+ const abs = ensureAllowedFile(workspaceCwd, m.filePath)
170
+ // eslint-disable-next-line no-await-in-loop
171
+ const st = await stat(abs)
172
+ if (!st.isFile()) {
173
+ omitted.push({ mode: m.mode, filePath: m.filePath, reason: 'not_file' })
174
+ continue
175
+ }
176
+
177
+ if (m.mode === 'path') {
178
+ files.push({ mode: 'path', relPath: path.relative(workspaceCwd, abs), absPath: abs, bytes: st.size, truncated: false })
179
+ continue
180
+ }
181
+
182
+ const budgetLeft = Math.max(0, maxTotalBytes - used)
183
+ if (budgetLeft <= 0) {
184
+ omitted.push({ mode: m.mode, filePath: m.filePath, reason: 'budget' })
185
+ for (let j = idx + 1; j < uniqMentions.length; j++) {
186
+ const rest = uniqMentions[j]
187
+ omitted.push({ mode: rest.mode, filePath: rest.filePath, reason: 'budget' })
188
+ }
189
+ break
190
+ }
191
+
192
+ const perFileMax = m.mode === 'summary' ? maxBytesPerFileSummary : maxBytesPerFile
193
+ const perFile = Math.min(perFileMax, budgetLeft)
194
+ // eslint-disable-next-line no-await-in-loop
195
+ const raw = await readTextFile(abs, perFile, st.size)
196
+ used += Math.min(raw.bytes, perFile)
197
+
198
+ if (m.mode === 'full') {
199
+ files.push({
200
+ mode: 'full',
201
+ relPath: path.relative(workspaceCwd, abs),
202
+ absPath: abs,
203
+ bytes: raw.bytes,
204
+ truncated: raw.truncated,
205
+ content: raw.text,
206
+ })
207
+ }
208
+ else {
209
+ const summary = summarizeText(raw.text, { headLines, tailLines, maxBytes: perFile })
210
+ files.push({
211
+ mode: 'summary',
212
+ relPath: path.relative(workspaceCwd, abs),
213
+ absPath: abs,
214
+ bytes: raw.bytes,
215
+ truncated: raw.truncated || summary.truncated,
216
+ content: summary.text,
217
+ })
218
+ }
219
+ }
220
+
221
+ let cleaned = String(input ?? '')
222
+ for (const m of mentions) {
223
+ cleaned = cleaned.replace(m.raw, '').replace(/\s{2,}/g, ' ')
224
+ }
225
+ cleaned = cleaned.trim()
226
+
227
+ const blocks = files
228
+ .filter(f => f.mode !== 'path')
229
+ .map((f) => {
230
+ const head = `---\nfile: ${f.relPath}\nmode: ${f.mode}${f.truncated ? ' (truncated)' : ''}\n---\n`
231
+ return `${head}${String(f.content ?? '')}`.trimEnd()
232
+ })
233
+
234
+ const pathsOnly = files.filter(f => f.mode === 'path').map(f => f.relPath)
235
+ const preface = [
236
+ blocks.length > 0 || pathsOnly.length > 0 ? '[attached files]' : '',
237
+ pathsOnly.length > 0 ? `paths: ${pathsOnly.join(', ')}` : '',
238
+ ].filter(Boolean).join('\n')
239
+
240
+ const expanded = [
241
+ cleaned,
242
+ preface,
243
+ ...blocks,
244
+ ].filter(Boolean).join('\n\n')
245
+
246
+ return { text: expanded, files, omitted }
247
+ }