@viveka10/cap-ai-token-optimizer 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,103 @@
1
+ 'use strict'
2
+ /**
3
+ * dedupe: remove exact duplicate instructions within one request.
4
+ * - messages: a system message identical to an earlier one is dropped; a user message
5
+ * identical to the user message right before it is dropped.
6
+ * - paragraphs: a paragraph (blank-line separated, at least `minParagraphLength` chars)
7
+ * that repeats a system paragraph is removed from later system and user messages;
8
+ * in user messages, an older copy of a paragraph that is repeated in a later user
9
+ * message is removed (the latest copy stays where it is).
10
+ * Messages with code fences, tool and assistant messages are never edited, and a message
11
+ * is never emptied.
12
+ */
13
+ const { isLocked, isSystem } = require('../messages')
14
+ const { stableStringify } = require('../../util/hash')
15
+
16
+ const DEFAULTS = { messages: true, paragraphs: true, minParagraphLength: 30 }
17
+ const PARA_SPLIT = /\n[ \t]*\n/
18
+
19
+ /**
20
+ * @param {import('../index').StepInput} input
21
+ * @param {object} options
22
+ */
23
+ function dedupe ({ messages }, options) {
24
+ const opts = { ...DEFAULTS, ...options }
25
+ const notes = []
26
+ let out = messages
27
+ if (opts.messages) {
28
+ const before = out.length
29
+ out = dedupeMessages(out)
30
+ if (out.length < before) notes.push(`${before - out.length} duplicate message(s) removed`)
31
+ }
32
+ if (opts.paragraphs) {
33
+ const { messages: next, removed } = dedupeParagraphs(out, opts.minParagraphLength)
34
+ out = next
35
+ if (removed) notes.push(`${removed} duplicate paragraph(s) removed`)
36
+ }
37
+ return { messages: out, notes }
38
+ }
39
+
40
+ function dedupeMessages (messages) {
41
+ const seenSystem = new Set()
42
+ const out = []
43
+ for (const m of messages) {
44
+ const key = stableStringify({ role: m?.role, content: m?.content })
45
+ if (isSystem(m)) {
46
+ if (seenSystem.has(key) && !isLocked(m)) continue
47
+ seenSystem.add(key)
48
+ } else if (m?.role === 'user' && !isLocked(m)) {
49
+ const prev = out.at(-1)
50
+ if (prev?.role === 'user' && stableStringify({ role: prev.role, content: prev.content }) === key) continue
51
+ }
52
+ out.push(m)
53
+ }
54
+ return out
55
+ }
56
+
57
+ const editable = (m) => typeof m?.content === 'string' && !isLocked(m) && !/^[ \t]*(```|~~~)/m.test(m.content)
58
+ const paragraphsOf = (text) => text.split(PARA_SPLIT)
59
+ const norm = (p) => p.trim()
60
+
61
+ function dedupeParagraphs (messages, minLength) {
62
+ const long = (p) => norm(p).length >= minLength
63
+ // paragraphs that appear in a system message, and in which user message a paragraph last appears
64
+ const systemParas = new Set()
65
+ const lastUserIndex = new Map()
66
+ messages.forEach((m, i) => {
67
+ if (typeof m?.content !== 'string') return
68
+ for (const p of paragraphsOf(m.content)) {
69
+ if (!long(p)) continue
70
+ if (isSystem(m)) systemParas.add(norm(p))
71
+ else if (m.role === 'user') lastUserIndex.set(norm(p), i)
72
+ }
73
+ })
74
+
75
+ let removed = 0
76
+ const seenSystem = new Set()
77
+ const out = messages.map((m, i) => {
78
+ if (!editable(m) || !(isSystem(m) || m.role === 'user')) {
79
+ if (isSystem(m) && typeof m?.content === 'string') for (const p of paragraphsOf(m.content)) if (long(p)) seenSystem.add(norm(p))
80
+ return m
81
+ }
82
+ const seenHere = new Set()
83
+ const kept = []
84
+ let dropped = 0
85
+ for (const p of paragraphsOf(m.content)) {
86
+ const key = norm(p)
87
+ const drop = long(p) && (
88
+ seenHere.has(key) ||
89
+ (isSystem(m) ? seenSystem.has(key) : (systemParas.has(key) || lastUserIndex.get(key) > i))
90
+ )
91
+ if (long(p)) seenHere.add(key)
92
+ if (drop) dropped++
93
+ else kept.push(p)
94
+ }
95
+ if (isSystem(m)) for (const p of kept) if (long(p)) seenSystem.add(norm(p))
96
+ if (!dropped || !kept.some(p => p.trim())) return m
97
+ removed += dropped
98
+ return { ...m, content: kept.join('\n\n') }
99
+ })
100
+ return { messages: out, removed }
101
+ }
102
+
103
+ module.exports = { dedupe, DEFAULTS }
@@ -0,0 +1,63 @@
1
+ 'use strict'
2
+ /**
3
+ * history: keep system messages plus the last N conversation turns.
4
+ *
5
+ * A turn starts at a user message and includes the assistant and tool messages that follow,
6
+ * so tool calls are never separated from their results. Locked (orchestration template)
7
+ * messages are always kept.
8
+ *
9
+ * Pluggable: `strategy` may be a function `({ messages, maxTurns }) => messages`.
10
+ * Optional `summarizer`: `async ({ dropped, kept }) => message | null`; the returned
11
+ * message is inserted after the leading system messages. No summarizer is built in.
12
+ */
13
+ const { isLocked, isSystem, ORIGIN } = require('../messages')
14
+
15
+ const DEFAULTS = { maxTurns: 10, strategy: 'sliding-window', summarizer: null }
16
+
17
+ /**
18
+ * Default strategy.
19
+ * @param {{ messages: object[], maxTurns: number }} input
20
+ * @returns {object[]}
21
+ */
22
+ function slidingWindow ({ messages, maxTurns }) {
23
+ // index where each turn starts
24
+ const starts = []
25
+ messages.forEach((m, i) => { if (m?.role === 'user' && !isSystem(m)) starts.push(i) })
26
+ if (starts.length <= maxTurns) return messages
27
+ const cut = starts[starts.length - maxTurns]
28
+ return messages.filter((m, i) => i >= cut || isSystem(m) || isLocked(m))
29
+ }
30
+
31
+ const STRATEGIES = { 'sliding-window': slidingWindow }
32
+
33
+ /**
34
+ * @param {import('../index').StepInput} input
35
+ * @param {object} options
36
+ */
37
+ async function history ({ messages, model }, options) {
38
+ const opts = { ...DEFAULTS, ...options }
39
+ if (!(opts.maxTurns > 0)) return { messages, notes: [] }
40
+ const strategy = typeof opts.strategy === 'function' ? opts.strategy : STRATEGIES[opts.strategy]
41
+ if (!strategy) throw new Error(`Unknown history strategy "${opts.strategy}"`)
42
+
43
+ const kept = strategy({ messages, maxTurns: opts.maxTurns, model })
44
+ if (!Array.isArray(kept)) throw new Error('history strategy must return an array of messages')
45
+ if (kept.length === messages.length) return { messages, notes: [] }
46
+ const keptSet = new Set(kept)
47
+ const dropped = messages.filter(m => !keptSet.has(m))
48
+ if (dropped.some(isLocked)) throw new Error('history strategy dropped a template message')
49
+ const notes = [`${dropped.length} message(s) outside the last ${opts.maxTurns} turns removed`]
50
+
51
+ if (typeof opts.summarizer === 'function') {
52
+ const summary = await opts.summarizer({ dropped, kept, model })
53
+ if (summary) {
54
+ const at = kept.findIndex(m => !isSystem(m))
55
+ const tagged = { ...summary, [ORIGIN]: dropped[0]?.[ORIGIN] }
56
+ kept.splice(at < 0 ? kept.length : at, 0, tagged)
57
+ notes.push('summary of removed turns inserted')
58
+ }
59
+ }
60
+ return { messages: kept, notes }
61
+ }
62
+
63
+ module.exports = { history, slidingWindow, STRATEGIES, DEFAULTS }
@@ -0,0 +1,88 @@
1
+ 'use strict'
2
+ /**
3
+ * normalize: trim, collapse repeated spaces and blank lines, minify JSON blocks.
4
+ * Fenced code blocks, inline code and indented (code-like) lines keep their whitespace.
5
+ * JSON is minified on the text itself, so numbers, key order and duplicate keys are kept.
6
+ */
7
+ const { splitFences, joinSegments, tryMinifyJson, splitJsonBlocks } = require('../text')
8
+ const { mapText, isLocked } = require('../messages')
9
+
10
+ const DEFAULTS = { minifyJson: true, minifyJsonFences: true, collapseSpaces: true }
11
+
12
+ /**
13
+ * @param {import('../index').StepInput} input
14
+ * @param {object} options
15
+ */
16
+ function normalize ({ messages }, options) {
17
+ const opts = { ...DEFAULTS, ...options }
18
+ let changed = 0
19
+ const out = messages.map(m => {
20
+ let next = mapText(m, text => normalizeText(text, opts))
21
+ if (opts.minifyJson && Array.isArray(next?.tool_calls) && !isLocked(next)) {
22
+ next = minifyToolCallArguments(next)
23
+ }
24
+ if (next !== m) changed++
25
+ return next
26
+ })
27
+ return { messages: out, notes: changed ? [`${changed} message(s) normalized`] : [] }
28
+ }
29
+
30
+ function minifyToolCallArguments (m) {
31
+ let changed = false
32
+ const toolCalls = m.tool_calls.map(tc => {
33
+ const args = tc?.function?.arguments
34
+ if (typeof args !== 'string') return tc
35
+ const min = tryMinifyJson(args)
36
+ if (min === null || min === args) return tc
37
+ changed = true
38
+ return { ...tc, function: { ...tc.function, arguments: min } }
39
+ })
40
+ return changed ? { ...m, tool_calls: toolCalls } : m
41
+ }
42
+
43
+ /**
44
+ * @param {string} text
45
+ * @param {typeof DEFAULTS} opts
46
+ */
47
+ function normalizeText (text, opts = DEFAULTS) {
48
+ const all = splitFences(text)
49
+ const segments = all.map((seg, i) => {
50
+ if (seg.type === 'code') return opts.minifyJsonFences && /^json\b/i.test(seg.info ?? '') ? minifyJsonFence(seg) : seg
51
+ const parts = splitJsonBlocks(seg.text).map(p => {
52
+ if (p.type === 'json') return opts.minifyJson ? tryMinifyJson(p.text) : p.text
53
+ return normalizeProse(p.text, opts)
54
+ })
55
+ // blank lines are collapsed in prose only; code keeps its blank lines.
56
+ // Next to a fence, the newline added when segments are joined counts too.
57
+ let prose = parts.join('').replace(/\n{3,}/g, '\n\n')
58
+ if (i > 0) prose = prose.replace(/^\n{2,}/, '\n')
59
+ if (i < all.length - 1) prose = prose.replace(/\n{2,}$/, '\n')
60
+ return { ...seg, text: prose }
61
+ })
62
+ return joinSegments(segments).trim()
63
+ }
64
+
65
+ function minifyJsonFence (seg) {
66
+ const lines = seg.text.split('\n')
67
+ if (lines.length < 3) return seg
68
+ const body = lines.slice(1, -1).join('\n')
69
+ const min = tryMinifyJson(body)
70
+ return min === null ? seg : { ...seg, text: `${lines[0].trim()}\n${min}\n${lines.at(-1).trim()}` }
71
+ }
72
+
73
+ /**
74
+ * @param {string} text
75
+ * @param {typeof DEFAULTS} opts
76
+ */
77
+ function normalizeProse (text, opts) {
78
+ return text.split('\n').map(line => {
79
+ const trimmed = line.replace(/[ \t\r]+$/, '')
80
+ if (!opts.collapseSpaces || /^( {4,}|\t)/.test(trimmed)) return trimmed
81
+ const indent = /^[ \t]*/.exec(trimmed)[0]
82
+ // keep inline code spans as they are
83
+ const body = trimmed.slice(indent.length).split(/(`[^`\n]*`)/).map((part, i) => (i % 2 ? part : part.replace(/ {2,}/g, ' '))).join('')
84
+ return indent + body
85
+ }).join('\n')
86
+ }
87
+
88
+ module.exports = { normalize, normalizeText, DEFAULTS }
@@ -0,0 +1,28 @@
1
+ 'use strict'
2
+ /**
3
+ * outputCap: set max_tokens from the (per-use-case) config when the caller set no limit.
4
+ * Reasoning models (o1/o3/o4, gpt-5) spend output tokens on hidden reasoning, so a cap can
5
+ * produce empty answers; they are skipped unless `reasoningModels: true`.
6
+ * Saves output tokens, which cannot be known before the call, so it reports 0 saved prompt tokens.
7
+ */
8
+ const { baseModelName } = require('../../tokens/estimator')
9
+
10
+ const DEFAULTS = { default: 1024, reasoningModels: false }
11
+ const REASONING = /^(o\d|gpt-5)/
12
+
13
+ /**
14
+ * @param {import('../index').StepInput} input
15
+ * @param {object} options
16
+ */
17
+ function outputCap ({ params, model }, options) {
18
+ const opts = { ...DEFAULTS, ...options }
19
+ const p = params ?? {}
20
+ if (p.max_tokens != null || p.max_completion_tokens != null) return { params, notes: [] }
21
+ if (!(opts.default > 0)) return { params, notes: [] }
22
+ if (REASONING.test(baseModelName(model)) && !opts.reasoningModels) {
23
+ return { params, notes: ['skipped: reasoning model'] }
24
+ }
25
+ return { params: { ...p, max_tokens: opts.default }, notes: [`max_tokens set to ${opts.default}`] }
26
+ }
27
+
28
+ module.exports = { outputCap, DEFAULTS }
@@ -0,0 +1,157 @@
1
+ 'use strict'
2
+ /**
3
+ * tools: for agents and MCP servers with many tools.
4
+ * - When there are more than `maxTools` tool definitions, keep the most relevant ones.
5
+ * Always kept: tools named in `pinned`, tools already used in the conversation, and the
6
+ * tool forced by `tool_choice`. The rest are ranked by keyword overlap with the latest
7
+ * user message (ties keep the original order). The original order is preserved.
8
+ * - Descriptions longer than `maxDescriptionLength` are shortened (at a sentence end
9
+ * when possible), for the tool and for its parameters.
10
+ *
11
+ * Pluggable: `selector` may be a function `({ tools, messages, maxTools, keep }) => tools`
12
+ * where `keep` is the Set of names that must be kept.
13
+ */
14
+ const { toolName } = require('../messages')
15
+
16
+ const DEFAULTS = { maxTools: 15, maxDescriptionLength: 600, pinned: [], selector: null }
17
+
18
+ const STOPWORDS = new Set(('the and for with that this from have what which when where will would could should about into ' +
19
+ 'your you are was were been can not but all any our out get set use using please there their them then than ' +
20
+ 'also just like make need want show give tell does did has had its it\'s let me my mine how who why').split(' '))
21
+
22
+ /** Lower-case keywords of a text, splitting snake_case and camelCase. */
23
+ function keywords (text) {
24
+ return new Set(String(text ?? '')
25
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
26
+ .toLowerCase()
27
+ .split(/[^\p{L}\p{N}]+/u)
28
+ .filter(w => w.length >= 3 && !STOPWORDS.has(w)))
29
+ }
30
+
31
+ function toolKeywords (t) {
32
+ const fn = t?.function ?? t
33
+ const params = fn?.parameters?.properties ? Object.keys(fn.parameters.properties).join(' ') : ''
34
+ return keywords(`${toolName(t) ?? ''} ${fn?.description ?? ''} ${params}`)
35
+ }
36
+
37
+ /** Names of tools the conversation already used. */
38
+ function usedToolNames (messages) {
39
+ const names = new Set()
40
+ for (const m of messages) {
41
+ for (const tc of m?.tool_calls ?? []) if (tc?.function?.name) names.add(tc.function.name)
42
+ if (m?.role === 'tool' && m.name) names.add(m.name)
43
+ }
44
+ return names
45
+ }
46
+
47
+ function contentText (content) {
48
+ if (typeof content === 'string') return content
49
+ if (Array.isArray(content)) return content.map(p => (typeof p === 'string' ? p : p?.text ?? '')).join(' ')
50
+ return ''
51
+ }
52
+
53
+ /**
54
+ * Default selector: keyword overlap with the latest user message.
55
+ * @param {{ tools: object[], messages: object[], maxTools: number, keep: Set<string> }} input
56
+ */
57
+ function keywordSelector ({ tools, messages, maxTools, keep }) {
58
+ const lastUser = [...messages].reverse().find(m => m?.role === 'user')
59
+ const query = keywords(contentText(lastUser?.content))
60
+ const scored = tools.map((t, i) => {
61
+ let score = 0
62
+ for (const w of toolKeywords(t)) if (query.has(w)) score++
63
+ return { t, i, score, must: keep.has(toolName(t)) || t?.type !== undefined && t.type !== 'function' }
64
+ })
65
+ const chosen = new Set(scored.filter(s => s.must).map(s => s.i))
66
+ const rest = scored.filter(s => !s.must).sort((a, b) => b.score - a.score || a.i - b.i)
67
+ for (const s of rest) {
68
+ if (chosen.size >= maxTools) break
69
+ chosen.add(s.i)
70
+ }
71
+ return tools.filter((_t, i) => chosen.has(i))
72
+ }
73
+
74
+ /**
75
+ * Shorten text to at most `max` characters, at a sentence end if one is in the second half.
76
+ * @param {string} text
77
+ * @param {number} max
78
+ */
79
+ function shorten (text, max) {
80
+ if (typeof text !== 'string' || text.length <= max) return text
81
+ const head = text.slice(0, max)
82
+ const sentenceEnd = Math.max(head.lastIndexOf('. '), head.lastIndexOf('.\n'), head.lastIndexOf('! '), head.lastIndexOf('? '))
83
+ if (sentenceEnd >= max / 2) return head.slice(0, sentenceEnd + 1)
84
+ return `${head.slice(0, max - 1).trimEnd()}…`
85
+ }
86
+
87
+ function shortenSchema (schema, max) {
88
+ if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return schema
89
+ let changed = false
90
+ const out = { ...schema }
91
+ if (typeof schema.description === 'string') {
92
+ const d = shorten(schema.description, max)
93
+ if (d !== schema.description) { out.description = d; changed = true }
94
+ }
95
+ for (const key of ['properties', '$defs', 'definitions']) {
96
+ if (schema[key] && typeof schema[key] === 'object') {
97
+ const next = {}
98
+ let sub = false
99
+ for (const [k, v] of Object.entries(schema[key])) {
100
+ next[k] = shortenSchema(v, max)
101
+ if (next[k] !== v) sub = true
102
+ }
103
+ if (sub) { out[key] = next; changed = true }
104
+ }
105
+ }
106
+ if (schema.items) {
107
+ const items = shortenSchema(schema.items, max)
108
+ if (items !== schema.items) { out.items = items; changed = true }
109
+ }
110
+ return changed ? out : schema
111
+ }
112
+
113
+ function shortenTool (t, max) {
114
+ const fn = t?.function
115
+ if (!fn) return t
116
+ const description = shorten(fn.description, max)
117
+ const parameters = shortenSchema(fn.parameters, max)
118
+ if (description === fn.description && parameters === fn.parameters) return t
119
+ return { ...t, function: { ...fn, ...(description !== undefined ? { description } : {}), ...(parameters !== undefined ? { parameters } : {}) } }
120
+ }
121
+
122
+ /**
123
+ * @param {import('../index').StepInput} input
124
+ * @param {object} options
125
+ */
126
+ function tools ({ messages, tools: defs, params }, options) {
127
+ const opts = { ...DEFAULTS, ...options }
128
+ if (!Array.isArray(defs) || !defs.length) return { tools: defs, notes: [] }
129
+ const notes = []
130
+ let out = defs
131
+
132
+ if (opts.maxTools > 0 && defs.length > opts.maxTools) {
133
+ const keep = new Set([...(opts.pinned ?? []), ...usedToolNames(messages)])
134
+ const forced = params?.tool_choice?.function?.name
135
+ if (forced) keep.add(forced)
136
+ const selector = typeof opts.selector === 'function' ? opts.selector : keywordSelector
137
+ out = selector({ tools: defs, messages, maxTools: opts.maxTools, keep })
138
+ if (!Array.isArray(out)) throw new Error('tools selector must return an array')
139
+ const names = new Set(out.map(toolName))
140
+ const missing = [...keep].filter(n => defs.some(t => toolName(t) === n) && !names.has(n))
141
+ if (missing.length) throw new Error(`tools selector dropped required tools: ${missing.join(', ')}`)
142
+ if (out.length < defs.length) notes.push(`${defs.length - out.length} of ${defs.length} tools removed`)
143
+ }
144
+
145
+ if (opts.maxDescriptionLength > 0) {
146
+ let shortened = 0
147
+ out = out.map(t => {
148
+ const s = shortenTool(t, opts.maxDescriptionLength)
149
+ if (s !== t) shortened++
150
+ return s
151
+ })
152
+ if (shortened) notes.push(`${shortened} tool description(s) shortened`)
153
+ }
154
+ return { tools: out, notes }
155
+ }
156
+
157
+ module.exports = { tools, keywordSelector, keywords, shorten, usedToolNames, DEFAULTS }
@@ -0,0 +1,166 @@
1
+ 'use strict'
2
+ /**
3
+ * Text helpers for the pipeline: split text into code / JSON / prose segments and
4
+ * minify JSON without changing its value.
5
+ */
6
+
7
+ const FENCE_OPEN = /^[ \t]*(`{3,}|~{3,})(.*)$/
8
+
9
+ /**
10
+ * @typedef {{ type: 'code'|'prose', text: string, info?: string }} Segment
11
+ */
12
+
13
+ /**
14
+ * Split text into fenced code blocks and prose. Fence lines belong to the code segment.
15
+ * An unclosed fence runs to the end of the text (as in CommonMark).
16
+ * @param {string} text
17
+ * @returns {Segment[]}
18
+ */
19
+ function splitFences (text) {
20
+ const lines = text.split('\n')
21
+ const out = []
22
+ let buf = []
23
+ let fence = null
24
+ const push = (type, info) => {
25
+ if (!buf.length) return
26
+ out.push({ type, text: buf.join('\n'), ...(info !== undefined ? { info } : {}) })
27
+ buf = []
28
+ }
29
+ for (const line of lines) {
30
+ if (!fence) {
31
+ const m = FENCE_OPEN.exec(line)
32
+ if (m && !(m[1][0] === '`' && m[2].includes('`'))) {
33
+ push('prose')
34
+ fence = { char: m[1][0], len: m[1].length, info: m[2].trim() }
35
+ buf.push(line)
36
+ continue
37
+ }
38
+ buf.push(line)
39
+ } else {
40
+ buf.push(line)
41
+ const close = new RegExp(`^[ \\t]*${fence.char === '`' ? '`' : '~'}{${fence.len},}[ \\t]*$`)
42
+ if (close.test(line)) {
43
+ push('code', fence.info)
44
+ fence = null
45
+ }
46
+ }
47
+ }
48
+ push(fence ? 'code' : 'prose', fence?.info)
49
+ // re-joining with '\n' between segments restores the original text
50
+ return out
51
+ }
52
+
53
+ /** Join segments produced by splitFences (or transformed copies of them). */
54
+ const joinSegments = (segments) => segments.map(s => s.text).join('\n')
55
+
56
+ /**
57
+ * Remove insignificant whitespace from valid JSON. Works on the text, so numbers,
58
+ * key order and duplicate keys stay exactly as written.
59
+ * @param {string} json must already be valid JSON
60
+ */
61
+ function minifyJson (json) {
62
+ let out = ''
63
+ let inString = false
64
+ let escaped = false
65
+ for (const ch of json) {
66
+ if (inString) {
67
+ out += ch
68
+ if (escaped) escaped = false
69
+ else if (ch === '\\') escaped = true
70
+ else if (ch === '"') inString = false
71
+ } else if (ch === '"') {
72
+ inString = true
73
+ out += ch
74
+ } else if (ch !== ' ' && ch !== '\t' && ch !== '\n' && ch !== '\r') {
75
+ out += ch
76
+ }
77
+ }
78
+ return out
79
+ }
80
+
81
+ /**
82
+ * Minify `text` if it is a JSON object or array; otherwise return null.
83
+ * @param {string} text
84
+ * @returns {string|null}
85
+ */
86
+ function tryMinifyJson (text) {
87
+ const t = text.trim()
88
+ if (!(t.startsWith('{') || t.startsWith('['))) return null
89
+ let parsed
90
+ try { parsed = JSON.parse(t) } catch { return null }
91
+ if (parsed === null || typeof parsed !== 'object') return null
92
+ return minifyJson(t)
93
+ }
94
+
95
+ /**
96
+ * Find the end index (exclusive) of the JSON value that starts with a bracket at `start`,
97
+ * respecting strings. Returns -1 if the brackets never balance.
98
+ * @param {string} text
99
+ * @param {number} start
100
+ */
101
+ function matchBracket (text, start) {
102
+ const stack = []
103
+ let inString = false
104
+ let escaped = false
105
+ for (let i = start; i < text.length; i++) {
106
+ const ch = text[i]
107
+ if (inString) {
108
+ if (escaped) escaped = false
109
+ else if (ch === '\\') escaped = true
110
+ else if (ch === '"') inString = false
111
+ continue
112
+ }
113
+ if (ch === '"') inString = true
114
+ else if (ch === '{' || ch === '[') stack.push(ch)
115
+ else if (ch === '}' || ch === ']') {
116
+ const open = stack.pop()
117
+ if ((ch === '}' && open !== '{') || (ch === ']' && open !== '[')) return -1
118
+ if (!stack.length) return i + 1
119
+ }
120
+ }
121
+ return -1
122
+ }
123
+
124
+ /**
125
+ * Split prose into JSON blocks and the text around them. A JSON block starts with `{` or `[`
126
+ * at the beginning of a line (after indentation), ends at the end of a line, and parses
127
+ * as a JSON object or array.
128
+ * @param {string} text
129
+ * @returns {Array<{ type: 'json'|'text', text: string }>}
130
+ */
131
+ function splitJsonBlocks (text) {
132
+ const out = []
133
+ let last = 0
134
+ let i = 0
135
+ while (i < text.length) {
136
+ const lineStart = i === 0 || text[i - 1] === '\n'
137
+ if (lineStart) {
138
+ let j = i
139
+ while (text[j] === ' ' || text[j] === '\t') j++
140
+ if (text[j] === '{' || text[j] === '[') {
141
+ const end = matchBracket(text, j)
142
+ if (end > 0) {
143
+ let k = end
144
+ while (text[k] === ' ' || text[k] === '\t' || text[k] === '\r') k++
145
+ if (k >= text.length || text[k] === '\n') {
146
+ const candidate = text.slice(j, end)
147
+ if (tryMinifyJson(candidate) !== null) {
148
+ if (j > last) out.push({ type: 'text', text: text.slice(last, j) })
149
+ out.push({ type: 'json', text: candidate })
150
+ last = end
151
+ i = end
152
+ continue
153
+ }
154
+ }
155
+ }
156
+ }
157
+ }
158
+ const nl = text.indexOf('\n', i)
159
+ if (nl < 0) break
160
+ i = nl + 1
161
+ }
162
+ if (last < text.length) out.push({ type: 'text', text: text.slice(last) })
163
+ return out
164
+ }
165
+
166
+ module.exports = { splitFences, joinSegments, minifyJson, tryMinifyJson, splitJsonBlocks, matchBracket }